android: add Client.postMultipart

Supports multipart requests to localapi

Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
pull/241/head
Percy Wegmann 3 months ago committed by Percy Wegmann
parent 6a875e8854
commit 28d0ab4dd6

@ -9,6 +9,7 @@ import com.tailscale.ipn.ui.model.Errors
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.IpnState import com.tailscale.ipn.ui.model.IpnState
import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.util.InputStreamAdapter import com.tailscale.ipn.ui.util.InputStreamAdapter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -18,6 +19,8 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.serializer import kotlinx.serialization.serializer
import libtailscale.FilePart
import java.io.File
import java.nio.charset.Charset import java.nio.charset.Charset
import kotlin.reflect.KType import kotlin.reflect.KType
import kotlin.reflect.typeOf import kotlin.reflect.typeOf
@ -113,6 +116,34 @@ class Client(private val scope: CoroutineScope) {
get(Endpoint.TKA_STATUS, responseHandler = responseHandler) get(Endpoint.TKA_STATUS, responseHandler = responseHandler)
} }
fun putTaildropFiles(
peerId: StableNodeID,
files: Collection<File>,
responseHandler: (Result<String>) -> Unit
) {
val manifest = Json.encodeToString(files.map { it.name to it.length() }.toMap())
val manifestPart = FilePart()
manifestPart.body = InputStreamAdapter(manifest.byteInputStream(Charset.defaultCharset()))
manifestPart.filename = "manifest.json"
manifestPart.contentType = "application/json"
manifestPart.contentLength = manifest.length.toLong()
val parts = mutableListOf(manifestPart)
parts.addAll(
files.map { file ->
val part = FilePart()
part.filename = file.name
part.contentLength = file.length()
part.body = InputStreamAdapter(file.inputStream())
part
})
return postMultipart(
"${Endpoint.FILE_PUT}/${peerId}",
FileParts(parts),
responseHandler,
)
}
private inline fun <reified T> get( private inline fun <reified T> get(
path: String, path: String,
body: ByteArray? = null, body: ByteArray? = null,
@ -158,6 +189,22 @@ class Client(private val scope: CoroutineScope) {
.execute() .execute()
} }
private inline fun <reified T> postMultipart(
path: String,
parts: FileParts,
noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "POST",
path = path,
parts = parts,
timeoutMillis = 24 * 60 * 60 * 1000, // 24 hours
responseType = typeOf<T>(),
responseHandler = responseHandler)
.execute()
}
private inline fun <reified T> patch( private inline fun <reified T> patch(
path: String, path: String,
body: ByteArray? = null, body: ByteArray? = null,
@ -187,11 +234,12 @@ class Client(private val scope: CoroutineScope) {
} }
} }
class Request<T>( public class Request<T>(
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val method: String, private val method: String,
path: String, path: String,
private val body: ByteArray? = null, private val body: ByteArray? = null,
private val parts: FileParts? = null,
private val timeoutMillis: Long = 30000, private val timeoutMillis: Long = 30000,
private val responseType: KType, private val responseType: KType,
private val responseHandler: (Result<T>) -> Unit private val responseHandler: (Result<T>) -> Unit
@ -217,13 +265,16 @@ class Request<T>(
Log.d(TAG, "Executing request:${method}:${fullPath} on app $app") Log.d(TAG, "Executing request:${method}:${fullPath} on app $app")
try { try {
val resp = val resp =
if (parts != null) app.callLocalAPIMultipart(timeoutMillis, method, fullPath, parts)
else
app.callLocalAPI( app.callLocalAPI(
timeoutMillis, method, fullPath, body?.let { InputStreamAdapter(it.inputStream()) }) timeoutMillis,
method,
fullPath,
body?.let { InputStreamAdapter(it.inputStream()) })
// TODO: use the streaming body for performance // TODO: use the streaming body for performance
Log.d(TAG, "Got Response")
// An empty body is a perfectly valid response and indicates success // An empty body is a perfectly valid response and indicates success
val respData = resp.bodyBytes() ?: ByteArray(0) val respData = resp.bodyBytes() ?: ByteArray(0)
Log.d(TAG, "Got response body")
val response: Result<T> = val response: Result<T> =
when (responseType) { when (responseType) {
typeOf<String>() -> Result.success(respData.decodeToString() as T) typeOf<String>() -> Result.success(respData.decodeToString() as T)
@ -258,3 +309,13 @@ class Request<T>(
} }
} }
} }
class FileParts(private val parts: List<FilePart>) : libtailscale.FileParts {
override fun get(i: Int): FilePart {
return parts[i]
}
override fun len(): Int {
return parts.size
}
}

@ -32,6 +32,7 @@ class Ipn {
val ErrMessage: String? = null, val ErrMessage: String? = null,
val LoginFinished: Empty.Message? = null, val LoginFinished: Empty.Message? = null,
val FilesWaiting: Empty.Message? = null, val FilesWaiting: Empty.Message? = null,
val OutgoingFiles: List<OutgoingFile>? = null,
val State: Int? = null, val State: Int? = null,
var Prefs: Prefs? = null, var Prefs: Prefs? = null,
var NetMap: Netmap.NetworkMap? = null, var NetMap: Netmap.NetworkMap? = null,
@ -154,6 +155,19 @@ class Ipn {
var FinalPath: String? = null, var FinalPath: String? = null,
val Done: Boolean? = null, val Done: Boolean? = null,
) )
@Serializable
data class OutgoingFile(
val Name: String,
val PeerID: StableNodeID,
val Started: String,
val DeclaredSize: Long,
val Sent: Long,
val PartialPath: String? = null,
var FinalPath: String? = null,
val Finished: Boolean,
val Succeeded: Boolean,
)
} }
class Persist { class Persist {

@ -40,6 +40,7 @@ object Notifier {
val vpnPermissionGranted: StateFlow<Boolean?> = MutableStateFlow(null) val vpnPermissionGranted: StateFlow<Boolean?> = MutableStateFlow(null)
val tileReady: StateFlow<Boolean> = MutableStateFlow(false) val tileReady: StateFlow<Boolean> = MutableStateFlow(false)
val readyToPrepareVPN: StateFlow<Boolean> = MutableStateFlow(false) val readyToPrepareVPN: StateFlow<Boolean> = MutableStateFlow(false)
val outgoingFiles: StateFlow<List<Ipn.OutgoingFile>?> = MutableStateFlow(null)
private lateinit var app: libtailscale.Application private lateinit var app: libtailscale.Application
private var manager: libtailscale.NotificationManager? = null private var manager: libtailscale.NotificationManager? = null
@ -68,12 +69,12 @@ object Notifier {
notify.BrowseToURL?.let(browseToURL::set) notify.BrowseToURL?.let(browseToURL::set)
notify.LoginFinished?.let { loginFinished.set(it.property) } notify.LoginFinished?.let { loginFinished.set(it.property) }
notify.Version?.let(version::set) notify.Version?.let(version::set)
notify.OutgoingFiles?.let(outgoingFiles::set)
} }
state.collect { currstate -> state.collect { currstate ->
readyToPrepareVPN.set(currstate > Ipn.State.Stopped) readyToPrepareVPN.set(currstate > Ipn.State.Stopped)
tileReady.set(currstate >= Ipn.State.Stopped) tileReady.set(currstate >= Ipn.State.Stopped)
} }
Log.d(TAG, "Stopped")
} }
} }

