diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml
index bb5e8ce..baaac9c 100644
--- a/android/src/main/AndroidManifest.xml
+++ b/android/src/main/AndroidManifest.xml
@@ -48,6 +48,14 @@
+
+
@@ -73,6 +81,8 @@
+
+
diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt
index 1536d92..1a7c799 100644
--- a/android/src/main/java/com/tailscale/ipn/App.kt
+++ b/android/src/main/java/com/tailscale/ipn/App.kt
@@ -62,7 +62,7 @@ import java.util.Locale
import java.util.Objects
class App : Application(), libtailscale.AppContext {
- private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
companion object {
const val STATUS_CHANNEL_ID = "tailscale-status"
@@ -82,7 +82,7 @@ class App : Application(), libtailscale.AppContext {
@JvmStatic
fun startActivityForResult(act: Activity, intent: Intent?, request: Int) {
- val f: Fragment = act.getFragmentManager().findFragmentByTag(PEER_TAG)
+ val f: Fragment = act.fragmentManager.findFragmentByTag(PEER_TAG)
f.startActivityForResult(intent, request)
}
@@ -245,7 +245,7 @@ class App : Application(), libtailscale.AppContext {
val manu = Build.MANUFACTURER
var model = Build.MODEL
// Strip manufacturer from model.
- val idx = model.toLowerCase(Locale.getDefault()).indexOf(manu.toLowerCase(Locale.getDefault()))
+ val idx = model.lowercase(Locale.getDefault()).indexOf(manu.lowercase(Locale.getDefault()))
if (idx != -1) {
model = model.substring(idx + manu.length).trim()
}
@@ -267,10 +267,10 @@ class App : Application(), libtailscale.AppContext {
fun attachPeer(act: Activity) {
act.runOnUiThread(
Runnable {
- val ft: FragmentTransaction = act.getFragmentManager().beginTransaction()
+ val ft: FragmentTransaction = act.fragmentManager.beginTransaction()
ft.add(Peer(), PEER_TAG)
ft.commit()
- act.getFragmentManager().executePendingTransactions()
+ act.fragmentManager.executePendingTransactions()
})
}
@@ -306,7 +306,7 @@ class App : Application(), libtailscale.AppContext {
// getPackageSignatureFingerprint returns the first package signing certificate, if any.
get() {
val info: PackageInfo
- info = getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_SIGNATURES)
+ info = packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES)
for (signature in info.signatures) {
return signature.toByteArray()
}
@@ -316,7 +316,7 @@ class App : Application(), libtailscale.AppContext {
@Throws(IOException::class)
fun insertMedia(name: String?, mimeType: String): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- val resolver: ContentResolver = getContentResolver()
+ val resolver: ContentResolver = contentResolver
val contentValues = ContentValues()
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name)
if ("" != mimeType) {
@@ -334,12 +334,12 @@ class App : Application(), libtailscale.AppContext {
@Throws(IOException::class)
fun openUri(uri: String?, mode: String?): Int? {
- val resolver: ContentResolver = getContentResolver()
+ val resolver: ContentResolver = contentResolver
return mode?.let { resolver.openFileDescriptor(Uri.parse(uri), it)?.detachFd() }
}
fun deleteUri(uri: String?) {
- val resolver: ContentResolver = getContentResolver()
+ val resolver: ContentResolver = contentResolver
resolver.delete(Uri.parse(uri), null, null)
}
diff --git a/android/src/main/java/com/tailscale/ipn/IPNActivity.java b/android/src/main/java/com/tailscale/ipn/IPNActivity.java
deleted file mode 100644
index 48792eb..0000000
--- a/android/src/main/java/com/tailscale/ipn/IPNActivity.java
+++ /dev/null
@@ -1,110 +0,0 @@
-// Copyright (c) Tailscale Inc & AUTHORS
-// SPDX-License-Identifier: BSD-3-Clause
-
-package com.tailscale.ipn;
-
-import android.app.Activity;
-import android.content.Intent;
-import android.content.res.Configuration;
-import android.database.Cursor;
-import android.net.Uri;
-import android.os.Bundle;
-import android.provider.OpenableColumns;
-
-import java.util.List;
-
-public final class IPNActivity extends Activity {
- @Override
- public void onCreate(Bundle state) {
- super.onCreate(state);
- handleIntent();
- }
-
- @Override
- public void onNewIntent(Intent i) {
- setIntent(i);
- handleIntent();
- }
-
- private void handleIntent() {
- Intent it = getIntent();
- String act = it.getAction();
- String[] texts;
- Uri[] uris;
- if (Intent.ACTION_SEND.equals(act)) {
- uris = new Uri[]{it.getParcelableExtra(Intent.EXTRA_STREAM)};
- texts = new String[]{it.getStringExtra(Intent.EXTRA_TEXT)};
- } else if (Intent.ACTION_SEND_MULTIPLE.equals(act)) {
- List extraUris = it.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
- uris = extraUris.toArray(new Uri[0]);
- texts = new String[uris.length];
- } else {
- return;
- }
- String mime = it.getType();
- int nitems = uris.length;
- String[] items = new String[nitems];
- String[] mimes = new String[nitems];
- int[] types = new int[nitems];
- String[] names = new String[nitems];
- long[] sizes = new long[nitems];
- int nfiles = 0;
- for (int i = 0; i < uris.length; i++) {
- String text = texts[i];
- Uri uri = uris[i];
- if (text != null) {
- types[nfiles] = 1; // FileTypeText
- names[nfiles] = "file.txt";
- mimes[nfiles] = mime;
- items[nfiles] = text;
- // Determined by len(text) in Go to eliminate UTF-8 encoding differences.
- sizes[nfiles] = 0;
- nfiles++;
- } else if (uri != null) {
- Cursor c = getContentResolver().query(uri, null, null, null, null);
- if (c == null) {
- // Ignore files we have no permission to access.
- continue;
- }
- int nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME);
- int sizeCol = c.getColumnIndex(OpenableColumns.SIZE);
- c.moveToFirst();
- String name = c.getString(nameCol);
- long size = c.getLong(sizeCol);
- types[nfiles] = 2; // FileTypeURI
- mimes[nfiles] = mime;
- items[nfiles] = uri.toString();
- names[nfiles] = name;
- sizes[nfiles] = size;
- nfiles++;
- }
- }
- // TODO(oxtoacart): actually implement this
-// App.onShareIntent(nfiles, types, mimes, items, names, sizes);
- }
-
- @Override
- public void onDestroy() {
- super.onDestroy();
- }
-
- @Override
- public void onStart() {
- super.onStart();
- }
-
- @Override
- public void onStop() {
- super.onStop();
- }
-
- @Override
- public void onConfigurationChanged(Configuration c) {
- super.onConfigurationChanged(c);
- }
-
- @Override
- public void onLowMemory() {
- super.onLowMemory();
- }
-}
diff --git a/android/src/main/java/com/tailscale/ipn/IPNService.kt b/android/src/main/java/com/tailscale/ipn/IPNService.kt
index 3a8bce7..ea2e54d 100644
--- a/android/src/main/java/com/tailscale/ipn/IPNService.kt
+++ b/android/src/main/java/com/tailscale/ipn/IPNService.kt
@@ -10,8 +10,8 @@ import android.os.Build
import android.system.OsConstants
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
-import libtailscale.Libtailscale
import java.util.UUID
+import libtailscale.Libtailscale
open class IPNService : VpnService(), libtailscale.IPNService {
private val randomID: String = UUID.randomUUID().toString()
@@ -63,7 +63,7 @@ open class IPNService : VpnService(), libtailscale.IPNService {
return PendingIntent.getActivity(
this,
0,
- Intent(this, IPNActivity::class.java),
+ Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt
index 5f0ee8e..9d98083 100644
--- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt
+++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt
@@ -62,6 +62,8 @@ class MainActivity : ComponentActivity() {
@JvmStatic val requestSignin: Int = 1000
// requestPrepareVPN is for when Android's VpnService.prepare completes.
@JvmStatic val requestPrepareVPN: Int = 1001
+
+ const val WRITE_STORAGE_RESULT = 1000
}
override fun onCreate(savedInstanceState: Bundle?) {
@@ -193,13 +195,9 @@ class MainActivity : ComponentActivity() {
val contract = VpnPermissionContract()
requestVpnPermission =
registerForActivityResult(contract) { granted ->
- Notifier.vpnPermissionGranted.set(granted)
Log.i("VPN", "VPN permission ${if (granted) "granted" else "denied"}")
}
requestVpnPermission.launch(Unit)
- } else {
- Notifier.vpnPermissionGranted.set(true)
- Log.i("VPN", "VPN permission granted")
}
}
diff --git a/android/src/main/java/com/tailscale/ipn/ShareActivity.kt b/android/src/main/java/com/tailscale/ipn/ShareActivity.kt
new file mode 100644
index 0000000..8f2cbaa
--- /dev/null
+++ b/android/src/main/java/com/tailscale/ipn/ShareActivity.kt
@@ -0,0 +1,106 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package com.tailscale.ipn
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.provider.OpenableColumns
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.lifecycle.lifecycleScope
+import com.tailscale.ipn.ui.model.Ipn
+import com.tailscale.ipn.ui.notifier.Notifier
+import com.tailscale.ipn.ui.theme.AppTheme
+import com.tailscale.ipn.ui.util.set
+import com.tailscale.ipn.ui.view.TaildropView
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+
+// ShareActivity is the entry point for Taildrop share intents
+class ShareActivity : ComponentActivity() {
+ private val TAG = ShareActivity::class.simpleName
+
+ private val requestedTransfers: StateFlow> = MutableStateFlow(emptyList())
+
+ override fun onCreate(state: Bundle?) {
+ super.onCreate(state)
+ setContent {
+ AppTheme { TaildropView(requestedTransfers, (application as App).applicationScope) }
+ }
+ }
+
+ override fun onStart() {
+ super.onStart()
+ Notifier.start(lifecycleScope)
+ loadFiles()
+ }
+
+ override fun onStop() {
+ super.onStop()
+ Notifier.stop()
+ }
+
+ override fun onNewIntent(intent: Intent?) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ loadFiles()
+ }
+
+ // Loads the files from the intent.
+ fun loadFiles() {
+ if (intent == null) {
+ Log.e(TAG, "Share failure - No intent found")
+ return
+ }
+
+ val act = intent.action
+ val uris: List?
+
+ uris =
+ when (act) {
+ Intent.ACTION_SEND -> {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ listOf(intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java))
+ } else {
+ listOf(intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri)
+ }
+ }
+ Intent.ACTION_SEND_MULTIPLE -> {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
+ } else {
+ intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
+ }
+ }
+ else -> {
+ Log.e(TAG, "No extras found in intent - nothing to share")
+ null
+ }
+ }
+
+ val pendingFiles: List =
+ uris?.filterNotNull()?.mapNotNull {
+ contentResolver?.query(it, null, null, null, null)?.let { c ->
+ val nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
+ val sizeCol = c.getColumnIndex(OpenableColumns.SIZE)
+ c.moveToFirst()
+ val name = c.getString(nameCol)
+ val size = c.getLong(sizeCol)
+ c.close()
+ val file = Ipn.OutgoingFile(Name = name, DeclaredSize = size)
+ file.uri = it
+ file
+ }
+ } ?: emptyList()
+
+ if (pendingFiles.isEmpty()) {
+ Log.e(TAG, "Share failure - no files extracted from intent")
+ }
+
+ requestedTransfers.set(pendingFiles)
+ }
+}
diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt
index 4305051..b313ba3 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt
@@ -3,6 +3,7 @@
package com.tailscale.ipn.ui.localapi
+import android.content.Context
import android.util.Log
import com.tailscale.ipn.ui.model.BugReportID
import com.tailscale.ipn.ui.model.Errors
@@ -20,7 +21,6 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.serializer
import libtailscale.FilePart
-import java.io.File
import java.nio.charset.Charset
import kotlin.reflect.KType
import kotlin.reflect.typeOf
@@ -61,6 +61,8 @@ typealias PrefsHandler = (Result) -> Unit
* corresponding method on this Client.
*/
class Client(private val scope: CoroutineScope) {
+ private val TAG = Client::class.simpleName
+
fun status(responseHandler: StatusResponseHandler) {
get(Endpoint.STATUS, responseHandler = responseHandler)
}
@@ -116,26 +118,42 @@ class Client(private val scope: CoroutineScope) {
get(Endpoint.TKA_STATUS, responseHandler = responseHandler)
}
+ fun fileTargets(responseHandler: (Result>) -> Unit) {
+ get(Endpoint.FILE_TARGETS, responseHandler = responseHandler)
+ }
+
fun putTaildropFiles(
+ context: Context,
peerId: StableNodeID,
- files: Collection,
+ files: Collection,
responseHandler: (Result) -> Unit
) {
- val manifest = Json.encodeToString(files.map { it.name to it.length() }.toMap())
+ val manifest = Json.encodeToString(files)
val manifestPart = FilePart()
manifestPart.body = InputStreamAdapter(manifest.byteInputStream(Charset.defaultCharset()))
manifestPart.filename = "manifest.json"
manifestPart.contentType = "application/json"
- manifestPart.contentLength = manifest.length.toLong()
val parts = mutableListOf(manifestPart)
- parts.addAll(
- files.map { file ->
- val part = FilePart()
- part.filename = file.name
- part.contentLength = file.length()
- part.body = InputStreamAdapter(file.inputStream())
- part
- })
+
+ try {
+ parts.addAll(
+ files.map { file ->
+ val stream =
+ context.contentResolver.openInputStream(file.uri)
+ ?: throw Exception("Error opening file stream")
+
+ val part = FilePart()
+ part.filename = file.Name
+ part.contentLength = file.DeclaredSize
+ part.body = InputStreamAdapter(stream)
+ part
+ })
+ } catch (e: Exception) {
+ parts.forEach { it.body.close() }
+ Log.e(TAG, "Error creating file upload body: $e")
+ responseHandler(Result.failure(e))
+ return
+ }
return postMultipart(
"${Endpoint.FILE_PUT}/${peerId}",
@@ -234,7 +252,7 @@ class Client(private val scope: CoroutineScope) {
}
}
-public class Request(
+class Request(
private val scope: CoroutineScope,
private val method: String,
path: String,
diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt
index a330626..9c95294 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt
@@ -3,7 +3,10 @@
package com.tailscale.ipn.ui.model
+import android.net.Uri
import kotlinx.serialization.Serializable
+import kotlinx.serialization.Transient
+import java.util.UUID
class Ipn {
@@ -158,16 +161,27 @@ class Ipn {
@Serializable
data class OutgoingFile(
+ val ID: String = "",
val Name: String,
- val PeerID: StableNodeID,
- val Started: String,
+ val PeerID: StableNodeID = "",
+ val Started: String = "",
val DeclaredSize: Long,
- val Sent: Long,
+ val Sent: Long = 0L,
val PartialPath: String? = null,
var FinalPath: String? = null,
- val Finished: Boolean,
- val Succeeded: Boolean,
- )
+ val Finished: Boolean = false,
+ val Succeeded: Boolean = false,
+ ) {
+ @Transient lateinit var uri: Uri // only used on client
+
+ fun prepare(peerId: StableNodeID): OutgoingFile {
+ val f = copy(ID = UUID.randomUUID().toString(), PeerID = peerId)
+ f.uri = uri
+ return f
+ }
+ }
+
+ @Serializable data class FileTarget(var Node: Tailcfg.Node, var PeerAPIURL: String)
}
class Persist {
diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt
index 6eec247..284d264 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt
@@ -25,7 +25,7 @@ typealias BugReportID = String
// Represents and empty message with a single 'property' field.
class Empty {
- @Serializable data class Message(val property: String)
+ @Serializable data class Message(val property: String = "")
}
// Parsable errors returned by localApiService
diff --git a/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt b/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt
index c16b505..ad038f7 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt
@@ -4,6 +4,7 @@
package com.tailscale.ipn.ui.notifier
import android.util.Log
+import com.tailscale.ipn.ui.model.Empty
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Ipn.Notify
import com.tailscale.ipn.ui.model.Netmap
@@ -29,6 +30,11 @@ object Notifier {
private val TAG = Notifier::class.simpleName
private val decoder = Json { ignoreUnknownKeys = true }
+ // Global App State
+ val tileReady: StateFlow = MutableStateFlow(false)
+ val readyToPrepareVPN: StateFlow = MutableStateFlow(false)
+
+ // General IPN Bus State
val state: StateFlow = MutableStateFlow(Ipn.State.NoState)
val netmap: StateFlow = MutableStateFlow(null)
val prefs: StateFlow = MutableStateFlow(null)
@@ -37,10 +43,11 @@ object Notifier {
val browseToURL: StateFlow = MutableStateFlow(null)
val loginFinished: StateFlow = MutableStateFlow(null)
val version: StateFlow = MutableStateFlow(null)
- val vpnPermissionGranted: StateFlow = MutableStateFlow(null)
- val tileReady: StateFlow = MutableStateFlow(false)
- val readyToPrepareVPN: StateFlow = MutableStateFlow(false)
+
+ // Taildrop-specific State
val outgoingFiles: StateFlow?> = MutableStateFlow(null)
+ val incomingFiles: StateFlow?> = MutableStateFlow(null)
+ val filesWaiting: StateFlow = MutableStateFlow(null)
private lateinit var app: libtailscale.Application
private var manager: libtailscale.NotificationManager? = null
@@ -70,6 +77,8 @@ object Notifier {
notify.LoginFinished?.let { loginFinished.set(it.property) }
notify.Version?.let(version::set)
notify.OutgoingFiles?.let(outgoingFiles::set)
+ notify.FilesWaiting?.let(filesWaiting::set)
+ notify.IncomingFiles?.let(incomingFiles::set)
}
state.collect { currstate ->
readyToPrepareVPN.set(currstate > Ipn.State.Stopped)
diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/Buttons.kt b/android/src/main/java/com/tailscale/ipn/ui/view/Buttons.kt
index d762edd..271fa0b 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/view/Buttons.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/view/Buttons.kt
@@ -6,14 +6,21 @@ package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Clear
+import androidx.compose.material.icons.outlined.Close
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.theme.ts_color_light_blue
@@ -45,3 +52,20 @@ fun OpenURLButton(title: String, url: String) {
contentColor = MaterialTheme.colorScheme.secondary,
containerColor = MaterialTheme.colorScheme.secondaryContainer))
}
+
+@Composable
+fun ClearButton(onClick: () -> Unit) {
+ IconButton(onClick = onClick, modifier = Modifier.size(24.dp)) {
+ Icon(Icons.Outlined.Clear, null)
+ }
+}
+
+
+@Composable
+fun CloseButton() {
+ val focusManager = LocalFocusManager.current
+
+ IconButton(onClick = { focusManager.clearFocus() }, modifier = Modifier.size(24.dp)) {
+ Icon(Icons.Outlined.Close, null)
+ }
+}
diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt b/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt
index 7e5771b..b389b0c 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt
@@ -13,7 +13,9 @@ import com.tailscale.ipn.R
enum class ErrorDialogType {
LOGOUT_FAILED,
SWITCH_USER_FAILED,
- ADD_PROFILE_FAILED;
+ ADD_PROFILE_FAILED,
+ SHARE_DEVICE_NOT_CONNECTED,
+ SHARE_FAILED;
val message: Int
get() {
@@ -21,10 +23,21 @@ enum class ErrorDialogType {
LOGOUT_FAILED -> R.string.logout_failed
SWITCH_USER_FAILED -> R.string.switch_user_failed
ADD_PROFILE_FAILED -> R.string.add_profile_failed
+ SHARE_DEVICE_NOT_CONNECTED -> R.string.share_device_not_connected
+ SHARE_FAILED -> R.string.taildrop_share_failed
}
}
- val title: Int = R.string.error
+ val title: Int
+ get() {
+ return when (this) {
+ LOGOUT_FAILED -> R.string.logout_failed_title
+ SWITCH_USER_FAILED -> R.string.switch_user_failed_title
+ ADD_PROFILE_FAILED -> R.string.add_profile_failed_title
+ SHARE_DEVICE_NOT_CONNECTED -> R.string.share_device_not_connected_title
+ SHARE_FAILED -> R.string.taildrop_share_failed_title
+ }
+ }
val buttonText: Int = R.string.ok
}
diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt
index c8ce7cd..c973b93 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt
@@ -21,8 +21,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowDropDown
-import androidx.compose.material.icons.outlined.Clear
-import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Button
@@ -42,7 +40,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -307,22 +304,6 @@ fun ConnectView(
}
}
-@Composable
-fun ClearButton(onClick: () -> Unit) {
- IconButton(onClick = onClick, modifier = Modifier.size(24.dp)) {
- Icon(Icons.Outlined.Clear, null)
- }
-}
-
-@Composable
-fun CloseButton() {
- val focusManager = LocalFocusManager.current
-
- IconButton(onClick = { focusManager.clearFocus() }, modifier = Modifier.size(24.dp)) {
- Icon(Icons.Outlined.Close, null)
- }
-}
-
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PeerList(
diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/PeerView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/PeerView.kt
new file mode 100644
index 0000000..b363019
--- /dev/null
+++ b/android/src/main/java/com/tailscale/ipn/ui/view/PeerView.kt
@@ -0,0 +1,65 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package com.tailscale.ipn.ui.view
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import com.tailscale.ipn.ui.model.Ipn
+import com.tailscale.ipn.ui.model.Tailcfg
+import com.tailscale.ipn.ui.theme.ts_color_light_green
+
+@Composable
+fun PeerView(
+ peer: Tailcfg.Node,
+ selfPeer: String? = null,
+ stateVal: Ipn.State? = null,
+ disabled: Boolean = false,
+ subtitle: () -> String = { peer.Addresses?.first()?.split("/")?.first() ?: "" },
+ onClick: (Tailcfg.Node) -> Unit = {},
+ trailingContent: @Composable () -> Unit = {}
+) {
+ val textColor = if (disabled) Color.Gray else MaterialTheme.colorScheme.primary
+
+ ListItem(
+ modifier = Modifier.clickable { onClick(peer) },
+ headlineContent = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ // By definition, SelfPeer is online since we will not show the peer list
+ // unless you're connected.
+ val isSelfAndRunning = (peer.StableID == selfPeer && stateVal == Ipn.State.Running)
+ val color: Color =
+ if ((peer.Online == true) || isSelfAndRunning) {
+ ts_color_light_green
+ } else {
+ Color.Gray
+ }
+ Box(
+ modifier =
+ Modifier.size(8.dp)
+ .background(color = color, shape = RoundedCornerShape(percent = 50))) {}
+ Spacer(modifier = Modifier.size(8.dp))
+ Text(
+ text = peer.ComputedName,
+ style = MaterialTheme.typography.titleMedium,
+ color = textColor)
+ }
+ },
+ supportingContent = {
+ Text(text = subtitle(), style = MaterialTheme.typography.bodyMedium, color = textColor)
+ },
+ trailingContent = trailingContent)
+}
diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SharedViews.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SharedViews.kt
index 897c4cb..6b90218 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/view/SharedViews.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/view/SharedViews.kt
@@ -13,6 +13,7 @@ import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
+import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -21,6 +22,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
+import com.tailscale.ipn.ui.theme.ts_color_light_blue
data class BackNavigation(
val onBack: () -> Unit,
@@ -51,14 +53,24 @@ fun BackArrow(action: () -> Unit) {
@Composable
fun CheckedIndicator() {
- Icon(Icons.Default.CheckCircle, null)
+ Icon(Icons.Default.CheckCircle, "selected", tint = ts_color_light_blue)
}
@Composable
fun SimpleActivityIndicator(size: Int = 32) {
CircularProgressIndicator(
modifier = Modifier.width(size.dp),
- color = MaterialTheme.colorScheme.primary,
+ color = ts_color_light_blue,
trackColor = MaterialTheme.colorScheme.secondary,
)
}
+
+@Composable
+fun ActivityIndicator(progress: Double, size: Int = 32) {
+ LinearProgressIndicator(
+ progress = { progress.toFloat() },
+ modifier = Modifier.width(size.dp),
+ color = ts_color_light_blue,
+ trackColor = MaterialTheme.colorScheme.secondary,
+ )
+}
diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/TaildropView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/TaildropView.kt
new file mode 100644
index 0000000..0facade
--- /dev/null
+++ b/android/src/main/java/com/tailscale/ipn/ui/view/TaildropView.kt
@@ -0,0 +1,195 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package com.tailscale.ipn.ui.view
+
+import android.text.format.Formatter
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import coil.compose.AsyncImage
+import com.tailscale.ipn.R
+import com.tailscale.ipn.ui.model.Ipn
+import com.tailscale.ipn.ui.model.Tailcfg
+import com.tailscale.ipn.ui.util.set
+import com.tailscale.ipn.ui.viewModel.TaildropViewModel
+import com.tailscale.ipn.ui.viewModel.TaildropViewModelFactory
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.StateFlow
+
+@Composable
+fun TaildropView(
+ requestedTransfers: StateFlow>,
+ applicationScope: CoroutineScope,
+ viewModel: TaildropViewModel =
+ viewModel(factory = TaildropViewModelFactory(requestedTransfers, applicationScope))
+) {
+ Scaffold(topBar = { Header(R.string.share) }) { paddingInsets ->
+ val showDialog = viewModel.showDialog.collectAsState().value
+
+ // Show the error overlay
+ showDialog?.let { ErrorDialog(type = it, action = { viewModel.showDialog.set(null) }) }
+
+ Column(modifier = Modifier.padding(paddingInsets)) {
+ FileShareHeader(
+ fileTransfers = requestedTransfers.collectAsState().value,
+ totalSize = viewModel.totalSize)
+ Spacer(modifier = Modifier.size(8.dp))
+
+ when (viewModel.state.collectAsState().value) {
+ Ipn.State.Running -> {
+ val peers = viewModel.myPeers.collectAsState().value
+ val context = LocalContext.current
+ FileSharePeerList(
+ peers = peers,
+ stateViewGenerator = { peerId -> viewModel.TrailingContentForPeer(peerId = peerId) },
+ onShare = { viewModel.share(context, it) })
+ }
+ else -> {
+ FileShareConnectView { viewModel.startVPN() }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun FileSharePeerList(
+ peers: List,
+ stateViewGenerator: @Composable (String) -> Unit,
+ onShare: (Tailcfg.Node) -> Unit
+) {
+ Column(modifier = Modifier.padding(horizontal = 8.dp)) {
+ Text(stringResource(R.string.my_devices), style = MaterialTheme.typography.titleMedium)
+ }
+
+ when (peers.isEmpty()) {
+ true -> {
+ Column(
+ modifier = Modifier.padding(horizontal = 8.dp).fillMaxHeight(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ stringResource(R.string.no_devices_to_share_with),
+ style = MaterialTheme.typography.titleMedium)
+ }
+ }
+ false -> {
+ LazyColumn {
+ peers.forEach { peer ->
+ val disabled = !(peer.Online ?: false)
+ item {
+ PeerView(
+ peer = peer,
+ onClick = { onShare(peer) },
+ disabled = disabled,
+ subtitle = { peer.Hostinfo.OS ?: "" },
+ trailingContent = { stateViewGenerator(peer.StableID) })
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun FileShareConnectView(onToggle: () -> Unit) {
+ Column(
+ modifier = Modifier.padding(horizontal = 8.dp).fillMaxHeight(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ stringResource(R.string.connect_to_your_tailnet_to_share_files),
+ style = MaterialTheme.typography.titleMedium)
+ Spacer(modifier = Modifier.size(1.dp))
+ PrimaryActionButton(onClick = onToggle) {
+ Text(
+ text = stringResource(id = R.string.connect),
+ fontSize = MaterialTheme.typography.titleMedium.fontSize)
+ }
+ }
+}
+
+@Composable
+fun FileShareHeader(fileTransfers: List, totalSize: Long) {
+ Column(modifier = Modifier.padding(horizontal = 8.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ IconForTransfer(fileTransfers)
+ Column(modifier = Modifier.padding(horizontal = 8.dp)) {
+ when (fileTransfers.isEmpty()) {
+ true ->
+ Text(
+ stringResource(R.string.no_files_to_share),
+ style = MaterialTheme.typography.titleMedium)
+ false -> {
+
+ when (fileTransfers.size) {
+ 1 -> Text(fileTransfers[0].Name, style = MaterialTheme.typography.titleMedium)
+ else ->
+ Text(
+ stringResource(R.string.file_count, fileTransfers.size),
+ style = MaterialTheme.typography.titleMedium)
+ }
+ }
+ }
+ val size = Formatter.formatFileSize(LocalContext.current, totalSize.toLong())
+ Text(size, style = MaterialTheme.typography.titleMedium)
+ }
+ }
+ HorizontalDivider()
+ }
+}
+
+@Composable
+fun IconForTransfer(transfers: List) {
+ // (jonathan) TODO: Thumbnails?
+ when (transfers.size) {
+ 0 ->
+ Icon(
+ painter = painterResource(R.drawable.warning),
+ contentDescription = "no files",
+ modifier = Modifier.size(32.dp))
+ 1 -> {
+ // Show a thumbnail for single image shares.
+ val context = LocalContext.current
+ context.contentResolver.getType(transfers[0].uri)?.let {
+ if (it.startsWith("image/")) {
+ AsyncImage(
+ model = transfers[0].uri,
+ contentDescription = "one file",
+ modifier = Modifier.size(40.dp))
+ return
+ }
+
+ Icon(
+ painter = painterResource(R.drawable.single_file),
+ contentDescription = "files",
+ modifier = Modifier.size(40.dp))
+ }
+ }
+ else ->
+ Icon(
+ painter = painterResource(R.drawable.single_file),
+ contentDescription = "files",
+ modifier = Modifier.size(40.dp))
+ }
+}
diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt
index 6911f00..d88026c 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt
@@ -6,11 +6,16 @@ package com.tailscale.ipn.ui.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.ListItem
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.IpnLocal
diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt
index 8c5a922..987f87d 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt
@@ -3,6 +3,9 @@
package com.tailscale.ipn.ui.viewModel
+import android.app.Activity
+import android.content.Context
+import android.content.ContextWrapper
import android.content.Intent
import android.util.Log
import androidx.lifecycle.ViewModel
@@ -35,7 +38,7 @@ open class IpnViewModel : ViewModel() {
val loginProfiles: StateFlow?> = MutableStateFlow(null)
// The userId associated with the current node. ie: The logged in user.
- var selfNodeUserId: UserID? = null
+ private var selfNodeUserId: UserID? = null
init {
viewModelScope.launch {
@@ -65,6 +68,13 @@ open class IpnViewModel : ViewModel() {
Log.d(TAG, "Created")
}
+ protected fun Context.findActivity(): Activity? =
+ when (this) {
+ is Activity -> this
+ is ContextWrapper -> baseContext.findActivity()
+ else -> null
+ }
+
private fun loadUserProfiles() {
Client(viewModelScope).profiles { result ->
result.onSuccess(loginProfiles::set).onFailure {
@@ -86,7 +96,7 @@ open class IpnViewModel : ViewModel() {
}
}
- private fun startVPN() {
+ fun startVPN() {
val context = App.getApplication().applicationContext
val intent = Intent(context, IPNReceiver::class.java)
intent.action = IPNReceiver.INTENT_CONNECT_VPN
diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/TaildropViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/TaildropViewModel.kt
new file mode 100644
index 0000000..7648830
--- /dev/null
+++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/TaildropViewModel.kt
@@ -0,0 +1,200 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package com.tailscale.ipn.ui.viewModel
+
+import android.content.Context
+import android.util.Log
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.tailscale.ipn.R
+import com.tailscale.ipn.ui.localapi.Client
+import com.tailscale.ipn.ui.model.Ipn
+import com.tailscale.ipn.ui.model.StableNodeID
+import com.tailscale.ipn.ui.model.Tailcfg
+import com.tailscale.ipn.ui.notifier.Notifier
+import com.tailscale.ipn.ui.util.set
+import com.tailscale.ipn.ui.view.ActivityIndicator
+import com.tailscale.ipn.ui.view.CheckedIndicator
+import com.tailscale.ipn.ui.view.ErrorDialogType
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.launch
+
+class TaildropViewModelFactory(
+ private val requestedTransfers: StateFlow>,
+ private val applicationScope: CoroutineScope
+) : ViewModelProvider.Factory {
+ override fun create(modelClass: Class): T {
+ return TaildropViewModel(requestedTransfers, applicationScope) as T
+ }
+}
+
+class TaildropViewModel(
+ val requestedTransfers: StateFlow>,
+ private val applicationScope: CoroutineScope
+) : IpnViewModel() {
+
+ // Represents the state of a file transfer
+ enum class TransferState {
+ SENDING,
+ SENT,
+ FAILED
+ }
+
+ // The overall VPN state
+ val state = Notifier.state
+
+ // Set of all nodes for which we've requested a file transfer. This is used to prevent us from
+ // request a transfer to the same peer twice.
+ private val selectedPeers: StateFlow> = MutableStateFlow(emptySet())
+ // Set of OutgoingFile.IDs that we're currently transferring.
+ private val currentTransferIDs: StateFlow> = MutableStateFlow(emptySet())
+ // Flow of Ipn.OutgoingFiles with updated statuses for every entry in transferWithStatuses.
+ private val transfers: StateFlow> = MutableStateFlow(emptyList())
+
+ // The total size of all pending files.
+ val totalSize: Long
+ get() = requestedTransfers.value.sumOf { it.DeclaredSize }
+
+ // The list of peers that we can share with. This includes only the nodes belonging to the user
+ // and excludes the current node. Sorted by online devices first, and offline second,
+ // alphabetically.
+ val myPeers: StateFlow> = MutableStateFlow(emptyList())
+
+ // Non null if there's an error to be rendered.
+ val showDialog: StateFlow = MutableStateFlow(null)
+
+ init {
+ viewModelScope.launch {
+ Notifier.state.collect {
+ if (it == Ipn.State.Running) {
+ loadTargets()
+ }
+ }
+ }
+
+ viewModelScope.launch {
+ // Map the outgoing files by their PeerId since we need to display them for each peer
+ // We only need to track files which are pending send, everything else is irrelevant.
+ Notifier.outgoingFiles
+ .combine(currentTransferIDs) { outgoingFiles, ongoingIDs ->
+ Pair(outgoingFiles, ongoingIDs)
+ }
+ .collect { (outgoingFiles, ongoingIDs) ->
+ outgoingFiles?.let {
+ transfers.set(outgoingFiles.filter { ongoingIDs.contains(it.ID) })
+ } ?: run { transfers.set(emptyList()) }
+ }
+ }
+
+ viewModelScope.launch {
+ requestedTransfers.collect {
+ // This means that we're processing a new share intent, clear current state
+ selectedPeers.set(emptySet())
+ currentTransferIDs.set(emptySet())
+ }
+ }
+ }
+
+ // Calculates the overall progress for a set of outgoing files
+ private fun progress(transfers: List): Double {
+ val total = transfers.sumOf { it.DeclaredSize }.toDouble()
+ val sent = transfers.sumOf { it.Sent }.toDouble()
+ if (total < 0.1) return 0.0
+ return (sent / total)
+ }
+
+ // Calculates the overall state of a set of file transfers.
+ // peerId: The peer ID to check for transfers.
+ // transfers: The list of outgoing file transfers for the peer.
+ private fun transferState(transfers: List): TransferState? {
+ // No transfers? Nothing state
+ if (transfers.isEmpty()) return null
+
+ return if (transfers.all { it.Finished }) {
+ // Everything done? SENT if all succeeded, FAILED if any failed.
+ if (transfers.any { !it.Succeeded }) TransferState.FAILED else TransferState.SENT
+ } else {
+ // Not complete, we're still sending
+ TransferState.SENDING
+ }
+ }
+
+ // Loads all of the valid fileTargets from localAPI
+ private fun loadTargets() {
+ Client(viewModelScope).fileTargets { result ->
+ result
+ .onSuccess { it ->
+ val allSharablePeers = it.map { it.Node }
+ val onlinePeers = allSharablePeers.filter { it.Online ?: false }.sortedBy { it.Name }
+ val offlinePeers =
+ allSharablePeers.filter { !(it.Online ?: false) }.sortedBy { it.Name }
+ myPeers.set(onlinePeers + offlinePeers)
+ }
+ .onFailure { Log.e(TAG, "Error loading targets: ${it.message}") }
+ }
+ }
+
+ // Creates the trailing status view for the peer list item depending on the state of
+ // any requested transfers.
+ @Composable
+ fun TrailingContentForPeer(peerId: String) {
+ // Check our outgoing files for the peer and determine the state of the transfer.
+ val transfers = this.transfers.collectAsState().value.filter { it.PeerID == peerId }
+ var status: TransferState = transferState(transfers) ?: return
+
+ // Still no status? Nothing to render for this peer
+
+ Column(modifier = Modifier.fillMaxHeight()) {
+ when (status) {
+ TransferState.SENDING -> {
+ val progress = progress(transfers)
+ Text(
+ stringResource(id = R.string.taildrop_sending),
+ style = MaterialTheme.typography.bodyMedium)
+ ActivityIndicator(progress, 60)
+ }
+ TransferState.SENT -> CheckedIndicator()
+ TransferState.FAILED -> Text(stringResource(id = R.string.taildrop_share_failed_short))
+ }
+ }
+ }
+
+ // Commences the file transfer to the specified node iff
+ fun share(context: Context, node: Tailcfg.Node) {
+ if (node.Online != true) {
+ showDialog.set(ErrorDialogType.SHARE_DEVICE_NOT_CONNECTED)
+ return
+ }
+
+ if (selectedPeers.value.contains(node.StableID)) {
+ // We've already selected this peer, ignore
+ return
+ }
+ selectedPeers.set(selectedPeers.value + node.StableID)
+
+ val preparedTransfers = requestedTransfers.value.map { it.prepare(node.StableID) }
+ currentTransferIDs.set(currentTransferIDs.value + preparedTransfers.map { it.ID })
+
+ Client(applicationScope).putTaildropFiles(context, node.StableID, preparedTransfers) {
+ // This is an early API failure and will not get communicated back up to us via
+ // outgoing files - things never made it that far.
+ if (it.isFailure) {
+ selectedPeers.set(selectedPeers.value - node.StableID)
+ showDialog.set(ErrorDialogType.SHARE_FAILED)
+ }
+ }
+ }
+}
diff --git a/android/src/main/res/drawable/single_file.xml b/android/src/main/res/drawable/single_file.xml
new file mode 100644
index 0000000..24e774e
--- /dev/null
+++ b/android/src/main/res/drawable/single_file.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/android/src/main/res/drawable/warning.xml b/android/src/main/res/drawable/warning.xml
new file mode 100644
index 0000000..b1726a3
--- /dev/null
+++ b/android/src/main/res/drawable/warning.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml
index fcf5c8f..3fe26a3 100644
--- a/android/src/main/res/values/strings.xml
+++ b/android/src/main/res/values/strings.xml
@@ -78,6 +78,8 @@
in %d days
in %d months
in %.1f years
+
+
Accounts
Unable to logout at this time. Please try again.
Error
@@ -104,6 +106,8 @@
Enabled
Disabled
Disable
+
+
Tailnet lock
Tailnet lock lets devices in your network verify public keys distributed by the coordination server before trusting them for connectivity.
Tailnet lock is currently enabled.
@@ -117,8 +121,12 @@
Tailnet Lock Key
Used to sign this node from another signing device in your tailnet.
Used to authorize changes to the Tailnet lock configuration.
+
+
Bug Report ID
Learn moreā¦
+
+
DNS Settings
Using Tailscale DNS
This device is using Tailscale to resolve DNS names.
@@ -172,4 +180,24 @@
Thank you for granting Tailscale the Notifications permission.
+
+ Send via Tailscale
+ Unable to share files with this device. This device is not currently connected to the tailnet.
+ No files to share
+ %1$s Files
+ Connect to your tailnet to share files
+ My Devices
+ There are no devices on your tailnet to share to
+ Taildrop failed. Some files were not shared. Please try again.
+ Sending
+ Failed
+
+
+
+ Logout Failed
+ Cannot Switch To User
+ Unable to Add Profile
+ Not Connected
+ Taildrop Failed
+
diff --git a/go.mod b/go.mod
index 5b1cc57..8b2730a 100644
--- a/go.mod
+++ b/go.mod
@@ -13,7 +13,7 @@ require (
golang.org/x/mobile v0.0.0-20240319015410-c58ccf4b0c87
golang.org/x/sys v0.18.0
inet.af/netaddr v0.0.0-20220617031823-097006376321
- tailscale.com v1.63.0-pre.0.20240324181545-f78928191539
+ tailscale.com v1.63.0-pre.0.20240327135352-66e4d843c138
)
require (
@@ -38,11 +38,13 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 // indirect
github.com/aws/smithy-go v1.19.0 // indirect
github.com/benoitkugler/textlayout v0.3.0 // indirect
+ github.com/bits-and-blooms/bitset v1.13.0 // indirect
github.com/coreos/go-iptables v0.7.0 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect
github.com/fxamacker/cbor/v2 v2.5.0 // indirect
+ github.com/gaissmai/bart v0.4.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-text/typesetting v0.0.0-20221214153724-0399769901d5 // indirect
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect
diff --git a/go.sum b/go.sum
index 6c27070..df2790d 100644
--- a/go.sum
+++ b/go.sum
@@ -81,6 +81,8 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
+github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE=
+github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
@@ -134,6 +136,8 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fxamacker/cbor/v2 v2.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADivE=
github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
+github.com/gaissmai/bart v0.4.1 h1:G1t58voWkNmT47lBDawH5QhtTDsdqRIO+ftq5x4P9Ls=
+github.com/gaissmai/bart v0.4.1/go.mod h1:KHeYECXQiBjTzQz/om2tqn3sZF1J7hw9m6z41ftj3fg=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo=
@@ -666,5 +670,5 @@ sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU=
-tailscale.com v1.63.0-pre.0.20240324181545-f78928191539 h1:DBpvudmMiWINWePS9RdY+dST7xOideFf26TSgqsu2rQ=
-tailscale.com v1.63.0-pre.0.20240324181545-f78928191539/go.mod h1:cC0b0vYCoSDOLufJX5J5zDUrvV3lYwOLqlt9NW8y4cY=
+tailscale.com v1.63.0-pre.0.20240327135352-66e4d843c138 h1:CUxCS0sUOwasW/zpNGt63oLCV7QZL7dnrwYMik4OGwQ=
+tailscale.com v1.63.0-pre.0.20240327135352-66e4d843c138/go.mod h1:6kGByHNxnFfK1i4gVpdtvpdS1HicHohWXnsfwmXy64I=