android/ui: implement outgoing taildrop support (#242)
* 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> * android/ui: add transfer ID to outgoing file transfers (#245) Helps track status of transfers. Updates #ENG-2868 Signed-off-by: Percy Wegmann <percy@tailscale.com> * android/ui: taildrop string change Updates tailscale/corp#18202 Co-authored-by: Andrea Gottardo <andrea@tailscale.com> Signed-off-by: Jonathan Nobels <jnobels@gmail.com> * android: bumping oss to pick up new taildrop support Updates tailscale/corp#18202 Signed-off-by: Jonathan Nobels <jonathan@tailscale.com> * android: remove write storage permission check Updates tailscale/corp#18202 This is not required and the jni callback does't actually do what we need in the new client. Signed-off-by: Jonathan Nobels <jonathan@tailscale.com> --------- Signed-off-by: Jonathan Nobels <jonathan@tailscale.com> Signed-off-by: Percy Wegmann <percy@tailscale.com> Signed-off-by: Jonathan Nobels <jnobels@gmail.com> Co-authored-by: Percy Wegmann <percy@tailscale.com> Co-authored-by: Andrea Gottardo <andrea@tailscale.com>pull/251/head
parent
db3ba696eb
commit
e59112a8fb
@ -1,110 +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.res.Configuration;
|
|
||||||
import android.database.Cursor;
|
|
||||||
import android.net.Uri;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.provider.OpenableColumns;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public final class IPNActivity extends Activity {
|
|
||||||
@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 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,106 @@
|
|||||||
|
// 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.Ipn
|
||||||
|
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 requestedTransfers: StateFlow<List<Ipn.OutgoingFile>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
|
override fun onCreate(state: Bundle?) {
|
||||||
|
super.onCreate(state)
|
||||||
|
setContent {
|
||||||
|
AppTheme { TaildropView(requestedTransfers, (application as App).applicationScope) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Ipn.OutgoingFile> =
|
||||||
|
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()
|
||||||
|
val file = Ipn.OutgoingFile(Name = name, DeclaredSize = size)
|
||||||
|
file.uri = it
|
||||||
|
file
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
if (pendingFiles.isEmpty()) {
|
||||||
|
Log.e(TAG, "Share failure - no files extracted from intent")
|
||||||
|
}
|
||||||
|
|
||||||
|
requestedTransfers.set(pendingFiles)
|
||||||
|
}
|
||||||
|
}
|
@ -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,195 @@
|
|||||||
|
// 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.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(
|
||||||
|
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
|
||||||
|
|
||||||
|
// Show the error overlay
|
||||||
|
showDialog?.let { ErrorDialog(type = it, action = { viewModel.showDialog.set(null) }) }
|
||||||
|
|
||||||
|
Column(modifier = Modifier.padding(paddingInsets)) {
|
||||||
|
FileShareHeader(
|
||||||
|
fileTransfers = requestedTransfers.collectAsState().value,
|
||||||
|
totalSize = viewModel.totalSize)
|
||||||
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
|
|
||||||
|
when (viewModel.state.collectAsState().value) {
|
||||||
|
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<Ipn.OutgoingFile>, 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].Name, 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<Ipn.OutgoingFile>) {
|
||||||
|
// (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,200 @@
|
|||||||
|
// 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.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 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 requestedTransfers: StateFlow<List<Ipn.OutgoingFile>>,
|
||||||
|
private val applicationScope: CoroutineScope
|
||||||
|
) : ViewModelProvider.Factory {
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
return TaildropViewModel(requestedTransfers, applicationScope) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TaildropViewModel(
|
||||||
|
val requestedTransfers: StateFlow<List<Ipn.OutgoingFile>>,
|
||||||
|
private val applicationScope: CoroutineScope
|
||||||
|
) : IpnViewModel() {
|
||||||
|
|
||||||
|
// Represents the state of a file transfer
|
||||||
|
enum class TransferState {
|
||||||
|
SENDING,
|
||||||
|
SENT,
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
// The overall VPN state
|
||||||
|
val state = Notifier.state
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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,
|
||||||
|
// 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
|
||||||
|
.combine(currentTransferIDs) { outgoingFiles, ongoingIDs ->
|
||||||
|
Pair(outgoingFiles, ongoingIDs)
|
||||||
|
}
|
||||||
|
.collect { (outgoingFiles, ongoingIDs) ->
|
||||||
|
outgoingFiles?.let {
|
||||||
|
transfers.set(outgoingFiles.filter { ongoingIDs.contains(it.ID) })
|
||||||
|
} ?: run { transfers.set(emptyList()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
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 outgoing files
|
||||||
|
private fun progress(transfers: List<Ipn.OutgoingFile>): Double {
|
||||||
|
val total = transfers.sumOf { it.DeclaredSize }.toDouble()
|
||||||
|
val sent = transfers.sumOf { it.Sent }.toDouble()
|
||||||
|
if (total < 0.1) return 0.0
|
||||||
|
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(transfers: List<Ipn.OutgoingFile>): TransferState? {
|
||||||
|
// No transfers? Nothing state
|
||||||
|
if (transfers.isEmpty()) 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) {
|
||||||
|
// Check our outgoing files for the peer and determine the state of the transfer.
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 })
|
||||||
|
|
||||||
|
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) {
|
||||||
|
selectedPeers.set(selectedPeers.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