From d42329e2e2044b7ceaee599f15a1ebf552de7fcb Mon Sep 17 00:00:00 2001 From: Percy Wegmann Date: Sun, 17 Mar 2024 14:23:51 -0500 Subject: [PATCH] android: simplify local API client Updates tailscale/corp#18202 Signed-off-by: Percy Wegmann --- .../java/com/tailscale/ipn/MainActivity.kt | 2 +- .../com/tailscale/ipn/ui/localapi/Client.kt | 230 ++++++++++++++++++ .../ipn/ui/localapi/LocalAPIClient.kt | 154 ------------ .../ipn/ui/localapi/LocalAPIRequest.kt | 173 ------------- .../com/tailscale/ipn/ui/localapi/Result.kt | 32 --- .../java/com/tailscale/ipn/ui/model/Types.kt | 6 - .../tailscale/ipn/ui/service/IpnManager.kt | 36 ++- .../com/tailscale/ipn/ui/service/IpnModel.kt | 28 +-- .../tailscale/ipn/ui/service/PrefsEditor.kt | 19 +- .../ipn/ui/viewModel/BugReportViewModel.kt | 15 +- .../ui/viewModel/ExitNodePickerViewModel.kt | 113 +++++---- cmd/localapiservice/localapishim.go | 30 ++- 12 files changed, 352 insertions(+), 486 deletions(-) create mode 100644 android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt delete mode 100644 android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIClient.kt delete mode 100644 android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIRequest.kt delete mode 100644 android/src/main/java/com/tailscale/ipn/ui/localapi/Result.kt diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index cce883d..1f6d789 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -110,7 +110,7 @@ class MainActivity : ComponentActivity() { ) } composable("bugReport") { - BugReportView(BugReportViewModel(manager.apiClient)) + BugReportView(BugReportViewModel()) } composable("about") { AboutView() diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt new file mode 100644 index 0000000..8800e7d --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt @@ -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) -> Unit +typealias BugReportIdHandler = (Result) -> Unit +typealias PrefsHandler = (Result) -> 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) -> Unit + ) { + val body = Json.encodeToString(prefs).toByteArray() + return patch(Endpoint.PREFS, body, responseHandler = responseHandler) + } + + fun profiles(responseHandler: (Result>) -> Unit) { + get(Endpoint.PROFILES, responseHandler = responseHandler) + } + + fun currentProfile(responseHandler: (Result) -> Unit) { + return get(Endpoint.PROFILES_CURRENT, responseHandler = responseHandler) + } + + fun startLoginInteractive(responseHandler: (Result) -> Unit) { + return post(Endpoint.LOGIN_INTERACTIVE, responseHandler = responseHandler) + } + + fun logout(responseHandler: (Result) -> Unit) { + return post(Endpoint.LOGOUT, responseHandler = responseHandler) + } + + private inline fun get( + path: String, body: ByteArray? = null, noinline responseHandler: (Result) -> Unit + ) { + Request( + scope = scope, + method = "GET", + path = path, + body = body, + responseType = typeOf(), + responseHandler = responseHandler + ).execute() + } + + private inline fun put( + path: String, body: ByteArray? = null, noinline responseHandler: (Result) -> Unit + ) { + Request( + scope = scope, + method = "PUT", + path = path, + body = body, + responseType = typeOf(), + responseHandler = responseHandler + ).execute() + } + + private inline fun post( + path: String, body: ByteArray? = null, noinline responseHandler: (Result) -> Unit + ) { + Request( + scope = scope, + method = "POST", + path = path, + body = body, + responseType = typeOf(), + responseHandler = responseHandler + ).execute() + } + + private inline fun patch( + path: String, body: ByteArray? = null, noinline responseHandler: (Result) -> Unit + ) { + Request( + scope = scope, + method = "PATCH", + path = path, + body = body, + responseType = typeOf(), + responseHandler = responseHandler + ).execute() + } + + private inline fun delete( + path: String, noinline responseHandler: (Result) -> Unit + ) { + Request( + scope = scope, + method = "DELETE", + path = path, + responseType = typeOf(), + responseHandler = responseHandler + ).execute() + } +} + +class Request( + private val scope: CoroutineScope, + private val method: String, + path: String, + private val body: ByteArray? = null, + private val responseType: KType, + private val responseHandler: (Result) -> Unit +) { + private val fullPath = "/localapi/v0/$path" + + companion object { + private const val TAG = "LocalAPIRequest" + + private val jsonDecoder = Json { ignoreUnknownKeys = true } + private val isReady = CompletableDeferred() + + // 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 = when (responseType) { + typeOf() -> 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(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) + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIClient.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIClient.kt deleted file mode 100644 index a8f2656..0000000 --- a/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIClient.kt +++ /dev/null @@ -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) -> Unit -typealias BugReportIdHandler = (Result) -> Unit -typealias PrefsHandler = (Result) -> Unit - -class LocalApiClient(private val scope: CoroutineScope) { - init { - Log.d("LocalApiClient", "LocalApiClient created") - } - - companion object { - val isReady = CompletableDeferred() - - // 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 executeRequest(request: LocalAPIRequest) { - 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>() - - // 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) -> Unit) { - val req = LocalAPIRequest.editPrefs(prefs, responseHandler) - executeRequest(req) - } - - fun getProfiles(responseHandler: (Result>) -> Unit) { - val req = LocalAPIRequest.profiles(responseHandler) - executeRequest(req) - } - - fun getCurrentProfile(responseHandler: (Result) -> Unit) { - val req = LocalAPIRequest.currentProfile(responseHandler) - executeRequest(req) - } - - 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(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(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") - } -} diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIRequest.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIRequest.kt deleted file mode 100644 index 4264357..0000000 --- a/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIRequest.kt +++ /dev/null @@ -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( - 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 get(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) = - LocalAPIRequest( - method = "GET", path = path, body = body, parser = parser - ) - - fun put(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) = - LocalAPIRequest( - method = "PUT", path = path, body = body, parser = parser - ) - - fun post(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) = - LocalAPIRequest( - method = "POST", path = path, body = body, parser = parser - ) - - fun patch(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) = - LocalAPIRequest( - method = "PATCH", path = path, body = body, parser = parser - ) - - fun status(responseHandler: StatusResponseHandler): LocalAPIRequest { - return get(Endpoint.STATUS) { resp -> - responseHandler(decode(resp)) - } - } - - fun bugReportId(responseHandler: BugReportIdHandler): LocalAPIRequest { - return post(Endpoint.BUG_REPORT) { resp -> - responseHandler(parseString(resp)) - } - } - - fun prefs(responseHandler: PrefsHandler): LocalAPIRequest { - return get(Endpoint.PREFS) { resp -> - responseHandler(decode(resp)) - } - } - - fun editPrefs( - prefs: Ipn.MaskedPrefs, responseHandler: (Result) -> Unit - ): LocalAPIRequest { - val body = Json.encodeToString(prefs).toByteArray() - return patch(Endpoint.PREFS, body) { resp -> - responseHandler(decode(resp)) - } - } - - fun profiles(responseHandler: (Result>) -> Unit): LocalAPIRequest> { - return get(Endpoint.PROFILES) { resp -> - responseHandler(decode>(resp)) - } - } - - fun currentProfile(responseHandler: (Result) -> Unit): LocalAPIRequest { - return get(Endpoint.PROFILES_CURRENT) { resp -> - responseHandler(decode(resp)) - } - } - - fun startLoginInteractive(responseHandler: (Result) -> Unit): LocalAPIRequest { - return post(Endpoint.LOGIN_INTERACTIVE) { resp -> - responseHandler(parseString(resp)) - } - } - - fun logout(responseHandler: (Result) -> Unit): LocalAPIRequest { - 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(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 { - 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 decode(respData: ByteArray): Result { - return try { - val message = decoder.decodeFromStream(respData.inputStream()) - Result(message) - } catch (e: Exception) { - Result(parseError(respData)) - } - } - } -} diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/Result.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/Result.kt deleted file mode 100644 index ee6a31d..0000000 --- a/android/src/main/java/com/tailscale/ipn/ui/localapi/Result.kt +++ /dev/null @@ -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 { - 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 -} \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt index 529ce10..67ab28c 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt @@ -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) -} \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/service/IpnManager.kt b/android/src/main/java/com/tailscale/ipn/ui/service/IpnManager.kt index a7aed2d..2599398 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/service/IpnManager.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/service/IpnManager.kt @@ -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)) } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/service/IpnModel.kt b/android/src/main/java/com/tailscale/ipn/ui/service/IpnModel.kt index 175f645..a714db9 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/service/IpnModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/service/IpnModel.kt @@ -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 = mutableListOf() val state: StateFlow = 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}") } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/service/PrefsEditor.kt b/android/src/main/java/com/tailscale/ipn/ui/service/PrefsEditor.kt index bf5c506..2964cf0 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/service/PrefsEditor.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/service/PrefsEditor.kt @@ -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) -> Unit) { Ipn.MaskedPrefs().WantRunning = wantRunning - apiClient.editPrefs(Ipn.MaskedPrefs(), callback) + Client(scope).editPrefs(Ipn.MaskedPrefs(), callback) } fun IpnModel.toggleCorpDNS(callback: (Result) -> 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) -> 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) -> Unit) { val prefsOut = Ipn.MaskedPrefs() prefsOut.ExitNodeID = id - apiClient.editPrefs(prefsOut, callback) + Client(scope).editPrefs(prefsOut, callback) } fun IpnModel.toggleRouteAll(callback: (Result) -> 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) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/BugReportViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/BugReportViewModel.kt index a873de0..188e3cc 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/BugReportViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/BugReportViewModel.kt @@ -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 = 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)") } } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt index 888e7fb..59e605b 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt @@ -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 = 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 }) } } } diff --git a/cmd/localapiservice/localapishim.go b/cmd/localapiservice/localapishim.go index e06a502..95148f4 100644 --- a/cmd/localapiservice/localapishim.go +++ b/cmd/localapiservice/localapishim.go @@ -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 }