From de67b7c5c1fb4a9a8d910871c2cc2bcae3cd6889 Mon Sep 17 00:00:00 2001 From: Jonathan Nobels Date: Fri, 22 Mar 2024 12:41:28 -0400 Subject: [PATCH] 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/src/main/AndroidManifest.xml | 10 + .../src/main/java/com/tailscale/ipn/App.kt | 43 ++-- .../java/com/tailscale/ipn/IPNActivity.java | 124 ---------- .../main/java/com/tailscale/ipn/IPNService.kt | 4 +- .../java/com/tailscale/ipn/MainActivity.kt | 48 +++- .../java/com/tailscale/ipn/ShareActivity.kt | 102 ++++++++ .../com/tailscale/ipn/ui/localapi/Client.kt | 56 +++-- .../tailscale/ipn/ui/model/FileTransfer.kt | 9 + .../java/com/tailscale/ipn/ui/model/Ipn.kt | 2 + .../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 | 194 ++++++++++++++++ .../com/tailscale/ipn/ui/view/UserView.kt | 4 + .../ipn/ui/viewModel/IpnViewModel.kt | 14 +- .../ipn/ui/viewModel/TaildropViewModel.kt | 217 ++++++++++++++++++ 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 +++ 23 files changed, 816 insertions(+), 207 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/model/FileTransfer.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 aa30b6d..d7b885b 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 d8f0924..675a59c 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -36,7 +36,6 @@ import androidx.browser.customtabs.CustomTabsIntent import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat -import androidx.core.content.ContextCompat import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import com.tailscale.ipn.mdm.AlwaysNeverUserDecidesSetting @@ -48,12 +47,6 @@ import com.tailscale.ipn.ui.localapi.Client import com.tailscale.ipn.ui.localapi.Request import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.notifier.Notifier -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import libtailscale.Libtailscale import java.io.File import java.io.IOException import java.net.InetAddress @@ -61,6 +54,12 @@ import java.net.NetworkInterface import java.security.GeneralSecurityException import java.util.Locale import java.util.Objects +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import libtailscale.Libtailscale class App : Application(), libtailscale.AppContext { private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -83,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) } @@ -246,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() } @@ -268,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() }) } @@ -307,31 +306,17 @@ 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() } return null } - fun requestWriteStoragePermission(act: Activity) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || - Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - // We can write files without permission. - return - } - if (ContextCompat.checkSelfPermission(act, Manifest.permission.WRITE_EXTERNAL_STORAGE) == - PackageManager.PERMISSION_GRANTED) { - return - } - act.requestPermissions( - arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), IPNActivity.WRITE_STORAGE_RESULT) - } - @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) { @@ -349,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 c5eb741..0000000 --- a/android/src/main/java/com/tailscale/ipn/IPNActivity.java +++ /dev/null @@ -1,124 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package com.tailscale.ipn; - -import android.app.Activity; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.content.res.Configuration; -import android.database.Cursor; -import android.net.Uri; -import android.os.Bundle; -import android.provider.OpenableColumns; - -import java.util.List; - -import libtailscale.Libtailscale; - -public final class IPNActivity extends Activity { - final static int WRITE_STORAGE_RESULT = 1000; - - @Override - public void onCreate(Bundle state) { - super.onCreate(state); - handleIntent(); - } - - @Override - public void onNewIntent(Intent i) { - setIntent(i); - handleIntent(); - } - - private void handleIntent() { - Intent it = getIntent(); - String act = it.getAction(); - String[] texts; - Uri[] uris; - if (Intent.ACTION_SEND.equals(act)) { - uris = new Uri[]{it.getParcelableExtra(Intent.EXTRA_STREAM)}; - texts = new String[]{it.getStringExtra(Intent.EXTRA_TEXT)}; - } else if (Intent.ACTION_SEND_MULTIPLE.equals(act)) { - List extraUris = it.getParcelableArrayListExtra(Intent.EXTRA_STREAM); - uris = extraUris.toArray(new Uri[0]); - texts = new String[uris.length]; - } else { - return; - } - String mime = it.getType(); - int nitems = uris.length; - String[] items = new String[nitems]; - String[] mimes = new String[nitems]; - int[] types = new int[nitems]; - String[] names = new String[nitems]; - long[] sizes = new long[nitems]; - int nfiles = 0; - for (int i = 0; i < uris.length; i++) { - String text = texts[i]; - Uri uri = uris[i]; - if (text != null) { - types[nfiles] = 1; // FileTypeText - names[nfiles] = "file.txt"; - mimes[nfiles] = mime; - items[nfiles] = text; - // Determined by len(text) in Go to eliminate UTF-8 encoding differences. - sizes[nfiles] = 0; - nfiles++; - } else if (uri != null) { - Cursor c = getContentResolver().query(uri, null, null, null, null); - if (c == null) { - // Ignore files we have no permission to access. - continue; - } - int nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME); - int sizeCol = c.getColumnIndex(OpenableColumns.SIZE); - c.moveToFirst(); - String name = c.getString(nameCol); - long size = c.getLong(sizeCol); - types[nfiles] = 2; // FileTypeURI - mimes[nfiles] = mime; - items[nfiles] = uri.toString(); - names[nfiles] = name; - sizes[nfiles] = size; - nfiles++; - } - } - // TODO(oxtoacart): actually implement this -// App.onShareIntent(nfiles, types, mimes, items, names, sizes); - } - - @Override - public void onRequestPermissionsResult(int reqCode, String[] perms, int[] grants) { - if (reqCode == WRITE_STORAGE_RESULT) { - if (grants.length > 0 && grants[0] == PackageManager.PERMISSION_GRANTED) { - Libtailscale.onWriteStorageGranted(); - } - } - } - - @Override - public void onDestroy() { - super.onDestroy(); - } - - @Override - public void onStart() { - super.onStart(); - } - - @Override - public void onStop() { - super.onStop(); - } - - @Override - public void onConfigurationChanged(Configuration c) { - super.onConfigurationChanged(c); - } - - @Override - public void onLowMemory() { - super.onLowMemory(); - } -} 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 ae46272..506b2c9 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -7,14 +7,18 @@ import android.app.Activity import android.content.Context import android.content.Intent import android.content.RestrictionsManager +import android.content.pm.PackageManager import android.net.Uri import android.net.VpnService +import android.os.Build import android.os.Bundle import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContract +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -48,6 +52,7 @@ import com.tailscale.ipn.ui.viewModel.SettingsNav import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import libtailscale.Libtailscale class MainActivity : ComponentActivity() { private var notifierScope: CoroutineScope? = null @@ -59,6 +64,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?) { @@ -110,9 +117,7 @@ class MainActivity : ComponentActivity() { MullvadExitNodePicker( it.arguments!!.getString("countryCode")!!, exitNodePickerNav) } - composable("runExitNode") { - RunExitNodeView(exitNodePickerNav) - } + composable("runExitNode") { RunExitNodeView(exitNodePickerNav) } } composable( "peerDetails/{nodeId}", @@ -169,6 +174,10 @@ class MainActivity : ComponentActivity() { // (jonathan) TODO: Requesting VPN permissions onStart is a bit aggressive. This should // be done when the user initiall starts the VPN requestVpnPermission() + + // (jonathan) TODO: Requesting storage permissions up front (repeatedly) might also be user + // hostile, but we need these to write incoming taildrop files + requestStoragePermission() } override fun onStop() { @@ -185,13 +194,38 @@ 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") + } + } + + private fun requestStoragePermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Libtailscale.onWriteStorageGranted() + return + } + + if (ContextCompat.checkSelfPermission( + this, android.Manifest.permission.WRITE_EXTERNAL_STORAGE) != + PackageManager.PERMISSION_GRANTED) { + + ActivityCompat.requestPermissions( + this, arrayOf(android.Manifest.permission.WRITE_EXTERNAL_STORAGE), WRITE_STORAGE_RESULT) + } + } + + @Deprecated("Deprecated in Android 11, but still needed for Android 10 and below.") + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == WRITE_STORAGE_RESULT) { + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + Libtailscale.onWriteStorageGranted() + } } } } 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..a15b03b --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ShareActivity.kt @@ -0,0 +1,102 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn + +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.OpenableColumns +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.lifecycle.lifecycleScope +import com.tailscale.ipn.ui.model.FileTransfer +import com.tailscale.ipn.ui.notifier.Notifier +import com.tailscale.ipn.ui.theme.AppTheme +import com.tailscale.ipn.ui.util.set +import com.tailscale.ipn.ui.view.TaildropView +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +// ShareActivity is the entry point for Taildrop share intents +class ShareActivity : ComponentActivity() { + private val TAG = ShareActivity::class.simpleName + + private val transfers: StateFlow> = MutableStateFlow(emptyList()) + + override fun onCreate(state: Bundle?) { + super.onCreate(state) + setContent { AppTheme { TaildropView(transfers) } } + } + + override fun onStart() { + super.onStart() + Notifier.start(lifecycleScope) + loadFiles() + } + + override fun onStop() { + super.onStop() + Notifier.stop() + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + setIntent(intent) + loadFiles() + } + + // Loads the files from the intent. + fun loadFiles() { + if (intent == null) { + Log.e(TAG, "Share failure - No intent found") + return + } + + val act = intent.action + val uris: List? + + 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() + FileTransfer(name, size, it) + } + } ?: emptyList() + + if (pendingFiles.isEmpty()) { + Log.e(TAG, "Share failure - no files extracted from intent") + } + + transfers.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..32723c9 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,14 +3,21 @@ 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 +import com.tailscale.ipn.ui.model.FileTransfer import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnState import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.util.InputStreamAdapter +import java.net.URLEncoder +import java.nio.charset.Charset +import java.nio.file.Files.delete +import kotlin.reflect.KType +import kotlin.reflect.typeOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -20,10 +27,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 private object Endpoint { const val DEBUG = "debug" @@ -61,6 +64,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 +121,45 @@ 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.map { URLEncoder.encode(it.filename, "utf-8") to it.size }.toMap()) 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) + if (stream == null) { + throw Exception("Error opening file stream") + } + + val part = FilePart() + part.filename = URLEncoder.encode(file.filename, "utf-8") + part.contentLength = file.size + 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 +258,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/FileTransfer.kt b/android/src/main/java/com/tailscale/ipn/ui/model/FileTransfer.kt new file mode 100644 index 0000000..daf5d44 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/model/FileTransfer.kt @@ -0,0 +1,9 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.model + +import android.net.Uri + +// Encapsulates a uri based file transfer for Taildrop. +data class FileTransfer(val filename: String, val size: Long, val uri: Uri) 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..777e207 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 @@ -168,6 +168,8 @@ class Ipn { val Finished: Boolean, val Succeeded: Boolean, ) + + @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 4065dbd..a6a08f7 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 b1d5e6a..906d49f 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.ExperimentalMaterial3Api @@ -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 @@ -290,22 +287,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 02af82c..c5f74b1 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..ffa3fcb --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/TaildropView.kt @@ -0,0 +1,194 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.view + +import android.text.format.Formatter +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import com.tailscale.ipn.R +import com.tailscale.ipn.ui.model.FileTransfer +import com.tailscale.ipn.ui.model.Ipn +import com.tailscale.ipn.ui.model.Tailcfg +import com.tailscale.ipn.ui.util.set +import com.tailscale.ipn.ui.viewModel.TaildropViewModel +import com.tailscale.ipn.ui.viewModel.TaildropViewModelFactory +import kotlinx.coroutines.flow.StateFlow + +@Composable +fun TaildropView( + transfersIn: StateFlow>, + viewModel: TaildropViewModel = viewModel(factory = TaildropViewModelFactory(transfersIn)) +) { + Scaffold(topBar = { Header(R.string.share) }) { paddingInsets -> + val showDialog = viewModel.showDialog.collectAsState().value + + // Show the error overlay + showDialog?.let { ErrorDialog(type = it, action = { viewModel.showDialog.set(null) }) } + + Column(modifier = Modifier.padding(paddingInsets)) { + val transfers = viewModel.transfers.collectAsState().value + val state = viewModel.state.collectAsState().value + + FileShareHeader(fileTransfers = transfers, totalSize = viewModel.totalSize) + Spacer(modifier = Modifier.size(8.dp)) + + when (state) { + Ipn.State.Running -> { + val peers = viewModel.myPeers.collectAsState().value + val context = LocalContext.current + FileSharePeerList( + peers = peers, + stateViewGenerator = { peerId -> viewModel.TrailingContentForPeer(peerId = peerId) }, + onShare = { viewModel.share(context, it) }) + } + else -> { + FileShareConnectView { viewModel.startVPN() } + } + } + } + } +} + +@Composable +fun FileSharePeerList( + peers: List, + 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].filename, style = MaterialTheme.typography.titleMedium) + else -> + Text( + stringResource(R.string.file_count, fileTransfers.size), + style = MaterialTheme.typography.titleMedium) + } + } + } + val size = Formatter.formatFileSize(LocalContext.current, totalSize.toLong()) + Text(size, style = MaterialTheme.typography.titleMedium) + } + } + HorizontalDivider() + } +} + +@Composable +fun IconForTransfer(transfers: List) { + // (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 7ec3690..a24600b 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 @@ -8,12 +8,15 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.Alignment 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 import com.tailscale.ipn.ui.util.defaultPaddingModifier @@ -65,6 +68,7 @@ fun UserView( UserActionState.NAV -> Unit UserActionState.NONE -> Unit } + Spacer(modifier = Modifier.padding(end = 4.dp)) } } } 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..13619d0 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/TaildropViewModel.kt @@ -0,0 +1,217 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.viewModel + +import android.content.Context +import android.util.Log +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.tailscale.ipn.R +import com.tailscale.ipn.ui.localapi.Client +import com.tailscale.ipn.ui.model.FileTransfer +import com.tailscale.ipn.ui.model.Ipn +import com.tailscale.ipn.ui.model.Tailcfg +import com.tailscale.ipn.ui.notifier.Notifier +import com.tailscale.ipn.ui.util.set +import com.tailscale.ipn.ui.view.ActivityIndicator +import com.tailscale.ipn.ui.view.CheckedIndicator +import com.tailscale.ipn.ui.view.ErrorDialogType +import java.net.URLEncoder +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class TaildropViewModelFactory(private val transfers: StateFlow>) : + ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return TaildropViewModel(transfers) as T + } +} + +class TaildropViewModel(val transfers: StateFlow>) : IpnViewModel() { + + // Represents the state of a file transfer + enum class TransferState { + SENDING, + SENT, + FAILED + } + + // The overall VPN state + val state = Notifier.state + + // Map of outgoing files to peer ID. This is the full state of the outgoing files. + private val outgoing: StateFlow>> = + MutableStateFlow(emptyMap()) + + // List of any nodes that have a file transfer pending FOR THE CURRENT SESSION + // This is used to filter outgoingFiles to ensure we only render the transfer state + // for things that are currently displayed. + private val pending: StateFlow> = MutableStateFlow(emptySet()) + + // The total size of all pending files. + var totalSize: Long = 0 + get() = transfers.value.sumOf { it.size } + + // The list of peers that we can share with. This includes only the nodes belonging to the user + // and excludes the current node. Sorted by online devices first, and offline second, + // alphabetically. + val myPeers: StateFlow> = 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.collect { outgoingFiles -> + val outgoingMap: MutableMap> = mutableMapOf() + val currentFiles = transfers.value.map { URLEncoder.encode(it.filename, "utf-8") } + + outgoingFiles?.let { files -> + files + .filter { currentFiles.contains(it.Name) && pending.value.contains(it.PeerID) } + .forEach { + val list = outgoingMap.getOrDefault(it.PeerID, emptyList()).toMutableList() + list += it + outgoingMap[it.PeerID] = list + } + Log.d("TaildropViewModel", "Outgoing files: $outgoingMap") + outgoing.set(outgoingMap) + } ?: run { outgoing.set(emptyMap()) } + } + } + + // Whenever our files list changes, we need to reset the outgoings files map and + // any pending requests. The user has changed the files they're attempting to share. + viewModelScope.launch { + transfers.collect { + pending.set(emptySet()) + outgoing.set(emptyMap()) + } + } + } + + // Calculates the overall progress for a set of outoing files + private fun progress(transfers: List): Double { + val total = transfers.sumOf { it.DeclaredSize }.toDouble() + val sent = transfers.sumOf { it.Sent }.toDouble() + return (sent / total) + } + + // Calculates the overall state of a set of file transfers. + // peerId: The peer ID to check for transfers. + // transfers: The list of outgoing file transfers for the peer. + private fun transferState(peerId: String, transfers: List): TransferState? { + // No transfers? Nothing state + if (transfers.isEmpty()) return null + + // We may have transfers from a prior session for files the user selected and for peers + // in our list.. but we don't care about those. We only care if the peerId is in teh pending + // list. + if (!pending.value.contains(peerId)) return null + + return if (transfers.all { it.Finished }) { + // Everything done? SENT if all succeeded, FAILED if any failed. + if (transfers.any { !it.Succeeded }) TransferState.FAILED else TransferState.SENT + } else { + // Not complete, we're still sending + TransferState.SENDING + } + } + + // Loads all of the valid fileTargets from localAPI + private fun loadTargets() { + Client(viewModelScope).fileTargets { result -> + result + .onSuccess { it -> + val allSharablePeers = it.map { it.Node } + val onlinePeers = allSharablePeers.filter { it.Online ?: false }.sortedBy { it.Name } + val offlinePeers = + allSharablePeers.filter { !(it.Online ?: false) }.sortedBy { it.Name } + myPeers.set(onlinePeers + offlinePeers) + } + .onFailure { Log.e(TAG, "Error loading targets: ${it.message}") } + } + } + + // Creates the trailing status view for the peer list item depending on the state of + // any requested transfers. + @Composable + fun TrailingContentForPeer(peerId: String) { + val outgoing = outgoing.collectAsState().value + val pending = pending.collectAsState().value + + // Check our outgoing files for the peer and determine the state of the transfer. + val transfers = outgoing[peerId] ?: emptyList() + var status = transferState(peerId, transfers) + + // Check if we have a pending transfer for this peer. We may not have an outgoing file + // yet, but we still want to show the sending state in the mean time. + if (status == null && pending.contains(peerId)) { + status = TransferState.SENDING + } + + // Still no status? Nothing to render for this peer + if (status == null) return + + Column(modifier = Modifier.fillMaxHeight()) { + when (status) { + TransferState.SENDING -> { + val progress = progress(transfers) + Text( + stringResource(id = R.string.taildrop_sending), + style = MaterialTheme.typography.bodyMedium) + ActivityIndicator(progress, 60) + } + TransferState.SENT -> CheckedIndicator() + TransferState.FAILED -> Text(stringResource(id = R.string.taildrop_share_failed_short)) + } + } + } + + // Commences the file transfer to the specified node iff + fun share(context: Context, node: Tailcfg.Node) { + if (node.Online != true) { + showDialog.set(ErrorDialogType.SHARE_DEVICE_NOT_CONNECTED) + return + } + + // Ignore requests to resend a file (the backend will not overwrite anyway) + outgoing.value[node.StableID]?.let { + val status = transferState(node.StableID, it) + if (status == TransferState.SENDING || status == TransferState.SENT) { + return + } + } + + pending.set(pending.value + node.StableID) + Client(viewModelScope).putTaildropFiles(context, node.StableID, transfers.value) { + // This is an early API failure and will not get communicated back up to us via + // outgoing files - things never made it that far. + if (it.isFailure) { + pending.set(pending.value - node.StableID) + showDialog.set(ErrorDialogType.SHARE_FAILED) + } + } + } +} 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 93f3482..719f330 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -76,6 +76,8 @@ in %d days in %d months in %.1f years + + Accounts Unable to logout at this time. Please try again. Error @@ -100,6 +102,8 @@ Other devices in your tailnet can now route their Internet traffic through this Android device. Make sure to approve this exit node in the admin console in order for other devices to see it. Enabled Disabled + + 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. @@ -113,8 +117,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. @@ -126,4 +134,24 @@ Not Using Tailscale DNS Allow LAN Access + + 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 +