@ -12,7 +12,7 @@ class InputStreamAdapter(private val inputStream: InputStream) : libtailscale.In
if (i == -1) { if (i == -1) {
return null return null
} }
return b.sliceArray(0..i) return b.sliceArray(0 ..< i)
} }
override fun close() { override fun close() {

@ -22,6 +22,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
@ -37,7 +38,8 @@ import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModelFactory
fun PeerDetails( fun PeerDetails(
nav: BackNavigation, nav: BackNavigation,
nodeId: String, nodeId: String,
model: PeerDetailsViewModel = viewModel(factory = PeerDetailsViewModelFactory(nodeId)) model: PeerDetailsViewModel =
viewModel(factory = PeerDetailsViewModelFactory(nodeId, LocalContext.current.filesDir))
) { ) {
Scaffold(topBar = { Header(title = R.string.peer_details, onBack = nav.onBack) }) { innerPadding Scaffold(topBar = { Header(title = R.string.peer_details, onBack = nav.onBack) }) { innerPadding
-> ->

@ -13,16 +13,18 @@ import com.tailscale.ipn.ui.theme.ts_color_light_green
import com.tailscale.ipn.ui.util.ComposableStringFormatter import com.tailscale.ipn.ui.util.ComposableStringFormatter
import com.tailscale.ipn.ui.util.DisplayAddress import com.tailscale.ipn.ui.util.DisplayAddress
import com.tailscale.ipn.ui.util.TimeUtil import com.tailscale.ipn.ui.util.TimeUtil
import java.io.File
data class PeerSettingInfo(val titleRes: Int, val value: ComposableStringFormatter) data class PeerSettingInfo(val titleRes: Int, val value: ComposableStringFormatter)
class PeerDetailsViewModelFactory(private val nodeId: StableNodeID) : ViewModelProvider.Factory { class PeerDetailsViewModelFactory(private val nodeId: StableNodeID, private val filesDir: File) :
ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return PeerDetailsViewModel(nodeId) as T return PeerDetailsViewModel(nodeId, filesDir) as T
} }
} }
class PeerDetailsViewModel(val nodeId: StableNodeID) : IpnViewModel() { class PeerDetailsViewModel(val nodeId: StableNodeID, val filesDir: File) : IpnViewModel() {
var addresses: List<DisplayAddress> = emptyList() var addresses: List<DisplayAddress> = emptyList()
var info: List<PeerSettingInfo> = emptyList() var info: List<PeerSettingInfo> = emptyList()

@ -13,7 +13,7 @@ require (
golang.org/x/mobile v0.0.0-20240319015410-c58ccf4b0c87 golang.org/x/mobile v0.0.0-20240319015410-c58ccf4b0c87
golang.org/x/sys v0.18.0 golang.org/x/sys v0.18.0
inet.af/netaddr v0.0.0-20220617031823-097006376321 inet.af/netaddr v0.0.0-20220617031823-097006376321
tailscale.com v1.63.0-pre.0.20240319225125-6da1dc84de57 tailscale.com v1.63.0-pre.0.20240324181545-f78928191539
) )
require ( require (

@ -666,5 +666,5 @@ sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU= sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU=
tailscale.com v1.63.0-pre.0.20240319225125-6da1dc84de57 h1:AkU/jPJff3JoDr3gbM8Aut/GK7zriPTOYL7IY99YTXc= tailscale.com v1.63.0-pre.0.20240324181545-f78928191539 h1:DBpvudmMiWINWePS9RdY+dST7xOideFf26TSgqsu2rQ=
tailscale.com v1.63.0-pre.0.20240319225125-6da1dc84de57/go.mod h1:cC0b0vYCoSDOLufJX5J5zDUrvV3lYwOLqlt9NW8y4cY= tailscale.com v1.63.0-pre.0.20240324181545-f78928191539/go.mod h1:cC0b0vYCoSDOLufJX5J5zDUrvV3lYwOLqlt9NW8y4cY=

@ -85,6 +85,10 @@ type Application interface {
// without having to call over the network. // without having to call over the network.
CallLocalAPI(timeoutMillis int, method, endpoint string, body InputStream) (LocalAPIResponse, error) CallLocalAPI(timeoutMillis int, method, endpoint string, body InputStream) (LocalAPIResponse, error)
// CallLocalAPIMultipart is like CallLocalAPI, but instead of a single body,
// it accepts multiple FileParts that get encoded as multipart/form-data.
CallLocalAPIMultipart(timeoutMillis int, method, endpoint string, parts FileParts) (LocalAPIResponse, error)
// WatchNotifications provides a mechanism for subscribing to ipn.Notify // WatchNotifications provides a mechanism for subscribing to ipn.Notify
// updates. The given NotificationCallback's OnNotify function is invoked // updates. The given NotificationCallback's OnNotify function is invoked
// on every new ipn.Notify message. The returned NotificationManager // on every new ipn.Notify message. The returned NotificationManager
@ -92,6 +96,20 @@ type Application interface {
WatchNotifications(mask int, cb NotificationCallback) NotificationManager WatchNotifications(mask int, cb NotificationCallback) NotificationManager
} }
// FileParts is an array of multiple FileParts.
type FileParts interface {
Len() int32
Get(int32) *FilePart
}
// FilePart is a multipart file that can be submitted via CallLocalAPIMultiPart.
type FilePart struct {
ContentLength int64
Filename string
Body InputStream
ContentType string // optional MIME content type
}
// LocalAPIResponse is a response to a localapi call, analogous to an http.Response. // LocalAPIResponse is a response to a localapi call, analogous to an http.Response.
type LocalAPIResponse interface { type LocalAPIResponse interface {
StatusCode() int StatusCode() int

@ -8,9 +8,14 @@ import (
"fmt" "fmt"
"io" "io"
"log" "log"
"maps"
"mime/multipart"
"net" "net"
"net/http" "net/http"
"net/textproto"
"runtime/debug" "runtime/debug"
"strconv"
"strings"
"sync" "sync"
"time" "time"
) )
@ -22,9 +27,86 @@ import (
// Note - Response includes a response body available from the Body method, it // Note - Response includes a response body available from the Body method, it
// is the caller's responsibility to close this. // is the caller's responsibility to close this.
func (app *App) CallLocalAPI(timeoutMillis int, method, endpoint string, body InputStream) (LocalAPIResponse, error) { func (app *App) CallLocalAPI(timeoutMillis int, method, endpoint string, body InputStream) (LocalAPIResponse, error) {
return app.callLocalAPI(timeoutMillis, method, endpoint, nil, adaptInputStream(body))
}
// CallLocalAPIMultipart is like CallLocalAPI, but instead of uploading a
// generic body, it uploads a multipart/form-encoded body consisting of the
// supplied parts.
func (app *App) CallLocalAPIMultipart(timeoutMillis int, method, endpoint string, parts FileParts) (LocalAPIResponse, error) {
defer func() { defer func() {
if p := recover(); p != nil { if p := recover(); p != nil {
log.Printf("panic in CallLocalAPI %s: %s", p, debug.Stack()) log.Printf("panic in CallLocalAPIMultipart %s: %s", p, debug.Stack())
panic(p)
}
}()
r, w := io.Pipe()
defer r.Close()
mw := multipart.NewWriter(w)
header := make(http.Header)
header.Set("Content-Type", mw.FormDataContentType())
resultCh := make(chan interface{})
go func() {
resp, err := app.callLocalAPI(timeoutMillis, method, endpoint, header, r)
if err != nil {
resultCh <- err
} else {
resultCh <- resp
}
}()
go func() {
for i := int32(0); i < parts.Len(); i++ {
part := parts.Get(i)
contentType := "application/octet-stream"
if part.ContentType != "" {
contentType = part.ContentType
}
header := make(textproto.MIMEHeader, 3)
header.Set("Content-Disposition",
fmt.Sprintf(`form-data; name="%s"; filename="%s"`,
escapeQuotes("file"), escapeQuotes(part.Filename)))
header.Set("Content-Type", contentType)
header.Set("Content-Length", strconv.FormatInt(part.ContentLength, 10))
p, err := mw.CreatePart(header)
if err != nil {
resultCh <- fmt.Errorf("CreatePart: %w", err)
return
}
_, err = io.Copy(p, adaptInputStream(part.Body))
if err != nil {
resultCh <- fmt.Errorf("Copy: %w", err)
return
}
}
err := mw.Close()
if err != nil {
resultCh <- fmt.Errorf("Close MultipartWriter: %w", err)
}
err = w.Close()
if err != nil {
resultCh <- fmt.Errorf("Close Writer: %w", err)
}
}()
result := <-resultCh
switch t := result.(type) {
case LocalAPIResponse:
return t, nil
case error:
return nil, t
default:
panic("unexpected result type, this shouldn't happen")
}
}
func (app *App) callLocalAPI(timeoutMillis int, method, endpoint string, header http.Header, body io.ReadCloser) (LocalAPIResponse, error) {
defer func() {
if p := recover(); p != nil {
log.Printf("panic in callLocalAPI %s: %s", p, debug.Stack())
panic(p) panic(p)
} }
}() }()
@ -38,7 +120,8 @@ func (app *App) CallLocalAPI(timeoutMillis int, method, endpoint string, body In
defer body.Close() defer body.Close()
} }
req, err := http.NewRequestWithContext(ctx, method, endpoint, adaptInputStream(body)) req, err := http.NewRequestWithContext(ctx, method, endpoint, body)
maps.Copy(req.Header, header)
if err != nil { if err != nil {
return nil, fmt.Errorf("error creating new request for %s: %w", endpoint, err) return nil, fmt.Errorf("error creating new request for %s: %w", endpoint, err)
} }
@ -63,9 +146,9 @@ func (app *App) CallLocalAPI(timeoutMillis int, method, endpoint string, body In
} }
}() }()
defer pipeWriter.Close()
app.localAPIHandler.ServeHTTP(resp, req) app.localAPIHandler.ServeHTTP(resp, req)
resp.Flush() resp.Flush()
pipeWriter.Close()
}() }()
select { select {
@ -127,7 +210,7 @@ func (r *Response) Flush() {
}) })
} }
func adaptInputStream(in InputStream) io.Reader { func adaptInputStream(in InputStream) io.ReadCloser {
if in == nil { if in == nil {
return nil return nil
} }
@ -147,3 +230,10 @@ func adaptInputStream(in InputStream) io.Reader {
}() }()
return r return r
} }
// Below taken from Go stdlib
var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"")
func escapeQuotes(s string) string {
return quoteEscaper.Replace(s)
}

Loading…
Cancel
Save