diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index f6242769ed..5bfc09d42f 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -28,7 +28,6 @@ import androidx.security.crypto.MasterKey import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.MDMSettingsChangedReceiver import com.tailscale.ipn.ui.localapi.Client -import com.tailscale.ipn.ui.localapi.Request import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.notifier.FavoritesManager @@ -255,7 +254,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { fun startLibtailscale(directFileRoot: String, hardwareAttestation: Boolean) { app = Libtailscale.start(this.filesDir.absolutePath, directFileRoot, hardwareAttestation, this) ShareFileHelper.init(this, app, directFileRoot, applicationScope) - Request.setApp(app) Notifier.setApp(app) Notifier.start(applicationScope) } 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 index 146a1a0463..64749ce308 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt @@ -68,12 +68,12 @@ typealias PingResultHandler = (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) { +class Client( + private val scope: CoroutineScope, + private val appProvider: () -> libtailscale.Application = { App.get().getLibtailscaleApp() }, +) { private val TAG = Client::class.simpleName - // Access libtailscale.Application lazily - private val app: libtailscale.Application by lazy { App.get().getLibtailscaleApp() } - fun start(options: Ipn.Options, responseHandler: (Result) -> Unit) { val body = Json.encodeToString(options).toByteArray() return post(Endpoint.START, body, responseHandler = responseHandler) @@ -213,6 +213,7 @@ class Client(private val scope: CoroutineScope) { ) { Request( scope = scope, + appProvider = appProvider, method = "GET", path = path, body = body, @@ -229,6 +230,7 @@ class Client(private val scope: CoroutineScope) { ) { Request( scope = scope, + appProvider = appProvider, method = "PUT", path = path, body = body, @@ -246,6 +248,7 @@ class Client(private val scope: CoroutineScope) { ) { Request( scope = scope, + appProvider = appProvider, method = "POST", path = path, body = body, @@ -263,6 +266,7 @@ class Client(private val scope: CoroutineScope) { ) { Request( scope = scope, + appProvider = appProvider, method = "POST", path = path, parts = parts, @@ -280,6 +284,7 @@ class Client(private val scope: CoroutineScope) { ) { Request( scope = scope, + appProvider = appProvider, method = "PATCH", path = path, body = body, @@ -295,6 +300,7 @@ class Client(private val scope: CoroutineScope) { ) { Request( scope = scope, + appProvider = appProvider, method = "DELETE", path = path, responseType = typeOf(), @@ -304,8 +310,9 @@ class Client(private val scope: CoroutineScope) { } } -class Request( +private class Request( private val scope: CoroutineScope, + private val appProvider: () -> libtailscale.Application, private val method: String, path: String, private val body: ByteArray? = null, @@ -320,20 +327,14 @@ class Request( private const val TAG = "LocalAPIRequest" private val jsonDecoder = Json { ignoreUnknownKeys = true } - - private lateinit var app: libtailscale.Application - - @JvmStatic - fun setApp(newApp: libtailscale.Application) { - app = newApp - } } @OptIn(ExperimentalSerializationApi::class) fun execute() { scope.launch(Dispatchers.IO) { - TSLog.d(TAG, "Executing request:${method}:${fullPath} on app $app") try { + val app = appProvider() + TSLog.d(TAG, "Executing request:${method}:${fullPath} on app $app") val resp = if (parts != null) app.callLocalAPIMultipart( diff --git a/android/src/test/kotlin/com/tailscale/ipn/ui/localapi/ClientTest.kt b/android/src/test/kotlin/com/tailscale/ipn/ui/localapi/ClientTest.kt new file mode 100644 index 0000000000..50db892f68 --- /dev/null +++ b/android/src/test/kotlin/com/tailscale/ipn/ui/localapi/ClientTest.kt @@ -0,0 +1,81 @@ +// 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.IpnState +import com.tailscale.ipn.util.TSLog +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Before +import org.junit.Test +import org.mockito.MockedStatic +import org.mockito.Mockito +import org.mockito.kotlin.mock + +class ClientTest { + private lateinit var androidLog: MockedStatic + private lateinit var originalLogWrapper: TSLog.LibtailscaleWrapper + + @Before + fun setUp() { + androidLog = Mockito.mockStatic(Log::class.java) + originalLogWrapper = TSLog.libtailscaleWrapper + TSLog.libtailscaleWrapper = mock() + } + + @After + fun tearDown() { + TSLog.libtailscaleWrapper = originalLogWrapper + androidLog.close() + } + + @Test + fun constructingClientDoesNotResolveApp() = runTest { + var calls = 0 + Client(this) { + calls++ + throw IllegalStateException("Unexpected initialization") + } + assertEquals(0, calls) + } + + @Test + fun providerFailureIsDeliveredToCallback() = runBlocking { + val failure = IllegalStateException("Backend initialization failed") + val response = CompletableDeferred>() + val client = Client(this) { throw failure } + + client.status { response.complete(it) } + + val result = withTimeout(5_000) { response.await() } + assertSame(failure, result.exceptionOrNull()) + } + + @Test + fun providerIsResolvedForEachRequest() = runBlocking { + val failures = + listOf( + IllegalStateException("First backend"), + IllegalStateException("Second backend"), + ) + var calls = 0 + val client = Client(this) { throw failures[calls++] } + + suspend fun request(): Result { + val response = CompletableDeferred>() + client.status { response.complete(it) } + return withTimeout(5_000) { response.await() } + } + + assertSame(failures[0], request().exceptionOrNull()) + assertSame(failures[1], request().exceptionOrNull()) + assertEquals(2, calls) + } +}