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 #IDE
.vscode .vscode
.idea

@ -6,6 +6,7 @@ DEBUG_APK=tailscale-debug.apk
RELEASE_AAB=tailscale-release.aab RELEASE_AAB=tailscale-release.aab
APPID=com.tailscale.ipn APPID=com.tailscale.ipn
AAR=android_legacy/libs/ipn.aar AAR=android_legacy/libs/ipn.aar
AAR_NEXTGEN=android/libs/ipn.aar
KEYSTORE=tailscale.jks KEYSTORE=tailscale.jks
KEYSTORE_ALIAS=tailscale KEYSTORE_ALIAS=tailscale
TAILSCALE_VERSION=$(shell ./version/tailscale-version.sh 200) TAILSCALE_VERSION=$(shell ./version/tailscale-version.sh 200)
@ -129,14 +130,18 @@ androidpath:
toolchain: $(TOOLCHAINDIR)/bin/go toolchain: $(TOOLCHAINDIR)/bin/go
android/libs: $(AAR): toolchain checkandroidsdk
mkdir -p android_legacy/libs @mkdir -p android_legacy/libs && \
$(AAR): toolchain checkandroidsdk android/libs
go run gioui.org/cmd/gogio \ 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)" \ -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 -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. # tailscale-debug.apk builds a debuggable APK with the Google Play SDK.
$(DEBUG_APK): $(AAR) $(DEBUG_APK): $(AAR)
(cd android_legacy && ./gradlew test assemblePlayDebug) (cd android_legacy && ./gradlew test assemblePlayDebug)
@ -180,4 +185,4 @@ clean:
-rm -rf android_legacy/build $(DEBUG_APK) $(RELEASE_AAB) $(AAR) tailscale-fdroid.apk -rm -rf android_legacy/build $(DEBUG_APK) $(RELEASE_AAB) $(AAR) tailscale-fdroid.apk
-pkill -f gradle -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 { buildscript {
ext.kotlin_version = "1.9.22" ext.kotlin_version = "1.9.22"
ext.kotlin_compose_version = "1.5.10"
repositories { repositories {
google() google()
@ -38,6 +39,12 @@ android {
sourceCompatibility JavaVersion.VERSION_17 sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17
} }
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion = "$kotlin_compose_version"
}
flavorDimensions "version" flavorDimensions "version"
productFlavors { productFlavors {
fdroid { fdroid {
@ -52,15 +59,33 @@ android {
} }
dependencies { dependencies {
// Android dependencies.
implementation "androidx.core:core:1.9.0" implementation "androidx.core:core:1.9.0"
implementation 'androidx.core:core-ktx:1.9.0' implementation 'androidx.core:core-ktx:1.9.0'
implementation "androidx.browser:browser:1.5.0" implementation "androidx.browser:browser:1.5.0"
implementation "androidx.security:security-crypto:1.1.0-alpha06" implementation "androidx.security:security-crypto:1.1.0-alpha06"
implementation "androidx.work:work-runtime:2.8.1" 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-serialization-json:1.6.3"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0" 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" 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' implementation ':ipn@aar'
// Tests
testImplementation "junit:junit:4.12" testImplementation "junit:junit:4.12"
// Non-free dependencies. // Non-free dependencies.

@ -70,8 +70,6 @@ import androidx.browser.customtabs.CustomTabsIntent;
import org.gioui.Gio; import org.gioui.Gio;
import com.tailscale.ipn.ui.localapi.LocalApiClient;
public class App extends Application { public class App extends Application {
private static final String PEER_TAG = "peer"; private static final String PEER_TAG = "peer";
@ -90,8 +88,6 @@ public class App extends Application {
public DnsConfig dns = new DnsConfig(); public DnsConfig dns = new DnsConfig();
public DnsConfig getDnsConfigObj() { return this.dns; } public DnsConfig getDnsConfigObj() { return this.dns; }
static final LocalApiClient api = new LocalApiClient();
@Override public void onCreate() { @Override public void onCreate() {
super.onCreate(); super.onCreate();
// Load and initialize the Go library. // Load and initialize the Go library.

@ -5,19 +5,34 @@
package com.tailscale.ipn.ui.localapi package com.tailscale.ipn.ui.localapi
import android.util.Log import android.util.Log
import com.tailscale.ipn.ui.model.* import com.tailscale.ipn.ui.model.BugReportID
import kotlinx.coroutines.* 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 StatusResponseHandler = (Result<IpnState.Status>) -> Unit
typealias BugReportIdHandler = (Result<BugReportID>) -> Unit typealias BugReportIdHandler = (Result<BugReportID>) -> Unit
typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit
class LocalApiClient { class LocalApiClient(private val scope: CoroutineScope) {
constructor() {
Log.d("LocalApiClient", "LocalApiClient created") 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 // 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. // body: The body of the request.
// cookie: A unique identifier for this request. This is used map responses to // cookie: A unique identifier for this request. This is used map responses to
// the corresponding request. Cookies must be unique for each request. // the corresponding request. Cookies must be unique for each request.
external fun doRequest(request: String, method: String, body: String, cookie: String) private external fun doRequest(request: String, method: String, body: ByteArray?, cookie: String)
fun <T> executeRequest(request: LocalAPIRequest<T>) { private fun <T> executeRequest(request: LocalAPIRequest<T>) {
Log.d("LocalApiClient", "Executing request:${request.method}:${request.path}") scope.launch {
addRequest(request) isReady.first { it }
// The jni handler will treat the empty string in the body as null. Log.d("LocalApiClient", "Executing request:${request.method}:${request.path}")
val body = request.body ?: "" requests[request.cookie] = request
doRequest(request.path, request.method, body, request.cookie) doRequest(request.path, request.method, request.body, request.cookie)
}
} }
// This is called from the JNI layer to publish localAPIResponses. This should execute on the // This is called from the JNI layer to publish localAPIResponses. This should execute on the
// same thread that called doRequest. // same thread that called doRequest.
fun onResponse(response: String, cookie: String) { @Suppress("unused")
val request = requests[cookie] fun onResponse(response: ByteArray, cookie: String) {
if (request != null) { requests.remove(cookie)?.let { request ->
Log.d("LocalApiClient", "Reponse for request:${request.path} cookie:${request.cookie}") 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) request.parser(response)
removeRequest(cookie) } ?: { Log.e("LocalApiClient", "Received response for unknown request: $cookie") }
} else {
Log.e("LocalApiClient", "Received response for unknown request: ${cookie}")
}
} }
// Tracks in-flight requests and their callback handlers by cookie. This should // Tracks in-flight requests and their callback handlers by cookie. This should
// always be manipulated via the addRequest and removeRequest methods. // always be manipulated via the addRequest and removeRequest methods.
private var requests = HashMap<String, LocalAPIRequest<*>>() private var requests = ConcurrentHashMap<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) }
}
// localapi Invocations // localapi Invocations
fun getStatus(responseHandler: StatusResponseHandler) { fun getStatus(responseHandler: StatusResponseHandler) {
val req = LocalAPIRequest.status(responseHandler) val req = LocalAPIRequest.status(responseHandler)
executeRequest<IpnState.Status>(req) executeRequest(req)
} }
fun getBugReportId(responseHandler: BugReportIdHandler) { fun getBugReportId(responseHandler: BugReportIdHandler) {
val req = LocalAPIRequest.bugReportId(responseHandler) val req = LocalAPIRequest.bugReportId(responseHandler)
executeRequest<BugReportID>(req) executeRequest(req)
} }
fun getPrefs(responseHandler: PrefsHandler) { fun getPrefs(responseHandler: PrefsHandler) {
val req = LocalAPIRequest.prefs(responseHandler) 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 // (jonathan) TODO: A (likely) exhaustive list of localapi endpoints required for
@ -111,40 +125,7 @@ class LocalApiClient {
// verifyDeepling // verifyDeepling
// ping // ping
// setTailFSFileServerAddress // setTailFSFileServerAddress
init {
// Run some tests to validate the APIs work before we have anything Log.d("LocalApiClient", "LocalApiClient created")
// 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}")
}
}
} }
} }

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

@ -4,7 +4,7 @@
package com.tailscale.ipn.ui.model package com.tailscale.ipn.ui.model
import kotlinx.serialization.* import kotlinx.serialization.Serializable
class Dns { class Dns {
@Serializable data class HostEntry(val addr: Addr?, val hosts: List<String>?) @Serializable data class HostEntry(val addr: Addr?, val hosts: List<String>?)

@ -4,7 +4,7 @@
package com.tailscale.ipn.ui.model package com.tailscale.ipn.ui.model
import kotlinx.serialization.* import kotlinx.serialization.Serializable
class Ipn { class Ipn {
@ -16,20 +16,14 @@ class Ipn {
NeedsMachineAuth(3), NeedsMachineAuth(3),
Stopped(4), Stopped(4),
Starting(5), Starting(5),
Running(6), Running(6);
}
// NotifyWatchOpt is a bitmask of options supplied to the notifier to specify which companion object {
// what we want to see on the Noitfy bus fun fromInt(value: Int): State? {
enum class NotifyWatchOpt(val value: Int) { return State.values().first { s -> s.value == value }
engineUpdates(0), }
initialState(1 shl 1), }
prefs(1 shl 2),
netmap(1 shl 3),
noPrivateKeys(1 shl 4),
initialTailFSShares(1 shl 5)
} }
// A nofitication message recieved on the Notify bus. Fields will be populated based // A nofitication message recieved on the Notify bus. Fields will be populated based
// on which NotifyWatchOpts were set when the Notifier was created. // on which NotifyWatchOpts were set when the Notifier was created.
@Serializable @Serializable
@ -38,7 +32,7 @@ class Ipn {
val ErrMessage: String? = null, val ErrMessage: String? = null,
val LoginFinished: Empty.Message? = null, val LoginFinished: Empty.Message? = null,
val FilesWaiting: Empty.Message? = null, val FilesWaiting: Empty.Message? = null,
val State: State? = null, val State: Int? = null,
var Prefs: Prefs? = null, var Prefs: Prefs? = null,
var NetMap: Netmap.NetworkMap? = null, var NetMap: Netmap.NetworkMap? = null,
var Engine: EngineStatus? = null, var Engine: EngineStatus? = null,

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

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

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

@ -2,9 +2,9 @@
// Use of this source code is governed by a BSD-style // Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
package com.tailscale.ipn.ui.model package com.tailscale.ipn.ui.model
import kotlinx.serialization.*
import kotlinx.serialization.Serializable
typealias Addr = String typealias Addr = String
typealias Prefix = 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 package localapiservice
import ( import (
"bytes"
"context" "context"
"encoding/json"
"io" "io"
"strings" "log"
"time" "time"
"unsafe" "unsafe"
"github.com/tailscale/tailscale-android/cmd/jni" "github.com/tailscale/tailscale-android/cmd/jni"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
) )
// #include <jni.h> // #include <jni.h>
@ -22,8 +26,17 @@ var shim struct {
// localApiClient is a global reference to the com.tailscale.ipn.ui.localapi.LocalApiClient class. // localApiClient is a global reference to the com.tailscale.ipn.ui.localapi.LocalApiClient class.
clientClass jni.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. // Typically a shared LocalAPIService instance.
service *LocalAPIService service *LocalAPIService
backend *ipnlocal.LocalBackend
busWatchers map[string]func()
jvm *jni.JVM
} }
//export Java_com_tailscale_ipn_ui_localapi_LocalApiClient_doRequest //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, cls C.jclass,
jpath C.jstring, jpath C.jstring,
jmethod C.jstring, jmethod C.jstring,
jbody C.jstring, jbody C.jbyteArray,
jcookie C.jstring) { jcookie C.jstring) {
jenv := (*jni.Env)(unsafe.Pointer(env)) 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. // The body string. This is optional and may be empty.
bodyRef := jni.NewGlobalRef(jenv, jni.Object(jbody)) 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) defer jni.DeleteGlobalRef(jenv, bodyRef)
resp := doLocalAPIRequest(pathStr, methodStr, bodyStr) resp := doLocalAPIRequest(pathStr, methodStr, bodyArray)
jrespBody := jni.JavaString(jenv, resp) jrespBody := jni.JavaString(jenv, resp)
respBody := jni.Value(jrespBody) respBody := jni.Value(jrespBody)
cookie := jni.Value(jcookie) 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) 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 { if shim.service == nil {
return "{\"error\":\"Not Ready\"}" return "{\"error\":\"Not Ready\"}"
} }
@ -71,7 +84,7 @@ func doLocalAPIRequest(path string, method string, body string) string {
defer cancel() defer cancel()
var reader io.Reader = nil var reader io.Reader = nil
if len(body) > 0 { if len(body) > 0 {
reader = strings.NewReader(body) reader = bytes.NewReader(body)
} }
r, err := shim.service.Call(ctx, method, path, reader) 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. // 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.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. // 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 { return jni.Do(jvm, func(env *jni.Env) error {
loader := jni.ClassLoaderFor(env, appCtx) loader := jni.ClassLoaderFor(env, appCtx)
cl, err := jni.LoadClass(env, loader, "com.tailscale.ipn.ui.localapi.LocalApiClient") 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 return err
} }
shim.clientClass = jni.Class(jni.NewGlobalRef(env, jni.Object(cl))) 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 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) fatalErr(err)
} }
err = localapiservice.ConfigureLocalApiJNIHandler(a.jvm, a.appCtx)
if err != nil {
fatalErr(err)
}
a.store = newStateStore(a.jvm, a.appCtx) a.store = newStateStore(a.jvm, a.appCtx)
interfaces.RegisterInterfaceGetter(a.getInterfaces) interfaces.RegisterInterfaceGetter(a.getInterfaces)
go func() { go func() {
@ -356,8 +351,7 @@ func (a *App) runBackend(ctx context.Context) error {
h.PermitWrite = true h.PermitWrite = true
a.localAPI = localapiservice.New(h) a.localAPI = localapiservice.New(h)
// Share the localAPI with the JNI shim localapiservice.ConfigureShim(a.jvm, a.appCtx, a.localAPI, b.backend)
localapiservice.SetLocalAPIService(a.localAPI)
// Contrary to the documentation for VpnService.Builder.addDnsServer, // Contrary to the documentation for VpnService.Builder.addDnsServer,
// ChromeOS doesn't fall back to the underlying network nameservers if // ChromeOS doesn't fall back to the underlying network nameservers if

Loading…
Cancel
Save