Skip to content

Commit 2d7aa5b

Browse files
committed
android: support text and link sharing
This enhances the Android share feature as follows: - Text and URLs will be recognized and shared via the .tdpl encapsulation described below - 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. Rather than sending text/urls raw and fighting OS differences or adding a different Tailscale feature beyond taildrop to support this, text and URLs will be encapsulated preflight in a minimal, json-formatted envelope, named [hash].tdpl .tdpl ("taildrop payload") was chosen both to conform to a 4-letter file deduplication suffix requirement and because it is not already a well-known extension. For version 1, the structure is simply: `{version:1, content:"the text or url", kind:"text|url"}` iOS & macOS (in tailscale/corp#44001), Android (with these changes), and soon Windows and Linux will recognize these file types and respond accordingly upon receipt of .tdpl files: - Kind "text": Notification presented to "copy" the text to the clipboard. - Kind "url": Notification presented to "open" the link For platforms that lack in-app notifications, the files will additionally be unwrapped & saved to the Taildrop folder as "Text [timestamp].txt", "URL [timestamp].webloc", 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
1 parent d8f28f4 commit 2d7aa5b

18 files changed

Lines changed: 1170 additions & 83 deletions

android/src/main/AndroidManifest.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@
102102
</intent-filter>
103103
</receiver>
104104

105+
<receiver
106+
android:name=".TdPayloadActionReceiver"
107+
android:exported="false" />
108+
105109
<receiver
106110
android:name=".MDMSettingsChangedReceiver"
107111
android:exported="false">

android/src/main/java/com/tailscale/ipn/App.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
6262
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
6363

6464
companion object {
65-
private const val FILE_CHANNEL_ID = "tailscale-files"
65+
const val FILE_CHANNEL_ID = "tailscale-files"
6666
// Key to store the SAF URI in EncryptedSharedPreferences.
6767
private val PREF_KEY_SAF_URI = "saf_directory_uri"
6868
private const val TAG = "App"

android/src/main/java/com/tailscale/ipn/ShareActivity.kt

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import android.net.Uri
88
import android.os.Build
99
import android.os.Bundle
1010
import android.provider.OpenableColumns
11+
import android.util.Patterns
1112
import android.webkit.MimeTypeMap
1213
import androidx.activity.ComponentActivity
1314
import androidx.activity.compose.setContent
@@ -31,6 +32,7 @@ import com.tailscale.ipn.ui.util.set
3132
import com.tailscale.ipn.ui.util.universalFit
3233
import com.tailscale.ipn.ui.view.TaildropView
3334
import com.tailscale.ipn.util.TSLog
35+
import com.tailscale.ipn.util.TdPayload
3436
import kotlin.random.Random
3537
import kotlinx.coroutines.Dispatchers
3638
import kotlinx.coroutines.flow.MutableStateFlow
@@ -120,7 +122,7 @@ class ShareActivity : ComponentActivity() {
120122
}
121123
}
122124

123-
val pendingFiles: List<Ipn.OutgoingFile> =
125+
val pendingFiles: MutableList<Ipn.OutgoingFile> =
124126
uris?.filterNotNull()?.mapNotNull { uri ->
125127
contentResolver?.query(uri, null, null, null, null)?.use { cursor ->
126128
val nameCol = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
@@ -135,7 +137,11 @@ class ShareActivity : ComponentActivity() {
135137
null
136138
}
137139
}
138-
} ?: emptyList()
140+
}?.toMutableList() ?: mutableListOf()
141+
142+
if (pendingFiles.isEmpty() && act == Intent.ACTION_SEND) {
143+
tdPayloadFromIntent(intent)?.let { pendingFiles.add(it) }
144+
}
139145

