android/ui: implement outgoing taildrop support
Updates tailscale/corp#18202 Adds share activity to handle outgoing taildrop requests. This unbreaks the WaitingFiles notification for incoming files, but does not yet properly handle them. Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>ox/taildrop
parent
bf74edd551
commit
de67b7c5c1
@ -1,124 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn;
|
|
||||||
|
|
||||||
import android.app.Activity;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.content.pm.PackageManager;
|
|
||||||
import android.content.res.Configuration;
|
|
||||||
import android.database.Cursor;
|
|
||||||
import android.net.Uri;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.provider.OpenableColumns;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import libtailscale.Libtailscale;
|
|
||||||
|
|
||||||
public final class IPNActivity extends Activity {
|
|
||||||
final static int WRITE_STORAGE_RESULT = 1000;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCreate(Bundle state) {
|
|
||||||
super.onCreate(state);
|
|
||||||
handleIntent();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onNewIntent(Intent i) {
|
|
||||||
setIntent(i);
|
|
||||||
handleIntent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleIntent() {
|
|
||||||
Intent it = getIntent();
|
|
||||||
String act = it.getAction();
|
|
||||||
String[] texts;
|
|
||||||
Uri[] uris;
|
|
||||||
if (Intent.ACTION_SEND.equals(act)) {
|
|
||||||
uris = new Uri[]{it.getParcelableExtra(Intent.EXTRA_STREAM)};
|
|
||||||
texts = new String[]{it.getStringExtra(Intent.EXTRA_TEXT)};
|
|
||||||
} else if (Intent.ACTION_SEND_MULTIPLE.equals(act)) {
|
|
||||||
List<Uri> extraUris = it.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
|
|
||||||
uris = extraUris.toArray(new Uri[0]);
|
|
||||||
texts = new String[uris.length];
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
String mime = it.getType();
|
|
||||||
int nitems = uris.length;
|
|
||||||
String[] items = new String[nitems];
|
|
||||||
String[] mimes = new String[nitems];
|
|
||||||
int[] types = new int[nitems];
|
|
||||||
String[] names = new String[nitems];
|
|
||||||
long[] sizes = new long[nitems];
|
|
||||||
int nfiles = 0;
|
|
||||||
for (int i = 0; i < uris.length; i++) {
|
|
||||||
String text = texts[i];
|
|
||||||
Uri uri = uris[i];
|
|
||||||
if (text != null) {
|
|
||||||
types[nfiles] = 1; // FileTypeText
|
|
||||||
names[nfiles] = "file.txt";
|
|
||||||
mimes[nfiles] = mime;
|
|
||||||
items[nfiles] = text;
|
|
||||||
// Determined by len(text) in Go to eliminate UTF-8 encoding differences.
|
|
||||||
sizes[nfiles] = 0;
|
|
||||||
nfiles++;
|
|
||||||
} else if (uri != null) {
|
|
||||||
Cursor c = getContentResolver().query(uri, null, null, null, null);
|
|
||||||
if (c == null) {
|
|
||||||
// Ignore files we have no permission to access.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
int nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
|
||||||
int sizeCol = c.getColumnIndex(OpenableColumns.SIZE);
|
|
||||||
c.moveToFirst();
|
|
||||||
String name = c.getString(nameCol);
|
|
||||||
long size = c.getLong(sizeCol);
|
|
||||||
types[nfiles] = 2; // FileTypeURI
|
|
||||||
mimes[nfiles] = mime;
|
|
||||||
items[nfiles] = uri.toString();
|
|
||||||
names[nfiles] = name;
|
|
||||||
sizes[nfiles] = size;
|
|
||||||
nfiles++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// TODO(oxtoacart): actually implement this
|
|
||||||
// App.onShareIntent(nfiles, types, mimes, items, names, sizes);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onRequestPermissionsResult(int reqCode, String[] perms, int[] grants) {
|
|
||||||
if (reqCode == WRITE_STORAGE_RESULT) {
|
|
||||||
if (grants.length > 0 && grants[0] == PackageManager.PERMISSION_GRANTED) {
|
|
||||||
Libtailscale.onWriteStorageGranted();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onDestroy() {
|
|
||||||
super.onDestroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onStart() {
|
|
||||||
super.onStart();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onStop() {
|
|
||||||
super.onStop();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onConfigurationChanged(Configuration c) {
|
|
||||||
super.onConfigurationChanged(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onLowMemory() {
|
|
||||||
super.onLowMemory();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,102 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
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.notifier.Notifier
|
||||||
|
import com.tailscale.ipn.ui.theme.AppTheme
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import com.tailscale.ipn.ui.view.TaildropView
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
// ShareActivity is the entry point for Taildrop share intents
|
||||||
|
class ShareActivity : ComponentActivity() {
|
||||||
|
private val TAG = ShareActivity::class.simpleName
|
||||||
|
|
||||||
|
private val transfers: StateFlow<List<FileTransfer>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
|
override fun onCreate(state: Bundle?) {
|
||||||
|
super.onCreate(state)
|
||||||
|
setContent { AppTheme { TaildropView(transfers) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
Notifier.start(lifecycleScope)
|
||||||
|
loadFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
super.onStop()
|
||||||
|
Notifier.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent?) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
loadFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loads the files from the intent.
|
||||||
|
fun loadFiles() {
|
||||||
|
if (intent == null) {
|
||||||
|
Log.e(TAG, "Share failure - No intent found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val act = intent.action
|
||||||
|
val uris: List<Uri?>?
|
||||||
|
|
||||||
|
uris =
|
||||||
|
when (act) {
|
||||||
|
Intent.ACTION_SEND -> {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
listOf(intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java))
|
||||||
|
} else {
|
||||||
|
listOf(intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Intent.ACTION_SEND_MULTIPLE -> {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
intent.getParcelableArrayListExtra<Uri?>(Intent.EXTRA_STREAM, Uri::class.java)
|
||||||
|
} else {
|
||||||
|
intent.getParcelableArrayListExtra<Uri?>(Intent.EXTRA_STREAM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Log.e(TAG, "No extras found in intent - nothing to share")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val pendingFiles: List<FileTransfer> =
|
||||||
|
uris?.filterNotNull()?.mapNotNull {
|
||||||
|
contentResolver?.query(it, null, null, null, null)?.let { c ->
|
||||||
|
val nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||||
|
val sizeCol = c.getColumnIndex(OpenableColumns.SIZE)
|
||||||
|
c.moveToFirst()
|
||||||
|
val name = c.getString(nameCol)
|
||||||
|
val size = c.getLong(sizeCol)
|
||||||
|
c.close()
|
||||||
|
FileTransfer(name, size, it)
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
if (pendingFiles.isEmpty()) {
|
||||||
|
Log.e(TAG, "Share failure - no files extracted from intent")
|
||||||
|
}
|
||||||
|
|
||||||
|
transfers.set(pendingFiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
// 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)
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.tailscale.ipn.ui.model.Ipn
|
||||||
|
import com.tailscale.ipn.ui.model.Tailcfg
|
||||||
|
import com.tailscale.ipn.ui.theme.ts_color_light_green
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PeerView(
|
||||||
|
peer: Tailcfg.Node,
|
||||||
|
selfPeer: String? = null,
|
||||||
|
stateVal: Ipn.State? = null,
|
||||||
|
disabled: Boolean = false,
|
||||||
|
subtitle: () -> String = { peer.Addresses?.first()?.split("/")?.first() ?: "" },
|
||||||
|
onClick: (Tailcfg.Node) -> Unit = {},
|
||||||
|
trailingContent: @Composable () -> Unit = {}
|
||||||
|
) {
|
||||||
|
val textColor = if (disabled) Color.Gray else MaterialTheme.colorScheme.primary
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable { onClick(peer) },
|
||||||
|
headlineContent = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
// By definition, SelfPeer is online since we will not show the peer list
|
||||||
|
// unless you're connected.
|
||||||
|
val isSelfAndRunning = (peer.StableID == selfPeer && stateVal == Ipn.State.Running)
|
||||||
|
val color: Color =
|
||||||
|
if ((peer.Online == true) || isSelfAndRunning) {
|
||||||
|
ts_color_light_green
|
||||||
|
} else {
|
||||||
|
Color.Gray
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.size(8.dp)
|
||||||
|
.background(color = color, shape = RoundedCornerShape(percent = 50))) {}
|
||||||
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
|
Text(
|
||||||
|
text = peer.ComputedName,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = textColor)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(text = subtitle(), style = MaterialTheme.typography.bodyMedium, color = textColor)
|
||||||
|
},
|
||||||
|
trailingContent = trailingContent)
|
||||||
|
}
|
||||||
@ -0,0 +1,194 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import android.text.format.Formatter
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
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.flow.StateFlow
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TaildropView(
|
||||||
|
transfersIn: StateFlow<List<FileTransfer>>,
|
||||||
|
viewModel: TaildropViewModel = viewModel(factory = TaildropViewModelFactory(transfersIn))
|
||||||
|
) {
|
||||||
|
Scaffold(topBar = { Header(R.string.share) }) { paddingInsets ->
|
||||||
|
val showDialog = viewModel.showDialog.collectAsState().value
|
||||||
|
|
||||||
|
// Show the error overlay
|
||||||
|
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)
|
||||||
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
|
|
||||||
|
when (state) {
|
||||||
|
Ipn.State.Running -> {
|
||||||
|
val peers = viewModel.myPeers.collectAsState().value
|
||||||
|
val context = LocalContext.current
|
||||||
|
FileSharePeerList(
|
||||||
|
peers = peers,
|
||||||
|
stateViewGenerator = { peerId -> viewModel.TrailingContentForPeer(peerId = peerId) },
|
||||||
|
onShare = { viewModel.share(context, it) })
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
FileShareConnectView { viewModel.startVPN() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FileSharePeerList(
|
||||||
|
peers: List<Tailcfg.Node>,
|
||||||
|
stateViewGenerator: @Composable (String) -> Unit,
|
||||||
|
onShare: (Tailcfg.Node) -> Unit
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
|
||||||
|
Text(stringResource(R.string.my_devices), style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (peers.isEmpty()) {
|
||||||
|
true -> {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp).fillMaxHeight(),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.no_devices_to_share_with),
|
||||||
|
style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false -> {
|
||||||
|
LazyColumn {
|
||||||
|
peers.forEach { peer ->
|
||||||
|
val disabled = !(peer.Online ?: false)
|
||||||
|
item {
|
||||||
|
PeerView(
|
||||||
|
peer = peer,
|
||||||
|
onClick = { onShare(peer) },
|
||||||
|
disabled = disabled,
|
||||||
|
subtitle = { peer.Hostinfo.OS ?: "" },
|
||||||
|
trailingContent = { stateViewGenerator(peer.StableID) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FileShareConnectView(onToggle: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp).fillMaxHeight(),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.connect_to_your_tailnet_to_share_files),
|
||||||
|
style = MaterialTheme.typography.titleMedium)
|
||||||
|
Spacer(modifier = Modifier.size(1.dp))
|
||||||
|
PrimaryActionButton(onClick = onToggle) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.connect),
|
||||||
|
fontSize = MaterialTheme.typography.titleMedium.fontSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FileShareHeader(fileTransfers: List<FileTransfer>, totalSize: Long) {
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
IconForTransfer(fileTransfers)
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
|
||||||
|
when (fileTransfers.isEmpty()) {
|
||||||
|
true ->
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.no_files_to_share),
|
||||||
|
style = MaterialTheme.typography.titleMedium)
|
||||||
|
false -> {
|
||||||
|
|
||||||
|
when (fileTransfers.size) {
|
||||||
|
1 -> Text(fileTransfers[0].filename, style = MaterialTheme.typography.titleMedium)
|
||||||
|
else ->
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.file_count, fileTransfers.size),
|
||||||
|
style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val size = Formatter.formatFileSize(LocalContext.current, totalSize.toLong())
|
||||||
|
Text(size, style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun IconForTransfer(transfers: List<FileTransfer>) {
|
||||||
|
// (jonathan) TODO: Thumbnails?
|
||||||
|
when (transfers.size) {
|
||||||
|
0 ->
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.warning),
|
||||||
|
contentDescription = "no files",
|
||||||
|
modifier = Modifier.size(32.dp))
|
||||||
|
1 -> {
|
||||||
|
// Show a thumbnail for single image shares.
|
||||||
|
val context = LocalContext.current
|
||||||
|
context.contentResolver.getType(transfers[0].uri)?.let {
|
||||||
|
if (it.startsWith("image/")) {
|
||||||
|
AsyncImage(
|
||||||
|
model = transfers[0].uri,
|
||||||
|
contentDescription = "one file",
|
||||||
|
modifier = Modifier.size(40.dp))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.single_file),
|
||||||
|
contentDescription = "files",
|
||||||
|
modifier = Modifier.size(40.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else ->
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.single_file),
|
||||||
|
contentDescription = "files",
|
||||||
|
modifier = Modifier.size(40.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,217 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.viewModel
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
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.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.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class TaildropViewModelFactory(private val transfers: StateFlow<List<FileTransfer>>) :
|
||||||
|
ViewModelProvider.Factory {
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
return TaildropViewModel(transfers) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TaildropViewModel(val transfers: StateFlow<List<FileTransfer>>) : IpnViewModel() {
|
||||||
|
|
||||||
|
// Represents the state of a file transfer
|
||||||
|
enum class TransferState {
|
||||||
|
SENDING,
|
||||||
|
SENT,
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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())
|
||||||
|
|
||||||
|
// The total size of all pending files.
|
||||||
|
var totalSize: Long = 0
|
||||||
|
get() = transfers.value.sumOf { it.size }
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
// alphabetically.
|
||||||
|
val myPeers: StateFlow<List<Tailcfg.Node>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
|
// Non null if there's an error to be rendered.
|
||||||
|
val showDialog: StateFlow<ErrorDialogType?> = MutableStateFlow(null)
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
Notifier.state.collect {
|
||||||
|
if (it == Ipn.State.Running) {
|
||||||
|
loadTargets()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculates the overall progress for a set of outoing files
|
||||||
|
private fun progress(transfers: List<Ipn.OutgoingFile>): Double {
|
||||||
|
val total = transfers.sumOf { it.DeclaredSize }.toDouble()
|
||||||
|
val sent = transfers.sumOf { it.Sent }.toDouble()
|
||||||
|
return (sent / total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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? {
|
||||||
|
// 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
|
||||||
|
} else {
|
||||||
|
// Not complete, we're still sending
|
||||||
|
TransferState.SENDING
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loads all of the valid fileTargets from localAPI
|
||||||
|
private fun loadTargets() {
|
||||||
|
Client(viewModelScope).fileTargets { result ->
|
||||||
|
result
|
||||||
|
.onSuccess { it ->
|
||||||
|
val allSharablePeers = it.map { it.Node }
|
||||||
|
val onlinePeers = allSharablePeers.filter { it.Online ?: false }.sortedBy { it.Name }
|
||||||
|
val offlinePeers =
|
||||||
|
allSharablePeers.filter { !(it.Online ?: false) }.sortedBy { it.Name }
|
||||||
|
myPeers.set(onlinePeers + offlinePeers)
|
||||||
|
}
|
||||||
|
.onFailure { Log.e(TAG, "Error loading targets: ${it.message}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates the trailing status view for the peer list item depending on the state of
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still no status? Nothing to render for this peer
|
||||||
|
if (status == null) return
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxHeight()) {
|
||||||
|
when (status) {
|
||||||
|
TransferState.SENDING -> {
|
||||||
|
val progress = progress(transfers)
|
||||||
|
Text(
|
||||||
|
stringResource(id = R.string.taildrop_sending),
|
||||||
|
style = MaterialTheme.typography.bodyMedium)
|
||||||
|
ActivityIndicator(progress, 60)
|
||||||
|
}
|
||||||
|
TransferState.SENT -> CheckedIndicator()
|
||||||
|
TransferState.FAILED -> Text(stringResource(id = R.string.taildrop_share_failed_short))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commences the file transfer to the specified node iff
|
||||||
|
fun share(context: Context, node: Tailcfg.Node) {
|
||||||
|
if (node.Online != true) {
|
||||||
|
showDialog.set(ErrorDialogType.SHARE_DEVICE_NOT_CONNECTED)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pending.set(pending.value + node.StableID)
|
||||||
|
Client(viewModelScope).putTaildropFiles(context, node.StableID, transfers.value) {
|
||||||
|
// 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)
|
||||||
|
showDialog.set(ErrorDialogType.SHARE_FAILED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
<vector android:height="24dp" android:tint="#000000"
|
||||||
|
android:viewportHeight="24" android:viewportWidth="24"
|
||||||
|
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<path android:fillColor="@android:color/white" android:pathData="M14,2L6,2c-1.1,0 -1.99,0.9 -1.99,2L4,20c0,1.1 0.89,2 1.99,2L18,22c1.1,0 2,-0.9 2,-2L20,8l-6,-6zM16,18L8,18v-2h8v2zM16,14L8,14v-2h8v2zM13,9L13,3.5L18.5,9L13,9z"/>
|
||||||
|
</vector>
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
<vector android:height="24dp" android:tint="#000000"
|
||||||
|
android:viewportHeight="24" android:viewportWidth="24"
|
||||||
|
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<path android:fillColor="@android:color/white" android:pathData="M1,21h22L12,2 1,21zM13,18h-2v-2h2v2zM13,14h-2v-4h2v4z"/>
|
||||||
|
</vector>
|
||||||
Loading…
Reference in New Issue