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
Jonathan Nobels 9 months ago committed by GitHub
parent a0f87846fd
commit 4f46c38c99
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

1
.gitignore vendored

@ -32,3 +32,4 @@ tailscale.jks
#IDE
.vscode
.idea

@ -6,6 +6,7 @@ DEBUG_APK=tailscale-debug.apk
RELEASE_AAB=tailscale-release.aab
APPID=com.tailscale.ipn
AAR=android_legacy/libs/ipn.aar
AAR_NEXTGEN=android/libs/ipn.aar
KEYSTORE=tailscale.jks
KEYSTORE_ALIAS=tailscale
TAILSCALE_VERSION=$(shell ./version/tailscale-version.sh 200)
@ -129,14 +130,18 @@ androidpath:
toolchain: $(TOOLCHAINDIR)/bin/go
android/libs:
mkdir -p android_legacy/libs
$(AAR): toolchain checkandroidsdk android/libs
$(AAR): toolchain checkandroidsdk
@mkdir -p android_legacy/libs && \
go run gioui.org/cmd/gogio \
-ldflags "-X tailscale.com/version.longStamp=$(VERSIONNAME) -X tailscale.com/version.shortStamp=$(VERSIONNAME_SHORT) -X tailscale.com/version.gitCommitStamp=$(TAILSCALE_COMMIT) -X tailscale.com/version.extraGitCommitStamp=$(OUR_VERSION)" \
-buildmode archive -target android -appid $(APPID) -tags novulkan,tailscale_go -o $@ github.com/tailscale/tailscale-android/cmd/tailscale
$(AAR_NEXTGEN): $(AAR)
@mkdir -p android/libs && \
cp $(AAR) $(AAR_NEXTGEN)
lib: $(AAR_NEXTGEN)
# tailscale-debug.apk builds a debuggable APK with the Google Play SDK.
$(DEBUG_APK): $(AAR)
(cd android_legacy && ./gradlew test assemblePlayDebug)
@ -180,4 +185,4 @@ clean:
-rm -rf android_legacy/build $(DEBUG_APK) $(RELEASE_AAB) $(AAR) tailscale-fdroid.apk
-pkill -f gradle
.PHONY: all clean install android_legacy/lib $(DEBUG_APK) $(RELEASE_AAB) $(AAR) release bump_version dockershell
.PHONY: all clean install android_legacy/lib $(DEBUG_APK) $(RELEASE_AAB) $(AAR) release bump_version dockershell lib

@ -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.

@ -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.

