android/ui: add transfer ID to outgoing file transfers

Helps track status of transfers.

Updates #ENG-2868

Signed-off-by: Percy Wegmann <percy@tailscale.com>
ox/taildrop
Percy Wegmann 2 months ago
parent 29bed882f7
commit 0e1f675375
No known key found for this signature in database
GPG Key ID: 29D8CDEB4C13D48B

@ -47,6 +47,12 @@ 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.notifier.Notifier
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import libtailscale.Libtailscale
import java.io.File
import java.io.IOException
import java.net.InetAddress
@ -54,15 +60,9 @@ import java.net.NetworkInterface
import java.security.GeneralSecurityException
import java.util.Locale
import java.util.Objects
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import libtailscale.Libtailscale
class App : Application(), libtailscale.AppContext {
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
companion object {
const val STATUS_CHANNEL_ID = "tailscale-status"

@ -12,7 +12,7 @@ import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.lifecycle.lifecycleScope
import com.tailscale.ipn.ui.model.FileTransfer
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.theme.AppTheme
import com.tailscale.ipn.ui.util.set
@ -24,11 +24,13 @@ import kotlinx.coroutines.flow.StateFlow
class ShareActivity : ComponentActivity() {
private val TAG = ShareActivity::class.simpleName
private val transfers: StateFlow<List<FileTransfer>> = MutableStateFlow(emptyList())
private val requestedTransfers: StateFlow<List<Ipn.OutgoingFile>> = MutableStateFlow(emptyList())
override fun onCreate(state: Bundle?) {
super.onCreate(state)
setContent { AppTheme { TaildropView(transfers) } }
setContent {
AppTheme { TaildropView(requestedTransfers, (application as App).applicationScope) }
}
}
override fun onStart() {
@ -80,7 +82,7 @@ class ShareActivity : ComponentActivity() {
}
}
val pendingFiles: List<FileTransfer> =
val pendingFiles: List<Ipn.OutgoingFile> =
uris?.filterNotNull()?.mapNotNull {
contentResolver?.query(it, null, null, null, null)?.let { c ->
val nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
@ -89,7 +91,9 @@ class ShareActivity : ComponentActivity() {
val name = c.getString(nameCol)
val size = c.getLong(sizeCol)
c.close()
FileTransfer(name, size, it)
val file = Ipn.OutgoingFile(Name = name, DeclaredSize = size)
file.uri = it
file
}
} ?: emptyList()
@ -97,6 +101,6 @@ class ShareActivity : ComponentActivity() {
Log.e(TAG, "Share failure - no files extracted from intent")
}
transfers.set(pendingFiles)
requestedTransfers.set(pendingFiles)
}
}

@ -7,17 +7,11 @@ import android.content.Context
import android.util.Log
import com.tailscale.ipn.ui.model.BugReportID
import com.tailscale.ipn.ui.model.Errors
import com.tailscale.ipn.ui.model.FileTransfer
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 java.net.URLEncoder
import java.nio.charset.Charset
import java.nio.file.Files.delete
import kotlin.reflect.KType
import kotlin.reflect.typeOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -27,6 +21,9 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.serializer
import libtailscale.FilePart
import java.nio.charset.Charset
import kotlin.reflect.KType
import kotlin.reflect.typeOf
private object Endpoint {
const val DEBUG = "debug"
@ -128,12 +125,10 @@ class Client(private val scope: CoroutineScope) {
fun putTaildropFiles(
context: Context,
peerId: StableNodeID,
files: Collection<FileTransfer>,
files: Collection<Ipn.OutgoingFile>,
responseHandler: (Result<String>) -> Unit
) {
val manifest =
Json.encodeToString(
files.map { URLEncoder.encode(it.filename, "utf-8") to it.size }.toMap())
val manifest = Json.encodeToString(files)
val manifestPart = FilePart()
manifestPart.body = InputStreamAdapter(manifest.byteInputStream(Charset.defaultCharset()))
manifestPart.filename = "manifest.json"
@ -143,14 +138,13 @@ class Client(private val scope: CoroutineScope) {
try {
parts.addAll(
files.map { file ->
val stream = context.contentResolver.openInputStream(file.uri)
if (stream == null) {
throw Exception("Error opening file stream")
}
val stream =
context.contentResolver.openInputStream(file.uri)
?: throw Exception("Error opening file stream")
val part = FilePart()
part.filename = URLEncoder.encode(file.filename, "utf-8")
part.contentLength = file.size
part.filename = file.Name
part.contentLength = file.DeclaredSize
part.body = InputStreamAdapter(stream)
part
})

@ -1,9 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.model
import android.net.Uri
// Encapsulates a uri based file transfer for Taildrop.
data class FileTransfer(val filename: String, val size: Long, val uri: Uri)

@ -3,7 +3,10 @@
package com.tailscale.ipn.ui.model
import android.net.Uri
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import java.util.UUID
class Ipn {
@ -158,16 +161,25 @@ class Ipn {
@Serializable
data class OutgoingFile(
val ID: String = "",
val Name: String,
val PeerID: StableNodeID,
val Started: String,
val PeerID: StableNodeID = "",
val Started: String = "",
val DeclaredSize: Long,
val Sent: Long,
val Sent: Long = 0L,
val PartialPath: String? = null,
var FinalPath: String? = null,
val Finished: Boolean,
val Succeeded: Boolean,
)
val Finished: Boolean = false,
val Succeeded: Boolean = false,
) {
@Transient lateinit var uri: Uri // only used on client
fun prepare(peerId: StableNodeID): OutgoingFile {
val f = copy(ID = UUID.randomUUID().toString(), PeerID = peerId)
f.uri = uri
return f
}
}
@Serializable data class FileTarget(var Node: Tailcfg.Node, var PeerAPIURL: String)
}

@ -28,18 +28,20 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.FileTransfer
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.TaildropViewModel
import com.tailscale.ipn.ui.viewModel.TaildropViewModelFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
@Composable
fun TaildropView(
transfersIn: StateFlow<List<FileTransfer>>,
viewModel: TaildropViewModel = viewModel(factory = TaildropViewModelFactory(transfersIn))
requestedTransfers: StateFlow<List<Ipn.OutgoingFile>>,
applicationScope: CoroutineScope,
viewModel: TaildropViewModel =
viewModel(factory = TaildropViewModelFactory(requestedTransfers, applicationScope))
) {
Scaffold(topBar = { Header(R.string.share) }) { paddingInsets ->
val showDialog = viewModel.showDialog.collectAsState().value
@ -48,13 +50,12 @@ fun TaildropView(
showDialog?.let { ErrorDialog(type = it, action = { viewModel.showDialog.set(null) }) }
Column(modifier = Modifier.padding(paddingInsets)) {
val transfers = viewModel.transfers.collectAsState().value
val state = viewModel.state.collectAsState().value
FileShareHeader(fileTransfers = transfers, totalSize = viewModel.totalSize)
FileShareHeader(
fileTransfers = requestedTransfers.collectAsState().value,
totalSize = viewModel.totalSize)
Spacer(modifier = Modifier.size(8.dp))
when (state) {
when (viewModel.state.collectAsState().value) {
Ipn.State.Running -> {
val peers = viewModel.myPeers.collectAsState().value
val context = LocalContext.current
@ -129,7 +130,7 @@ fun FileShareConnectView(onToggle: () -> Unit) {
}
@Composable
fun FileShareHeader(fileTransfers: List<FileTransfer>, totalSize: Long) {
fun FileShareHeader(fileTransfers: List<Ipn.OutgoingFile>, totalSize: Long) {
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconForTransfer(fileTransfers)
@ -142,7 +143,7 @@ fun FileShareHeader(fileTransfers: List<FileTransfer>, totalSize: Long) {
false -> {
when (fileTransfers.size) {
1 -> Text(fileTransfers[0].filename, style = MaterialTheme.typography.titleMedium)
1 -> Text(fileTransfers[0].Name, style = MaterialTheme.typography.titleMedium)
else ->
Text(
stringResource(R.string.file_count, fileTransfers.size),
@ -159,7 +160,7 @@ fun FileShareHeader(fileTransfers: List<FileTransfer>, totalSize: Long) {
}
@Composable
fun IconForTransfer(transfers: List<FileTransfer>) {
fun IconForTransfer(transfers: List<Ipn.OutgoingFile>) {
// (jonathan) TODO: Thumbnails?
when (transfers.size) {
0 ->

@ -18,27 +18,33 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.FileTransfer
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.view.ActivityIndicator
import com.tailscale.ipn.ui.view.CheckedIndicator
import com.tailscale.ipn.ui.view.ErrorDialogType
import java.net.URLEncoder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
class TaildropViewModelFactory(private val transfers: StateFlow<List<FileTransfer>>) :
ViewModelProvider.Factory {
class TaildropViewModelFactory(
private val requestedTransfers: StateFlow<List<Ipn.OutgoingFile>>,
private val applicationScope: CoroutineScope
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return TaildropViewModel(transfers) as T
return TaildropViewModel(requestedTransfers, applicationScope) as T
}
}
class TaildropViewModel(val transfers: StateFlow<List<FileTransfer>>) : IpnViewModel() {
class TaildropViewModel(
val requestedTransfers: StateFlow<List<Ipn.OutgoingFile>>,
private val applicationScope: CoroutineScope
) : IpnViewModel() {
// Represents the state of a file transfer
enum class TransferState {
@ -50,18 +56,17 @@ class TaildropViewModel(val transfers: StateFlow<List<FileTransfer>>) : IpnViewM
// The overall VPN state
val state = Notifier.state
// Map of outgoing files to peer ID. This is the full state of the outgoing files.
private val outgoing: StateFlow<Map<String, List<Ipn.OutgoingFile>>> =
MutableStateFlow(emptyMap())
// List of any nodes that have a file transfer pending FOR THE CURRENT SESSION
// This is used to filter outgoingFiles to ensure we only render the transfer state
// for things that are currently displayed.
private val pending: StateFlow<Set<String>> = MutableStateFlow(emptySet())
// Set of all nodes for which we've requested a file transfer. This is used to prevent us from
// request a transfer to the same peer twice.
private val selectedPeers: StateFlow<Set<StableNodeID>> = MutableStateFlow(emptySet())
// Set of OutgoingFile.IDs that we're currently transferring.
private val currentTransferIDs: StateFlow<Set<String>> = MutableStateFlow(emptySet())
// Flow of Ipn.OutgoingFiles with updated statuses for every entry in transferWithStatuses.
private val transfers: StateFlow<List<Ipn.OutgoingFile>> = MutableStateFlow(emptyList())
// The total size of all pending files.
var totalSize: Long = 0
get() = transfers.value.sumOf { it.size }
val totalSize: Long
get() = requestedTransfers.value.sumOf { it.DeclaredSize }
// The list of peers that we can share with. This includes only the nodes belonging to the user
// and excludes the current node. Sorted by online devices first, and offline second,
@ -83,35 +88,27 @@ class TaildropViewModel(val transfers: StateFlow<List<FileTransfer>>) : IpnViewM
viewModelScope.launch {
// Map the outgoing files by their PeerId since we need to display them for each peer
// We only need to track files which are pending send, everything else is irrelevant.
Notifier.outgoingFiles.collect { outgoingFiles ->
val outgoingMap: MutableMap<String, List<Ipn.OutgoingFile>> = mutableMapOf()
val currentFiles = transfers.value.map { URLEncoder.encode(it.filename, "utf-8") }
outgoingFiles?.let { files ->
files
.filter { currentFiles.contains(it.Name) && pending.value.contains(it.PeerID) }
.forEach {
val list = outgoingMap.getOrDefault(it.PeerID, emptyList()).toMutableList()
list += it
outgoingMap[it.PeerID] = list
}
Log.d("TaildropViewModel", "Outgoing files: $outgoingMap")
outgoing.set(outgoingMap)
} ?: run { outgoing.set(emptyMap()) }
}
Notifier.outgoingFiles
.combine(currentTransferIDs) { outgoingFiles, ongoingIDs ->
Pair(outgoingFiles, ongoingIDs)
}
.collect { (outgoingFiles, ongoingIDs) ->
outgoingFiles?.let {
transfers.set(outgoingFiles.filter { ongoingIDs.contains(it.ID) })
} ?: run { transfers.set(emptyList()) }
}
}
// Whenever our files list changes, we need to reset the outgoings files map and
// any pending requests. The user has changed the files they're attempting to share.
viewModelScope.launch {
transfers.collect {
pending.set(emptySet())
outgoing.set(emptyMap())
requestedTransfers.collect {
// This means that we're processing a new share intent, clear current state
selectedPeers.set(emptySet())
currentTransferIDs.set(emptySet())
}
}
}
// Calculates the overall progress for a set of outoing files
// Calculates the overall progress for a set of outgoing files
private fun progress(transfers: List<Ipn.OutgoingFile>): Double {
val total = transfers.sumOf { it.DeclaredSize }.toDouble()
val sent = transfers.sumOf { it.Sent }.toDouble()
@ -122,15 +119,10 @@ class TaildropViewModel(val transfers: StateFlow<List<FileTransfer>>) : IpnViewM
// Calculates the overall state of a set of file transfers.
// peerId: The peer ID to check for transfers.
// transfers: The list of outgoing file transfers for the peer.
private fun transferState(peerId: String, transfers: List<Ipn.OutgoingFile>): TransferState? {
private fun transferState(transfers: List<Ipn.OutgoingFile>): TransferState? {
// No transfers? Nothing state
if (transfers.isEmpty()) return null
// We may have transfers from a prior session for files the user selected and for peers
// in our list.. but we don't care about those. We only care if the peerId is in teh pending
// list.
if (!pending.value.contains(peerId)) return null
return if (transfers.all { it.Finished }) {
// Everything done? SENT if all succeeded, FAILED if any failed.
if (transfers.any { !it.Succeeded }) TransferState.FAILED else TransferState.SENT
@ -159,21 +151,11 @@ class TaildropViewModel(val transfers: StateFlow<List<FileTransfer>>) : IpnViewM
// any requested transfers.
@Composable
fun TrailingContentForPeer(peerId: String) {
val outgoing = outgoing.collectAsState().value
val pending = pending.collectAsState().value
// Check our outgoing files for the peer and determine the state of the transfer.
val transfers = outgoing[peerId] ?: emptyList()
var status = transferState(peerId, transfers)
// Check if we have a pending transfer for this peer. We may not have an outgoing file
// yet, but we still want to show the sending state in the mean time.
if (status == null && pending.contains(peerId)) {
status = TransferState.SENDING
}
val transfers = this.transfers.collectAsState().value.filter { it.PeerID == peerId }
var status: TransferState = transferState(transfers) ?: return
// Still no status? Nothing to render for this peer
if (status == null) return
Column(modifier = Modifier.fillMaxHeight()) {
when (status) {
@ -197,20 +179,20 @@ class TaildropViewModel(val transfers: StateFlow<List<FileTransfer>>) : IpnViewM
return
}
// Ignore requests to resend a file (the backend will not overwrite anyway)
outgoing.value[node.StableID]?.let {
val status = transferState(node.StableID, it)
if (status == TransferState.SENDING || status == TransferState.SENT) {
return
}
if (selectedPeers.value.contains(node.StableID)) {
// We've already selected this peer, ignore
return
}
selectedPeers.set(selectedPeers.value + node.StableID)
val preparedTransfers = requestedTransfers.value.map { it.prepare(node.StableID) }
currentTransferIDs.set(currentTransferIDs.value + preparedTransfers.map { it.ID })
pending.set(pending.value + node.StableID)
Client(viewModelScope).putTaildropFiles(context, node.StableID, transfers.value) {
Client(applicationScope).putTaildropFiles(context, node.StableID, preparedTransfers) {
// This is an early API failure and will not get communicated back up to us via
// outgoing files - things never made it that far.
if (it.isFailure) {
pending.set(pending.value - node.StableID)
selectedPeers.set(selectedPeers.value - node.StableID)
showDialog.set(ErrorDialogType.SHARE_FAILED)
}
}

@ -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.20240324181545-f78928191539
tailscale.com v1.63.0-pre.0.20240326143037-34ae89e4972c
)
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.20240324181545-f78928191539 h1:DBpvudmMiWINWePS9RdY+dST7xOideFf26TSgqsu2rQ=
tailscale.com v1.63.0-pre.0.20240324181545-f78928191539/go.mod h1:cC0b0vYCoSDOLufJX5J5zDUrvV3lYwOLqlt9NW8y4cY=
tailscale.com v1.63.0-pre.0.20240326143037-34ae89e4972c h1:j7pSWQa+9OdNavdMVq0ZN7RDQ7CZDIxzTveKSeZ7I0c=
tailscale.com v1.63.0-pre.0.20240326143037-34ae89e4972c/go.mod h1:cC0b0vYCoSDOLufJX5J5zDUrvV3lYwOLqlt9NW8y4cY=

Loading…
Cancel
Save