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