@ -5,19 +5,34 @@
package com.tailscale.ipn.ui.localapi
import android.util.Log
import com.tailscale.ipn.ui.model.*
import kotlinx.coroutines.*
import com.tailscale.ipn.ui.model.BugReportID
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.IpnState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
// A response from the echo endpoint.
typealias StatusResponseHandler = (Result<IpnState.Status>) -> Unit
typealias BugReportIdHandler = (Result<BugReportID>) -> Unit
typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit
class LocalApiClient {
constructor() {
Log.d("LocalApiClient", "LocalApiClient created")
class LocalApiClient(private val scope: CoroutineScope) {
companion object {
private val _isReady = MutableStateFlow(false)
val isReady: StateFlow<Boolean> = _isReady
// Called by the backend when the localAPI is ready to accept requests.
@JvmStatic
@Suppress("unused")
fun onReady() {
_isReady.value = true
Log.d("LocalApiClient", "LocalApiClient is ready")
}
}
// Perform a request to the local API in the go backend. This is
@ -31,58 +46,57 @@ class LocalApiClient {
// body: The body of the request.
// cookie: A unique identifier for this request. This is used map responses to
// the corresponding request. Cookies must be unique for each request.
external fun doRequest(request: String, method: String, body: String, cookie: String)
fun <T> executeRequest(request: LocalAPIRequest<T>) {
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)
private external fun doRequest(request: String, method: String, body: ByteArray?, cookie: String)
private fun <T> executeRequest(request: LocalAPIRequest<T>) {
scope.launch {
isReady.first { it }
Log.d("LocalApiClient", "Executing request:${request.method}:${request.path}")
requests[request.cookie] = request
doRequest(request.path, request.method, request.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) {
@Suppress("unused")
fun onResponse(response: ByteArray, cookie: String) {
requests.remove(cookie)?.let { request ->
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
// always be manipulated via the addRequest and removeRequest methods.
private var requests = HashMap<String, LocalAPIRequest<*>>()
private var requestLock = Any()
fun addRequest(request: LocalAPIRequest<*>) {
synchronized(requestLock) { requests[request.cookie] = request }
}
fun removeRequest(cookie: String) {
synchronized(requestLock) { requests.remove(cookie) }
}
private var requests = ConcurrentHashMap<String, LocalAPIRequest<*>>()
// localapi Invocations
fun getStatus(responseHandler: StatusResponseHandler) {
val req = LocalAPIRequest.status(responseHandler)
executeRequest<IpnState.Status>(req)
executeRequest(req)
}
fun getBugReportId(responseHandler: BugReportIdHandler) {
val req = LocalAPIRequest.bugReportId(responseHandler)
executeRequest<BugReportID>(req)
executeRequest(req)
}
fun getPrefs(responseHandler: PrefsHandler) {
val req = LocalAPIRequest.prefs(responseHandler)
executeRequest<Ipn.Prefs>(req)
executeRequest(req)
}
fun getProfiles(responseHandler: (Result<List<IpnLocal.LoginProfile>>) -> Unit) {
val req = LocalAPIRequest.profiles(responseHandler)
executeRequest(req)
}
fun getCurrentProfile(responseHandler: (Result<IpnLocal.LoginProfile>) -> Unit) {
val req = LocalAPIRequest.currentProfile(responseHandler)
executeRequest(req)
}
// (jonathan) TODO: A (likely) exhaustive list of localapi endpoints required for
@ -111,40 +125,7 @@ class LocalApiClient {
// verifyDeepling
// 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}")
}
}
init {
Log.d("LocalApiClient", "LocalApiClient created")
}
}

@ -4,37 +4,37 @@
package com.tailscale.ipn.ui.localapi
import com.tailscale.ipn.ui.model.*
import kotlinx.serialization.decodeFromString
import com.tailscale.ipn.ui.model.BugReportID
import com.tailscale.ipn.ui.model.Errors
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.IpnState
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
enum class LocalAPIEndpoint(val rawValue: String) {
Debug("debug"),
Debug_Log("debug-log"),
BugReport("bugreport"),
Prefs("prefs"),
FileTargets("file-targets"),
UploadMetrics("upload-client-metrics"),
Start("start"),
LoginInteractive("login-interactive"),
ResetAuth("reset-auth"),
Logout("logout"),
Profiles("profiles"),
ProfilesCurrent("profiles/current"),
Status("status"),
TKAStatus("tka/status"),
TKASitng("tka/sign"),
TKAVerifyDeeplink("tka/verify-deeplink"),
Ping("ping"),
Files("files"),
FilePut("file-put"),
TailFSServerAddress("tailfs/fileserver-address");
val prefix = "/localapi/v0/"
fun path(): String {
return prefix + rawValue
}
import kotlinx.serialization.json.decodeFromStream
import java.util.UUID
private object Endpoint {
const val DEBUG = "debug"
const val DEBUG_LOG = "debug-log"
const val BUG_REPORT = "bugreport"
const val PREFS = "prefs"
const val FILE_TARGETS = "file-targets"
const val UPLOAD_METRICS = "upload-client-metrics"
const val START = "start"
const val LOGIN_INTERACTIVE = "login-interactive"
const val RESET_AUTH = "reset-auth"
const val LOGOUT = "logout"
const val PROFILES = "profiles/"
const val PROFILES_CURRENT = "profiles/current"
const val STATUS = "status"
const val TKA_STATUS = "tka/status"
const val TKA_SIGN = "tka/sign"
const val TKA_VERIFY_DEEP_LINK = "tka/verify-deeplink"
const val PING = "ping"
const val FILES = "files"
const val FILE_PUT = "file-put"
const val TAILFS_SERVER_ADDRESS = "tailfs/fileserver-address"
}
// Potential local and upstream errors. Error handling in localapi in the go layer
@ -47,9 +47,8 @@ enum class LocalAPIEndpoint(val rawValue: String) {
//
// (jonathan) TODO: Audit local API for all of the possible error results and clean
// it up if possible.
enum class APIErrorVals(val rawValue: String) {
UNPARSEABLE_RESPONSE("Unparseable localAPI response"),
NOT_READY("Not Ready");
enum class APIErrorVals(private val rawValue: String) {
UNPARSEABLE_RESPONSE("Unparseable localAPI response"), NOT_READY("Not Ready");
fun toError(): Error {
return Error(rawValue)
@ -57,73 +56,90 @@ enum class APIErrorVals(val rawValue: String) {
}
class LocalAPIRequest<T>(
val path: String,
val method: String,
val body: String? = null,
val responseHandler: (Result<T>) -> Unit,
val parser: (String) -> Unit,
path: String,
val method: String,
val body: ByteArray? = null,
val parser: (ByteArray) -> Unit,
) {
val path = "/localapi/v0/$path"
val cookie = UUID.randomUUID().toString()
companion object {
val cookieLock = Any()
var cookieCounter: Int = 0
val decoder = Json { ignoreUnknownKeys = true }
fun getCookie(): String {
synchronized(cookieLock) {
cookieCounter += 1
return cookieCounter.toString()
}
}
fun <T> get(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) =
LocalAPIRequest<T>(
method = "GET", path = path, body = body, parser = parser
)
fun <T> put(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) =
LocalAPIRequest<T>(
method = "PUT", path = path, body = body, parser = parser
)
private fun <T> post(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) =
LocalAPIRequest<T>(
method = "POST", path = path, body = body, parser = parser
)
fun status(responseHandler: StatusResponseHandler): LocalAPIRequest<IpnState.Status> {
val path = LocalAPIEndpoint.Status.path()
return LocalAPIRequest<IpnState.Status>(path, "GET", null, responseHandler) { resp ->
return get(Endpoint.STATUS) { resp ->
responseHandler(decode<IpnState.Status>(resp))
}
}
fun bugReportId(responseHandler: BugReportIdHandler): LocalAPIRequest<BugReportID> {
val path = LocalAPIEndpoint.BugReport.path()
return LocalAPIRequest<BugReportID>(path, "POST", null, responseHandler) { resp ->
return post(Endpoint.BUG_REPORT) { resp ->
responseHandler(parseString(resp))
}
}
fun prefs(responseHandler: PrefsHandler): LocalAPIRequest<Ipn.Prefs> {
val path = LocalAPIEndpoint.Prefs.path()
return LocalAPIRequest<Ipn.Prefs>(path, "GET", null, responseHandler) { resp ->
return get(Endpoint.PREFS) { resp ->
responseHandler(decode<Ipn.Prefs>(resp))
}
}
fun profiles(responseHandler: (Result<List<IpnLocal.LoginProfile>>) -> Unit): LocalAPIRequest<List<IpnLocal.LoginProfile>> {
return get(Endpoint.PROFILES) { resp ->
responseHandler(decode<List<IpnLocal.LoginProfile>>(resp))
}
}
fun currentProfile(responseHandler: (Result<IpnLocal.LoginProfile>) -> Unit): LocalAPIRequest<IpnLocal.LoginProfile> {
return get(Endpoint.PROFILES_CURRENT) { resp ->
responseHandler(decode<IpnLocal.LoginProfile>(resp))
}
}
// Check if the response was a generic error
fun parseError(respData: String): Error {
try {
val err = Json.decodeFromString<Errors.GenericError>(respData)
return Error(err.error)
@OptIn(ExperimentalSerializationApi::class)
fun parseError(respData: ByteArray): Error {
return try {
val err = Json.decodeFromStream<Errors.GenericError>(respData.inputStream())
Error(err.error)
} catch (e: Exception) {
return Error(APIErrorVals.UNPARSEABLE_RESPONSE.toError())
Error(APIErrorVals.UNPARSEABLE_RESPONSE.toError())
}
}
// Handles responses that are raw strings. Returns an error result if the string
// is empty
fun parseString(respData: String): Result<String> {
return if (respData.length > 0) Result(respData)
private fun parseString(respData: ByteArray): Result<String> {
return if (respData.isNotEmpty()) Result(respData.decodeToString())
else Result(APIErrorVals.UNPARSEABLE_RESPONSE.toError())
}
// Attempt to decode the response into the expected type. If that fails, then try
// parsing as an error.
inline fun <reified T> decode(respData: String): Result<T> {
try {
val message = decoder.decodeFromString<T>(respData)
return Result(message)
@OptIn(ExperimentalSerializationApi::class)
private inline fun <reified T> decode(respData: ByteArray): Result<T> {
return try {
val message = decoder.decodeFromStream<T>(respData.inputStream())
Result(message)
} catch (e: Exception) {
return Result(parseError(respData))
Result(parseError(respData))
}
}
}
val cookie: String = getCookie()
}

@ -10,7 +10,7 @@ class Result<T> {
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")
}

@ -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<String>?)

@ -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,

@ -4,7 +4,7 @@
package com.tailscale.ipn.ui.model
import kotlinx.serialization.*
import kotlinx.serialization.Serializable
class IpnState {
@Serializable

@ -4,7 +4,7 @@
package com.tailscale.ipn.ui.model
import kotlinx.serialization.*
import kotlinx.serialization.Serializable
class Netmap {
@Serializable

@ -4,7 +4,7 @@
package com.tailscale.ipn.ui.model
import kotlinx.serialization.*
import kotlinx.serialization.Serializable
class Tailcfg {
@Serializable

@ -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

@ -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() }
}
}

@ -5,13 +5,17 @@
package localapiservice
import (
"bytes"
"context"
"encoding/json"
"io"
"strings"
"log"
"time"
"unsafe"
"github.com/tailscale/tailscale-android/cmd/jni"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
)
// #include <jni.h>
@ -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
@ -32,7 +45,7 @@ func Java_com_tailscale_ipn_ui_localapi_LocalApiClient_doRequest(
cls C.jclass,
jpath C.jstring,
jmethod C.jstring,
jbody C.jstring,
jbody C.jbyteArray,
jcookie C.jstring) {
jenv := (*jni.Env)(unsafe.Pointer(env))
@ -49,20 +62,20 @@ func Java_com_tailscale_ipn_ui_localapi_LocalApiClient_doRequest(
// The body string. This is optional and may be empty.
bodyRef := jni.NewGlobalRef(jenv, jni.Object(jbody))
bodyStr := jni.GoString(jenv, jni.String(bodyRef))
bodyArray := jni.GetByteArrayElements(jenv, jni.ByteArray(bodyRef))
defer jni.DeleteGlobalRef(jenv, bodyRef)
resp := doLocalAPIRequest(pathStr, methodStr, bodyStr)
resp := doLocalAPIRequest(pathStr, methodStr, bodyArray)
jrespBody := jni.JavaString(jenv, resp)
respBody := jni.Value(jrespBody)
cookie := jni.Value(jcookie)
onResponse := jni.GetMethodID(jenv, shim.clientClass, "onResponse", "(Ljava/lang/String;Ljava/lang/String;)V")
onResponse := jni.GetMethodID(jenv, shim.clientClass, "onResponse", "(Lbyte[];Ljava/lang/String;)V")
jni.CallVoidMethod(jenv, jni.Object(cls), onResponse, respBody, cookie)
}
func doLocalAPIRequest(path string, method string, body string) string {
func doLocalAPIRequest(path string, method string, body []byte) string {
if shim.service == nil {
return "{\"error\":\"Not Ready\"}"
}
@ -71,7 +84,7 @@ func doLocalAPIRequest(path string, method string, body string) string {
defer cancel()
var reader io.Reader = nil
if len(body) > 0 {
reader = strings.NewReader(body)
reader = bytes.NewReader(body)
}
r, err := shim.service.Call(ctx, method, path, reader)
@ -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
})
}

@ -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

Loading…
Cancel
Save