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 2 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.IpnLocal
import com.tailscale.ipn.ui.model.IpnState
import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.util.InputStreamAdapter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -18,6 +19,8 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.serializer
import libtailscale.FilePart
import java.io.File
import java.nio.charset.Charset
import kotlin.reflect.KType
import kotlin.reflect.typeOf
@ -113,6 +116,34 @@ class Client(private val scope: CoroutineScope) {
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(
path: String,
body: ByteArray? = null,
@ -158,6 +189,22 @@ class Client(private val scope: CoroutineScope) {
.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(
path: String,
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 method: String,
path: String,
private val body: ByteArray? = null,
private val parts: FileParts? = null,
private val timeoutMillis: Long = 30000,
private val responseType: KType,
private val responseHandler: (Result<T>) -> Unit
@ -217,13 +265,16 @@ class Request<T>(
Log.d(TAG, "Executing request:${method}:${fullPath} on app $app")
try {
val resp =
app.callLocalAPI(
timeoutMillis, method, fullPath, body?.let { InputStreamAdapter(it.inputStream()) })
if (parts != null) app.callLocalAPIMultipart(timeoutMillis, method, fullPath, parts)
else
app.callLocalAPI(
timeoutMillis,
method,
fullPath,
body?.let { InputStreamAdapter(it.inputStream()) })
// TODO: use the streaming body for performance
Log.d(TAG, "Got Response")
// An empty body is a perfectly valid response and indicates success
val respData = resp.bodyBytes() ?: ByteArray(0)
Log.d(TAG, "Got response body")
val response: Result<T> =
when (responseType) {
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 LoginFinished: Empty.Message? = null,
val FilesWaiting: Empty.Message? = null,
val OutgoingFiles: List<OutgoingFile>? = null,
val State: Int? = null,
var Prefs: Prefs? = null,
var NetMap: Netmap.NetworkMap? = null,
@ -154,6 +155,19 @@ class Ipn {
var FinalPath: String? = 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 {

@ -40,6 +40,7 @@ object Notifier {
val vpnPermissionGranted: StateFlow<Boolean?> = MutableStateFlow(null)
val tileReady: 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 var manager: libtailscale.NotificationManager? = null
@ -68,12 +69,12 @@ object Notifier {
notify.BrowseToURL?.let(browseToURL::set)
notify.LoginFinished?.let { loginFinished.set(it.property) }
notify.Version?.let(version::set)
notify.OutgoingFiles?.let(outgoingFiles::set)
}
state.collect { currstate ->
readyToPrepareVPN.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) {
return null
}
return b.sliceArray(0..i)
return b.sliceArray(0 ..< i)
}
override fun close() {

@ -22,6 +22,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
@ -37,7 +38,8 @@ import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModelFactory
fun PeerDetails(
nav: BackNavigation,
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
->

@ -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.DisplayAddress
import com.tailscale.ipn.ui.util.TimeUtil
import java.io.File
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 {
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 info: List<PeerSettingInfo> = emptyList()

@ -13,7 +13,7 @@ require (
golang.org/x/mobile v0.0.0-20240319015410-c58ccf4b0c87
golang.org/x/sys v0.18.0
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 (

@ -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/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
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.20240319225125-6da1dc84de57/go.mod h1:cC0b0vYCoSDOLufJX5J5zDUrvV3lYwOLqlt9NW8y4cY=
tailscale.com v1.63.0-pre.0.20240324181545-f78928191539 h1:DBpvudmMiWINWePS9RdY+dST7xOideFf26TSgqsu2rQ=
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.
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
// updates. The given NotificationCallback's OnNotify function is invoked
// on every new ipn.Notify message. The returned NotificationManager
@ -92,6 +96,20 @@ type Application interface {
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.
type LocalAPIResponse interface {
StatusCode() int

@ -8,9 +8,14 @@ import (
"fmt"
"io"
"log"
"maps"
"mime/multipart"
"net"
"net/http"
"net/textproto"
"runtime/debug"
"strconv"
"strings"
"sync"
"time"
)
@ -22,9 +27,86 @@ import (
// Note - Response includes a response body available from the Body method, it
// is the caller's responsibility to close this.
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() {
if p := recover(); p != nil {
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())
log.Printf("panic in callLocalAPI %s: %s", p, debug.Stack())
panic(p)
}
}()
@ -38,7 +120,8 @@ func (app *App) CallLocalAPI(timeoutMillis int, method, endpoint string, body In
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 {
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)
resp.Flush()
pipeWriter.Close()
}()
select {
@ -127,7 +210,7 @@ func (r *Response) Flush() {
})
}
func adaptInputStream(in InputStream) io.Reader {
func adaptInputStream(in InputStream) io.ReadCloser {
if in == nil {
return nil
}
@ -147,3 +230,10 @@ func adaptInputStream(in InputStream) io.Reader {
}()
return r
}
// Below taken from Go stdlib
var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"")
func escapeQuotes(s string) string {
return quoteEscaper.Replace(s)
}

Loading…
Cancel
Save