diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml
index 9633e2b2bd..6551c89763 100644
--- a/android/src/main/AndroidManifest.xml
+++ b/android/src/main/AndroidManifest.xml
@@ -102,6 +102,10 @@
+
+
diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt
index 2d3ba020f3..d6d559bea9 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 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
companion object {
- private const val FILE_CHANNEL_ID = "tailscale-files"
+ const val FILE_CHANNEL_ID = "tailscale-files"
// Key to store the SAF URI in EncryptedSharedPreferences.
private val PREF_KEY_SAF_URI = "saf_directory_uri"
private const val TAG = "App"
diff --git a/android/src/main/java/com/tailscale/ipn/InlineShareActionReceiver.kt b/android/src/main/java/com/tailscale/ipn/InlineShareActionReceiver.kt
new file mode 100644
index 0000000000..b2f8487c0b
--- /dev/null
+++ b/android/src/main/java/com/tailscale/ipn/InlineShareActionReceiver.kt
@@ -0,0 +1,66 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package com.tailscale.ipn
+
+import android.content.BroadcastReceiver
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.widget.Toast
+import com.tailscale.ipn.util.BrowserOpener
+import com.tailscale.ipn.util.InlineShare
+import com.tailscale.ipn.util.TSLog
+
+// Handles taps on Taildrop inline-share notifications: URL → browser, text → clipboard.
+class InlineShareActionReceiver : BroadcastReceiver() {
+ companion object {
+ private const val TAG = "InlineShareActionReceiver"
+ const val ACTION_CONSUME = "com.tailscale.ipn.INLINE_SHARE_CONSUME"
+ const val EXTRA_KIND = "kind"
+ const val EXTRA_CONTENT = "content"
+ const val EXTRA_ID = "id"
+ }
+
+ override fun onReceive(context: Context, intent: Intent) {
+ val kindRaw = intent.getStringExtra(EXTRA_KIND) ?: return
+ val content = intent.getStringExtra(EXTRA_CONTENT) ?: return
+ val id = intent.getStringExtra(EXTRA_ID)
+
+ val kind =
+ runCatching { InlineShare.Kind.valueOf(kindRaw.uppercase()) }.getOrNull()
+ ?: run {
+ TSLog.w(TAG, "unknown inline share kind: $kindRaw")
+ return
+ }
+
+ when (kind) {
+ InlineShare.Kind.URL -> openUrl(context, content)
+ InlineShare.Kind.TEXT -> copyToClipboard(context, content)
+ }
+
+ if (id != null) {
+ com.tailscale.ipn.ui.notifier.Notifier.removeInlineShare(id)
+ }
+ }
+
+ private fun openUrl(context: Context, content: String) {
+ val uri = runCatching { Uri.parse(content) }.getOrNull()?.takeIf { !it.scheme.isNullOrEmpty() }
+ if (uri == null) {
+ copyToClipboard(context, content)
+ return
+ }
+ if (!BrowserOpener.openInDefaultBrowser(context, uri)) {
+ TSLog.w(TAG, "failed to open URL $content")
+ copyToClipboard(context, content)
+ }
+ }
+
+ private fun copyToClipboard(context: Context, content: String) {
+ val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
+ cm.setPrimaryClip(ClipData.newPlainText("Tailscale", content))
+ Toast.makeText(context, R.string.taildrop_copied_to_clipboard, Toast.LENGTH_SHORT).show()
+ }
+}
diff --git a/android/src/main/java/com/tailscale/ipn/ShareActivity.kt b/android/src/main/java/com/tailscale/ipn/ShareActivity.kt
index 121c1c0d41..d74766742f 100644
--- a/android/src/main/java/com/tailscale/ipn/ShareActivity.kt
+++ b/android/src/main/java/com/tailscale/ipn/ShareActivity.kt
@@ -8,6 +8,7 @@ import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.OpenableColumns
+import android.util.Patterns
import android.webkit.MimeTypeMap
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -30,6 +31,7 @@ import com.tailscale.ipn.ui.theme.AppTheme
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.util.universalFit
import com.tailscale.ipn.ui.view.TaildropView
+import com.tailscale.ipn.util.InlineShare
import com.tailscale.ipn.util.TSLog
import kotlin.random.Random
import kotlinx.coroutines.Dispatchers
@@ -120,22 +122,29 @@ class ShareActivity : ComponentActivity() {
}
}
- val pendingFiles: List =
- uris?.filterNotNull()?.mapNotNull { uri ->
- contentResolver?.query(uri, null, null, null, null)?.use { cursor ->
- val nameCol = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
- val sizeCol = cursor.getColumnIndex(OpenableColumns.SIZE)
+ val pendingFiles: MutableList =
+ uris
+ ?.filterNotNull()
+ ?.mapNotNull { uri ->
+ contentResolver?.query(uri, null, null, null, null)?.use { cursor ->
+ val nameCol = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
+ val sizeCol = cursor.getColumnIndex(OpenableColumns.SIZE)
- if (cursor.moveToFirst()) {
- val name: String = cursor.getString(nameCol) ?: generateFallbackName(uri)
- val size: Long = cursor.getLong(sizeCol)
- Ipn.OutgoingFile(Name = name, DeclaredSize = size).apply { this.uri = uri }
- } else {
- TSLog.e(TAG, "Cursor is empty for URI: $uri")
- null
+ if (cursor.moveToFirst()) {
+ val name: String = cursor.getString(nameCol) ?: generateFallbackName(uri)
+ val size: Long = cursor.getLong(sizeCol)
+ Ipn.OutgoingFile(Name = name, DeclaredSize = size).apply { this.uri = uri }
+ } else {
+ TSLog.e(TAG, "Cursor is empty for URI: $uri")
+ null
+ }
+ }
}
- }
- } ?: emptyList()
+ ?.toMutableList() ?: mutableListOf()
+
+ if (pendingFiles.isEmpty() && act == Intent.ACTION_SEND) {
+ inlineShareFromIntent(intent)?.let { pendingFiles.add(it) }
+ }
if (pendingFiles.isEmpty()) {
TSLog.e(TAG, "Share failure - no files extracted from intent")
@@ -144,6 +153,55 @@ class ShareActivity : ComponentActivity() {
requestedTransfers.set(pendingFiles)
}
+ private fun inlineShareFromIntent(intent: Intent): Ipn.OutgoingFile? {
+ val raw = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()?.trim().orEmpty()
+ if (raw.isEmpty()) return null
+
+ val text = stripChromeHighlightedTextShare(raw)
+ val kind = classifyKind(text)
+
+ return try {
+ val file = InlineShare.writeToCache(applicationContext, kind, text)
+ Ipn.OutgoingFile(Name = file.name, DeclaredSize = file.length()).apply {
+ this.uri = Uri.fromFile(file)
+ this.inlineShare = InlineShare(kind = kind, content = text)
+ }
+ } catch (e: Exception) {
+ TSLog.e(TAG, "Failed to write inline share: $e")
+ null
+ }
+ }
+
+ private fun classifyKind(text: String): InlineShare.Kind {
+ // Patterns.WEB_URL covers http(s)://, ftp://, www., bare hostnames. Use .matches()
+ // so a URL embedded in a sentence stays TEXT.
+ if (Patterns.WEB_URL.matcher(text).matches()) {
+ val scheme = runCatching { Uri.parse(text) }.getOrNull()?.scheme
+ if (scheme != null && scheme != "file") return InlineShare.Kind.URL
+ }
+ // Opaque schemes (tel:, sms:, mailto:, ssh:, magnet:, geo:, tailscale:, …) have
+ // no `//` so Patterns.WEB_URL misses them. Accept anything that's whitespace-free
+ // and parses to a non-file scheme with a non-empty body. The whitespace gate keeps
+ // "see: this thing" out without us having to maintain a scheme allowlist.
+ if (text.none { it.isWhitespace() }) {
+ val parsed = runCatching { Uri.parse(text) }.getOrNull()
+ val scheme = parsed?.scheme?.lowercase()
+ if (scheme != null && scheme != "file" && !parsed.schemeSpecificPart.isNullOrBlank()) {
+ return InlineShare.Kind.URL
+ }
+ }
+ return InlineShare.Kind.TEXT
+ }
+
+ // Chromium highlighted-text shares come through as `""\n #:~:text=…`.
+ // Receivers paste the blob verbatim; drop the URL tail and outer quotes.
+ private fun stripChromeHighlightedTextShare(text: String): String {
+ val urlLine = Regex("""(?m)^[ \t]*https?://\S*#:~:text=\S*[ \t]*$""").find(text) ?: return text
+ val before = text.substring(0, urlLine.range.first).trimEnd()
+ val unquoted = before.removeSurrounding("\"")
+ return if (unquoted.isNotEmpty()) unquoted else text
+ }
+
private fun generateFallbackName(uri: Uri): String {
val randomId = Random.nextLong()
val mimeType = contentResolver?.getType(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 1753bc6a19..97bfc2d62e 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
@@ -4,6 +4,7 @@
package com.tailscale.ipn.ui.model
import android.net.Uri
+import com.tailscale.ipn.util.InlineShare
import java.util.UUID
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
@@ -212,10 +213,12 @@ class Ipn {
val Succeeded: Boolean = false,
) {
@Transient lateinit var uri: Uri // only used on client
+ @Transient var inlineShare: InlineShare? = null
fun prepare(peerId: StableNodeID): OutgoingFile {
val f = copy(ID = UUID.randomUUID().toString(), PeerID = peerId)
f.uri = uri
+ f.inlineShare = inlineShare
return f
}
}
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 0d97ad12fd..4460680967 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
@@ -14,11 +14,13 @@ import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.model.NodeID
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.util.set
+import com.tailscale.ipn.util.PendingInlineShare
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
@@ -57,6 +59,17 @@ object Notifier {
val incomingFiles: StateFlow?> = MutableStateFlow(null)
val filesWaiting: StateFlow = MutableStateFlow(null)
+ private val _inlineShareInbox = MutableStateFlow>(emptyList())
+ val inlineShareInbox: StateFlow> = _inlineShareInbox
+
+ fun appendInlineShare(p: PendingInlineShare) {
+ _inlineShareInbox.update { it + p }
+ }
+
+ fun removeInlineShare(id: String) {
+ _inlineShareInbox.update { list -> list.filterNot { it.id == id } }
+ }
+
private val userProfiles = mutableMapOf()
private lateinit var app: libtailscale.Application
diff --git a/android/src/main/java/com/tailscale/ipn/ui/notifier/TaildropNotifier.kt b/android/src/main/java/com/tailscale/ipn/ui/notifier/TaildropNotifier.kt
new file mode 100644
index 0000000000..94ef37696a
--- /dev/null
+++ b/android/src/main/java/com/tailscale/ipn/ui/notifier/TaildropNotifier.kt
@@ -0,0 +1,80 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package com.tailscale.ipn.ui.notifier
+
+import android.Manifest
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import com.tailscale.ipn.App
+import com.tailscale.ipn.InlineShareActionReceiver
+import com.tailscale.ipn.R
+import com.tailscale.ipn.util.InlineShare
+import com.tailscale.ipn.util.PendingInlineShare
+import com.tailscale.ipn.util.TSLog
+
+object TaildropNotifier {
+ private const val TAG = "TaildropNotifier"
+
+ fun cancel(context: Context, id: String) {
+ NotificationManagerCompat.from(context).cancel(id.hashCode())
+ }
+
+ fun notify(context: Context, pending: PendingInlineShare) {
+ if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
+ PackageManager.PERMISSION_GRANTED) {
+ TSLog.d(TAG, "POST_NOTIFICATIONS not granted; skipping inline share notification")
+ return
+ }
+
+ val title: String
+ val body: String
+ val subtitle: String
+ when (pending.kind) {
+ InlineShare.Kind.URL -> {
+ title = context.getString(R.string.taildrop_link_received)
+ body = pending.content
+ subtitle = context.getString(R.string.taildrop_tap_to_open)
+ }
+ InlineShare.Kind.TEXT -> {
+ title = context.getString(R.string.taildrop_text_received)
+ body = pending.content.take(120).replace("\n", " ")
+ subtitle = context.getString(R.string.taildrop_tap_to_copy)
+ }
+ }
+
+ val tapIntent =
+ Intent(context, InlineShareActionReceiver::class.java).apply {
+ action = InlineShareActionReceiver.ACTION_CONSUME
+ putExtra(InlineShareActionReceiver.EXTRA_KIND, pending.kind.name.lowercase())
+ putExtra(InlineShareActionReceiver.EXTRA_CONTENT, pending.content)
+ putExtra(InlineShareActionReceiver.EXTRA_ID, pending.id)
+ }
+
+ val pendingIntent =
+ PendingIntent.getBroadcast(
+ context,
+ pending.id.hashCode(),
+ tapIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+
+ val notification =
+ NotificationCompat.Builder(context, App.FILE_CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(title)
+ .setContentText(body)
+ .setSubText(subtitle)
+ .setStyle(NotificationCompat.BigTextStyle().bigText(body))
+ .setAutoCancel(true)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setContentIntent(pendingIntent)
+ .build()
+
+ NotificationManagerCompat.from(context).notify(pending.id.hashCode(), notification)
+ }
+}
diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/InlineShareListSheet.kt b/android/src/main/java/com/tailscale/ipn/ui/view/InlineShareListSheet.kt
new file mode 100644
index 0000000000..fcc425e1a0
--- /dev/null
+++ b/android/src/main/java/com/tailscale/ipn/ui/view/InlineShareListSheet.kt
@@ -0,0 +1,154 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package com.tailscale.ipn.ui.view
+
+import androidx.compose.foundation.clickable
+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.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Close
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+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.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.tailscale.ipn.R
+import com.tailscale.ipn.ui.viewModel.PendingTaildropViewModel
+import com.tailscale.ipn.util.InlineShare
+
+// Bottom sheet of pending Taildrop items; mirrors iOS InlineShareListSheet.
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun InlineShareListSheet(viewModel: PendingTaildropViewModel) {
+ val items by viewModel.pendingItems.collectAsState()
+ val context = LocalContext.current
+
+ Column(modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp)) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
+ Text(
+ text = stringResource(R.string.taildrop_received_sheet_title),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.weight(1f))
+ TextButton(onClick = { viewModel.isPresentingPendingItemsList.value = false }) {
+ Text(text = stringResource(R.string.taildrop_done))
+ }
+ }
+
+ HorizontalDivider()
+
+ LazyColumn {
+ items.forEachIndexed { index, item ->
+ item(key = item.id) {
+ PendingItemRow(
+ item = item,
+ onConsume = { viewModel.consume(context, item) },
+ onDismiss = { viewModel.dismiss(context, item) },
+ onOpenFolder = { viewModel.openTaildropFolder(context) })
+ if (index < items.size - 1) HorizontalDivider()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun PendingItemRow(
+ item: PendingTaildropViewModel.PendingTaildropItem,
+ onConsume: () -> Unit,
+ onDismiss: () -> Unit,
+ onOpenFolder: () -> Unit,
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier =
+ Modifier.fillMaxWidth()
+ .clickable { onConsume() }
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ ) {
+ Icon(
+ painter = painterResource(id = iconFor(item)),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(modifier = Modifier.size(12.dp))
+ Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text(
+ text = primaryText(item),
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = secondaryText(item),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ when (item) {
+ is PendingTaildropViewModel.PendingTaildropItem.InlineShareItem ->
+ IconButton(onClick = onDismiss) {
+ Icon(
+ imageVector = Icons.Outlined.Close,
+ contentDescription = stringResource(R.string.taildrop_dismiss),
+ )
+ }
+ is PendingTaildropViewModel.PendingTaildropItem.File ->
+ IconButton(onClick = onOpenFolder) {
+ Icon(
+ painter = painterResource(id = R.drawable.baseline_folder_open_24),
+ contentDescription = stringResource(R.string.taildrop_open_folder),
+ )
+ }
+ }
+ }
+}
+
+private fun iconFor(item: PendingTaildropViewModel.PendingTaildropItem): Int =
+ when (item) {
+ is PendingTaildropViewModel.PendingTaildropItem.File -> R.drawable.single_file
+ is PendingTaildropViewModel.PendingTaildropItem.InlineShareItem ->
+ if (item.share.kind == InlineShare.Kind.URL) R.drawable.link else R.drawable.single_file
+ }
+
+@Composable
+private fun primaryText(item: PendingTaildropViewModel.PendingTaildropItem): String =
+ when (item) {
+ is PendingTaildropViewModel.PendingTaildropItem.File -> item.partial.Name
+ is PendingTaildropViewModel.PendingTaildropItem.InlineShareItem ->
+ when (item.share.kind) {
+ InlineShare.Kind.URL -> item.share.content
+ InlineShare.Kind.TEXT -> item.share.content.take(80).replace("\n", " ")
+ }
+ }
+
+@Composable
+private fun secondaryText(item: PendingTaildropViewModel.PendingTaildropItem): String =
+ when (item) {
+ is PendingTaildropViewModel.PendingTaildropItem.File ->
+ stringResource(R.string.taildrop_tap_to_open)
+ is PendingTaildropViewModel.PendingTaildropItem.InlineShareItem ->
+ if (item.share.kind == InlineShare.Kind.URL) stringResource(R.string.taildrop_tap_to_open)
+ else stringResource(R.string.taildrop_tap_to_copy)
+ }
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 23a22b2cd5..e16469e0a3 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
@@ -222,6 +222,10 @@ fun MainView(
ExitNodeStatus(
navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
}
+ val pending by viewModel.pendingTaildrop.pendingItems.collectAsState()
+ if (pending.isNotEmpty()) {
+ TaildropBannerView(viewModel = viewModel.pendingTaildrop)
+ }
PeerList(
viewModel = viewModel,
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
@@ -251,6 +255,16 @@ fun MainView(
PingView(model = viewModel.pingViewModel)
}
}
+ val showPendingSheet by
+ viewModel.pendingTaildrop.isPresentingPendingItemsList.collectAsState()
+ if (showPendingSheet) {
+ ModalBottomSheet(
+ onDismissRequest = {
+ viewModel.pendingTaildrop.isPresentingPendingItemsList.value = false
+ }) {
+ InlineShareListSheet(viewModel = viewModel.pendingTaildrop)
+ }
+ }
}
}
}
diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/TaildropBannerView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/TaildropBannerView.kt
new file mode 100644
index 0000000000..155abb82c5
--- /dev/null
+++ b/android/src/main/java/com/tailscale/ipn/ui/view/TaildropBannerView.kt
@@ -0,0 +1,167 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package com.tailscale.ipn.ui.view
+
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectHorizontalDragGestures
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.tailscale.ipn.R
+import com.tailscale.ipn.ui.theme.onWarning
+import com.tailscale.ipn.ui.theme.warning
+import com.tailscale.ipn.ui.viewModel.PendingTaildropViewModel
+import com.tailscale.ipn.util.InlineShare
+import kotlin.math.abs
+import kotlinx.coroutines.delay
+
+@Composable
+fun TaildropBannerView(viewModel: PendingTaildropViewModel) {
+ val items by viewModel.pendingItems.collectAsState()
+ val context = LocalContext.current
+
+ if (items.isEmpty()) return
+
+ val onlyItem = items.singleOrNull()
+ val swipeDismissable = onlyItem is PendingTaildropViewModel.PendingTaildropItem.InlineShareItem
+
+ val density = LocalDensity.current
+ val dismissThresholdPx = with(density) { 60.dp.toPx() }
+ val flingPx = with(density) { 800.dp.toPx() }
+ val flingTriggerPx = with(density) { 667.dp.toPx() }
+ val fadeRangePx = with(density) { 200.dp.toPx() }
+
+ var dragOffsetPx by remember { mutableStateOf(0f) }
+ val animatedOffset by
+ animateFloatAsState(targetValue = dragOffsetPx, animationSpec = tween(durationMillis = 60))
+ val opacity = 1f - (abs(animatedOffset) / fadeRangePx).coerceAtMost(0.5f)
+
+ LaunchedEffect(onlyItem?.id) { dragOffsetPx = 0f }
+
+ Surface(
+ modifier =
+ Modifier.fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ .offset { IntOffset(animatedOffset.toInt(), 0) }
+ .alpha(opacity)
+ .pointerInput(swipeDismissable, onlyItem?.id) {
+ if (!swipeDismissable || onlyItem == null) return@pointerInput
+ detectHorizontalDragGestures(
+ onDragEnd = {
+ dragOffsetPx =
+ when {
+ abs(dragOffsetPx) > dismissThresholdPx ->
+ if (dragOffsetPx > 0) flingPx else -flingPx
+ else -> 0f
+ }
+ },
+ onHorizontalDrag = { _, dragAmount -> dragOffsetPx += dragAmount })
+ },
+ shape = RoundedCornerShape(10.dp),
+ color = MaterialTheme.colorScheme.warning,
+ contentColor = MaterialTheme.colorScheme.onWarning,
+ ) {
+ Row(
+ modifier =
+ Modifier.fillMaxWidth()
+ .clickable { viewModel.handleBannerTap(context) }
+ .padding(start = 16.dp, top = 16.dp, bottom = 16.dp, end = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ painter = painterResource(id = iconFor(items)),
+ contentDescription = null,
+ modifier = Modifier.size(28.dp))
+ Column(modifier = Modifier.padding(start = 12.dp).weight(1f)) {
+ Text(
+ text = titleFor(items).uppercase(),
+ style = MaterialTheme.typography.labelMedium,
+ fontSize = 11.sp,
+ )
+ Text(text = subtitleFor(items), style = MaterialTheme.typography.bodyMedium)
+ }
+ if (onlyItem is PendingTaildropViewModel.PendingTaildropItem.File) {
+ IconButton(onClick = { viewModel.openTaildropFolder(context) }) {
+ Icon(
+ painter = painterResource(id = R.drawable.baseline_folder_open_24),
+ contentDescription = stringResource(R.string.taildrop_open_folder),
+ tint = LocalContentColor.current,
+ )
+ }
+ }
+ }
+ }
+
+ LaunchedEffect(dragOffsetPx, onlyItem?.id) {
+ if (swipeDismissable && onlyItem != null && abs(dragOffsetPx) >= flingTriggerPx) {
+ delay(120)
+ viewModel.dismiss(context, onlyItem)
+ dragOffsetPx = 0f
+ }
+ }
+}
+
+private fun iconFor(items: List): Int {
+ val only = items.singleOrNull() ?: return R.drawable.baseline_drive_folder_upload_24
+ return when (only) {
+ is PendingTaildropViewModel.PendingTaildropItem.File -> R.drawable.single_file
+ is PendingTaildropViewModel.PendingTaildropItem.InlineShareItem ->
+ if (only.share.kind == InlineShare.Kind.URL) R.drawable.link else R.drawable.single_file
+ }
+}
+
+@Composable
+private fun titleFor(items: List): String {
+ val only =
+ items.singleOrNull() ?: return stringResource(R.string.taildrop_pending_count, items.size)
+ return when (only) {
+ is PendingTaildropViewModel.PendingTaildropItem.File ->
+ stringResource(R.string.taildrop_file_received)
+ is PendingTaildropViewModel.PendingTaildropItem.InlineShareItem ->
+ if (only.share.kind == InlineShare.Kind.URL) stringResource(R.string.taildrop_link_received)
+ else stringResource(R.string.taildrop_text_received)
+ }
+}
+
+@Composable
+private fun subtitleFor(items: List): String {
+ val only = items.singleOrNull() ?: return stringResource(R.string.taildrop_tap_to_open)
+ return when (only) {
+ is PendingTaildropViewModel.PendingTaildropItem.File ->
+ stringResource(R.string.taildrop_saved_in_directory)
+ is PendingTaildropViewModel.PendingTaildropItem.InlineShareItem ->
+ if (only.share.kind == InlineShare.Kind.URL) stringResource(R.string.taildrop_tap_to_open)
+ else stringResource(R.string.taildrop_tap_to_copy)
+ }
+}
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
index b246a55e4f..13798dc30c 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/view/TaildropView.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/view/TaildropView.kt
@@ -13,6 +13,7 @@ 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.foundation.lazy.items
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -28,6 +29,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
@@ -38,6 +40,7 @@ import com.tailscale.ipn.ui.util.Lists.SectionDivider
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.TaildropViewModel
import com.tailscale.ipn.ui.viewModel.TaildropViewModelFactory
+import com.tailscale.ipn.util.InlineShare
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
@@ -73,10 +76,12 @@ fun TaildropView(
when (viewModel.state.collectAsState().value) {
Ipn.State.Running -> {
- val peers by viewModel.myPeers.collectAsState()
+ val recent by viewModel.recentPeers.collectAsState()
+ val other by viewModel.otherPeers.collectAsState()
val context = LocalContext.current
FileSharePeerList(
- peers = peers,
+ recent = recent,
+ other = other,
stateViewGenerator = { peerId -> viewModel.TrailingContentForPeer(peerId = peerId) },
onShare = { viewModel.share(context, it) },
)
@@ -90,42 +95,52 @@ fun TaildropView(
@Composable
fun FileSharePeerList(
- peers: List,
+ recent: List,
+ other: List,
stateViewGenerator: @Composable (String) -> Unit,
onShare: (Tailcfg.Node) -> Unit,
) {
- SectionDivider(stringResource(R.string.my_devices))
-
- 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,
- )
- }
+ if (recent.isEmpty() && other.isEmpty()) {
+ SectionDivider(stringResource(R.string.my_devices))
+ 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 ->
- item {
- PeerView(
- peer = peer,
- onClick = { onShare(peer) },
- subtitle = { peer.Hostinfo.OS ?: "" },
- trailingContent = { stateViewGenerator(peer.StableID) },
- )
- }
- }
- }
+ return
+ }
+
+ LazyColumn {
+ if (recent.isNotEmpty()) {
+ item { SectionDivider(stringResource(R.string.taildrop_recently_used)) }
+ items(recent) { PeerRow(it, stateViewGenerator, onShare) }
+ item { SectionDivider(stringResource(R.string.taildrop_all_devices)) }
+ } else {
+ item { SectionDivider(stringResource(R.string.my_devices)) }
}
+ items(other) { PeerRow(it, stateViewGenerator, onShare) }
}
}
+@Composable
+private fun PeerRow(
+ peer: Tailcfg.Node,
+ stateViewGenerator: @Composable (String) -> Unit,
+ onShare: (Tailcfg.Node) -> Unit,
+) {
+ PeerView(
+ peer = peer,
+ onClick = { onShare(peer) },
+ subtitle = { peer.Hostinfo.OS ?: "" },
+ trailingContent = { stateViewGenerator(peer.StableID) },
+ )
+}
+
@Composable
fun FileShareConnectView(onToggle: () -> Unit) {
Column(
@@ -153,35 +168,46 @@ fun FileShareHeader(fileTransfers: List, totalSize: Long) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconForTransfer(fileTransfers)
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
- when (fileTransfers.isEmpty()) {
- true ->
+ val share = fileTransfers.singleOrNull()?.inlineShare
+ when {
+ fileTransfers.isEmpty() ->
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,
- )
- }
+ share != null ->
+ Text(
+ text = share.content,
+ style = MaterialTheme.typography.titleMedium,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ fileTransfers.size == 1 -> {
+ Text(fileTransfers[0].Name, style = MaterialTheme.typography.titleMedium)
+ FileSizeText(totalSize)
+ }
+ else -> {
+ Text(
+ stringResource(R.string.file_count, fileTransfers.size),
+ style = MaterialTheme.typography.titleMedium,
+ )
+ FileSizeText(totalSize)
}
}
- val size = Formatter.formatFileSize(LocalContext.current, totalSize.toLong())
- Text(
- size,
- style = MaterialTheme.typography.titleSmall,
- color = MaterialTheme.colorScheme.secondary,
- )
}
}
}
}
+@Composable
+private fun FileSizeText(totalSize: Long) {
+ Text(
+ Formatter.formatFileSize(LocalContext.current, totalSize),
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.secondary,
+ )
+}
+
@Composable
fun IconForTransfer(transfers: List) {
// (jonathan) TODO: Thumbnails?
@@ -193,12 +219,24 @@ fun IconForTransfer(transfers: List) {
modifier = Modifier.size(32.dp),
)
1 -> {
+ val only = transfers[0]
+ val share = only.inlineShare
+ if (share != null) {
+ val resource =
+ if (share.kind == InlineShare.Kind.URL) R.drawable.link else R.drawable.single_file
+ Icon(
+ painter = painterResource(resource),
+ contentDescription = share.kind.name.lowercase(),
+ modifier = Modifier.size(40.dp),
+ )
+ return
+ }
// Show a thumbnail for single image shares.
val context = LocalContext.current
- context.contentResolver.getType(transfers[0].uri)?.let {
+ context.contentResolver.getType(only.uri)?.let {
if (it.startsWith("image/")) {
AsyncImage(
- model = transfers[0].uri,
+ model = only.uri,
contentDescription = "one file",
modifier = Modifier.size(40.dp),
)
diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt
index 04bf0e4b9b..06d8df8b8d 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt
@@ -83,6 +83,9 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() {
var pingViewModel: PingViewModel = PingViewModel()
+ // Self-contained controller for the Taildrop in-app banner/list sheet.
+ val pendingTaildrop: PendingTaildropViewModel = PendingTaildropViewModel()
+
val isVpnPrepared: StateFlow = appViewModel.vpnPrepared
val isVpnActive: StateFlow = appViewModel.vpnActive
diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/PendingTaildropViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PendingTaildropViewModel.kt
new file mode 100644
index 0000000000..9627bba0ba
--- /dev/null
+++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PendingTaildropViewModel.kt
@@ -0,0 +1,190 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+package com.tailscale.ipn.ui.viewModel
+
+import android.content.ClipData
+import android.content.ClipboardManager as AndroidClipboardManager
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.provider.DocumentsContract
+import android.widget.Toast
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.tailscale.ipn.R
+import com.tailscale.ipn.TaildropDirectoryStore
+import com.tailscale.ipn.ui.model.Ipn
+import com.tailscale.ipn.ui.notifier.Notifier
+import com.tailscale.ipn.ui.notifier.TaildropNotifier
+import com.tailscale.ipn.util.BrowserOpener
+import com.tailscale.ipn.util.InlineShare
+import com.tailscale.ipn.util.PendingInlineShare
+import com.tailscale.ipn.util.TSLog
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+class PendingTaildropViewModel : ViewModel() {
+ private val TAG = "PendingTaildropViewModel"
+
+ sealed class PendingTaildropItem {
+ abstract val id: String
+
+ data class File(val partial: Ipn.PartialFile) : PendingTaildropItem() {
+ override val id: String =
+ partial.FinalPath ?: partial.PartialPath ?: "${partial.Name}-${partial.Started}"
+ }
+
+ data class InlineShareItem(val share: PendingInlineShare) : PendingTaildropItem() {
+ override val id: String = share.id
+ }
+ }
+
+ private val consumedFileIds = MutableStateFlow>(emptySet())
+
+ // Go drops files from incomingFiles soon after they finish, so we accumulate
+ // Done=true entries here once and let the user act on them.
+ private val _pendingFiles = MutableStateFlow>(emptyList())
+
+ val pendingFiles: StateFlow> =
+ _pendingFiles
+ .combine(consumedFileIds) { files, consumed ->
+ files.filter { PendingTaildropItem.File(it).id !in consumed }
+ }
+ .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
+
+ val pendingItems: StateFlow> =
+ Notifier.inlineShareInbox
+ .combine(pendingFiles) { shares, files ->
+ files.map { PendingTaildropItem.File(it) } +
+ shares.map { PendingTaildropItem.InlineShareItem(it) }
+ }
+ .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
+
+ val isPresentingPendingItemsList = MutableStateFlow(false)
+
+ init {
+ viewModelScope.launch {
+ Notifier.incomingFiles.collect { list ->
+ val arrivals =
+ list.orEmpty().filter { f ->
+ f.Done == true && !InlineShare.matches(f.Name) && !f.Name.endsWith(".partial")
+ }
+ if (arrivals.isEmpty()) return@collect
+ _pendingFiles.update { current ->
+ val knownIds = current.map { PendingTaildropItem.File(it).id }.toSet()
+ current + arrivals.filter { PendingTaildropItem.File(it).id !in knownIds }
+ }
+ }
+ }
+ // Auto-dismiss the bottom sheet when the queue empties out, so the dismissal
+ // tracks pendingItems instead of relying on a stale .value check after each consume.
+ viewModelScope.launch {
+ pendingItems.collect { items ->
+ if (items.isEmpty()) isPresentingPendingItemsList.value = false
+ }
+ }
+ }
+
+ fun handleBannerTap(context: Context) {
+ val items = pendingItems.value
+ if (items.size >= 2) {
+ isPresentingPendingItemsList.value = true
+ return
+ }
+ items.firstOrNull()?.let { consume(context, it) }
+ }
+
+ fun consume(context: Context, item: PendingTaildropItem) {
+ when (item) {
+ is PendingTaildropItem.File -> {
+ openFile(context, item.partial)
+ consumedFileIds.update { it + item.id }
+ }
+ is PendingTaildropItem.InlineShareItem -> {
+ when (item.share.kind) {
+ InlineShare.Kind.URL -> openUrl(context, item.share.content)
+ InlineShare.Kind.TEXT -> copyToClipboard(context, item.share.content)
+ }
+ Notifier.removeInlineShare(item.share.id)
+ TaildropNotifier.cancel(context, item.share.id)
+ }
+ }
+ }
+
+ fun dismiss(context: Context, item: PendingTaildropItem) {
+ when (item) {
+ is PendingTaildropItem.File -> consumedFileIds.update { it + item.id }
+ is PendingTaildropItem.InlineShareItem -> {
+ Notifier.removeInlineShare(item.share.id)
+ TaildropNotifier.cancel(context, item.share.id)
+ }
+ }
+ }
+
+ private fun openUrl(context: Context, content: String) {
+ val uri = runCatching { Uri.parse(content) }.getOrNull()?.takeIf { !it.scheme.isNullOrEmpty() }
+ if (uri == null) {
+ copyToClipboard(context, content)
+ return
+ }
+ if (!BrowserOpener.openInDefaultBrowser(context, uri)) {
+ TSLog.w(TAG, "openUrl failed for $content")
+ copyToClipboard(context, content)
+ }
+ }
+
+ private fun copyToClipboard(context: Context, content: String) {
+ val cm =
+ context.getSystemService(Context.CLIPBOARD_SERVICE) as? AndroidClipboardManager ?: return
+ cm.setPrimaryClip(ClipData.newPlainText("Tailscale", content))
+ Toast.makeText(context, R.string.taildrop_copied_to_clipboard, Toast.LENGTH_SHORT).show()
+ }
+
+ private fun openFile(context: Context, partial: Ipn.PartialFile) {
+ val uri =
+ partial.FinalPath?.let { runCatching { Uri.parse(it) }.getOrNull() }
+ ?: return openTaildropFolder(context)
+ val mime = runCatching { context.contentResolver.getType(uri) }.getOrNull() ?: "*/*"
+ val intent =
+ Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(uri, mime)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ try {
+ context.startActivity(intent)
+ } catch (e: Exception) {
+ TSLog.w(TAG, "openFile fallback to folder: $e")
+ openTaildropFolder(context)
+ }
+ }
+
+ fun openTaildropFolder(context: Context) {
+ val treeUri =
+ runCatching { TaildropDirectoryStore.loadSavedDir() }.getOrNull()
+ ?: run {
+ TSLog.w(TAG, "openTaildropFolder: no saved Taildrop dir")
+ return
+ }
+ val docUri =
+ runCatching {
+ DocumentsContract.buildDocumentUriUsingTree(
+ treeUri, DocumentsContract.getTreeDocumentId(treeUri))
+ }
+ .getOrNull() ?: treeUri
+ val intent =
+ Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(docUri, "vnd.android.document/directory")
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ try {
+ context.startActivity(intent)
+ } catch (e: Exception) {
+ TSLog.w(TAG, "openTaildropFolder failed: $e")
+ }
+ }
+}
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
index 92bce33271..f05ed78bcb 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/TaildropViewModel.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/TaildropViewModel.kt
@@ -15,10 +15,10 @@ import androidx.compose.ui.res.stringResource
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
+import com.tailscale.ipn.App
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
@@ -26,7 +26,10 @@ import com.tailscale.ipn.ui.view.ActivityIndicator
import com.tailscale.ipn.ui.view.CheckedIndicator
import com.tailscale.ipn.ui.view.ErrorDialogType
import com.tailscale.ipn.util.TSLog
+import com.tailscale.ipn.util.TaildropUsageTracker
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
@@ -57,9 +60,6 @@ class TaildropViewModel(
// 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.
@@ -69,19 +69,24 @@ class TaildropViewModel(
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())
+ // Recently shared-to devices (capped at 3) and everything else.
+ val recentPeers: StateFlow> = MutableStateFlow(emptyList())
+ val otherPeers: StateFlow> = MutableStateFlow(emptyList())
// Non null if there's an error to be rendered.
val showDialog: StateFlow = MutableStateFlow(null)
+ private var refreshJob: Job? = null
+ private val refreshIntervalMs = 5_000L
+
init {
viewModelScope.launch {
Notifier.state.collect {
if (it == Ipn.State.Running) {
- loadTargets()
+ startPeriodicTargetRefresh()
+ } else {
+ refreshJob?.cancel()
+ refreshJob = null
}
}
}
@@ -102,8 +107,7 @@ class TaildropViewModel(
viewModelScope.launch {
requestedTransfers.collect {
- // This means that we're processing a new share intent, clear current state
- selectedPeers.set(emptySet())
+ // New share intent — drop tracking of any prior transfer IDs.
currentTransferIDs.set(emptySet())
}
}
@@ -133,16 +137,33 @@ class TaildropViewModel(
}
}
- // Loads all of the valid fileTargets from localAPI
+ // Re-polls fileTargets so the device list updates as peers come online.
+ private fun startPeriodicTargetRefresh() {
+ refreshJob?.cancel()
+ refreshJob =
+ viewModelScope.launch {
+ while (true) {
+ loadTargets()
+ delay(refreshIntervalMs)
+ }
+ }
+ }
+
+ // Loads valid fileTargets from localAPI and splits into recent vs other.
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)
+ .onSuccess { targets ->
+ val ctx = App.get()
+ val userID = Notifier.netmap.value?.SelfNode?.User ?: 0L
+ val all = targets.map { it.Node }
+ val (recent, other) = TaildropUsageTracker.partitionByRecency(ctx, userID, all)
+ val onlineFirst =
+ other.sortedWith(
+ compareByDescending { it.Online ?: false }
+ .thenBy { (it.ComputedName ?: it.Name).lowercase() })
+ recentPeers.set(recent)
+ otherPeers.set(onlineFirst)
}
.onFailure { TSLog.e(TAG, "Error loading targets: ${it.message}") }
}
@@ -180,21 +201,20 @@ class TaildropViewModel(
return
}
- if (selectedPeers.value.contains(node.StableID)) {
- // We've already selected this peer, ignore
- return
- }
- selectedPeers.set(selectedPeers.value + node.StableID)
+ // Gate on an actually-in-flight transfer rather than a one-shot selection
+ // set, since requestedTransfers may not re-emit on a new intent when the
+ // share content hashes identically.
+ if (transfers.value.any { it.PeerID == node.StableID && !it.Finished }) return
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)
+ } else {
+ val userID = Notifier.netmap.value?.SelfNode?.User ?: 0L
+ TaildropUsageTracker.updateLastUsed(context, userID, node.StableID)
}
}
}
diff --git a/android/src/main/java/com/tailscale/ipn/util/BrowserOpener.kt b/android/src/main/java/com/tailscale/ipn/util/BrowserOpener.kt
new file mode 100644
index 0000000000..f7a8eaa919
--- /dev/null
+++ b/android/src/main/java/com/tailscale/ipn/util/BrowserOpener.kt
@@ -0,0 +1,44 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package com.tailscale.ipn.util
+
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+
+object BrowserOpener {
+ // Launches ACTION_VIEW for any URI. For http(s), pins to the user's default browser
+ // package so non-browser apps that also claim http(s) (e.g. the Google app) don't
+ // trigger the chooser. For other schemes (tel:, mailto:, sms:, ssh:, tailscale:, …),
+ // lets the system pick the registered handler — pinning to a browser package would
+ // throw ActivityNotFoundException since browsers don't claim those schemes.
+ fun openInDefaultBrowser(context: Context, uri: Uri): Boolean {
+ val scheme = uri.scheme?.lowercase()
+ val isHttp = scheme == "http" || scheme == "https"
+ val intent = Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ if (isHttp) {
+ intent.addCategory(Intent.CATEGORY_BROWSABLE)
+ defaultBrowserPackage(context)?.let { intent.setPackage(it) }
+ }
+ return try {
+ context.startActivity(intent)
+ true
+ } catch (_: Exception) {
+ false
+ }
+ }
+
+ private fun defaultBrowserPackage(context: Context): String? {
+ val probe =
+ Intent(Intent.ACTION_VIEW, Uri.parse("https://www.example.com"))
+ .addCategory(Intent.CATEGORY_BROWSABLE)
+ val ri =
+ context.packageManager.resolveActivity(probe, PackageManager.MATCH_DEFAULT_ONLY)
+ ?: return null
+ val pkg = ri.activityInfo?.packageName ?: return null
+ // "android" / *.resolver means no default is set; let the chooser handle it.
+ return if (pkg == "android" || pkg.contains("resolver", ignoreCase = true)) null else pkg
+ }
+}
diff --git a/android/src/main/java/com/tailscale/ipn/util/InlineShare.kt b/android/src/main/java/com/tailscale/ipn/util/InlineShare.kt
new file mode 100644
index 0000000000..9da3ed994e
--- /dev/null
+++ b/android/src/main/java/com/tailscale/ipn/util/InlineShare.kt
@@ -0,0 +1,84 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package com.tailscale.ipn.util
+
+import android.content.Context
+import java.io.File
+import java.security.MessageDigest
+import java.util.UUID
+
+// Wire format:
+// .txt raw UTF-8 text
+// .url Windows Internet Shortcut INI (CRLF)
+data class InlineShare(
+ val kind: Kind,
+ val content: String,
+) {
+ enum class Kind(val extension: String) {
+ URL("url"),
+ TEXT("txt"),
+ }
+
+ fun encoded(): ByteArray =
+ when (kind) {
+ Kind.TEXT -> content.toByteArray(Charsets.UTF_8)
+ // CRLF: Windows-native format; some third-party parsers reject LF-only.
+ Kind.URL -> "[InternetShortcut]\r\nURL=$content\r\n".toByteArray(Charsets.UTF_8)
+ }
+
+ companion object {
+ private val finalNameRegex = Regex("^[0-9a-f]{32}\\.(txt|url)$", RegexOption.IGNORE_CASE)
+
+ // Also matches partial-transfer variants: `.partial`, `..partial`,
+ // `.partial.bin`. Used by receive-side plumbing that sees mid-flight files.
+ private val anyStageNameRegex =
+ Regex("^[0-9a-f]{32}\\.(txt|url)(\\..+)?$", RegexOption.IGNORE_CASE)
+
+ fun matches(filename: String): Boolean = finalNameRegex.matches(filename)
+
+ fun matchesAnyStage(filename: String): Boolean = anyStageNameRegex.matches(filename)
+
+ fun decode(filename: String, data: ByteArray): InlineShare? {
+ val ext = filename.substringAfterLast('.', "").lowercase()
+ return when (ext) {
+ "txt" -> {
+ val text = runCatching { data.toString(Charsets.UTF_8) }.getOrNull().orEmpty()
+ if (text.isEmpty()) null else InlineShare(Kind.TEXT, text)
+ }
+ "url" -> parseInternetShortcut(data)?.let { InlineShare(Kind.URL, it) }
+ else -> null
+ }
+ }
+
+ private fun parseInternetShortcut(data: ByteArray): String? {
+ val text = runCatching { data.toString(Charsets.UTF_8) }.getOrNull() ?: return null
+ val start = text.indexOf("URL=", ignoreCase = true).takeIf { it >= 0 } ?: return null
+ val after = text.substring(start + 4)
+ val end = after.indexOfAny(charArrayOf('\r', '\n')).takeIf { it >= 0 } ?: after.length
+ return after.substring(0, end).trim().takeIf { it.isNotEmpty() }
+ }
+
+ fun suggestedFilename(kind: Kind, content: String): String =
+ "${md5Hex(content)}.${kind.extension}"
+
+ fun writeToCache(context: Context, kind: Kind, content: String): File {
+ val share = InlineShare(kind, content)
+ val file = File(context.cacheDir, suggestedFilename(kind, content))
+ file.writeBytes(share.encoded())
+ return file
+ }
+
+ private fun md5Hex(input: String): String {
+ val digest = MessageDigest.getInstance("MD5").digest(input.toByteArray(Charsets.UTF_8))
+ return digest.joinToString(separator = "") { b -> "%02x".format(b) }
+ }
+ }
+}
+
+data class PendingInlineShare(
+ val id: String = UUID.randomUUID().toString(),
+ val kind: InlineShare.Kind,
+ val content: String,
+ val received: Long = System.currentTimeMillis(),
+)
diff --git a/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt b/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt
index c8a4506fa1..114a00e15b 100644
--- a/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt
+++ b/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt
@@ -8,8 +8,11 @@ import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract
import androidx.documentfile.provider.DocumentFile
import com.tailscale.ipn.TaildropDirectoryStore
+import com.tailscale.ipn.ui.notifier.Notifier
+import com.tailscale.ipn.ui.notifier.TaildropNotifier
import com.tailscale.ipn.ui.util.InputStreamAdapter
import com.tailscale.ipn.ui.util.OutputStreamAdapter
+import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.OutputStream
@@ -120,8 +123,26 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
private val currentUri = ConcurrentHashMap()
+ private val inlineShareCacheFiles = ConcurrentHashMap()
+
+ private fun inlineShareCacheRoot(ctx: Context): File {
+ val dir = File(ctx.cacheDir, "inline-share-in")
+ if (!dir.exists()) dir.mkdirs()
+ return dir
+ }
+
@Throws(IOException::class)
override fun openFileWriter(fileName: String, offset: Long): libtailscale.OutputStream {
+ if (InlineShare.matchesAnyStage(fileName)) {
+ val ctx = appContext ?: throw IOException("App context not initialized")
+ val cached =
+ inlineShareCacheFiles.computeIfAbsent(fileName) {
+ File(inlineShareCacheRoot(ctx), fileName)
+ }
+ val fos = FileOutputStream(cached, offset != 0L)
+ if (offset == 0L) fos.channel.truncate(0)
+ return OutputStreamAdapter(fos)
+ }
runBlocking { waitUntilTaildropDirReady() }
val (uri, stream) = openWriterFD(fileName, offset)
currentUri[fileName] = uri
@@ -130,6 +151,15 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
@Throws(IOException::class)
override fun getFileURI(fileName: String): String {
+ if (InlineShare.matchesAnyStage(fileName)) {
+ val ctx = appContext ?: throw IOException("App context not initialized")
+ val cached =
+ inlineShareCacheFiles[fileName]
+ ?: File(inlineShareCacheRoot(ctx), fileName).also {
+ inlineShareCacheFiles[fileName] = it
+ }
+ return Uri.fromFile(cached).toString()
+ }
runBlocking { waitUntilTaildropDirReady() }
currentUri[fileName]?.let {
return it
@@ -147,6 +177,9 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
@Throws(IOException::class)
override fun renameFile(oldPath: String, targetName: String): String {
+ if (InlineShare.matches(targetName)) {
+ return consumeInlineShare(oldPath, targetName)
+ }
val ctx = appContext ?: throw IOException("not initialized")
val dirUri = savedUri ?: throw IOException("directory not set")
val srcUri = Uri.parse(oldPath)
@@ -207,14 +240,83 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
@Throws(IOException::class)
override fun deleteFile(uri: String) {
+ val parsed = Uri.parse(uri)
+ // Cache-dir plain files; SAF can't resolve them.
+ if (parsed.scheme == "file" ||
+ parsed.lastPathSegment?.let { InlineShare.matchesAnyStage(it) } == true) {
+ parsed.path?.let { File(it).delete() }
+ return
+ }
runBlocking { waitUntilTaildropDirReady() }
val ctx = appContext ?: throw IOException("DeleteFile: not initialized")
- val parsedUri = Uri.parse(uri)
val doc =
- DocumentFile.fromSingleUri(ctx, parsedUri)
- ?: throw IOException("DeleteFile: cannot resolve URI $parsedUri")
+ DocumentFile.fromSingleUri(ctx, parsed)
+ ?: throw IOException("DeleteFile: cannot resolve URI $parsed")
if (!doc.delete()) {
- throw IOException("DeleteFile: delete() returned false for $parsedUri")
+ throw IOException("DeleteFile: delete() returned false for $parsed")
+ }
+ }
+
+ private fun consumeInlineShare(oldPath: String, targetName: String): String {
+ val ctx = appContext ?: throw IOException("inline share: not initialized")
+ val root = inlineShareCacheRoot(ctx)
+ val parsedOld = runCatching { Uri.parse(oldPath) }.getOrNull()
+ val partialFromUri = parsedOld?.lastPathSegment?.takeIf { it.isNotEmpty() }
+
+ val partialName =
+ partialFromUri
+ ?: root
+ .listFiles { _, n -> n.startsWith("$targetName.") && n.endsWith(".partial") }
+ ?.firstOrNull()
+ ?.name
+ ?: "$targetName.partial"
+ val cachedPartial = inlineShareCacheFiles[partialName] ?: File(root, partialName)
+ var bytes: ByteArray =
+ if (cachedPartial.exists()) {
+ runCatching { cachedPartial.readBytes() }
+ .onFailure {
+ TSLog.w("ShareFileHelper", "consumeInlineShare: cache read failed: $it")
+ }
+ .getOrDefault(ByteArray(0))
+ } else ByteArray(0)
+
+ // Fallback if openFileWriter missed and the partial landed in SAF instead.
+ if (bytes.isEmpty() && parsedOld != null && parsedOld.scheme == "content") {
+ bytes =
+ runCatching { ctx.contentResolver.openInputStream(parsedOld)?.use { it.readBytes() } }
+ .onFailure { TSLog.w("ShareFileHelper", "consumeInlineShare: SAF read failed: $it") }
+ .getOrNull() ?: ByteArray(0)
+ runCatching { ctx.contentResolver.delete(parsedOld, null, null) }
+ }
+
+ if (bytes.isNotEmpty()) {
+ val share = InlineShare.decode(targetName, bytes)
+ if (share != null) {
+ val pending = PendingInlineShare(kind = share.kind, content = share.content)
+ Notifier.appendInlineShare(pending)
+ TaildropNotifier.notify(ctx, pending)
+ } else {
+ TSLog.w("ShareFileHelper", "consumeInlineShare: decode failed for $targetName")
+ }
+ } else {
+ TSLog.w("ShareFileHelper", "consumeInlineShare: no bytes for $targetName")
+ }
+
+ cachedPartial.delete()
+ inlineShareCacheFiles.remove(partialName)
+ sweepSafInlineShareArtifacts(ctx)
+
+ return Uri.fromFile(File(root, targetName)).toString()
+ }
+
+ private fun sweepSafInlineShareArtifacts(ctx: Context) {
+ val dirUri = savedUri ?: return
+ val dir = runCatching { DocumentFile.fromTreeUri(ctx, Uri.parse(dirUri)) }.getOrNull() ?: return
+ for (child in runCatching { dir.listFiles() }.getOrNull().orEmpty()) {
+ val n = child.name ?: continue
+ if (InlineShare.matchesAnyStage(n)) {
+ runCatching { child.delete() }
+ }
}
}
diff --git a/android/src/main/java/com/tailscale/ipn/util/TaildropUsageTracker.kt b/android/src/main/java/com/tailscale/ipn/util/TaildropUsageTracker.kt
new file mode 100644
index 0000000000..5e1dc58136
--- /dev/null
+++ b/android/src/main/java/com/tailscale/ipn/util/TaildropUsageTracker.kt
@@ -0,0 +1,83 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package com.tailscale.ipn.util
+
+import android.content.Context
+import com.tailscale.ipn.ui.model.Tailcfg
+import kotlinx.serialization.builtins.MapSerializer
+import kotlinx.serialization.builtins.serializer
+import kotlinx.serialization.json.Json
+
+// Persists recent Taildrop recipients per user. Mirrors iOS TaildropUsageTracker.
+object TaildropUsageTracker {
+ private const val PREFS_NAME = "TaildropUsage"
+ private const val KEY = "lastUsedByUser"
+ private const val MAX_TRACKED = 3
+
+ private val serializer =
+ MapSerializer(String.serializer(), MapSerializer(String.serializer(), Long.serializer()))
+
+ fun getLastUsed(context: Context, userID: Long, stableID: String): Long? =
+ loadAll(context)[userID.toString()]?.get(stableID)
+
+ fun updateLastUsed(context: Context, userID: Long, stableID: String) {
+ val all = loadAll(context).toMutableMap()
+ val byStable = (all[userID.toString()] ?: emptyMap()).toMutableMap()
+ byStable[stableID] = System.currentTimeMillis()
+ val capped =
+ byStable.entries
+ .sortedByDescending { it.value }
+ .take(MAX_TRACKED)
+ .associate { it.key to it.value }
+ all[userID.toString()] = capped
+ saveAll(context, all)
+ }
+
+ fun partitionByRecency(
+ context: Context,
+ userID: Long,
+ peers: List,
+ ): Pair, List> {
+ val lastUsed = loadAll(context)[userID.toString()].orEmpty()
+ val sorted = sortPeers(peers, lastUsed)
+ val pivot =
+ sorted
+ .indexOfFirst { lastUsed[it.StableID] == null }
+ .let { if (it < 0) sorted.size else it }
+ return sorted.subList(0, pivot).toList() to sorted.subList(pivot, sorted.size).toList()
+ }
+
+ fun sortPeers(
+ context: Context,
+ userID: Long,
+ peers: List,
+ ): List = sortPeers(peers, loadAll(context)[userID.toString()].orEmpty())
+
+ private fun sortPeers(
+ peers: List,
+ lastUsed: Map,
+ ): List =
+ peers.sortedWith { a, b ->
+ val ta = lastUsed[a.StableID]
+ val tb = lastUsed[b.StableID]
+ when {
+ ta != null && tb != null -> tb.compareTo(ta)
+ ta != null -> -1
+ tb != null -> 1
+ else -> (a.ComputedName ?: a.Name).compareTo(b.ComputedName ?: b.Name, ignoreCase = true)
+ }
+ }
+
+ private fun loadAll(context: Context): Map> {
+ val raw = prefs(context).getString(KEY, null) ?: return emptyMap()
+ return runCatching { Json.decodeFromString(serializer, raw) }.getOrDefault(emptyMap())
+ }
+
+ private fun saveAll(context: Context, data: Map>) {
+ prefs(context).edit().putString(KEY, Json.encodeToString(serializer, data)).apply()
+ }
+
+ private fun prefs(context: Context) =
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+}
diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml
index 608f1f2a2e..80d82df031 100644
--- a/android/src/main/res/values/strings.xml
+++ b/android/src/main/res/values/strings.xml
@@ -257,6 +257,8 @@
%1$s files
Connect to your tailnet to share files.
My devices
+ Recently used
+ All devices
There are no devices on your tailnet to share to
Taildrop failed. Some files were not shared. Please try again.
Sending
@@ -374,6 +376,20 @@
What is taildrop?
Open Directory Picker
+
+ Taildrop link received
+ Taildrop text received
+ Taildrop file received
+ Tap to open
+ Tap to copy
+ Saved in Taildrop directory
+ Received via Taildrop
+ Received %1$d Taildrop items
+ Copied to clipboard
+ Done
+ Dismiss
+ Open Taildrop folder
+
Enable hardware attestation
Use hardware-backed keys to bind node identity to the device