android: more code review suggestions

updates ENG-2084

Suggestions for PR #179

Signed-off-by: Percy Wegmann <percy@tailscale.com>
oxtoacart/android-nextgen-infra
Percy Wegmann 3 months ago committed by Jonathan Nobels
parent 5204448f1e
commit d5ad4b31d1

1
.gitignore vendored

@ -32,3 +32,4 @@ tailscale.jks
#IDE #IDE
.vscode .vscode
.idea

@ -6,6 +6,7 @@ DEBUG_APK=tailscale-debug.apk
RELEASE_AAB=tailscale-release.aab RELEASE_AAB=tailscale-release.aab
APPID=com.tailscale.ipn APPID=com.tailscale.ipn
AAR=android_legacy/libs/ipn.aar AAR=android_legacy/libs/ipn.aar
AAR_NEXTGEN=android/libs/ipn.aar
KEYSTORE=tailscale.jks KEYSTORE=tailscale.jks
KEYSTORE_ALIAS=tailscale KEYSTORE_ALIAS=tailscale
TAILSCALE_VERSION=$(shell ./version/tailscale-version.sh 200) TAILSCALE_VERSION=$(shell ./version/tailscale-version.sh 200)
@ -129,14 +130,18 @@ androidpath:
toolchain: $(TOOLCHAINDIR)/bin/go toolchain: $(TOOLCHAINDIR)/bin/go
android/libs: $(AAR): toolchain checkandroidsdk
mkdir -p android_legacy/libs @mkdir -p android_legacy/libs && \
$(AAR): toolchain checkandroidsdk android/libs
go run gioui.org/cmd/gogio \ go run gioui.org/cmd/gogio \
-ldflags "-X tailscale.com/version.longStamp=$(VERSIONNAME) -X tailscale.com/version.shortStamp=$(VERSIONNAME_SHORT) -X tailscale.com/version.gitCommitStamp=$(TAILSCALE_COMMIT) -X tailscale.com/version.extraGitCommitStamp=$(OUR_VERSION)" \ -ldflags "-X tailscale.com/version.longStamp=$(VERSIONNAME) -X tailscale.com/version.shortStamp=$(VERSIONNAME_SHORT) -X tailscale.com/version.gitCommitStamp=$(TAILSCALE_COMMIT) -X tailscale.com/version.extraGitCommitStamp=$(OUR_VERSION)" \
-buildmode archive -target android -appid $(APPID) -tags novulkan,tailscale_go -o $@ github.com/tailscale/tailscale-android/cmd/tailscale -buildmode archive -target android -appid $(APPID) -tags novulkan,tailscale_go -o $@ github.com/tailscale/tailscale-android/cmd/tailscale
$(AAR_NEXTGEN): $(AAR)
@mkdir -p android/libs && \
cp $(AAR) $(AAR_NEXTGEN)
lib: $(AAR_NEXTGEN)
# tailscale-debug.apk builds a debuggable APK with the Google Play SDK. # tailscale-debug.apk builds a debuggable APK with the Google Play SDK.
$(DEBUG_APK): $(AAR) $(DEBUG_APK): $(AAR)
(cd android_legacy && ./gradlew test assemblePlayDebug) (cd android_legacy && ./gradlew test assemblePlayDebug)
@ -180,4 +185,4 @@ clean:
-rm -rf android_legacy/build $(DEBUG_APK) $(RELEASE_AAB) $(AAR) tailscale-fdroid.apk -rm -rf android_legacy/build $(DEBUG_APK) $(RELEASE_AAB) $(AAR) tailscale-fdroid.apk
-pkill -f gradle -pkill -f gradle
.PHONY: all clean install android_legacy/lib $(DEBUG_APK) $(RELEASE_AAB) $(AAR) release bump_version dockershell .PHONY: all clean install android_legacy/lib $(DEBUG_APK) $(RELEASE_AAB) $(AAR) release bump_version dockershell lib