140146
if (pendingFiles.isEmpty()) {
141147
TSLog.e(TAG, "Share failure - no files extracted from intent")
@@ -144,6 +150,47 @@ class ShareActivity : ComponentActivity() {
144150
requestedTransfers.set(pendingFiles)
145151
}
146152

153+
// Wraps text/URL EXTRA_TEXT in a `.tdpl` envelope so it rides the regular
154+
// Taildrop file pipeline.
155+
private fun tdPayloadFromIntent(intent: Intent): Ipn.OutgoingFile? {
156+
val raw = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()?.trim().orEmpty()
157+
if (raw.isEmpty()) return null
158+
159+
val text = stripChromeHighlightedTextShare(raw)
160+
161+
val title =
162+
intent.getCharSequenceExtra(Intent.EXTRA_SUBJECT)?.toString()?.takeIf { it.isNotBlank() }
163+
164+
val kind =
165+
if (Patterns.WEB_URL.matcher(text).matches()) {
166+
val parsed = runCatching { Uri.parse(text) }.getOrNull()
167+
if (parsed != null && parsed.scheme != "file") TdPayload.Kind.URL else TdPayload.Kind.TEXT
168+
} else {
169+
TdPayload.Kind.TEXT
170+
}
171+
172+
return try {
173+
val file = TdPayload.writeToCache(applicationContext, kind, text, title)
174+
Ipn.OutgoingFile(Name = file.name, DeclaredSize = file.length()).apply {
175+
this.uri = Uri.fromFile(file)
176+
this.tdPayload = TdPayload(kind = kind, content = text, title = title)
177+
}
178+
} catch (e: Exception) {
179+
TSLog.e(TAG, "Failed to write tdpayload: $e")
180+
null
181+
}
182+
}
183+
184+
// Chromium highlighted-text shares come through as `"<snippet>"\n <url>#:~:text=…`.
185+
// Receivers paste the blob verbatim; drop the URL tail and outer quotes.
186+
private fun stripChromeHighlightedTextShare(text: String): String {
187+
val urlLine =
188+
Regex("""(?m)^[ \t]*https?://\S*#:~:text=\S*[ \t]*$""").find(text) ?: return text
189+
val before = text.substring(0, urlLine.range.first).trimEnd()
190+
val unquoted = before.removeSurrounding("\"")
191+
return if (unquoted.isNotEmpty()) unquoted else text
192+
}
193+
147194
private fun generateFallbackName(uri: Uri): String {
148195
val randomId = Random.nextLong()
149196
val mimeType = contentResolver?.getType(uri)
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright (c) Tailscale Inc & AUTHORS
2+
// SPDX-License-Identifier: BSD-3-Clause
3+
4+
package com.tailscale.ipn
5+
6+
import android.content.BroadcastReceiver
7+
import android.content.ClipData
8+
import android.content.ClipboardManager
9+
import android.content.Context
10+
import android.content.Intent
11+
import android.net.Uri
12+
import android.widget.Toast
13+
import com.tailscale.ipn.util.BrowserOpener
14+
import com.tailscale.ipn.util.TSLog
15+
import com.tailscale.ipn.util.TdPayload
16+
17+
// Handles taps on Taildrop tdpayload notifications: URL → browser, text → clipboard.
18+
class TdPayloadActionReceiver : BroadcastReceiver() {
19+
companion object {
20+
private const val TAG = "TdPayloadActionReceiver"
21+
const val ACTION_CONSUME = "com.tailscale.ipn.TDPAYLOAD_CONSUME"
22+
const val EXTRA_KIND = "kind"
23+
const val EXTRA_CONTENT = "content"
24+
const val EXTRA_ID = "id"
25+
}
26+
27+
override fun onReceive(context: Context, intent: Intent) {
28+
val kindRaw = intent.getStringExtra(EXTRA_KIND) ?: return
29+
val content = intent.getStringExtra(EXTRA_CONTENT) ?: return
30+
val id = intent.getStringExtra(EXTRA_ID)
31+
32+
val kind =
33+
runCatching { TdPayload.Kind.valueOf(kindRaw.uppercase()) }
34+
.getOrNull()
35+
?: run {
36+
TSLog.w(TAG, "unknown tdpayload kind: $kindRaw")
37+
return
38+
}
39+
40+
when (kind) {
41+
TdPayload.Kind.URL -> openUrl(context, content)
42+
TdPayload.Kind.TEXT -> copyToClipboard(context, content)
43+
}
44+
45+
if (id != null) {
46+
com.tailscale.ipn.ui.notifier.Notifier.removeTdPayload(id)
47+
}
48+
}
49+
50+
private fun openUrl(context: Context, content: String) {
51+
val uri =
52+
runCatching { Uri.parse(content) }
53+
.getOrNull()
54+
?.takeIf { !it.scheme.isNullOrEmpty() }
55+
if (uri == null) {
56+
copyToClipboard(context, content)
57+
return
58+
}
59+
if (!BrowserOpener.openInDefaultBrowser(context, uri)) {
60+
TSLog.w(TAG, "failed to open URL $content")
61+
copyToClipboard(context, content)
62+
}
63+
}
64+
65+
private fun copyToClipboard(context: Context, content: String) {
66+
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
67+
cm.setPrimaryClip(ClipData.newPlainText("Tailscale", content))
68+
Toast.makeText(context, R.string.taildrop_copied_to_clipboard, Toast.LENGTH_SHORT).show()
69+
}
70+
}

android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package com.tailscale.ipn.ui.model
55

66
import android.net.Uri
7+
import com.tailscale.ipn.util.TdPayload
78
import java.util.UUID
89
import kotlinx.serialization.Serializable
910
import kotlinx.serialization.Transient
@@ -212,10 +213,12 @@ class Ipn {
212213
val Succeeded: Boolean = false,
213214
) {
214215
@Transient lateinit var uri: Uri // only used on client
216+
@Transient var tdPayload: TdPayload? = null // set for `.tdpl` shares
215217

216218
fun prepare(peerId: StableNodeID): OutgoingFile {
217219
val f = copy(ID = UUID.randomUUID().toString(), PeerID = peerId)
218220
f.uri = uri
221+
f.tdPayload = tdPayload
219222
return f
220223
}
221224
}

android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ import com.tailscale.ipn.ui.model.Netmap
1414
import com.tailscale.ipn.ui.model.NodeID
1515
import com.tailscale.ipn.ui.model.Tailcfg
1616
import com.tailscale.ipn.ui.util.set
17+
import com.tailscale.ipn.util.PendingTdPayload
1718
import com.tailscale.ipn.util.TSLog
1819
import kotlinx.coroutines.CoroutineScope
1920
import kotlinx.coroutines.Dispatchers
2021
import kotlinx.coroutines.flow.MutableStateFlow
2122
import kotlinx.coroutines.flow.StateFlow
23+
import kotlinx.coroutines.flow.update
2224
import kotlinx.coroutines.launch
2325
import kotlinx.serialization.ExperimentalSerializationApi
2426
import kotlinx.serialization.json.Json
@@ -57,6 +59,17 @@ object Notifier {
5759
val incomingFiles: StateFlow<List<Ipn.PartialFile>?> = MutableStateFlow(null)
5860
val filesWaiting: StateFlow<Empty.Message?> = MutableStateFlow(null)
5961

62+
private val _tdPayloadInbox = MutableStateFlow<List<PendingTdPayload>>(emptyList())
63+
val tdPayloadInbox: StateFlow<List<PendingTdPayload>> = _tdPayloadInbox
64+
65+
fun appendTdPayload(p: PendingTdPayload) {
66+
_tdPayloadInbox.update { it + p }
67+
}
68+
69+
fun removeTdPayload(id: String) {
70+
_tdPayloadInbox.update { list -> list.filterNot { it.id == id } }
71+
}
72+
6073
private val userProfiles = mutableMapOf<String, Tailcfg.UserProfile>()
6174

6275
private lateinit var app: libtailscale.Application
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright (c) Tailscale Inc & AUTHORS
2+
// SPDX-License-Identifier: BSD-3-Clause
3+
4+
package com.tailscale.ipn.ui.notifier
5+
6+
import android.Manifest
7+
import android.app.PendingIntent
8+
import android.content.Context
9+
import android.content.Intent
10+
import android.content.pm.PackageManager
11+
import androidx.core.app.ActivityCompat
12+
import androidx.core.app.NotificationCompat
13+
import androidx.core.app.NotificationManagerCompat
14+
import com.tailscale.ipn.App
15+
import com.tailscale.ipn.R
16+
import com.tailscale.ipn.TdPayloadActionReceiver
17+
import com.tailscale.ipn.util.PendingTdPayload
18+
import com.tailscale.ipn.util.TSLog
19+
import com.tailscale.ipn.util.TdPayload
20+
21+
// Posts a system notification for an incoming TdPayload on the Taildrop
22+
// file channel; tap fires TdPayloadActionReceiver.
23+
object TaildropNotifier {
24+
private const val TAG = "TaildropNotifier"
25+
26+
fun cancel(context: Context, id: String) {
27+
NotificationManagerCompat.from(context).cancel(id.hashCode())
28+
}
29+
30+
fun notify(context: Context, pending: PendingTdPayload) {
31+
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
32+
PackageManager.PERMISSION_GRANTED) {
33+
TSLog.d(TAG, "POST_NOTIFICATIONS not granted; skipping tdpayload notification")
34+
return
35+
}
36+
37+
val title: String
38+
val body: String
39+
val subtitle: String
40+
when (pending.kind) {
41+
TdPayload.Kind.URL -> {
42+
title = context.getString(R.string.taildrop_link_received)
43+
body = pending.title ?: pending.content
44+
subtitle = context.getString(R.string.taildrop_tap_to_open)
45+
}
46+
TdPayload.Kind.TEXT -> {
47+
title = context.getString(R.string.taildrop_text_received)
48+
body = pending.content.take(120).replace("\n", " ")
49+
subtitle = context.getString(R.string.taildrop_tap_to_copy)
50+
}
51+
}
52+
53+
val tapIntent =
54+
Intent(context, TdPayloadActionReceiver::class.java).apply {
55+
action = TdPayloadActionReceiver.ACTION_CONSUME
56+
putExtra(TdPayloadActionReceiver.EXTRA_KIND, pending.kind.name.lowercase())
57+
putExtra(TdPayloadActionReceiver.EXTRA_CONTENT, pending.content)
58+
putExtra(TdPayloadActionReceiver.EXTRA_ID, pending.id)
59+
}
60+
61+
val pendingIntent =
62+
PendingIntent.getBroadcast(
63+
context,
64+
pending.id.hashCode(),
65+
tapIntent,
66+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
67+
68+
val notification =
69+
NotificationCompat.Builder(context, App.FILE_CHANNEL_ID)
70+
.setSmallIcon(R.drawable.ic_notification)
71+
.setContentTitle(title)
72+
.setContentText(body)
73+
.setSubText(subtitle)
74+
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
75+
.setAutoCancel(true)
76+
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
77+
.setContentIntent(pendingIntent)
78+
.build()
79+
80+
NotificationManagerCompat.from(context).notify(pending.id.hashCode(), notification)
81+
}
82+
}

android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,10 @@ fun MainView(
222222
ExitNodeStatus(
223223
navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
224224
}
225+
val pending by viewModel.pendingItems.collectAsState()
226+
if (pending.isNotEmpty()) {
227+
TaildropBannerView(viewModel = viewModel)
228+
}
225229
PeerList(
226230
viewModel = viewModel,
227231
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
@@ -251,6 +255,12 @@ fun MainView(
251255
PingView(model = viewModel.pingViewModel)
252256
}
253257
}
258+
val showPendingSheet by viewModel.isPresentingPendingItemsList.collectAsState()
259+
if (showPendingSheet) {
260+
ModalBottomSheet(onDismissRequest = { viewModel.isPresentingPendingItemsList.value = false }) {
261+
TdPayloadListSheet(viewModel = viewModel)
262+
}
263+
}
254264
}
255265
}
256266
}

0 commit comments

Comments
 (0)