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