@ -5,23 +5,22 @@
package com.tailscale.ipn.ui.localapi package com.tailscale.ipn.ui.localapi
import android.util.Log import android.util.Log
import com.tailscale.ipn.ui.model.* 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.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.Job
typealias StatusResponseHandler = (Result<IpnState.Status>) -> Unit typealias StatusResponseHandler = (Result<IpnState.Status>) -> Unit
typealias BugReportIdHandler = (Result<BugReportID>) -> Unit typealias BugReportIdHandler = (Result<BugReportID>) -> Unit
typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit
class LocalApiClient { class LocalApiClient(private val scope: CoroutineScope) {
constructor() {
Log.d("LocalApiClient", "LocalApiClient created")
}
companion object { companion object {
private val _isReady = MutableStateFlow(false) private val _isReady = MutableStateFlow(false)
@ -29,6 +28,7 @@ class LocalApiClient {
// Called by the backend when the localAPI is ready to accept requests. // Called by the backend when the localAPI is ready to accept requests.
@JvmStatic @JvmStatic
@Suppress("unused")
fun onReady() { fun onReady() {
_isReady.value = true _isReady.value = true
Log.d("LocalApiClient", "LocalApiClient is ready") Log.d("LocalApiClient", "LocalApiClient is ready")
@ -46,73 +46,57 @@ class LocalApiClient {
// body: The body of the request. // body: The body of the request.
// cookie: A unique identifier for this request. This is used map responses to // cookie: A unique identifier for this request. This is used map responses to
// the corresponding request. Cookies must be unique for each request. // the corresponding request. Cookies must be unique for each request.
external fun doRequest(request: String, method: String, body: String, cookie: String) private external fun doRequest(request: String, method: String, body: ByteArray?, cookie: String)
protected val scope = CoroutineScope(Dispatchers.IO + Job()) private fun <T> executeRequest(request: LocalAPIRequest<T>) {
fun <T> executeRequest(request: LocalAPIRequest<T>) {
scope.launch { scope.launch {
isReady.first { it == true } isReady.first { it }
Log.d("LocalApiClient", "Executing request:${request.method}:${request.path}") Log.d("LocalApiClient", "Executing request:${request.method}:${request.path}")
addRequest(request) requests[request.cookie] = request
// The jni handler will treat the empty string in the body as null. doRequest(request.path, request.method, request.body, request.cookie)
val body = request.body ?: ""
doRequest(request.path, request.method, body, request.cookie)
} }
} }
// This is called from the JNI layer to publish localAPIResponses. This should execute on the // This is called from the JNI layer to publish localAPIResponses. This should execute on the
// same thread that called doRequest. // same thread that called doRequest.
fun onResponse(response: String, cookie: String) { @Suppress("unused")
val request = requests[cookie] fun onResponse(response: ByteArray, cookie: String) {
requests.remove(cookie)?.let { request ->
request?.let {
Log.d("LocalApiClient", "Reponse for request:${request.path} cookie:${request.cookie}") Log.d("LocalApiClient", "Reponse for request:${request.path} cookie:${request.cookie}")
// The response handler will invoked internally by the request parser // The response handler will invoked internally by the request parser
request.parser(response) request.parser(response)
removeRequest(cookie) } ?: { Log.e("LocalApiClient", "Received response for unknown request: $cookie") }
}
?: { Log.e("LocalApiClient", "Received response for unknown request: ${cookie}") }
} }
// Tracks in-flight requests and their callback handlers by cookie. This should // Tracks in-flight requests and their callback handlers by cookie. This should
// always be manipulated via the addRequest and removeRequest methods. // always be manipulated via the addRequest and removeRequest methods.
private var requests = HashMap<String, LocalAPIRequest<*>>() private var requests = ConcurrentHashMap<String, LocalAPIRequest<*>>()
private var requestLock = Any()
fun addRequest(request: LocalAPIRequest<*>) {
synchronized(requestLock) { requests[request.cookie] = request }
}
fun removeRequest(cookie: String) {
synchronized(requestLock) { requests.remove(cookie) }
}
// localapi Invocations // localapi Invocations
fun getStatus(responseHandler: StatusResponseHandler) { fun getStatus(responseHandler: StatusResponseHandler) {
val req = LocalAPIRequest.status(responseHandler) val req = LocalAPIRequest.status(responseHandler)
executeRequest<IpnState.Status>(req) executeRequest(req)
} }
fun getBugReportId(responseHandler: BugReportIdHandler) { fun getBugReportId(responseHandler: BugReportIdHandler) {
val req = LocalAPIRequest.bugReportId(responseHandler) val req = LocalAPIRequest.bugReportId(responseHandler)
executeRequest<BugReportID>(req) executeRequest(req)
} }
fun getPrefs(responseHandler: PrefsHandler) { fun getPrefs(responseHandler: PrefsHandler) {
val req = LocalAPIRequest.prefs(responseHandler) val req = LocalAPIRequest.prefs(responseHandler)
executeRequest<Ipn.Prefs>(req) executeRequest(req)
} }
fun getProfiles(responseHandler: (Result<List<IpnLocal.LoginProfile>>) -> Unit) { fun getProfiles(responseHandler: (Result<List<IpnLocal.LoginProfile>>) -> Unit) {
val req = LocalAPIRequest.profiles(responseHandler) val req = LocalAPIRequest.profiles(responseHandler)
executeRequest<List<IpnLocal.LoginProfile>>(req) executeRequest(req)
} }
fun getCurrentProfile(responseHandler: (Result<IpnLocal.LoginProfile>) -> Unit) { fun getCurrentProfile(responseHandler: (Result<IpnLocal.LoginProfile>) -> Unit) {
val req = LocalAPIRequest.currentProfile(responseHandler) val req = LocalAPIRequest.currentProfile(responseHandler)
executeRequest<IpnLocal.LoginProfile>(req) executeRequest(req)
} }
// (jonathan) TODO: A (likely) exhaustive list of localapi endpoints required for // (jonathan) TODO: A (likely) exhaustive list of localapi endpoints required for
@ -141,5 +125,7 @@ class LocalApiClient {
// verifyDeepling // verifyDeepling
// ping // ping
// setTailFSFileServerAddress // setTailFSFileServerAddress
init {
Log.d("LocalApiClient", "LocalApiClient created")
}
} }

@ -4,9 +4,15 @@
package com.tailscale.ipn.ui.localapi package com.tailscale.ipn.ui.localapi
import com.tailscale.ipn.ui.model.* import com.tailscale.ipn.ui.model.BugReportID
import kotlinx.serialization.decodeFromString 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.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import java.util.UUID
private object Endpoint { private object Endpoint {
const val DEBUG = "debug" const val DEBUG = "debug"
@ -41,9 +47,8 @@ private object Endpoint {
// //
// (jonathan) TODO: Audit local API for all of the possible error results and clean // (jonathan) TODO: Audit local API for all of the possible error results and clean
// it up if possible. // it up if possible.
enum class APIErrorVals(val rawValue: String) { enum class APIErrorVals(private val rawValue: String) {
UNPARSEABLE_RESPONSE("Unparseable localAPI response"), UNPARSEABLE_RESPONSE("Unparseable localAPI response"), NOT_READY("Not Ready");
NOT_READY("Not Ready");
fun toError(): Error { fun toError(): Error {
return Error(rawValue) return Error(rawValue)
@ -51,49 +56,32 @@ enum class APIErrorVals(val rawValue: String) {
} }
class LocalAPIRequest<T>( class LocalAPIRequest<T>(
path: String, path: String,
val method: String, val method: String,
val body: String? = null, val body: ByteArray? = null,
val parser: (String) -> Unit, val parser: (ByteArray) -> Unit,
) { ) {
val path = "/localapi/v0/$path" val path = "/localapi/v0/$path"
val cookie = UUID.randomUUID().toString()
companion object { companion object {
val cookieLock = Any()
var cookieCounter: Int = 0
val decoder = Json { ignoreUnknownKeys = true } val decoder = Json { ignoreUnknownKeys = true }
fun <T> get(path: String, body: String? = null, parser: (String) -> Unit) = fun <T> get(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) =
LocalAPIRequest<T>( LocalAPIRequest<T>(
method = "GET", method = "GET", path = path, body = body, parser = parser
path = path,
body = body,
parser = parser
) )
fun <T> put(path: String, body: String? = null, parser: (String) -> Unit) = fun <T> put(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) =
LocalAPIRequest<T>( LocalAPIRequest<T>(
method = "PUT", method = "PUT", path = path, body = body, parser = parser
path = path,
body = body,
parser = parser
) )
fun <T> post(path: String, body: String? = null, parser: (String) -> Unit) = private fun <T> post(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) =
LocalAPIRequest<T>( LocalAPIRequest<T>(
method = "POST", method = "POST", path = path, body = body, parser = parser
path = path,
body = body,
parser = parser
) )
fun getCookie(): String {
synchronized(cookieLock) {
cookieCounter += 1
return cookieCounter.toString()
}
}
fun status(responseHandler: StatusResponseHandler): LocalAPIRequest<IpnState.Status> { fun status(responseHandler: StatusResponseHandler): LocalAPIRequest<IpnState.Status> {
return get(Endpoint.STATUS) { resp -> return get(Endpoint.STATUS) { resp ->
responseHandler(decode<IpnState.Status>(resp)) responseHandler(decode<IpnState.Status>(resp))
@ -125,33 +113,33 @@ class LocalAPIRequest<T>(
} }
// Check if the response was a generic error // Check if the response was a generic error
fun parseError(respData: String): Error { @OptIn(ExperimentalSerializationApi::class)
try { fun parseError(respData: ByteArray): Error {
val err = Json.decodeFromString<Errors.GenericError>(respData) return try {
return Error(err.error) val err = Json.decodeFromStream<Errors.GenericError>(respData.inputStream())
Error(err.error)
} catch (e: Exception) { } catch (e: Exception) {
return Error(APIErrorVals.UNPARSEABLE_RESPONSE.toError()) Error(APIErrorVals.UNPARSEABLE_RESPONSE.toError())
} }
} }
// Handles responses that are raw strings. Returns an error result if the string // Handles responses that are raw strings. Returns an error result if the string
// is empty // is empty
fun parseString(respData: String): Result<String> { private fun parseString(respData: ByteArray): Result<String> {
return if (respData.length > 0) Result(respData) return if (respData.isNotEmpty()) Result(respData.decodeToString())
else Result(APIErrorVals.UNPARSEABLE_RESPONSE.toError()) else Result(APIErrorVals.UNPARSEABLE_RESPONSE.toError())
} }
// Attempt to decode the response into the expected type. If that fails, then try // Attempt to decode the response into the expected type. If that fails, then try
// parsing as an error. // parsing as an error.
inline fun <reified T> decode(respData: String): Result<T> { @OptIn(ExperimentalSerializationApi::class)
try { private inline fun <reified T> decode(respData: ByteArray): Result<T> {
val message = decoder.decodeFromString<T>(respData) return try {
return Result(message) val message = decoder.decodeFromStream<T>(respData.inputStream())
Result(message)
} catch (e: Exception) { } catch (e: Exception) {
return Result(parseError(respData)) Result(parseError(respData))
} }
} }
} }
val cookie: String = getCookie()
} }

@ -4,27 +4,26 @@
package com.tailscale.ipn.ui.service package com.tailscale.ipn.ui.service
import android.util.Log
import com.tailscale.ipn.ui.localapi.LocalApiClient import com.tailscale.ipn.ui.localapi.LocalApiClient
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
class IpnManager { class IpnManager {
var notifier = Notifier() private var notifier = Notifier()
var apiClient = LocalApiClient() private var scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
val model: IpnModel private var apiClient = LocalApiClient(scope)
private val model = IpnModel(notifier, apiClient, scope)
constructor() {
model = IpnModel(notifier, apiClient)
}
// We share a single instance of the IPNManager across the entire application. // We share a single instance of the IPNManager across the entire application.
companion object { companion object {
@Volatile @Volatile
private var instance: IpnManager? = null private var instance: IpnManager? = null
fun getInstance() = fun getInstance() = instance ?: synchronized(this) {
instance ?: synchronized(this) { instance ?: IpnManager().also { instance = it }
instance ?: IpnManager().also { instance = it } }
}
} }
} }

@ -6,31 +6,22 @@ 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.LocalApiClient
import com.tailscale.ipn.ui.model.* import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class IpnModel { class IpnModel(
protected val scope = CoroutineScope(Dispatchers.Default + Job()) notifier: Notifier,
var notifierSessions: MutableList<String> = mutableListOf() private val apiClient: LocalApiClient,
scope: CoroutineScope
val apiClient: LocalApiClient ) {
private var notifierSessions: MutableList<String> = mutableListOf()
constructor(notifier: Notifier, apiClient: LocalApiClient) {
Log.d("IpnModel", "IpnModel created")
this.apiClient = apiClient
val session = notifier.watchAll { n -> onNotifyChange(n) }
notifierSessions.add(session)
scope.launch { loadUserProfiles() }
}
private val _state: MutableStateFlow<Ipn.State?> = MutableStateFlow(null) private val _state: MutableStateFlow<Ipn.State?> = MutableStateFlow(null)
private val _netmap: MutableStateFlow<Netmap.NetworkMap?> = MutableStateFlow(null) private val _netmap: MutableStateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
@ -43,7 +34,7 @@ class IpnModel {
private val _loggedInUser: MutableStateFlow<IpnLocal.LoginProfile?> = MutableStateFlow(null) private val _loggedInUser: MutableStateFlow<IpnLocal.LoginProfile?> = MutableStateFlow(null)
private val _loginProfiles: MutableStateFlow<List<IpnLocal.LoginProfile>?> = private val _loginProfiles: MutableStateFlow<List<IpnLocal.LoginProfile>?> =
MutableStateFlow(null) MutableStateFlow(null)
val state: StateFlow<Ipn.State?> = _state val state: StateFlow<Ipn.State?> = _state
@ -57,7 +48,7 @@ class IpnModel {
val loggedInUser: StateFlow<IpnLocal.LoginProfile?> = _loggedInUser val loggedInUser: StateFlow<IpnLocal.LoginProfile?> = _loggedInUser
val loginProfiles: StateFlow<List<IpnLocal.LoginProfile>?> = _loginProfiles val loginProfiles: StateFlow<List<IpnLocal.LoginProfile>?> = _loginProfiles
var isUsingExitNode: Boolean = false val isUsingExitNode: Boolean
get() { get() {
return prefs.value != null return prefs.value != null
} }
@ -66,16 +57,16 @@ class IpnModel {
// Backend Observation // Backend Observation
private suspend fun loadUserProfiles() { private suspend fun loadUserProfiles() {
LocalApiClient.isReady.first { it == true } LocalApiClient.isReady.first { it }
apiClient.getProfiles { result -> apiClient.getProfiles { result ->
result.success?.let { users -> _loginProfiles.value = users } result.success?.let { users -> _loginProfiles.value = users }
?: run { Log.e("IpnManager", "Error loading profiles: ${result.error}") } ?: run { Log.e("IpnManager", "Error loading profiles: ${result.error}") }
} }
apiClient.getCurrentProfile { result -> apiClient.getCurrentProfile { result ->
result.success?.let { user -> _loggedInUser.value = user } result.success?.let { user -> _loggedInUser.value = user }
?: run { Log.e("IpnManager", "Error loading profiles: ${result.error}") } ?: run { Log.e("IpnManager", "Error loading profiles: ${result.error}") }
} }
} }
@ -97,4 +88,11 @@ class IpnModel {
notify.Version?.let { version -> _version.value = version } notify.Version?.let { version -> _version.value = version }
} }
init {
Log.d("IpnModel", "IpnModel created")
val session = notifier.watchAll { n -> onNotifyChange(n) }
notifierSessions.add(session)
scope.launch { loadUserProfiles() }
}
} }

@ -5,11 +5,11 @@
package localapiservice package localapiservice
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"io" "io"
"log" "log"
"strings"
"time" "time"
"unsafe" "unsafe"
@ -45,7 +45,7 @@ func Java_com_tailscale_ipn_ui_localapi_LocalApiClient_doRequest(
cls C.jclass, cls C.jclass,
jpath C.jstring, jpath C.jstring,
jmethod C.jstring, jmethod C.jstring,
jbody C.jstring, jbody C.jbyteArray,
jcookie C.jstring) { jcookie C.jstring) {
jenv := (*jni.Env)(unsafe.Pointer(env)) jenv := (*jni.Env)(unsafe.Pointer(env))
@ -62,20 +62,20 @@ func Java_com_tailscale_ipn_ui_localapi_LocalApiClient_doRequest(
// 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))
bodyStr := jni.GoString(jenv, jni.String(bodyRef)) bodyArray := jni.GetByteArrayElements(jenv, jni.ByteArray(bodyRef))
defer jni.DeleteGlobalRef(jenv, bodyRef) defer jni.DeleteGlobalRef(jenv, bodyRef)
resp := doLocalAPIRequest(pathStr, methodStr, bodyStr) resp := doLocalAPIRequest(pathStr, methodStr, bodyArray)
jrespBody := jni.JavaString(jenv, resp) jrespBody := jni.JavaString(jenv, resp)
respBody := jni.Value(jrespBody) respBody := jni.Value(jrespBody)
cookie := jni.Value(jcookie) cookie := jni.Value(jcookie)
onResponse := jni.GetMethodID(jenv, shim.clientClass, "onResponse", "(Ljava/lang/String;Ljava/lang/String;)V") onResponse := jni.GetMethodID(jenv, shim.clientClass, "onResponse", "(Lbyte[];Ljava/lang/String;)V")
jni.CallVoidMethod(jenv, jni.Object(cls), onResponse, respBody, cookie) jni.CallVoidMethod(jenv, jni.Object(cls), onResponse, respBody, cookie)
} }
func doLocalAPIRequest(path string, method string, body string) string { func doLocalAPIRequest(path string, method string, body []byte) string {
if shim.service == nil { if shim.service == nil {
return "{\"error\":\"Not Ready\"}" return "{\"error\":\"Not Ready\"}"
} }
@ -84,7 +84,7 @@ func doLocalAPIRequest(path string, method string, body string) string {
defer cancel() defer cancel()
var reader io.Reader = nil var reader io.Reader = nil
if len(body) > 0 { if len(body) > 0 {
reader = strings.NewReader(body) reader = bytes.NewReader(body)
} }
r, err := shim.service.Call(ctx, method, path, reader) r, err := shim.service.Call(ctx, method, path, reader)

Loading…
Cancel
Save