From 53a231efccb0ed0ad224bbe30230e8377018a811 Mon Sep 17 00:00:00 2001 From: Will Hannah Date: Fri, 26 Jun 2026 15:09:16 -0400 Subject: [PATCH] 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. Upon use or dismissal of URL shares, the .url file will be deleted. This is done to avoid piling up generally unusable .url files on Android in the Taildrop folder. 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 --- android/src/main/AndroidManifest.xml | 4 + .../src/main/java/com/tailscale/ipn/App.kt | 2 +- .../ipn/InlineShareActionReceiver.kt | 40 +++++ .../java/com/tailscale/ipn/ShareActivity.kt | 86 +++++++-- .../java/com/tailscale/ipn/ui/model/Ipn.kt | 3 + .../com/tailscale/ipn/ui/notifier/Notifier.kt | 19 ++ .../ipn/ui/notifier/TaildropNotifier.kt | 96 ++++++++++ .../ipn/ui/view/InlineShareListSheet.kt | 154 ++++++++++++++++ .../com/tailscale/ipn/ui/view/MainView.kt | 14 ++ .../ipn/ui/view/TaildropBannerView.kt | 167 ++++++++++++++++++ .../com/tailscale/ipn/ui/view/TaildropView.kt | 138 +++++++++------ .../ipn/ui/viewModel/MainViewModel.kt | 3 + .../ui/viewModel/PendingTaildropViewModel.kt | 166 +++++++++++++++++ .../ipn/ui/viewModel/TaildropViewModel.kt | 72 +++++--- .../com/tailscale/ipn/util/BrowserOpener.kt | 44 +++++ .../com/tailscale/ipn/util/InlineShare.kt | 82 +++++++++ .../tailscale/ipn/util/InlineShareConsumer.kt | 79 +++++++++ .../ipn/util/InlineShareInboxStore.kt | 40 +++++ .../com/tailscale/ipn/util/ShareFileHelper.kt | 46 ++++- .../ipn/util/TaildropUsageTracker.kt | 83 +++++++++ android/src/main/res/values/strings.xml | 16 ++ 21 files changed, 1259 insertions(+), 95 deletions(-) create mode 100644 android/src/main/java/com/tailscale/ipn/InlineShareActionReceiver.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/notifier/TaildropNotifier.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/view/InlineShareListSheet.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/view/TaildropBannerView.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/viewModel/PendingTaildropViewModel.kt create mode 100644 android/src/main/java/com/tailscale/ipn/util/BrowserOpener.kt create mode 100644 android/src/main/java/com/tailscale/ipn/util/InlineShare.kt create mode 100644 android/src/main/java/com/tailscale/ipn/util/InlineShareConsumer.kt create mode 100644 android/src/main/java/com/tailscale/ipn/util/InlineShareInboxStore.kt create mode 100644 android/src/main/java/com/tailscale/ipn/util/TaildropUsageTracker.kt 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..55b4518b64 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/InlineShareActionReceiver.kt @@ -0,0 +1,40 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.tailscale.ipn.ui.notifier.Notifier +import com.tailscale.ipn.ui.notifier.TaildropNotifier +import com.tailscale.ipn.util.InlineShareConsumer + +// Handles taps on Taildrop inline-share notifications: URL → browser, text → clipboard. +class InlineShareActionReceiver : BroadcastReceiver() { + companion object { + const val ACTION_CONSUME = "com.tailscale.ipn.INLINE_SHARE_CONSUME" + const val ACTION_DISMISS = "com.tailscale.ipn.INLINE_SHARE_DISMISS" + const val EXTRA_ID = "id" + } + + // Looking the share up by id makes this idempotent: whichever of tap or swipe lands + // second finds no entry and does nothing. + override fun onReceive(context: Context, intent: Intent) { + val id = intent.getStringExtra(EXTRA_ID) ?: return + val share = Notifier.inlineShareInbox.value.find { it.id == id } ?: return + when (intent.action) { + ACTION_CONSUME -> { + // Leave the entry alone if nothing happened, so the user can retry. + if (!InlineShareConsumer.consume(context, share)) return + Notifier.removeInlineShare(id) + TaildropNotifier.cancel(context, id) + } + // Swiped away: the notification is already gone, so there's nothing to cancel. + ACTION_DISMISS -> { + InlineShareConsumer.dismiss(context, share) + Notifier.removeInlineShare(id) + } + } + } +} 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..8140d2946f 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,14 @@ 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.InlineShareInboxStore +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 +60,22 @@ object Notifier { val incomingFiles: StateFlow?> = MutableStateFlow(null) val filesWaiting: StateFlow = MutableStateFlow(null) + // Seeded from disk so pending shares survive process death, e.g. if the app is killed + // in the background before the user acts on a share that couldn't be surfaced as a + // notification because POST_NOTIFICATIONS was denied. + private val _inlineShareInbox = MutableStateFlow(InlineShareInboxStore.load()) + val inlineShareInbox: StateFlow> = _inlineShareInbox + + fun appendInlineShare(p: PendingInlineShare) { + _inlineShareInbox.update { it + p } + InlineShareInboxStore.save(_inlineShareInbox.value) + } + + fun removeInlineShare(id: String) { + _inlineShareInbox.update { list -> list.filterNot { it.id == id } } + InlineShareInboxStore.save(_inlineShareInbox.value) + } + 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..248df075f0 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/notifier/TaildropNotifier.kt @@ -0,0 +1,96 @@ +// 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" + // Notification extras cross a binder transaction; keep the preview well clear of its limit. + private const val PREVIEW_CHARS = 120 + + 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_ID, pending.id) + } + + val pendingIntent = + PendingIntent.getBroadcast( + context, + pending.id.hashCode(), + tapIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + + // Without a delete intent a swipe-away is invisible to us, leaving the inbox entry + // (and, for URLs, the file) behind. + val dismissIntent = + Intent(context, InlineShareActionReceiver::class.java).apply { + action = InlineShareActionReceiver.ACTION_DISMISS + putExtra(InlineShareActionReceiver.EXTRA_ID, pending.id) + } + + val dismissPendingIntent = + PendingIntent.getBroadcast( + context, + "dismiss-${pending.id}".hashCode(), + dismissIntent, + 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) + .setDeleteIntent(dismissPendingIntent) + .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..bbc6742f97 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PendingTaildropViewModel.kt @@ -0,0 +1,166 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause +package com.tailscale.ipn.ui.viewModel + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.DocumentsContract +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.InlineShare +import com.tailscale.ipn.util.InlineShareConsumer +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 -> { + // Leave the entry alone if nothing happened, so the user can retry. + if (!InlineShareConsumer.consume(context, item.share)) return + 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 -> { + InlineShareConsumer.dismiss(context, item.share) + Notifier.removeInlineShare(item.share.id) + TaildropNotifier.cancel(context, item.share.id) + } + } + } + + 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..6725252a1c --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/util/InlineShare.kt @@ -0,0 +1,82 @@ +// 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 +import kotlinx.serialization.Serializable + +// Wire format: +// .txt raw UTF-8 text +// .url Windows Internet Shortcut INI (CRLF) +data class InlineShare( + val kind: Kind, + val content: String, +) { + @Serializable + 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) + + fun matches(filename: String): Boolean = finalNameRegex.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) } + } + } +} + +@Serializable +data class PendingInlineShare( + val id: String = UUID.randomUUID().toString(), + val kind: InlineShare.Kind, + val content: String, + val received: Long = System.currentTimeMillis(), + // SAF URI of the saved file: backs the open-file fallback and delete-on-open for URLs. + val uri: String? = null, +) diff --git a/android/src/main/java/com/tailscale/ipn/util/InlineShareConsumer.kt b/android/src/main/java/com/tailscale/ipn/util/InlineShareConsumer.kt new file mode 100644 index 0000000000..3e617eb207 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/util/InlineShareConsumer.kt @@ -0,0 +1,79 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.util + +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 androidx.documentfile.provider.DocumentFile +import com.tailscale.ipn.R + +// Acting on an inline share: URL opens the browser, text goes to the clipboard, with the +// saved file as the fallback for both. Shared by the notification receiver and the UI so +// the two can't drift. +object InlineShareConsumer { + private const val TAG = "InlineShareConsumer" + + // Returns true if the share was acted on and its inbox entry should be cleared. + fun consume(context: Context, share: PendingInlineShare): Boolean = + when (share.kind) { + InlineShare.Kind.URL -> consumeUrl(context, share) + InlineShare.Kind.TEXT -> + copyToClipboard(context, share.content) || openSavedFile(context, share) + } + + // Dismissing a URL drops its file too: Android has no handler for .url, so keeping one + // around serves nobody. Text is always kept, since the file is the point. + fun dismiss(context: Context, share: PendingInlineShare) { + if (share.kind == InlineShare.Kind.URL) deleteSavedFile(context, share) + } + + private fun consumeUrl(context: Context, share: PendingInlineShare): Boolean { + val uri = + runCatching { Uri.parse(share.content) }.getOrNull()?.takeIf { !it.scheme.isNullOrEmpty() } + if (uri != null && BrowserOpener.openInDefaultBrowser(context, uri)) { + // Opened links have served their purpose, so don't leave the file behind. + deleteSavedFile(context, share) + return true + } + TSLog.w(TAG, "openUrl failed for ${share.content}") + return copyToClipboard(context, share.content) || openSavedFile(context, share) + } + + private fun copyToClipboard(context: Context, content: String): Boolean { + val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + if (cm == null) { + TSLog.w(TAG, "no ClipboardManager") + return false + } + // setPrimaryClip is a binder call: it throws on oversized payloads. + return runCatching { + cm.setPrimaryClip(ClipData.newPlainText("Tailscale", content)) + Toast.makeText(context, R.string.taildrop_copied_to_clipboard, Toast.LENGTH_SHORT).show() + } + .onFailure { TSLog.w(TAG, "copyToClipboard failed: $it") } + .isSuccess + } + + private fun openSavedFile(context: Context, share: PendingInlineShare): Boolean { + val uri = share.uri?.let { runCatching { Uri.parse(it) }.getOrNull() } ?: return false + val intent = + Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "text/plain") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + return runCatching { context.startActivity(intent) } + .onFailure { TSLog.w(TAG, "openSavedFile failed: $it") } + .isSuccess + } + + private fun deleteSavedFile(context: Context, share: PendingInlineShare) { + val uri = share.uri?.let { runCatching { Uri.parse(it) }.getOrNull() } ?: return + runCatching { DocumentFile.fromSingleUri(context, uri)?.delete() } + .onFailure { TSLog.w(TAG, "deleteSavedFile failed: $it") } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/util/InlineShareInboxStore.kt b/android/src/main/java/com/tailscale/ipn/util/InlineShareInboxStore.kt new file mode 100644 index 0000000000..ae2a6bbd9d --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/util/InlineShareInboxStore.kt @@ -0,0 +1,40 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause +package com.tailscale.ipn.util + +import com.tailscale.ipn.App +import com.tailscale.ipn.UninitializedApp +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +// Persists the inline-share inbox so pending shares survive process death. +// +// UninitializedApp.get() rather than App.get(): loaded from Notifier's field initializer, +// which runs inside App's initOnce() before isInitialized is set. App.get() would re-enter +// initOnce() and start Libtailscale a second time. +object InlineShareInboxStore { + private const val PREF_KEY_INBOX = "inline_share_inbox" + private val json = Json { ignoreUnknownKeys = true } + + private fun encryptedPrefs() = (UninitializedApp.get() as App).getEncryptedPrefs() + + // apply(), not commit(): the saved file is the durable record, this is just UI state. + fun save(inbox: List) { + val encoded = json.encodeToString(inbox) + runCatching { encryptedPrefs().edit().putString(PREF_KEY_INBOX, encoded).apply() } + .onFailure { TSLog.w("InlineShareInboxStore", "save failed: $it") } + } + + fun load(): List { + val prefs = encryptedPrefs() + val raw = prefs.getString(PREF_KEY_INBOX, null) ?: return emptyList() + return try { + json.decodeFromString(raw) + } catch (e: Exception) { + TSLog.w("InlineShareInboxStore", "load: invalid inbox in prefs; clearing") + prefs.edit().remove(PREF_KEY_INBOX).apply() + emptyList() + } + } +} 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..cc0132b417 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 @@ -27,6 +30,9 @@ import org.json.JSONObject data class SafFile(val fd: Int, val uri: String) object ShareFileHelper : libtailscale.ShareFileHelper { + // Cap on reading a received file back to decode it for the notification preview. + private const val MAX_INLINE_SHARE_BYTES = 64 * 1024 + private var appContext: Context? = null private var app: libtailscale.Application? = null private var savedUri: String? = null @@ -167,6 +173,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper { DocumentsContract.renameDocument(ctx.contentResolver, srcUri, finalName)?.also { newUri -> runCatching { ctx.contentResolver.delete(srcUri, null, null) } cleanupPartials(dir, targetName) + maybeNotifyInlineShare(ctx, newUri.toString(), targetName) return newUri.toString() } } catch (e: Exception) { @@ -190,9 +197,35 @@ object ShareFileHelper : libtailscale.ShareFileHelper { ctx.contentResolver.delete(srcUri, null, null) cleanupPartials(dir, targetName) + maybeNotifyInlineShare(ctx, dest.uri.toString(), targetName) return dest.uri.toString() } + // Inline shares are saved like any other Taildrop file; this only adds the notification + // and inbox entry on top. Anything unreadable or undecodable just stays a plain file. + private fun maybeNotifyInlineShare(ctx: Context, uri: String, targetName: String) { + if (!InlineShare.matches(targetName)) return + val parsed = runCatching { Uri.parse(uri) }.getOrNull() ?: return + val size = runCatching { lengthOfUri(ctx, parsed) }.getOrDefault(-1L) + if (size > MAX_INLINE_SHARE_BYTES) { + TSLog.w("ShareFileHelper", "inline share $targetName too large to decode ($size bytes)") + return + } + val bytes = + runCatching { ctx.contentResolver.openInputStream(parsed)?.use { it.readBytes() } } + .onFailure { TSLog.w("ShareFileHelper", "inline share read failed: $it") } + .getOrNull() ?: return + val share = + InlineShare.decode(targetName, bytes) + ?: run { + TSLog.w("ShareFileHelper", "inline share decode failed: $targetName, ${bytes.size}B") + return + } + val pending = PendingInlineShare(kind = share.kind, content = share.content, uri = uri) + Notifier.appendInlineShare(pending) + TaildropNotifier.notify(ctx, pending) + } + private fun lengthOfUri(ctx: Context, uri: Uri): Long = ctx.contentResolver.openAssetFileDescriptor(uri, "r").use { it?.length ?: -1 } // delete any stray “.partial” files for this base name @@ -207,14 +240,19 @@ 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.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") } } 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