android: simplify local API client

Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
pull/213/head
Percy Wegmann 3 months ago committed by Percy Wegmann
parent e16303e1d8
commit d42329e2e2

@ -110,7 +110,7 @@ class MainActivity : ComponentActivity() {
) )
} }
composable("bugReport") { composable("bugReport") {
BugReportView(BugReportViewModel(manager.apiClient)) BugReportView(BugReportViewModel())
} }
composable("about") { composable("about") {
AboutView() AboutView()

@ -0,0 +1,230 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.localapi
import android.util.Log
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.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.serializer
import kotlin.reflect.KType
import kotlin.reflect.typeOf
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"
}
typealias StatusResponseHandler = (Result<IpnState.Status>) -> Unit
typealias BugReportIdHandler = (Result<BugReportID>) -> Unit
typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit
/**
* Client provides a mechanism for calling Go's LocalAPIClient. Every LocalAPI endpoint has a
* corresponding method on this Client.
*/
class Client(private val scope: CoroutineScope) {
fun status(responseHandler: StatusResponseHandler) {
get(Endpoint.STATUS, responseHandler = responseHandler)
}
fun bugReportId(responseHandler: BugReportIdHandler) {
post(Endpoint.BUG_REPORT, responseHandler = responseHandler)
}
fun prefs(responseHandler: PrefsHandler) {
get(Endpoint.PREFS, responseHandler = responseHandler)
}
fun editPrefs(
prefs: Ipn.MaskedPrefs, responseHandler: (Result<Ipn.Prefs>) -> Unit
) {
val body = Json.encodeToString(prefs).toByteArray()
return patch(Endpoint.PREFS, body, responseHandler = responseHandler)
}
fun profiles(responseHandler: (Result<List<IpnLocal.LoginProfile>>) -> Unit) {
get(Endpoint.PROFILES, responseHandler = responseHandler)
}
fun currentProfile(responseHandler: (Result<IpnLocal.LoginProfile>) -> Unit) {
return get(Endpoint.PROFILES_CURRENT, responseHandler = responseHandler)
}
fun startLoginInteractive(responseHandler: (Result<String>) -> Unit) {
return post(Endpoint.LOGIN_INTERACTIVE, responseHandler = responseHandler)
}
fun logout(responseHandler: (Result<String>) -> Unit) {
return post(Endpoint.LOGOUT, responseHandler = responseHandler)
}
private inline fun <reified T> get(
path: String, body: ByteArray? = null, noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "GET",
path = path,
body = body,
responseType = typeOf<T>(),
responseHandler = responseHandler
).execute()
}
private inline fun <reified T> put(
path: String, body: ByteArray? = null, noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "PUT",
path = path,
body = body,
responseType = typeOf<T>(),
responseHandler = responseHandler
).execute()
}
private inline fun <reified T> post(
path: String, body: ByteArray? = null, noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "POST",
path = path,
body = body,
responseType = typeOf<T>(),
responseHandler = responseHandler
).execute()
}
private inline fun <reified T> patch(
path: String, body: ByteArray? = null, noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "PATCH",
path = path,
body = body,
responseType = typeOf<T>(),
responseHandler = responseHandler
).execute()
}
private inline fun <reified T> delete(
path: String, noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "DELETE",
path = path,
responseType = typeOf<T>(),
responseHandler = responseHandler
).execute()
}
}
class Request<T>(
private val scope: CoroutineScope,
private val method: String,
path: String,
private val body: ByteArray? = null,
private val responseType: KType,
private val responseHandler: (Result<T>) -> Unit
) {
private val fullPath = "/localapi/v0/$path"
companion object {
private const val TAG = "LocalAPIRequest"
private val jsonDecoder = Json { ignoreUnknownKeys = true }
private val isReady = CompletableDeferred<Boolean>()
// Called by the backend when the localAPI is ready to accept requests.
@JvmStatic
@Suppress("unused")
fun onReady() {
isReady.complete(true)
Log.d(TAG, "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.
// @see cmd/localapiclient/localapishim.go
//
// method: The HTTP method to use.
// request: The path to the localAPI endpoint.
// body: The body of the request.
private external fun doRequest(method: String, request: String, body: ByteArray?)
fun execute() {
scope.launch(Dispatchers.IO) {
isReady.await()
Log.d(TAG, "Executing request:${method}:${fullPath}")
doRequest(method, fullPath, body)
}
}
// This is called from the JNI layer to publish responses.
@OptIn(ExperimentalSerializationApi::class)
@Suppress("unused", "UNCHECKED_CAST")
fun onResponse(respData: ByteArray) {
Log.d(TAG, "Response for request: $fullPath")
val response: Result<T> = when (responseType) {
typeOf<String>() -> Result.success(respData.decodeToString() as T)
else -> try {
Result.success(
jsonDecoder.decodeFromStream(
Json.serializersModule.serializer(responseType), respData.inputStream()
) as T
)
} catch (t: Throwable) {
// If we couldn't parse the response body, assume it's an error response
try {
val error =
jsonDecoder.decodeFromStream<Errors.GenericError>(respData.inputStream())
throw Exception(error.error)
} catch (t: Throwable) {
Result.failure(t)
}
}
}
// The response handler will invoked internally by the request parser
scope.launch {
responseHandler(response)
}
}
}

@ -1,154 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.localapi
import android.util.Log
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.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
typealias StatusResponseHandler = (Result<IpnState.Status>) -> Unit
typealias BugReportIdHandler = (Result<BugReportID>) -> Unit
typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit
class LocalApiClient(private val scope: CoroutineScope) {
init {
Log.d("LocalApiClient", "LocalApiClient created")
}
companion object {
val isReady = CompletableDeferred<Boolean>()
// Called by the backend when the localAPI is ready to accept requests.
@JvmStatic
@Suppress("unused")
fun onReady() {
isReady.complete(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
// from the backend with a matching cookie.
// @see cmd/localapiclient/localapishim.go
//
// request: The path to the localAPI endpoint.
// method: The HTTP method to use.
// 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.
private external fun doRequest(
request: String, method: String, body: ByteArray?, cookie: String
)
private fun <T> executeRequest(request: LocalAPIRequest<T>) {
scope.launch(Dispatchers.IO) {
isReady.await()
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.
@Suppress("unused")
fun onResponse(response: ByteArray, cookie: String) {
requests.remove(cookie)?.let { request ->
Log.d("LocalApiClient", "Response for request:${request.path} cookie:${request.cookie}")
// The response handler will invoked internally by the request parser
scope.launch {
request.parser(response)
}
} ?: { 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 = ConcurrentHashMap<String, LocalAPIRequest<*>>()
// localapi Invocations
fun getStatus(responseHandler: StatusResponseHandler) {
val req = LocalAPIRequest.status(responseHandler)
executeRequest(req)
}
fun getBugReportId(responseHandler: BugReportIdHandler) {
val req = LocalAPIRequest.bugReportId(responseHandler)
executeRequest(req)
}
fun getPrefs(responseHandler: PrefsHandler) {
val req = LocalAPIRequest.prefs(responseHandler)
executeRequest(req)
}
fun editPrefs(prefs: Ipn.MaskedPrefs, responseHandler: (Result<Ipn.Prefs>) -> Unit) {
val req = LocalAPIRequest.editPrefs(prefs, responseHandler)
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)
}
fun startLoginInteractive() {
val req = LocalAPIRequest.startLoginInteractive { result ->
result.success?.let { Log.d("LocalApiClient", "Login started: $it") }
?: run { Log.e("LocalApiClient", "Error starting login: ${result.error}") }
}
executeRequest<String>(req)
}
fun logout() {
val req = LocalAPIRequest.logout { result ->
result.success?.let { Log.d("LocalApiClient", "Logout started: $it") }
?: run { Log.e("LocalApiClient", "Error starting logout: ${result.error}") }
}
executeRequest<String>(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,
// and body contents for each endpoint. Endpoints are defined in LocalAPIEndpoint
//
// fetchFileTargets
// sendFiles
// getWaitingFiles
// recieveWaitingFile
// inidicateFileRecieved
// debug
// debugLog
// uploadClientMetrics
// start
// startLoginInteractive
// logout
// addProfile
// switchProfile
// deleteProfile
// tailnetLocalStatus
// signNode
// verifyDeepling
// ping
// setTailFSFileServerAddress
init {
Log.d("LocalApiClient", "LocalApiClient created")
}
}

@ -1,173 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.localapi
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.encodeToString
import kotlinx.serialization.json.Json
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
// is inconsistent but different clients already deal with that inconsistency so
// 'fixing' it will likely break other things.
//
// For now, anything that isn't an { error: "message" } will be passed along
// as UNPARSEABLE_RESPONSE. We can add additional error translation in the parseError
// method as needed.
//
// (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"), NO_PREFS("Current prefs not available");
fun toError(): Error {
return Error(rawValue)
}
}
class LocalAPIRequest<T>(
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 decoder = Json { ignoreUnknownKeys = true }
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
)
fun <T> post(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) =
LocalAPIRequest<T>(
method = "POST", path = path, body = body, parser = parser
)
fun <T> patch(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) =
LocalAPIRequest<T>(
method = "PATCH", path = path, body = body, parser = parser
)
fun status(responseHandler: StatusResponseHandler): LocalAPIRequest<IpnState.Status> {
return get(Endpoint.STATUS) { resp ->
responseHandler(decode<IpnState.Status>(resp))
}
}
fun bugReportId(responseHandler: BugReportIdHandler): LocalAPIRequest<BugReportID> {
return post(Endpoint.BUG_REPORT) { resp ->
responseHandler(parseString(resp))
}
}
fun prefs(responseHandler: PrefsHandler): LocalAPIRequest<Ipn.Prefs> {
return get(Endpoint.PREFS) { resp ->
responseHandler(decode<Ipn.Prefs>(resp))
}
}
fun editPrefs(
prefs: Ipn.MaskedPrefs, responseHandler: (Result<Ipn.Prefs>) -> Unit
): LocalAPIRequest<Ipn.Prefs> {
val body = Json.encodeToString(prefs).toByteArray()
return patch(Endpoint.PREFS, body) { 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))
}
}
fun startLoginInteractive(responseHandler: (Result<String>) -> Unit): LocalAPIRequest<String> {
return post(Endpoint.LOGIN_INTERACTIVE) { resp ->
responseHandler(parseString(resp))
}
}
fun logout(responseHandler: (Result<String>) -> Unit): LocalAPIRequest<String> {
return post(Endpoint.LOGOUT) { resp ->
responseHandler(parseString(resp))
}
}
// Check if the response was a generic 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) {
Error(APIErrorVals.UNPARSEABLE_RESPONSE.toError())
}
}
// Handles responses that are raw strings. Returns an error result if the string
// is empty
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.
@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) {
Result(parseError(respData))
}
}
}
}

@ -1,32 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.localapi
// Go-like result type with an optional value and an optional Error
// This guarantees that only one of the two is non-null
class Result<T> {
val success: T?
val error: Error?
private constructor(success: T?, error: Error?) {
if (success != null && error != null) {
throw IllegalArgumentException("Result cannot have both a success and an error")
}
if (success == null && error == null) {
throw IllegalArgumentException("Result must have either a success or an error")
}
this.success = success
this.error = error
}
constructor(success: T) : this(success, null)
constructor(error: Error) : this(null, error)
var successful: Boolean = false
get() = success != null
var failed: Boolean = false
get() = error != null
}

@ -26,9 +26,3 @@ class Errors {
@Serializable @Serializable
data class GenericError(val error: String) data class GenericError(val error: String)
} }
// Returned on successful operations with no explicit response body
class Success {
@Serializable
data class GenericSuccess(val message: String)
}

@ -5,10 +5,11 @@ package com.tailscale.ipn.ui.service
import android.content.Intent import android.content.Intent
import android.util.Log
import com.tailscale.ipn.App import com.tailscale.ipn.App
import com.tailscale.ipn.IPNReceiver import com.tailscale.ipn.IPNReceiver
import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.localapi.LocalApiClient import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -25,11 +26,14 @@ interface IpnActions {
fun updatePrefs(prefs: Ipn.MaskedPrefs, callback: PrefChangeCallback) fun updatePrefs(prefs: Ipn.MaskedPrefs, callback: PrefChangeCallback)
} }
class IpnManager(scope: CoroutineScope) : IpnActions { class IpnManager(private val scope: CoroutineScope) : IpnActions {
companion object {
private const val TAG = "IpnManager"
}
private var notifier = Notifier(scope) private var notifier = Notifier(scope)
var apiClient = LocalApiClient(scope)
var mdmSettings = MDMSettings() var mdmSettings = MDMSettings()
val model = IpnModel(notifier, apiClient, scope) val model = IpnModel(notifier, scope)
override fun startVPN() { override fun startVPN() {
val context = App.getApplication().applicationContext val context = App.getApplication().applicationContext
@ -46,19 +50,31 @@ class IpnManager(scope: CoroutineScope) : IpnActions {
} }
override fun login() { override fun login() {
apiClient.startLoginInteractive() Client(scope).startLoginInteractive { result ->
result.onSuccess {
Log.d(TAG, "Login started: $it")
}.onFailure {
Log.e(TAG, "Error starting login: ${it.message}")
}
}
} }
override fun logout() { override fun logout() {
apiClient.logout() Client(scope).logout { result ->
result.onSuccess {
Log.d(TAG, "Logout started: $it")
}.onFailure {
Log.e(TAG, "Error starting logout: ${it.message}")
}
}
} }
override fun updatePrefs(prefs: Ipn.MaskedPrefs, callback: PrefChangeCallback) { override fun updatePrefs(prefs: Ipn.MaskedPrefs, callback: PrefChangeCallback) {
apiClient.editPrefs(prefs) { result -> Client(scope).editPrefs(prefs) { result ->
result.success?.let { result.onSuccess {
callback(Result.success(true)) callback(Result.success(true))
} ?: run { }.onFailure {
callback(Result.failure(Throwable(result.error))) callback(Result.failure(it))
} }
} }
} }

@ -4,7 +4,7 @@
package com.tailscale.ipn.ui.service package com.tailscale.ipn.ui.service
import android.util.Log import android.util.Log
import com.tailscale.ipn.ui.localapi.LocalApiClient import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.model.Netmap
@ -15,9 +15,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class IpnModel( class IpnModel(notifier: Notifier, val scope: CoroutineScope) {
notifier: Notifier, val apiClient: LocalApiClient, val scope: CoroutineScope companion object {
) { private const val TAG = "IpnModel"
}
private var notifierSessions: MutableList<String> = mutableListOf() private var notifierSessions: MutableList<String> = mutableListOf()
val state: StateFlow<Ipn.State> = MutableStateFlow(Ipn.State.NoState) val state: StateFlow<Ipn.State> = MutableStateFlow(Ipn.State.NoState)
@ -39,21 +41,15 @@ class IpnModel(
// Backend Observation // Backend Observation
private suspend fun loadUserProfiles() { private suspend fun loadUserProfiles() {
LocalApiClient.isReady.await() Client(scope).profiles { result ->
result.onSuccess(loginProfiles::set).onFailure {
apiClient.getProfiles { result -> Log.e(TAG, "Error loading profiles: ${it.message}")
result.success?.let(loginProfiles::set) ?: run {
Log.e(
"IpnManager", "Error loading profiles: ${result.error}"
)
} }
} }
apiClient.getCurrentProfile { result -> Client(scope).currentProfile { result ->
result.success?.let(loggedInUser::set) ?: run { result.onSuccess(loggedInUser::set).onFailure {
Log.e( Log.e(TAG, "Error loading current profile: ${it.message}")
"IpnManager", "Error loading current profile: ${result.error}"
)
} }
} }
} }

@ -3,8 +3,7 @@
package com.tailscale.ipn.ui.service package com.tailscale.ipn.ui.service
import com.tailscale.ipn.ui.localapi.APIErrorVals import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.localapi.Result
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
@ -28,46 +27,46 @@ import com.tailscale.ipn.ui.model.Ipn
fun IpnModel.setWantRunning(wantRunning: Boolean, callback: (Result<Ipn.Prefs>) -> Unit) { fun IpnModel.setWantRunning(wantRunning: Boolean, callback: (Result<Ipn.Prefs>) -> Unit) {
Ipn.MaskedPrefs().WantRunning = wantRunning Ipn.MaskedPrefs().WantRunning = wantRunning
apiClient.editPrefs(Ipn.MaskedPrefs(), callback) Client(scope).editPrefs(Ipn.MaskedPrefs(), callback)
} }
fun IpnModel.toggleCorpDNS(callback: (Result<Ipn.Prefs>) -> Unit) { fun IpnModel.toggleCorpDNS(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs = prefs.value ?: run { val prefs = prefs.value ?: run {
callback(Result(Error(APIErrorVals.NO_PREFS.rawValue))) callback(Result.failure(Exception("no prefs")))
return@toggleCorpDNS return@toggleCorpDNS
} }
val prefsOut = Ipn.MaskedPrefs() val prefsOut = Ipn.MaskedPrefs()
prefsOut.CorpDNS = !prefs.CorpDNS prefsOut.CorpDNS = !prefs.CorpDNS
apiClient.editPrefs(prefsOut, callback) Client(scope).editPrefs(prefsOut, callback)
} }
fun IpnModel.toggleShieldsUp(callback: (Result<Ipn.Prefs>) -> Unit) { fun IpnModel.toggleShieldsUp(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs = prefs.value ?: run { val prefs = prefs.value ?: run {
callback(Result(Error(APIErrorVals.NO_PREFS.rawValue))) callback(Result.failure(Exception("no prefs")))
return@toggleShieldsUp return@toggleShieldsUp
} }
val prefsOut = Ipn.MaskedPrefs() val prefsOut = Ipn.MaskedPrefs()
prefsOut.ShieldsUp = !prefs.ShieldsUp prefsOut.ShieldsUp = !prefs.ShieldsUp
apiClient.editPrefs(prefsOut, callback) Client(scope).editPrefs(prefsOut, callback)
} }
fun IpnModel.setExitNodeId(id: String?, callback: (Result<Ipn.Prefs>) -> Unit) { fun IpnModel.setExitNodeId(id: String?, callback: (Result<Ipn.Prefs>) -> Unit) {
val prefsOut = Ipn.MaskedPrefs() val prefsOut = Ipn.MaskedPrefs()
prefsOut.ExitNodeID = id prefsOut.ExitNodeID = id
apiClient.editPrefs(prefsOut, callback) Client(scope).editPrefs(prefsOut, callback)
} }
fun IpnModel.toggleRouteAll(callback: (Result<Ipn.Prefs>) -> Unit) { fun IpnModel.toggleRouteAll(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs = prefs.value ?: run { val prefs = prefs.value ?: run {
callback(Result(Error(APIErrorVals.NO_PREFS.rawValue))) callback(Result.failure(Exception("no prefs")))
return@toggleRouteAll return@toggleRouteAll
} }
val prefsOut = Ipn.MaskedPrefs() val prefsOut = Ipn.MaskedPrefs()
prefsOut.RouteAll = !prefs.RouteAll prefsOut.RouteAll = !prefs.RouteAll
apiClient.editPrefs(prefsOut, callback) Client(scope).editPrefs(prefsOut, callback)
} }

@ -5,24 +5,19 @@ package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.ui.localapi.LocalApiClient import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class BugReportViewModel(localAPI: LocalApiClient) : ViewModel() { class BugReportViewModel : ViewModel() {
val bugReportID: StateFlow<String> = MutableStateFlow("") val bugReportID: StateFlow<String> = MutableStateFlow("")
init { init {
viewModelScope.launch { Client(viewModelScope).bugReportId { result ->
localAPI.getBugReportId { result.onSuccess { bugReportID.set(it) }
when (it.successful) { .onFailure { bugReportID.set("(Error fetching ID)") }
true -> bugReportID.set(it.success ?: "(Error fetching ID)")
false -> bugReportID.set("(Error fetching ID)")
}
}
} }
} }
} }

@ -7,6 +7,7 @@ package com.tailscale.ipn.ui.viewModel
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.service.IpnModel import com.tailscale.ipn.ui.service.IpnModel
import com.tailscale.ipn.ui.service.setExitNodeId import com.tailscale.ipn.ui.service.setExitNodeId
@ -14,7 +15,6 @@ import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import java.util.TreeMap import java.util.TreeMap
class ExitNodePickerViewModel(private val model: IpnModel, private val onNavigateHome: () -> Unit) : class ExitNodePickerViewModel(private val model: IpnModel, private val onNavigateHome: () -> Unit) :
@ -45,67 +45,64 @@ class ExitNodePickerViewModel(private val model: IpnModel, private val onNavigat
val anyActive: StateFlow<Boolean> = MutableStateFlow(false) val anyActive: StateFlow<Boolean> = MutableStateFlow(false)
init { init {
viewModelScope.launch { Client(viewModelScope).status { result ->
model.apiClient.getStatus { status -> result.onFailure {
when (status.successful) { Log.e(TAG, "getStatus: ${it.message}")
false -> Log.e(TAG, "getStatus: ${status.error}") }.onSuccess {
true -> status.success?.let { it -> it.Peer?.values?.let { peers ->
it.Peer?.values?.let { peers -> val allNodes = peers.filter { it.ExitNodeOption }.map {
val allNodes = peers.filter { it.ExitNodeOption }.map { ExitNode(
ExitNode( id = it.ID,
id = it.ID, label = it.DNSName,
label = it.DNSName, online = it.Online,
online = it.Online, selected = it.ExitNode,
selected = it.ExitNode, mullvad = it.DNSName.endsWith(".mullvad.ts.net."),
mullvad = it.DNSName.endsWith(".mullvad.ts.net."), priority = it.Location?.Priority ?: 0,
priority = it.Location?.Priority ?: 0, countryCode = it.Location?.CountryCode ?: "",
countryCode = it.Location?.CountryCode ?: "", country = it.Location?.Country ?: "",
country = it.Location?.Country ?: "", city = it.Location?.City ?: "",
city = it.Location?.City ?: "", )
) }
}
val tailnetNodes = allNodes.filter { !it.mullvad }
tailnetExitNodes.set(tailnetNodes.sortedWith { a, b ->
a.label.compareTo(
b.label
)
})
val mullvadExitNodes = allNodes.filter { val tailnetNodes = allNodes.filter { !it.mullvad }
// Pick all mullvad nodes that are online or the currently selected tailnetExitNodes.set(tailnetNodes.sortedWith { a, b ->
it.mullvad && (it.selected || it.online) a.label.compareTo(
}.groupBy { b.label
// Group by countryCode )
it.countryCode })
}.mapValues { (_, nodes) ->
// Group by city
nodes.groupBy {
it.city
}.mapValues { (_, nodes) ->
// Pick one node per city, either the selected one or the best
// available
nodes.sortedWith { a, b ->
if (a.selected && !b.selected) {
-1
} else if (b.selected && !a.selected) {
1
} else {
b.priority.compareTo(a.priority)
}
}.first()
}.values.sortedBy { it.city.lowercase() }
}
mullvadExitNodesByCountryCode.set(mullvadExitNodes)
val bestAvailableByCountry = mullvadExitNodes.mapValues { (_, nodes) -> val mullvadExitNodes = allNodes.filter {
nodes.minByOrNull { -1 * it.priority }!! // Pick all mullvad nodes that are online or the currently selected
} it.mullvad && (it.selected || it.online)
mullvadBestAvailableByCountry.set(bestAvailableByCountry) }.groupBy {
// Group by countryCode
it.countryCode
}.mapValues { (_, nodes) ->
// Group by city
nodes.groupBy {
it.city
}.mapValues { (_, nodes) ->
// Pick one node per city, either the selected one or the best
// available
nodes.sortedWith { a, b ->
if (a.selected && !b.selected) {
-1
} else if (b.selected && !a.selected) {
1
} else {
b.priority.compareTo(a.priority)
}
}.first()
}.values.sortedBy { it.city.lowercase() }
}
mullvadExitNodesByCountryCode.set(mullvadExitNodes)
anyActive.set(allNodes.any { it.selected }) val bestAvailableByCountry = mullvadExitNodes.mapValues { (_, nodes) ->
} nodes.minByOrNull { -1 * it.priority }!!
} }
mullvadBestAvailableByCountry.set(bestAvailableByCountry)
anyActive.set(allNodes.any { it.selected })
} }
} }
} }

@ -39,14 +39,13 @@ var shim struct {
jvm *jni.JVM jvm *jni.JVM
} }
//export Java_com_tailscale_ipn_ui_localapi_LocalApiClient_doRequest //export Java_com_tailscale_ipn_ui_localapi_Request_doRequest
func Java_com_tailscale_ipn_ui_localapi_LocalApiClient_doRequest( func Java_com_tailscale_ipn_ui_localapi_Request_doRequest(
env *C.JNIEnv, env *C.JNIEnv,
cls C.jclass, cls C.jclass,
jpath C.jstring,
jmethod C.jstring, jmethod C.jstring,
jbody C.jbyteArray, jpath C.jstring,
jcookie C.jstring) { jbody C.jbyteArray) {
defer func() { defer func() {
if p := recover(); p != nil { if p := recover(); p != nil {
@ -57,16 +56,16 @@ func Java_com_tailscale_ipn_ui_localapi_LocalApiClient_doRequest(
jenv := (*jni.Env)(unsafe.Pointer(env)) jenv := (*jni.Env)(unsafe.Pointer(env))
// The API Path
pathRef := jni.NewGlobalRef(jenv, jni.Object(jpath))
pathStr := jni.GoString(jenv, jni.String(pathRef))
defer jni.DeleteGlobalRef(jenv, pathRef)
// The HTTP verb // The HTTP verb
methodRef := jni.NewGlobalRef(jenv, jni.Object(jmethod)) methodRef := jni.NewGlobalRef(jenv, jni.Object(jmethod))
methodStr := jni.GoString(jenv, jni.String(methodRef)) methodStr := jni.GoString(jenv, jni.String(methodRef))
defer jni.DeleteGlobalRef(jenv, methodRef) defer jni.DeleteGlobalRef(jenv, methodRef)
// The API Path
pathRef := jni.NewGlobalRef(jenv, jni.Object(jpath))
pathStr := jni.GoString(jenv, jni.String(pathRef))
defer jni.DeleteGlobalRef(jenv, pathRef)
// 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))
bodyArray := jni.GetByteArrayElements(jenv, jni.ByteArray(bodyRef)) bodyArray := jni.GetByteArrayElements(jenv, jni.ByteArray(bodyRef))
@ -76,10 +75,9 @@ func Java_com_tailscale_ipn_ui_localapi_LocalApiClient_doRequest(
jrespBody := jni.NewByteArray(jenv, resp) jrespBody := jni.NewByteArray(jenv, resp)
respBody := jni.Value(jrespBody) respBody := jni.Value(jrespBody)
cookie := jni.Value(jcookie) onResponse := jni.GetMethodID(jenv, shim.clientClass, "onResponse", "([B)V")
onResponse := jni.GetMethodID(jenv, shim.clientClass, "onResponse", "([BLjava/lang/String;)V")
jni.CallVoidMethod(jenv, jni.Object(cls), onResponse, respBody, cookie) jni.CallVoidMethod(jenv, jni.Object(cls), onResponse, respBody)
} }
func doLocalAPIRequest(path string, method string, body []byte) []byte { func doLocalAPIRequest(path string, method string, body []byte) []byte {
@ -114,7 +112,7 @@ func ConfigureShim(jvm *jni.JVM, appCtx jni.Object, s *LocalAPIService, b *ipnlo
shim.service = s shim.service = s
shim.backend = b shim.backend = b
configureLocalApiJNIHandler(jvm, appCtx) configureLocalAPIJNIHandler(jvm, appCtx)
// Let the Kotlin side know we're ready to handle requests. // Let the Kotlin side know we're ready to handle requests.
jni.Do(jvm, func(env *jni.Env) error { jni.Do(jvm, func(env *jni.Env) error {
@ -130,12 +128,12 @@ func ConfigureShim(jvm *jni.JVM, appCtx jni.Object, s *LocalAPIService, b *ipnlo
} }
// 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 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.Request")
if err != nil { if err != nil {
return err return err
} }

Loading…
Cancel
Save