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