From e59112a8fbe871cbf2cc6c9c3028979612b80263 Mon Sep 17 00:00:00 2001 From: Jonathan Nobels Date: Wed, 27 Mar 2024 13:39:22 -0400 Subject: [PATCH] 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 * android/ui: add transfer ID to outgoing file transfers (#245) Helps track status of transfers. Updates #ENG-2868 Signed-off-by: Percy Wegmann * android/ui: taildrop string change Updates tailscale/corp#18202 Co-authored-by: Andrea Gottardo Signed-off-by: Jonathan Nobels * android: bumping oss to pick up new taildrop support Updates tailscale/corp#18202 Signed-off-by: Jonathan Nobels * 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 --------- Signed-off-by: Jonathan Nobels Signed-off-by: Percy Wegmann Signed-off-by: Jonathan Nobels Co-authored-by: Percy Wegmann Co-authored-by: Andrea Gottardo --- android/src/main/AndroidManifest.xml | 10 + .../src/main/java/com/tailscale/ipn/App.kt | 18 +- .../java/com/tailscale/ipn/IPNActivity.java | 110 ---------- .../main/java/com/tailscale/ipn/IPNService.kt | 4 +- .../java/com/tailscale/ipn/MainActivity.kt | 6 +- .../java/com/tailscale/ipn/ShareActivity.kt | 106 ++++++++++ .../com/tailscale/ipn/ui/localapi/Client.kt | 44 ++-- .../java/com/tailscale/ipn/ui/model/Ipn.kt | 26 ++- .../java/com/tailscale/ipn/ui/model/Types.kt | 2 +- .../com/tailscale/ipn/ui/notifier/Notifier.kt | 15 +- .../java/com/tailscale/ipn/ui/view/Buttons.kt | 24 +++ .../com/tailscale/ipn/ui/view/ErrorDialog.kt | 17 +- .../com/tailscale/ipn/ui/view/MainView.kt | 19 -- .../com/tailscale/ipn/ui/view/PeerView.kt | 65 ++++++ .../com/tailscale/ipn/ui/view/SharedViews.kt | 16 +- .../com/tailscale/ipn/ui/view/TaildropView.kt | 195 +++++++++++++++++ .../com/tailscale/ipn/ui/view/UserView.kt | 5 + .../ipn/ui/viewModel/IpnViewModel.kt | 14 +- .../ipn/ui/viewModel/TaildropViewModel.kt | 200 ++++++++++++++++++ android/src/main/res/drawable/single_file.xml | 5 + android/src/main/res/drawable/warning.xml | 5 + android/src/main/res/values/strings.xml | 28 +++ go.mod | 4 +- go.sum | 8 +- 24 files changed, 770 insertions(+), 176 deletions(-) delete mode 100644 android/src/main/java/com/tailscale/ipn/IPNActivity.java create mode 100644 android/src/main/java/com/tailscale/ipn/ShareActivity.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/view/PeerView.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/view/TaildropView.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/viewModel/TaildropViewModel.kt create mode 100644 android/src/main/res/drawable/single_file.xml create mode 100644 android/src/main/res/drawable/warning.xml diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index bb5e8ce..baaac9c 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -48,6 +48,14 @@ + + @@ -73,6 +81,8 @@ + + diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index 1536d92..1a7c799 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -62,7 +62,7 @@ import java.util.Locale import java.util.Objects 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" @@ -82,7 +82,7 @@ class App : Application(), libtailscale.AppContext { @JvmStatic fun startActivityForResult(act: Activity, intent: Intent?, request: Int) { - val f: Fragment = act.getFragmentManager().findFragmentByTag(PEER_TAG) + val f: Fragment = act.fragmentManager.findFragmentByTag(PEER_TAG) f.startActivityForResult(intent, request) } @@ -245,7 +245,7 @@ class App : Application(), libtailscale.AppContext { val manu = Build.MANUFACTURER var model = Build.MODEL // Strip manufacturer from model. - val idx = model.toLowerCase(Locale.getDefault()).indexOf(manu.toLowerCase(Locale.getDefault())) + val idx = model.lowercase(Locale.getDefault()).indexOf(manu.lowercase(Locale.getDefault())) if (idx != -1) { model = model.substring(idx + manu.length).trim() } @@ -267,10 +267,10 @@ class App : Application(), libtailscale.AppContext { fun attachPeer(act: Activity) { act.runOnUiThread( Runnable { - val ft: FragmentTransaction = act.getFragmentManager().beginTransaction() + val ft: FragmentTransaction = act.fragmentManager.beginTransaction() ft.add(Peer(), PEER_TAG) ft.commit() - act.getFragmentManager().executePendingTransactions() + act.fragmentManager.executePendingTransactions() }) } @@ -306,7 +306,7 @@ class App : Application(), libtailscale.AppContext { // getPackageSignatureFingerprint returns the first package signing certificate, if any. get() { val info: PackageInfo - info = getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_SIGNATURES) + info = packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES) for (signature in info.signatures) { return signature.toByteArray() } @@ -316,7 +316,7 @@ class App : Application(), libtailscale.AppContext { @Throws(IOException::class) fun insertMedia(name: String?, mimeType: String): String { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val resolver: ContentResolver = getContentResolver() + val resolver: ContentResolver = contentResolver val contentValues = ContentValues() contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name) if ("" != mimeType) { @@ -334,12 +334,12 @@ class App : Application(), libtailscale.AppContext { @Throws(IOException::class) fun openUri(uri: String?, mode: String?): Int? { - val resolver: ContentResolver = getContentResolver() + val resolver: ContentResolver = contentResolver return mode?.let { resolver.openFileDescriptor(Uri.parse(uri), it)?.detachFd() } } fun deleteUri(uri: String?) { - val resolver: ContentResolver = getContentResolver() + val resolver: ContentResolver = contentResolver resolver.delete(Uri.parse(uri), null, null) } diff --git a/android/src/main/java/com/tailscale/ipn/IPNActivity.java b/android/src/main/java/com/tailscale/ipn/IPNActivity.java deleted file mode 100644 index 48792eb..0000000 --- a/android/src/main/java/com/tailscale/ipn/IPNActivity.java +++ /dev/null @@ -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 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(); - } -} diff --git a/android/src/main/java/com/tailscale/ipn/IPNService.kt b/android/src/main/java/com/tailscale/ipn/IPNService.kt index 3a8bce7..ea2e54d 100644 --- a/android/src/main/java/com/tailscale/ipn/IPNService.kt +++ b/android/src/main/java/com/tailscale/ipn/IPNService.kt @@ -10,8 +10,8 @@ import android.os.Build import android.system.OsConstants import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat -import libtailscale.Libtailscale import java.util.UUID +import libtailscale.Libtailscale open class IPNService : VpnService(), libtailscale.IPNService { private val randomID: String = UUID.randomUUID().toString() @@ -63,7 +63,7 @@ open class IPNService : VpnService(), libtailscale.IPNService { return PendingIntent.getActivity( this, 0, - Intent(this, IPNActivity::class.java), + Intent(this, MainActivity::class.java), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index 5f0ee8e..9d98083 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -62,6 +62,8 @@ class MainActivity : ComponentActivity() { @JvmStatic val requestSignin: Int = 1000 // requestPrepareVPN is for when Android's VpnService.prepare completes. @JvmStatic val requestPrepareVPN: Int = 1001 + + const val WRITE_STORAGE_RESULT = 1000 } override fun onCreate(savedInstanceState: Bundle?) { @@ -193,13 +195,9 @@ class MainActivity : ComponentActivity() { val contract = VpnPermissionContract() requestVpnPermission = registerForActivityResult(contract) { granted -> - Notifier.vpnPermissionGranted.set(granted) Log.i("VPN", "VPN permission ${if (granted) "granted" else "denied"}") } requestVpnPermission.launch(Unit) - } else { - Notifier.vpnPermissionGranted.set(true) - Log.i("VPN", "VPN permission granted") } } diff --git a/android/src/main/java/com/tailscale/ipn/ShareActivity.kt b/android/src/main/java/com/tailscale/ipn/ShareActivity.kt new file mode 100644 index 0000000..8f2cbaa --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ShareActivity.kt @@ -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> = 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? + + 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(Intent.EXTRA_STREAM, Uri::class.java) + } else { + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) + } + } + else -> { + Log.e(TAG, "No extras found in intent - nothing to share") + null + } + } + + val pendingFiles: List = + 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) + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt index 4305051..b313ba3 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt @@ -3,6 +3,7 @@ package com.tailscale.ipn.ui.localapi +import android.content.Context import android.util.Log import com.tailscale.ipn.ui.model.BugReportID import com.tailscale.ipn.ui.model.Errors @@ -20,7 +21,6 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.serializer import libtailscale.FilePart -import java.io.File import java.nio.charset.Charset import kotlin.reflect.KType import kotlin.reflect.typeOf @@ -61,6 +61,8 @@ typealias PrefsHandler = (Result) -> Unit * corresponding method on this Client. */ class Client(private val scope: CoroutineScope) { + private val TAG = Client::class.simpleName + fun status(responseHandler: StatusResponseHandler) { get(Endpoint.STATUS, responseHandler = responseHandler) } @@ -116,26 +118,42 @@ class Client(private val scope: CoroutineScope) { get(Endpoint.TKA_STATUS, responseHandler = responseHandler) } + fun fileTargets(responseHandler: (Result>) -> Unit) { + get(Endpoint.FILE_TARGETS, responseHandler = responseHandler) + } + fun putTaildropFiles( + context: Context, peerId: StableNodeID, - files: Collection, + files: Collection, responseHandler: (Result) -> Unit ) { - val manifest = Json.encodeToString(files.map { it.name to it.length() }.toMap()) + val manifest = Json.encodeToString(files) val manifestPart = FilePart() manifestPart.body = InputStreamAdapter(manifest.byteInputStream(Charset.defaultCharset())) manifestPart.filename = "manifest.json" manifestPart.contentType = "application/json" - manifestPart.contentLength = manifest.length.toLong() val parts = mutableListOf(manifestPart) - parts.addAll( - files.map { file -> - val part = FilePart() - part.filename = file.name - part.contentLength = file.length() - part.body = InputStreamAdapter(file.inputStream()) - part - }) + + try { + parts.addAll( + files.map { file -> + val stream = + context.contentResolver.openInputStream(file.uri) + ?: throw Exception("Error opening file stream") + + val part = FilePart() + part.filename = file.Name + part.contentLength = file.DeclaredSize + part.body = InputStreamAdapter(stream) + part + }) + } catch (e: Exception) { + parts.forEach { it.body.close() } + Log.e(TAG, "Error creating file upload body: $e") + responseHandler(Result.failure(e)) + return + } return postMultipart( "${Endpoint.FILE_PUT}/${peerId}", @@ -234,7 +252,7 @@ class Client(private val scope: CoroutineScope) { } } -public class Request( +class Request( private val scope: CoroutineScope, private val method: String, path: String, diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt index a330626..9c95294 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt @@ -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,27 @@ 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) } class Persist { diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt index 6eec247..284d264 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt @@ -25,7 +25,7 @@ typealias BugReportID = String // Represents and empty message with a single 'property' field. class Empty { - @Serializable data class Message(val property: String) + @Serializable data class Message(val property: String = "") } // Parsable errors returned by localApiService diff --git a/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt b/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt index c16b505..ad038f7 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt @@ -4,6 +4,7 @@ package com.tailscale.ipn.ui.notifier import android.util.Log +import com.tailscale.ipn.ui.model.Empty import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn.Notify import com.tailscale.ipn.ui.model.Netmap @@ -29,6 +30,11 @@ object Notifier { private val TAG = Notifier::class.simpleName private val decoder = Json { ignoreUnknownKeys = true } + // Global App State + val tileReady: StateFlow = MutableStateFlow(false) + val readyToPrepareVPN: StateFlow = MutableStateFlow(false) + + // General IPN Bus State val state: StateFlow = MutableStateFlow(Ipn.State.NoState) val netmap: StateFlow = MutableStateFlow(null) val prefs: StateFlow = MutableStateFlow(null) @@ -37,10 +43,11 @@ object Notifier { val browseToURL: StateFlow = MutableStateFlow(null) val loginFinished: StateFlow = MutableStateFlow(null) val version: StateFlow = MutableStateFlow(null) - val vpnPermissionGranted: StateFlow = MutableStateFlow(null) - val tileReady: StateFlow = MutableStateFlow(false) - val readyToPrepareVPN: StateFlow = MutableStateFlow(false) + + // Taildrop-specific State val outgoingFiles: StateFlow?> = MutableStateFlow(null) + val incomingFiles: StateFlow?> = MutableStateFlow(null) + val filesWaiting: StateFlow = MutableStateFlow(null) private lateinit var app: libtailscale.Application private var manager: libtailscale.NotificationManager? = null @@ -70,6 +77,8 @@ object Notifier { notify.LoginFinished?.let { loginFinished.set(it.property) } notify.Version?.let(version::set) notify.OutgoingFiles?.let(outgoingFiles::set) + notify.FilesWaiting?.let(filesWaiting::set) + notify.IncomingFiles?.let(incomingFiles::set) } state.collect { currstate -> readyToPrepareVPN.set(currstate > Ipn.State.Stopped) diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/Buttons.kt b/android/src/main/java/com/tailscale/ipn/ui/view/Buttons.kt index d762edd..271fa0b 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/Buttons.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/Buttons.kt @@ -6,14 +6,21 @@ package com.tailscale.ipn.ui.view import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Clear +import androidx.compose.material.icons.outlined.Close import androidx.compose.material3.Button import androidx.compose.material3.ButtonColors import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.dp import com.tailscale.ipn.ui.theme.ts_color_light_blue @@ -45,3 +52,20 @@ fun OpenURLButton(title: String, url: String) { contentColor = MaterialTheme.colorScheme.secondary, containerColor = MaterialTheme.colorScheme.secondaryContainer)) } + +@Composable +fun ClearButton(onClick: () -> Unit) { + IconButton(onClick = onClick, modifier = Modifier.size(24.dp)) { + Icon(Icons.Outlined.Clear, null) + } +} + + +@Composable +fun CloseButton() { + val focusManager = LocalFocusManager.current + + IconButton(onClick = { focusManager.clearFocus() }, modifier = Modifier.size(24.dp)) { + Icon(Icons.Outlined.Close, null) + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt b/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt index 7e5771b..b389b0c 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt @@ -13,7 +13,9 @@ import com.tailscale.ipn.R enum class ErrorDialogType { LOGOUT_FAILED, SWITCH_USER_FAILED, - ADD_PROFILE_FAILED; + ADD_PROFILE_FAILED, + SHARE_DEVICE_NOT_CONNECTED, + SHARE_FAILED; val message: Int get() { @@ -21,10 +23,21 @@ enum class ErrorDialogType { LOGOUT_FAILED -> R.string.logout_failed SWITCH_USER_FAILED -> R.string.switch_user_failed ADD_PROFILE_FAILED -> R.string.add_profile_failed + SHARE_DEVICE_NOT_CONNECTED -> R.string.share_device_not_connected + SHARE_FAILED -> R.string.taildrop_share_failed } } - val title: Int = R.string.error + val title: Int + get() { + return when (this) { + LOGOUT_FAILED -> R.string.logout_failed_title + SWITCH_USER_FAILED -> R.string.switch_user_failed_title + ADD_PROFILE_FAILED -> R.string.add_profile_failed_title + SHARE_DEVICE_NOT_CONNECTED -> R.string.share_device_not_connected_title + SHARE_FAILED -> R.string.taildrop_share_failed_title + } + } val buttonText: Int = R.string.ok } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt index c8ce7cd..c973b93 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -21,8 +21,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ArrowDropDown -import androidx.compose.material.icons.outlined.Clear -import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.Button @@ -42,7 +40,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -307,22 +304,6 @@ fun ConnectView( } } -@Composable -fun ClearButton(onClick: () -> Unit) { - IconButton(onClick = onClick, modifier = Modifier.size(24.dp)) { - Icon(Icons.Outlined.Clear, null) - } -} - -@Composable -fun CloseButton() { - val focusManager = LocalFocusManager.current - - IconButton(onClick = { focusManager.clearFocus() }, modifier = Modifier.size(24.dp)) { - Icon(Icons.Outlined.Close, null) - } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable fun PeerList( diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/PeerView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/PeerView.kt new file mode 100644 index 0000000..b363019 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/PeerView.kt @@ -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) +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SharedViews.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SharedViews.kt index 897c4cb..6b90218 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SharedViews.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SharedViews.kt @@ -13,6 +13,7 @@ import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -21,6 +22,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.tailscale.ipn.ui.theme.ts_color_light_blue data class BackNavigation( val onBack: () -> Unit, @@ -51,14 +53,24 @@ fun BackArrow(action: () -> Unit) { @Composable fun CheckedIndicator() { - Icon(Icons.Default.CheckCircle, null) + Icon(Icons.Default.CheckCircle, "selected", tint = ts_color_light_blue) } @Composable fun SimpleActivityIndicator(size: Int = 32) { CircularProgressIndicator( modifier = Modifier.width(size.dp), - color = MaterialTheme.colorScheme.primary, + color = ts_color_light_blue, trackColor = MaterialTheme.colorScheme.secondary, ) } + +@Composable +fun ActivityIndicator(progress: Double, size: Int = 32) { + LinearProgressIndicator( + progress = { progress.toFloat() }, + modifier = Modifier.width(size.dp), + color = ts_color_light_blue, + trackColor = MaterialTheme.colorScheme.secondary, + ) +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/TaildropView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/TaildropView.kt new file mode 100644 index 0000000..0facade --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/TaildropView.kt @@ -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>, + 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, + 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, 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) { + // (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)) + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt index 6911f00..d88026c 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt @@ -6,11 +6,16 @@ package com.tailscale.ipn.ui.view import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.material3.ListItem +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import com.tailscale.ipn.R import com.tailscale.ipn.ui.model.IpnLocal diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt index 8c5a922..987f87d 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt @@ -3,6 +3,9 @@ package com.tailscale.ipn.ui.viewModel +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper import android.content.Intent import android.util.Log import androidx.lifecycle.ViewModel @@ -35,7 +38,7 @@ open class IpnViewModel : ViewModel() { val loginProfiles: StateFlow?> = MutableStateFlow(null) // The userId associated with the current node. ie: The logged in user. - var selfNodeUserId: UserID? = null + private var selfNodeUserId: UserID? = null init { viewModelScope.launch { @@ -65,6 +68,13 @@ open class IpnViewModel : ViewModel() { Log.d(TAG, "Created") } + protected fun Context.findActivity(): Activity? = + when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null + } + private fun loadUserProfiles() { Client(viewModelScope).profiles { result -> result.onSuccess(loginProfiles::set).onFailure { @@ -86,7 +96,7 @@ open class IpnViewModel : ViewModel() { } } - private fun startVPN() { + fun startVPN() { val context = App.getApplication().applicationContext val intent = Intent(context, IPNReceiver::class.java) intent.action = IPNReceiver.INTENT_CONNECT_VPN diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/TaildropViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/TaildropViewModel.kt new file mode 100644 index 0000000..7648830 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/TaildropViewModel.kt @@ -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>, + private val applicationScope: CoroutineScope +) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return TaildropViewModel(requestedTransfers, applicationScope) as T + } +} + +class TaildropViewModel( + val requestedTransfers: StateFlow>, + 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> = MutableStateFlow(emptySet()) + // Set of OutgoingFile.IDs that we're currently transferring. + private val currentTransferIDs: StateFlow> = MutableStateFlow(emptySet()) + // Flow of Ipn.OutgoingFiles with updated statuses for every entry in transferWithStatuses. + private val transfers: StateFlow> = 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> = MutableStateFlow(emptyList()) + + // Non null if there's an error to be rendered. + val showDialog: StateFlow = 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): 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): 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) + } + } + } +} diff --git a/android/src/main/res/drawable/single_file.xml b/android/src/main/res/drawable/single_file.xml new file mode 100644 index 0000000..24e774e --- /dev/null +++ b/android/src/main/res/drawable/single_file.xml @@ -0,0 +1,5 @@ + + + diff --git a/android/src/main/res/drawable/warning.xml b/android/src/main/res/drawable/warning.xml new file mode 100644 index 0000000..b1726a3 --- /dev/null +++ b/android/src/main/res/drawable/warning.xml @@ -0,0 +1,5 @@ + + + diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index fcf5c8f..3fe26a3 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -78,6 +78,8 @@ in %d days in %d months in %.1f years + + Accounts Unable to logout at this time. Please try again. Error @@ -104,6 +106,8 @@ Enabled Disabled Disable + + Tailnet lock Tailnet lock lets devices in your network verify public keys distributed by the coordination server before trusting them for connectivity. Tailnet lock is currently enabled. @@ -117,8 +121,12 @@ Tailnet Lock Key Used to sign this node from another signing device in your tailnet. Used to authorize changes to the Tailnet lock configuration. + + Bug Report ID Learn moreā€¦ + + DNS Settings Using Tailscale DNS This device is using Tailscale to resolve DNS names. @@ -172,4 +180,24 @@ Thank you for granting Tailscale the Notifications permission. + + Send via Tailscale + Unable to share files with this device. This device is not currently connected to the tailnet. + No files to share + %1$s Files + Connect to your tailnet to share files + My Devices + There are no devices on your tailnet to share to + Taildrop failed. Some files were not shared. Please try again. + Sending + Failed + + + + Logout Failed + Cannot Switch To User + Unable to Add Profile + Not Connected + Taildrop Failed + diff --git a/go.mod b/go.mod index 5b1cc57..8b2730a 100644 --- a/go.mod +++ b/go.mod @@ -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.20240327135352-66e4d843c138 ) require ( @@ -38,11 +38,13 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 // indirect github.com/aws/smithy-go v1.19.0 // indirect github.com/benoitkugler/textlayout v0.3.0 // indirect + github.com/bits-and-blooms/bitset v1.13.0 // indirect github.com/coreos/go-iptables v0.7.0 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect github.com/fxamacker/cbor/v2 v2.5.0 // indirect + github.com/gaissmai/bart v0.4.1 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-text/typesetting v0.0.0-20221214153724-0399769901d5 // indirect github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect diff --git a/go.sum b/go.sum index 6c27070..df2790d 100644 --- a/go.sum +++ b/go.sum @@ -81,6 +81,8 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE= +github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ= github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -134,6 +136,8 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fxamacker/cbor/v2 v2.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADivE= github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= +github.com/gaissmai/bart v0.4.1 h1:G1t58voWkNmT47lBDawH5QhtTDsdqRIO+ftq5x4P9Ls= +github.com/gaissmai/bart v0.4.1/go.mod h1:KHeYECXQiBjTzQz/om2tqn3sZF1J7hw9m6z41ftj3fg= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= @@ -666,5 +670,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.20240327135352-66e4d843c138 h1:CUxCS0sUOwasW/zpNGt63oLCV7QZL7dnrwYMik4OGwQ= +tailscale.com v1.63.0-pre.0.20240327135352-66e4d843c138/go.mod h1:6kGByHNxnFfK1i4gVpdtvpdS1HicHohWXnsfwmXy64I=