From ddd7dce889459a87b0a1fc1428510479c5cd544d Mon Sep 17 00:00:00 2001 From: Jonathan Nobels Date: Thu, 29 Feb 2024 18:05:01 -0500 Subject: [PATCH] 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 --- android/build.gradle | 25 +++ .../src/main/java/com/tailscale/ipn/App.java | 4 - .../ipn/ui/localapi/LocalAPIClient.kt | 91 +++++------ .../ipn/ui/localapi/LocalAPIRequest.kt | 16 +- .../com/tailscale/ipn/ui/localapi/Result.kt | 2 +- .../java/com/tailscale/ipn/ui/model/Dns.kt | 2 +- .../java/com/tailscale/ipn/ui/model/Ipn.kt | 22 +-- .../com/tailscale/ipn/ui/model/IpnState.kt | 2 +- .../java/com/tailscale/ipn/ui/model/NetMap.kt | 2 +- .../com/tailscale/ipn/ui/model/TailCfg.kt | 2 +- .../java/com/tailscale/ipn/ui/model/Types.kt | 4 +- .../com/tailscale/ipn/ui/notifier/Notifier.kt | 154 ++++++++++++++++++ .../tailscale/ipn/ui/service/IpnManager.kt | 30 ++++ .../com/tailscale/ipn/ui/service/IpnModel.kt | 100 ++++++++++++ cmd/localapiservice/localapishim.go | 101 +++++++++++- cmd/tailscale/main.go | 8 +- 16 files changed, 482 insertions(+), 83 deletions(-) create mode 100644 android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/service/IpnManager.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/service/IpnModel.kt diff --git a/android/build.gradle b/android/build.gradle index d028b2f..d84bbcd 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,6 +1,7 @@ buildscript { ext.kotlin_version = "1.9.22" + ext.kotlin_compose_version = "1.5.10" repositories { google() @@ -38,6 +39,12 @@ android { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } + buildFeatures { + compose true + } + composeOptions { + kotlinCompilerExtensionVersion = "$kotlin_compose_version" + } flavorDimensions "version" productFlavors { fdroid { @@ -52,15 +59,33 @@ android { } dependencies { + // Android dependencies. implementation "androidx.core:core:1.9.0" implementation 'androidx.core:core-ktx:1.9.0' implementation "androidx.browser:browser:1.5.0" implementation "androidx.security:security-crypto:1.1.0-alpha06" implementation "androidx.work:work-runtime:2.8.1" + + // Kotlin dependencies. implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + + // Compose dependencies. + def composeBom = platform('androidx.compose:compose-bom:2023.06.01') + implementation composeBom + androidTestImplementation composeBom + implementation 'androidx.compose.material3:material3:1.0.0' + implementation "androidx.compose.ui:ui:1.4.3" + implementation "androidx.compose.ui:ui-tooling:1.4.3" + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1' + implementation 'androidx.activity:activity-compose:1.7.2' + + // Tailscale dependencies. implementation ':ipn@aar' + + // Tests testImplementation "junit:junit:4.12" // Non-free dependencies. diff --git a/android/src/main/java/com/tailscale/ipn/App.java b/android/src/main/java/com/tailscale/ipn/App.java index c4a2529..d44efaf 100644 --- a/android/src/main/java/com/tailscale/ipn/App.java +++ b/android/src/main/java/com/tailscale/ipn/App.java @@ -70,8 +70,6 @@ import androidx.browser.customtabs.CustomTabsIntent; import org.gioui.Gio; -import com.tailscale.ipn.ui.localapi.LocalApiClient; - public class App extends Application { private static final String PEER_TAG = "peer"; @@ -90,8 +88,6 @@ public class App extends Application { public DnsConfig dns = new DnsConfig(); public DnsConfig getDnsConfigObj() { return this.dns; } - static final LocalApiClient api = new LocalApiClient(); - @Override public void onCreate() { super.onCreate(); // Load and initialize the Go library. diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIClient.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIClient.kt index 9741679..9ef7a81 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIClient.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIClient.kt @@ -6,13 +6,16 @@ package com.tailscale.ipn.ui.localapi import android.util.Log import com.tailscale.ipn.ui.model.* -import kotlinx.coroutines.* +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 -// A response from the echo endpoint. typealias StatusResponseHandler = (Result) -> Unit - typealias BugReportIdHandler = (Result) -> Unit - typealias PrefsHandler = (Result) -> Unit class LocalApiClient { @@ -20,6 +23,18 @@ class LocalApiClient { Log.d("LocalApiClient", "LocalApiClient created") } + companion object { + private val _isReady = MutableStateFlow(false) + val isReady: StateFlow = _isReady + + // Called by the backend when the localAPI is ready to accept requests. + @JvmStatic + fun onReady() { + _isReady.value = true + Log.d("LocalApiClient", "LocalApiClient is ready") + } + } + // Perform a request to the local API in the go backend. This is // the primary JNI method for servicing a localAPI call. This // is GUARANTEED to call back into onResponse with the response @@ -33,26 +48,31 @@ class LocalApiClient { // the corresponding request. Cookies must be unique for each request. external fun doRequest(request: String, method: String, body: String, cookie: String) + protected val scope = CoroutineScope(Dispatchers.IO + Job()) + fun executeRequest(request: LocalAPIRequest) { - Log.d("LocalApiClient", "Executing request:${request.method}:${request.path}") - addRequest(request) - // The jni handler will treat the empty string in the body as null. - val body = request.body ?: "" - doRequest(request.path, request.method, body, request.cookie) + scope.launch { + isReady.first { it == true } + Log.d("LocalApiClient", "Executing request:${request.method}:${request.path}") + addRequest(request) + // The jni handler will treat the empty string in the body as null. + val body = request.body ?: "" + doRequest(request.path, request.method, body, request.cookie) + } } // This is called from the JNI layer to publish localAPIResponses. This should execute on the // same thread that called doRequest. fun onResponse(response: String, cookie: String) { val request = requests[cookie] - if (request != null) { + + request?.let { Log.d("LocalApiClient", "Reponse for request:${request.path} cookie:${request.cookie}") - // The response handler will invoked internally by the request parser + // The response handler will invoked internally by the request parser request.parser(response) removeRequest(cookie) - } else { - Log.e("LocalApiClient", "Received response for unknown request: ${cookie}") } + ?: { Log.e("LocalApiClient", "Received response for unknown request: ${cookie}") } } // Tracks in-flight requests and their callback handlers by cookie. This should @@ -85,6 +105,16 @@ class LocalApiClient { executeRequest(req) } + fun getProfiles(responseHandler: (Result>) -> Unit) { + val req = LocalAPIRequest.profiles(responseHandler) + executeRequest>(req) + } + + fun getCurrentProfile(responseHandler: (Result) -> Unit) { + val req = LocalAPIRequest.currentProfile(responseHandler) + executeRequest(req) + } + // (jonathan) TODO: A (likely) exhaustive list of localapi endpoints required for // a fully functioning client. This is a work in progress and will be updated // See: corp/xcode/Shared/LocalAPIClient.swift for the various verbs, parameters, @@ -112,39 +142,4 @@ class LocalApiClient { // ping // setTailFSFileServerAddress - // Run some tests to validate the APIs work before we have anything - // that calls them. This runs after a short delay to avoid not-ready - // errors - // (jonathan) TODO: Do we need some kind of "onReady" callback? - // (jonathan) TODO: Remove these we're further along - - fun runAPITests() = runBlocking { - delay(5000L) - getStatus { result -> - if (result.failed) { - Log.e("LocalApiClient", "Error getting status: ${result.error}") - } else { - val status = result.success - Log.d("LocalApiClient", "Got status: ${status}") - } - } - - getBugReportId { result -> - if (result.failed) { - Log.e("LocalApiClient", "Error getting bug report id: ${result.error}") - } else { - val bugReportId = result.success - Log.d("LocalApiClient", "Got bug report id: ${bugReportId}") - } - } - - getPrefs { result -> - if (result.failed) { - Log.e("LocalApiClient", "Error getting prefs: ${result.error}") - } else { - val prefs = result.success - Log.d("LocalApiClient", "Got prefs: ${prefs}") - } - } - } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIRequest.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIRequest.kt index c9380d2..401d62e 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIRequest.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIRequest.kt @@ -19,7 +19,7 @@ enum class LocalAPIEndpoint(val rawValue: String) { LoginInteractive("login-interactive"), ResetAuth("reset-auth"), Logout("logout"), - Profiles("profiles"), + Profiles("profiles/"), ProfilesCurrent("profiles/current"), Status("status"), TKAStatus("tka/status"), @@ -96,6 +96,20 @@ class LocalAPIRequest( } } + fun profiles(responseHandler: (Result>) -> Unit): LocalAPIRequest> { + val path = LocalAPIEndpoint.Profiles.path() + return LocalAPIRequest>(path, "GET", null, responseHandler) { resp -> + responseHandler(decode>(resp)) + } + } + + fun currentProfile(responseHandler: (Result) -> Unit): LocalAPIRequest { + val path = LocalAPIEndpoint.ProfilesCurrent.path() + return LocalAPIRequest(path, "GET", null, responseHandler) { resp -> + responseHandler(decode(resp)) + } + } + // Check if the response was a generic error fun parseError(respData: String): Error { try { diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/Result.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/Result.kt index 7d9ec5a..a80f925 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/localapi/Result.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/Result.kt @@ -10,7 +10,7 @@ class Result { val success: T? val error: Error? - constructor(success: T?, error: Error?) { + private constructor(success: T?, error: Error?) { if (success != null && error != null) { throw IllegalArgumentException("Result cannot have both a success and an error") } diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Dns.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Dns.kt index 3a702e9..c1f97fe 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Dns.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Dns.kt @@ -4,7 +4,7 @@ package com.tailscale.ipn.ui.model -import kotlinx.serialization.* +import kotlinx.serialization.Serializable class Dns { @Serializable data class HostEntry(val addr: Addr?, val hosts: List?) diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt index 4b83953..6b1c007 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt @@ -4,7 +4,7 @@ package com.tailscale.ipn.ui.model -import kotlinx.serialization.* +import kotlinx.serialization.Serializable class Ipn { @@ -16,20 +16,14 @@ class Ipn { NeedsMachineAuth(3), Stopped(4), Starting(5), - Running(6), - } + Running(6); - // 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(0), - initialState(1 shl 1), - prefs(1 shl 2), - netmap(1 shl 3), - noPrivateKeys(1 shl 4), - initialTailFSShares(1 shl 5) + companion object { + fun fromInt(value: Int): State? { + return State.values().first { s -> s.value == value } + } + } } - // A nofitication message recieved on the Notify bus. Fields will be populated based // on which NotifyWatchOpts were set when the Notifier was created. @Serializable @@ -38,7 +32,7 @@ class Ipn { val ErrMessage: String? = null, val LoginFinished: Empty.Message? = null, val FilesWaiting: Empty.Message? = null, - val State: State? = null, + val State: Int? = null, var Prefs: Prefs? = null, var NetMap: Netmap.NetworkMap? = null, var Engine: EngineStatus? = null, diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/IpnState.kt b/android/src/main/java/com/tailscale/ipn/ui/model/IpnState.kt index 779dbca..f187e5c 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/IpnState.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/IpnState.kt @@ -4,7 +4,7 @@ package com.tailscale.ipn.ui.model -import kotlinx.serialization.* +import kotlinx.serialization.Serializable class IpnState { @Serializable diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/NetMap.kt b/android/src/main/java/com/tailscale/ipn/ui/model/NetMap.kt index 2ca1511..623b104 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/NetMap.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/NetMap.kt @@ -4,7 +4,7 @@ package com.tailscale.ipn.ui.model -import kotlinx.serialization.* +import kotlinx.serialization.Serializable class Netmap { @Serializable diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt b/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt index 6a774af..4e94f8e 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt @@ -4,7 +4,7 @@ package com.tailscale.ipn.ui.model -import kotlinx.serialization.* +import kotlinx.serialization.Serializable class Tailcfg { @Serializable diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt index 32e0415..ff70f75 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt @@ -2,9 +2,9 @@ // 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.model -import kotlinx.serialization.* + +import kotlinx.serialization.Serializable typealias Addr = String typealias Prefix = String diff --git a/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt b/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt new file mode 100644 index 0000000..a9a8c2a --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt @@ -0,0 +1,154 @@ +// 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 the +// 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 { + constructor() { + Log.d("Notifier", "Notifier created") + } + + protected 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 = _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 excuted 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() + + // Callback from jni when a new notification is received + fun onNotify(notification: String, sessionId: String) { + val notify = decoder.decodeFromString(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 { + 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 + ) + } +} + diff --git a/android/src/main/java/com/tailscale/ipn/ui/service/IpnManager.kt b/android/src/main/java/com/tailscale/ipn/ui/service/IpnManager.kt new file mode 100644 index 0000000..9d32e23 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/service/IpnManager.kt @@ -0,0 +1,30 @@ +// 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.notifier.Notifier + +class IpnManager { + var notifier = Notifier() + var apiClient = LocalApiClient() + val model: IpnModel + + constructor() { + model = IpnModel(notifier, apiClient) + } + + // 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 } + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/service/IpnModel.kt b/android/src/main/java/com/tailscale/ipn/ui/service/IpnModel.kt new file mode 100644 index 0000000..5b077cf --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/service/IpnModel.kt @@ -0,0 +1,100 @@ +// 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.* +import com.tailscale.ipn.ui.notifier.Notifier +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first + +class IpnModel { + protected val scope = CoroutineScope(Dispatchers.Default + Job()) + var notifierSessions: MutableList = mutableListOf() + + val apiClient: LocalApiClient + + constructor(notifier: Notifier, apiClient: LocalApiClient) { + Log.d("IpnModel", "IpnModel created") + this.apiClient = apiClient + + val session = notifier.watchAll { n -> onNotifyChange(n) } + notifierSessions.add(session) + + scope.launch { loadUserProfiles() } + } + + private val _state: MutableStateFlow = MutableStateFlow(null) + private val _netmap: MutableStateFlow = MutableStateFlow(null) + private val _prefs: MutableStateFlow = MutableStateFlow(null) + private val _engineStatus: MutableStateFlow = MutableStateFlow(null) + private val _tailFSShares: MutableStateFlow?> = MutableStateFlow(null) + private val _browseToURL: MutableStateFlow = MutableStateFlow(null) + private val _loginFinished: MutableStateFlow = MutableStateFlow(null) + private val _version: MutableStateFlow = MutableStateFlow(null) + + private val _loggedInUser: MutableStateFlow = MutableStateFlow(null) + private val _loginProfiles: MutableStateFlow?> = + MutableStateFlow(null) + + + val state: StateFlow = _state + val netmap: StateFlow = _netmap + val prefs: StateFlow = _prefs + val engineStatus: StateFlow = _engineStatus + val tailFSShares: StateFlow?> = _tailFSShares + val browseToURL: StateFlow = _browseToURL + val loginFinished: StateFlow = _loginFinished + val version: StateFlow = _version + val loggedInUser: StateFlow = _loggedInUser + val loginProfiles: StateFlow?> = _loginProfiles + + var isUsingExitNode: Boolean = false + get() { + return prefs.value != null + } + + + // Backend Observation + + private suspend fun loadUserProfiles() { + LocalApiClient.isReady.first { it == true } + + 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 } + } +} diff --git a/cmd/localapiservice/localapishim.go b/cmd/localapiservice/localapishim.go index 9b26aeb..ebfc25e 100644 --- a/cmd/localapiservice/localapishim.go +++ b/cmd/localapiservice/localapishim.go @@ -6,12 +6,16 @@ package localapiservice import ( "context" + "encoding/json" "io" + "log" "strings" "time" "unsafe" "github.com/tailscale/tailscale-android/cmd/jni" + "tailscale.com/ipn" + "tailscale.com/ipn/ipnlocal" ) // #include @@ -22,8 +26,17 @@ var shim struct { // localApiClient is a global reference to the com.tailscale.ipn.ui.localapi.LocalApiClient class. clientClass jni.Class + // notifierClass is a global reference to the com.tailscale.ipn.ui.notifier.Notifier class. + notifierClass jni.Class + // Typically a shared LocalAPIService instance. service *LocalAPIService + + backend *ipnlocal.LocalBackend + + busWatchers map[string]func() + + jvm *jni.JVM } //export Java_com_tailscale_ipn_ui_localapi_LocalApiClient_doRequest @@ -88,12 +101,30 @@ func doLocalAPIRequest(path string, method string, body string) string { } // Assign a localAPIService to our shim for handling incoming localapi requests from the Kotlin side. -func SetLocalAPIService(s *LocalAPIService) { +func ConfigureShim(jvm *jni.JVM, appCtx jni.Object, s *LocalAPIService, b *ipnlocal.LocalBackend) { + shim.busWatchers = make(map[string]func()) shim.service = s + shim.backend = b + + configureLocalApiJNIHandler(jvm, appCtx) + + // Let the Kotlin side know we're ready to handle requests. + jni.Do(jvm, func(env *jni.Env) error { + onReadyAPI := jni.GetStaticMethodID(env, shim.clientClass, "onReady", "()V") + jni.CallStaticVoidMethod(env, shim.clientClass, onReadyAPI) + + onNotifyNot := jni.GetStaticMethodID(env, shim.notifierClass, "onReady", "()V") + jni.CallStaticVoidMethod(env, shim.notifierClass, onNotifyNot) + + log.Printf("LocalAPI Shim ready") + return nil + }) } // Loads the Kotlin-side LocalApiClient class and stores it in a global reference. -func ConfigureLocalApiJNIHandler(jvm *jni.JVM, appCtx jni.Object) error { +func configureLocalApiJNIHandler(jvm *jni.JVM, appCtx jni.Object) error { + shim.jvm = jvm + return jni.Do(jvm, func(env *jni.Env) error { loader := jni.ClassLoaderFor(env, appCtx) cl, err := jni.LoadClass(env, loader, "com.tailscale.ipn.ui.localapi.LocalApiClient") @@ -101,6 +132,72 @@ func ConfigureLocalApiJNIHandler(jvm *jni.JVM, appCtx jni.Object) error { return err } shim.clientClass = jni.Class(jni.NewGlobalRef(env, jni.Object(cl))) + + cl, err = jni.LoadClass(env, loader, "com.tailscale.ipn.ui.notifier.Notifier") + if err != nil { + return err + } + shim.notifierClass = jni.Class(jni.NewGlobalRef(env, jni.Object(cl))) + return nil }) } + +//export Java_com_tailscale_ipn_ui_notifier_Notifier_stopIPNBusWatcher +func Java_com_tailscale_ipn_ui_notifier_Notifier_stopIPNBusWatcher( + env *C.JNIEnv, + cls C.jclass, + jsessionId C.jstring) { + + jenv := (*jni.Env)(unsafe.Pointer(env)) + + sessionIdRef := jni.NewGlobalRef(jenv, jni.Object(jsessionId)) + sessionId := jni.GoString(jenv, jni.String(sessionIdRef)) + defer jni.DeleteGlobalRef(jenv, sessionIdRef) + + cancel := shim.busWatchers[sessionId] + if cancel != nil { + log.Printf("Deregistering app layer bus watcher with sessionid: %s", sessionId) + cancel() + delete(shim.busWatchers, sessionId) + } else { + log.Printf("Error: Could not find bus watcher with sessionid: %s", sessionId) + } +} + +//export Java_com_tailscale_ipn_ui_notifier_Notifier_startIPNBusWatcher +func Java_com_tailscale_ipn_ui_notifier_Notifier_startIPNBusWatcher( + env *C.JNIEnv, + cls C.jclass, + jsessionId C.jstring, + jmask C.jint) { + + jenv := (*jni.Env)(unsafe.Pointer(env)) + + sessionIdRef := jni.NewGlobalRef(jenv, jni.Object(jsessionId)) + sessionId := jni.GoString(jenv, jni.String(sessionIdRef)) + defer jni.DeleteGlobalRef(jenv, sessionIdRef) + + log.Printf("Registering app layer bus watcher with sessionid: %s", sessionId) + + ctx, cancel := context.WithCancel(context.Background()) + shim.busWatchers[sessionId] = cancel + opts := ipn.NotifyWatchOpt(jmask) + + shim.backend.WatchNotifications(ctx, opts, func() { + // onWatchAdded + }, func(roNotify *ipn.Notify) bool { + js, err := json.Marshal(roNotify) + if err != nil { + return true + } + jni.Do(shim.jvm, func(env *jni.Env) error { + jjson := jni.JavaString(env, string(js)) + onNotify := jni.GetMethodID(env, shim.notifierClass, "onNotify", "(Ljava/lang/String;Ljava/lang/String;)V") + jni.CallVoidMethod(env, jni.Object(cls), onNotify, jni.Value(jjson), jni.Value(jsessionId)) + return nil + }) + return true + }) + +} diff --git a/cmd/tailscale/main.go b/cmd/tailscale/main.go index 5360574..f477190 100644 --- a/cmd/tailscale/main.go +++ b/cmd/tailscale/main.go @@ -279,11 +279,6 @@ func main() { fatalErr(err) } - err = localapiservice.ConfigureLocalApiJNIHandler(a.jvm, a.appCtx) - if err != nil { - fatalErr(err) - } - a.store = newStateStore(a.jvm, a.appCtx) interfaces.RegisterInterfaceGetter(a.getInterfaces) go func() { @@ -356,8 +351,7 @@ func (a *App) runBackend(ctx context.Context) error { h.PermitWrite = true a.localAPI = localapiservice.New(h) - // Share the localAPI with the JNI shim - localapiservice.SetLocalAPIService(a.localAPI) + localapiservice.ConfigureShim(a.jvm, a.appCtx, a.localAPI, b.backend) // Contrary to the documentation for VpnService.Builder.addDnsServer, // ChromeOS doesn't fall back to the underlying network nameservers if