android: support text and link sharing

This enhances the Android share feature as follows:
- Text and URLs will be recognized and shared via the .txt and .url files
- Files, text, and URLs will now show a small banner atop the devices list, mirroring iOS behavior.
- If multiple taildrops arrive, tapping the banner will open a bottom sheet to take action on them individually.
- Files can be opened or the enclosing taildrop folder opened.
- Text can be copied.
- URLs can be opened in the default browser.
- "Recently Used" taildrop targets will be locally cached and presented atop the list. Presentation & max length matches iOS behavior.
- Simple polling for online status is added to avoid the need to reload the share sheet to find devices that connect while it is visible.
- Fixes a bug that can cause the share sheet to be non-functional if you share; don't exit the view; share again

The iOS and macOS share extensions are currently receiving minor
Taildrop enhancements to support sharing of Text and URLs from
the OS-level share menu. To allow older clients to seamlessly receive
shares from newer clients, the .txt and .url suffixes were chosen.

iOS & macOS (in https://github.com/tailscale/corp/pull/44001), Android
(with these changes), and soon Windows and Linux will recognize these
file types and respond accordingly upon receipt of them:
- .txt: Notification presented to "copy" the text to the clipboard.
- .url: Notification presented to "open" the link

For platforms that lack in-app notifications, the files will additionally
be saved to the Taildrop folder as "Text [timestamp].txt",
"URL [timestamp].inetloc", or "URL [timestamp].url". This addresses
a limitation for users who explicitly opt-out of app notifications. Those
users would otherwise receive no transferred content whatsoever.

All other taildrop file transfer functionality remain unchanged.

updates tailscale/tailscale#4896
updates tailscale/tailscale#4996
fixes tailscale/tailscale#16850

Signed-off-by: Will Hannah <willh@tailscale.com>
This commit is contained in:
Will Hannah
2026-07-02 15:33:02 -04:00
parent d8f28f4673
commit 57adc08138
19 changed files with 1235 additions and 96 deletions
+4
View File
@@ -102,6 +102,10 @@
</intent-filter>
</receiver>
<receiver
android:name=".InlineShareActionReceiver"
android:exported="false" />
<receiver
android:name=".MDMSettingsChangedReceiver"
android:exported="false">
@@ -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"
@@ -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()
}
}
@@ -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<Ipn.OutgoingFile> =
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<Ipn.OutgoingFile> =
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 `"<snippet>"\n <url>#:~: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)
@@ -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
}
}
@@ -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<List<Ipn.PartialFile>?> = MutableStateFlow(null)
val filesWaiting: StateFlow<Empty.Message?> = MutableStateFlow(null)
private val _inlineShareInbox = MutableStateFlow<List<PendingInlineShare>>(emptyList())
val inlineShareInbox: StateFlow<List<PendingInlineShare>> = _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<String, Tailcfg.UserProfile>()
private lateinit var app: libtailscale.Application
@@ -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)
}
}
@@ -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)
}
@@ -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)
}
}
}
}
}
@@ -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<PendingTaildropViewModel.PendingTaildropItem>): 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<PendingTaildropViewModel.PendingTaildropItem>): 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<PendingTaildropViewModel.PendingTaildropItem>): 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)
}
}
@@ -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,40 +95,50 @@ fun TaildropView(
@Composable
fun FileSharePeerList(
peers: List<Tailcfg.Node>,
recent: List<Tailcfg.Node>,
other: List<Tailcfg.Node>,
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,
)
}
}
false -> {
LazyColumn {
peers.forEach { peer ->
item {
PeerView(
peer = peer,
onClick = { onShare(peer) },
subtitle = { peer.Hostinfo.OS ?: "" },
trailingContent = { stateViewGenerator(peer.StableID) },
)
}
}
}
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,
)
}
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
@@ -153,35 +168,46 @@ fun FileShareHeader(fileTransfers: List<Ipn.OutgoingFile>, 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<Ipn.OutgoingFile>) {
// (jonathan) TODO: Thumbnails?
@@ -193,12 +219,24 @@ fun IconForTransfer(transfers: List<Ipn.OutgoingFile>) {
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),
)
@@ -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<Boolean> = appViewModel.vpnPrepared
val isVpnActive: StateFlow<Boolean> = appViewModel.vpnActive
@@ -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<Set<String>>(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<List<Ipn.PartialFile>>(emptyList())
val pendingFiles: StateFlow<List<Ipn.PartialFile>> =
_pendingFiles
.combine(consumedFileIds) { files, consumed ->
files.filter { PendingTaildropItem.File(it).id !in consumed }
}
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
val pendingItems: StateFlow<List<PendingTaildropItem>> =
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")
}
}
}
@@ -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<Set<StableNodeID>> = MutableStateFlow(emptySet())
// Set of OutgoingFile.IDs that we're currently transferring.
private val currentTransferIDs: StateFlow<Set<String>> = 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<List<Tailcfg.Node>> = MutableStateFlow(emptyList())
// Recently shared-to devices (capped at 3) and everything else.
val recentPeers: StateFlow<List<Tailcfg.Node>> = MutableStateFlow(emptyList())
val otherPeers: StateFlow<List<Tailcfg.Node>> = MutableStateFlow(emptyList())
// Non null if there's an error to be rendered.
val showDialog: StateFlow<ErrorDialogType?> = 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<Tailcfg.Node> { 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)
}
}
}
@@ -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
}
}
@@ -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:
// <md5-of-content-hex>.txt raw UTF-8 text
// <md5-of-content-hex>.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: `<name>.partial`, `<name>.<clientID>.partial`,
// `<name>.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(),
)
@@ -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<String, String>()
private val inlineShareCacheFiles = ConcurrentHashMap<String, File>()
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() }
}
}
}
@@ -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<Tailcfg.Node>,
): Pair<List<Tailcfg.Node>, List<Tailcfg.Node>> {
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<Tailcfg.Node>,
): List<Tailcfg.Node> = sortPeers(peers, loadAll(context)[userID.toString()].orEmpty())
private fun sortPeers(
peers: List<Tailcfg.Node>,
lastUsed: Map<String, Long>,
): List<Tailcfg.Node> =
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<String, Map<String, Long>> {
val raw = prefs(context).getString(KEY, null) ?: return emptyMap()
return runCatching { Json.decodeFromString(serializer, raw) }.getOrDefault(emptyMap())
}
private fun saveAll(context: Context, data: Map<String, Map<String, Long>>) {
prefs(context).edit().putString(KEY, Json.encodeToString(serializer, data)).apply()
}
private fun prefs(context: Context) =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
+16
View File
@@ -257,6 +257,8 @@
<string name="file_count">%1$s files</string>
<string name="connect_to_your_tailnet_to_share_files">Connect to your tailnet to share files.</string>
<string name="my_devices">My devices</string>
<string name="taildrop_recently_used">Recently used</string>
<string name="taildrop_all_devices">All devices</string>
<string name="no_devices_to_share_with">There are no devices on your tailnet to share to</string>
<string name="taildrop_share_failed">Taildrop failed. Some files were not shared. Please try again.</string>
<string name="taildrop_sending">Sending</string>
@@ -374,6 +376,20 @@
<string name="taildrop_directory_picker_info">What is taildrop?</string>
<string name="taildrop_directory_picker_button">Open Directory Picker</string>
<!-- Strings for Taildrop receive notifications and in-app banner/sheet -->
<string name="taildrop_link_received">Taildrop link received</string>
<string name="taildrop_text_received">Taildrop text received</string>
<string name="taildrop_file_received">Taildrop file received</string>
<string name="taildrop_tap_to_open">Tap to open</string>
<string name="taildrop_tap_to_copy">Tap to copy</string>
<string name="taildrop_saved_in_directory">Saved in Taildrop directory</string>
<string name="taildrop_received_sheet_title">Received via Taildrop</string>
<string name="taildrop_pending_count">Received %1$d Taildrop items</string>
<string name="taildrop_copied_to_clipboard">Copied to clipboard</string>
<string name="taildrop_done">Done</string>
<string name="taildrop_dismiss">Dismiss</string>
<string name="taildrop_open_folder">Open Taildrop folder</string>
<!-- Strings for Hardware Attestation MDM setting -->
<string name="enable_hardware_attestation">Enable hardware attestation</string>
<string name="use_hardware_backed_keys_to_bind_node_identity_to_the_device">Use hardware-backed keys to bind node identity to the device</string>