Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions android/src/main/java/com/tailscale/ipn/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
27 changes: 14 additions & 13 deletions android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,12 @@ typealias PingResultHandler = (Result<IpnState.PingResult>) -> 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>) -> Unit) {
val body = Json.encodeToString(options).toByteArray()
return post(Endpoint.START, body, responseHandler = responseHandler)
Expand Down Expand Up @@ -213,6 +213,7 @@ class Client(private val scope: CoroutineScope) {
) {
Request(
scope = scope,
appProvider = appProvider,
method = "GET",
path = path,
body = body,
Expand All @@ -229,6 +230,7 @@ class Client(private val scope: CoroutineScope) {
) {
Request(
scope = scope,
appProvider = appProvider,
method = "PUT",
path = path,
body = body,
Expand All @@ -246,6 +248,7 @@ class Client(private val scope: CoroutineScope) {
) {
Request(
scope = scope,
appProvider = appProvider,
method = "POST",
path = path,
body = body,
Expand All @@ -263,6 +266,7 @@ class Client(private val scope: CoroutineScope) {
) {
Request(
scope = scope,
appProvider = appProvider,
method = "POST",
path = path,
parts = parts,
Expand All @@ -280,6 +284,7 @@ class Client(private val scope: CoroutineScope) {
) {
Request(
scope = scope,
appProvider = appProvider,
method = "PATCH",
path = path,
body = body,
Expand All @@ -295,6 +300,7 @@ class Client(private val scope: CoroutineScope) {
) {
Request(
scope = scope,
appProvider = appProvider,
method = "DELETE",
path = path,
responseType = typeOf<T>(),
Expand All @@ -304,8 +310,9 @@ class Client(private val scope: CoroutineScope) {
}
}

class Request<T>(
private class Request<T>(
private val scope: CoroutineScope,
private val appProvider: () -> libtailscale.Application,
private val method: String,
path: String,
private val body: ByteArray? = null,
Expand All @@ -320,20 +327,14 @@ class Request<T>(
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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Log>
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<Result<IpnState.Status>>()
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<IpnState.Status> {
val response = CompletableDeferred<Result<IpnState.Status>>()
client.status { response.complete(it) }
return withTimeout(5_000) { response.await() }
}

assertSame(failures[0], request().exceptionOrNull())
assertSame(failures[1], request().exceptionOrNull())
assertEquals(2, calls)
}
}
Loading