android: simplify local API client

Updates tailscale/corp#18202

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

@ -110,7 +110,7 @@ class MainActivity : ComponentActivity() {
)
}
composable("bugReport") {
BugReportView(BugReportViewModel(manager.apiClient))
BugReportView(BugReportViewModel())
}
composable("about") {
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
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.util.Log
import com.tailscale.ipn.App
import com.tailscale.ipn.IPNReceiver
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.notifier.Notifier
import kotlinx.coroutines.CoroutineScope
@ -25,11 +26,14 @@ interface IpnActions {
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)
var apiClient = LocalApiClient(scope)
var mdmSettings = MDMSettings()
val model = IpnModel(notifier, apiClient, scope)
val model = IpnModel(notifier, scope)
override fun startVPN() {
val context = App.getApplication().applicationContext
@ -46,19 +50,31 @@ class IpnManager(scope: CoroutineScope) : IpnActions {
}
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() {
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) {
apiClient.editPrefs(prefs) { result ->
result.success?.let {
Client(scope).editPrefs(prefs) { result ->
result.onSuccess {
callback(Result.success(true))
} ?: run {
callback(Result.failure(Throwable(result.error)))
}.onFailure {
callback(Result.failure(it))
}
}
}

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

@ -3,8 +3,7 @@
package com.tailscale.ipn.ui.service
import com.tailscale.ipn.ui.localapi.APIErrorVals
import com.tailscale.ipn.ui.localapi.Result
import com.tailscale.ipn.ui.localapi.Client
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) {
Ipn.MaskedPrefs().WantRunning = wantRunning
apiClient.editPrefs(Ipn.MaskedPrefs(), callback)
Client(scope).editPrefs(Ipn.MaskedPrefs(), callback)
}
fun IpnModel.toggleCorpDNS(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs = prefs.value ?: run {
callback(Result(Error(APIErrorVals.NO_PREFS.rawValue)))
callback(Result.failure(Exception("no prefs")))
return@toggleCorpDNS
}
val prefsOut = Ipn.MaskedPrefs()
prefsOut.CorpDNS = !prefs.CorpDNS
apiClient.editPrefs(prefsOut, callback)
Client(scope).editPrefs(prefsOut, callback)
}
fun IpnModel.toggleShieldsUp(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs = prefs.value ?: run {
callback(Result(Error(APIErrorVals.NO_PREFS.rawValue)))
callback(Result.failure(Exception("no prefs")))
return@toggleShieldsUp
}
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ShieldsUp = !prefs.ShieldsUp
apiClient.editPrefs(prefsOut, callback)
Client(scope).editPrefs(prefsOut, callback)
}
fun IpnModel.setExitNodeId(id: String?, callback: (Result<Ipn.Prefs>) -> Unit) {
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ExitNodeID = id
apiClient.editPrefs(prefsOut, callback)
Client(scope).editPrefs(prefsOut, callback)
}
fun IpnModel.toggleRouteAll(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs = prefs.value ?: run {
callback(Result(Error(APIErrorVals.NO_PREFS.rawValue)))
callback(Result.failure(Exception("no prefs")))
return@toggleRouteAll
}
val prefsOut = Ipn.MaskedPrefs()
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.viewModelScope
import com.tailscale.ipn.ui.localapi.LocalApiClient
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class BugReportViewModel(localAPI: LocalApiClient) : ViewModel() {
class BugReportViewModel : ViewModel() {
val bugReportID: StateFlow<String> = MutableStateFlow("")
init {
viewModelScope.launch {
localAPI.getBugReportId {
when (it.successful) {
true -> bugReportID.set(it.success ?: "(Error fetching ID)")
false -> bugReportID.set("(Error fetching ID)")
}
}
Client(viewModelScope).bugReportId { result ->
result.onSuccess { bugReportID.set(it) }
.onFailure { bugReportID.set("(Error fetching ID)") }
}
}
}

@ -7,6 +7,7 @@ package com.tailscale.ipn.ui.viewModel
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.service.IpnModel
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 kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import java.util.TreeMap
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)
init {
viewModelScope.launch {
model.apiClient.getStatus { status ->
when (status.successful) {
false -> Log.e(TAG, "getStatus: ${status.error}")
true -> status.success?.let { it ->
it.Peer?.values?.let { peers ->
val allNodes = peers.filter { it.ExitNodeOption }.map {
ExitNode(
id = it.ID,
label = it.DNSName,
online = it.Online,
selected = it.ExitNode,
mullvad = it.DNSName.endsWith(".mullvad.ts.net."),
priority = it.Location?.Priority ?: 0,
countryCode = it.Location?.CountryCode ?: "",
country = it.Location?.Country ?: "",
city = it.Location?.City ?: "",
)
}
val tailnetNodes = allNodes.filter { !it.mullvad }
tailnetExitNodes.set(tailnetNodes.sortedWith { a, b ->
a.label.compareTo(
b.label
)
})
Client(viewModelScope).status { result ->
result.onFailure {
Log.e(TAG, "getStatus: ${it.message}")
}.onSuccess {
it.Peer?.values?.let { peers ->
val allNodes = peers.filter { it.ExitNodeOption }.map {
ExitNode(
id = it.ID,
label = it.DNSName,
online = it.Online,
selected = it.ExitNode,
mullvad = it.DNSName.endsWith(".mullvad.ts.net."),
priority = it.Location?.Priority ?: 0,
countryCode = it.Location?.CountryCode ?: "",
country = it.Location?.Country ?: "",
city = it.Location?.City ?: "",
)
}
val mullvadExitNodes = allNodes.filter {
// Pick all mullvad nodes that are online or the currently selected
it.mullvad && (it.selected || it.online)
}.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)
val tailnetNodes = allNodes.filter { !it.mullvad }
tailnetExitNodes.set(tailnetNodes.sortedWith { a, b ->
a.label.compareTo(
b.label
)
})
val bestAvailableByCountry = mullvadExitNodes.mapValues { (_, nodes) ->
nodes.minByOrNull { -1 * it.priority }!!
}
mullvadBestAvailableByCountry.set(bestAvailableByCountry)
val mullvadExitNodes = allNodes.filter {
// Pick all mullvad nodes that are online or the currently selected
it.mullvad && (it.selected || it.online)
}.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
}
//export Java_com_tailscale_ipn_ui_localapi_LocalApiClient_doRequest
func Java_com_tailscale_ipn_ui_localapi_LocalApiClient_doRequest(
//export Java_com_tailscale_ipn_ui_localapi_Request_doRequest
func Java_com_tailscale_ipn_ui_localapi_Request_doRequest(
env *C.JNIEnv,
cls C.jclass,
jpath C.jstring,
jmethod C.jstring,
jbody C.jbyteArray,
jcookie C.jstring) {
jpath C.jstring,
jbody C.jbyteArray) {
defer func() {
if p := recover(); p != nil {
@ -57,16 +56,16 @@ func Java_com_tailscale_ipn_ui_localapi_LocalApiClient_doRequest(
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
methodRef := jni.NewGlobalRef(jenv, jni.Object(jmethod))
methodStr := jni.GoString(jenv, jni.String(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.
bodyRef := jni.NewGlobalRef(jenv, jni.Object(jbody))
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)
respBody := jni.Value(jrespBody)
cookie := jni.Value(jcookie)
onResponse := jni.GetMethodID(jenv, shim.clientClass, "onResponse", "([BLjava/lang/String;)V")
onResponse := jni.GetMethodID(jenv, shim.clientClass, "onResponse", "([B)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 {
@ -114,7 +112,7 @@ func ConfigureShim(jvm *jni.JVM, appCtx jni.Object, s *LocalAPIService, b *ipnlo
shim.service = s
shim.backend = b
configureLocalApiJNIHandler(jvm, appCtx)
configureLocalAPIJNIHandler(jvm, appCtx)
// Let the Kotlin side know we're ready to handle requests.
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.
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")
cl, err := jni.LoadClass(env, loader, "com.tailscale.ipn.ui.localapi.Request")
if err != nil {
return err
}

Loading…
Cancel
Save