Skip to content

Commit f894b47

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 .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 tailscale/corp#44001), Android (with these changes), and soon Windows and Linux will recognize these file types and respond accordingly upon receipt of them: - .txt: Notification presented to "copy" the text to the clipboard. - .url: Notification presented to "open" the link For platforms that lack in-app notifications, the files will additionally be saved to the Taildrop folder as "Text [timestamp].txt", "URL [timestamp].inetloc", or "URL [timestamp].url". This addresses a limitation for users who explicitly opt-out of app notifications. Those users would otherwise receive no transferred content whatsoever. All other taildrop file transfer functionality remain unchanged. updates tailscale/tailscale#4896 updates tailscale/tailscale#4996 fixes tailscale/tailscale#16850 Signed-off-by: Will Hannah <willh@tailscale.com>
1 parent d8f28f4 commit f894b47

19 files changed

Lines changed: 1236 additions & 95 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=".InlineShareActionReceiver"
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"
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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.InlineShare
15+
import com.tailscale.ipn.util.TSLog
16+
17+
// Handles taps on Taildrop inline-share notifications: URL → browser, text → clipboard.
18+
class InlineShareActionReceiver : BroadcastReceiver() {
19+
companion object {
20+
private const val TAG = "InlineShareActionReceiver"
21+
const val ACTION_CONSUME = "com.tailscale.ipn.INLINE_SHARE_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 { InlineShare.Kind.valueOf(kindRaw.uppercase()) }.getOrNull()
34+
?: run {
35+
TSLog.w(TAG, "unknown inline share kind: $kindRaw")
36+
return
37+
}
38+
39+
when (kind) {
40+
InlineShare.Kind.URL -> openUrl(context, content)
41+
InlineShare.Kind.TEXT -> copyToClipboard(context, content)
42+
}
43+
44+
if (id != null) {
45+
com.tailscale.ipn.ui.notifier.Notifier.removeInlineShare(id)
46+
}
47+
}
48+
49+
private fun openUrl(context: Context, content: String) {
50+
val uri = runCatching { Uri.parse(content) }.getOrNull()?.takeIf { !it.scheme.isNullOrEmpty() }
51+
if (uri == null) {
52+
copyToClipboard(context, content)
53+
return
54+
}
55+
if (!BrowserOpener.openInDefaultBrowser(context, uri)) {
56+
TSLog.w(TAG, "failed to open URL $content")
57+
copyToClipboard(context, content)
58+
}
59+
}
60+
61+
private fun copyToClipboard(context: Context, content: String) {
62+
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
63+
cm.setPrimaryClip(ClipData.newPlainText("Tailscale", content))
64+
Toast.makeText(context, R.string.taildrop_copied_to_clipboard, Toast.LENGTH_SHORT).show()
65+
}
66+
}

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

Lines changed: 72 additions & 14 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
@@ -30,6 +31,7 @@ import com.tailscale.ipn.ui.theme.AppTheme
3031
import com.tailscale.ipn.ui.util.set
3132
import com.tailscale.ipn.ui.util.universalFit
3233
import com.tailscale.ipn.ui.view.TaildropView
34+
import com.tailscale.ipn.util.InlineShare
3335
import com.tailscale.ipn.util.TSLog
3436
import kotlin.random.Random
3537
import kotlinx.coroutines.Dispatchers
@@ -120,22 +122,29 @@ class ShareActivity : ComponentActivity() {
120122
}
121123
}
122124

123-
val pendingFiles: List<Ipn.OutgoingFile> =
124-
uris?.filterNotNull()?.mapNotNull { uri ->
125-
contentResolver?.query(uri, null, null, null, null)?.use { cursor ->
126-
val nameCol = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
127-
val sizeCol = cursor.getColumnIndex(OpenableColumns.SIZE)
125+
val pendingFiles: MutableList<Ipn.OutgoingFile> =
126+
uris
127+
?.filterNotNull()
128+
?.mapNotNull { uri ->
129+
contentResolver?.query(uri, null, null, null, null)?.use { cursor ->
130+
val nameCol = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
131+
val sizeCol = cursor.getColumnIndex(OpenableColumns.SIZE)
128132

129-
if (cursor.moveToFirst()) {
130-
val name: String = cursor.getString(nameCol) ?: generateFallbackName(uri)
131-
val size: Long = cursor.getLong(sizeCol)
132-
Ipn.OutgoingFile(Name = name, DeclaredSize = size).apply { this.uri = uri }
133-
} else {
134-
TSLog.e(TAG, "Cursor is empty for URI: $uri")
135-
null
133+
if (cursor.moveToFirst()) {
134+
val name: String = cursor.getString(nameCol) ?: generateFallbackName(uri)
135+
val size: Long = cursor.getLong(sizeCol)
136+
Ipn.OutgoingFile(Name = name, DeclaredSize = size).apply { this.uri = uri }
137+
} else {
138+
TSLog.e(TAG, "Cursor is empty for URI: $uri")
139+
null
140+
}
141+
}
136142
}
137-
}
138-
} ?: emptyList()
143+
?.toMutableList() ?: mutableListOf()
144+
145+
if (pendingFiles.isEmpty() && act == Intent.ACTION_SEND) {
146+
inlineShareFromIntent(intent)?.let { pendingFiles.add(it) }
147+
}
139148

140149
if (pendingFiles.isEmpty()) {
141150
TSLog.e(TAG, "Share failure - no files extracted from intent")
@@ -144,6 +153,55 @@ class ShareActivity : ComponentActivity() {
144153
requestedTransfers.set(pendingFiles)
145154
}
146155

156+
private fun inlineShareFromIntent(intent: Intent): Ipn.OutgoingFile? {
157+
val raw = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()?.trim().orEmpty()
158+
if (raw.isEmpty()) return null
159+
160+
val text = stripChromeHighlightedTextShare(raw)
161+
val kind = classifyKind(text)
162+
163+
return try {
164+
val file = InlineShare.writeToCache(applicationContext, kind, text)
165+
Ipn.OutgoingFile(Name = file.name, DeclaredSize = file.length()).apply {
166+
this.uri = Uri.fromFile(file)
167+
this.inlineShare = InlineShare(kind = kind, content = text)
168+
}
169+
} catch (e: Exception) {
170+
TSLog.e(TAG, "Failed to write inline share: $e")
171+
null
172+
}
173+
}
174+
175+
private fun classifyKind(text: String): InlineShare.Kind {
176+
// Patterns.WEB_URL covers http(s)://, ftp://, www., bare hostnames. Use .matches()
177+
// so a URL embedded in a sentence stays TEXT.
178+
if (Patterns.WEB_URL.matcher(text).matches()) {
179+
val scheme = runCatching { Uri.parse(text) }.getOrNull()?.scheme
180+
if (scheme != null && scheme != "file") return InlineShare.Kind.URL
181+
}
182+
// Opaque schemes (tel:, sms:, mailto:, ssh:, magnet:, geo:, tailscale:, …) have
183+
// no `//` so Patterns.WEB_URL misses them. Accept anything that's whitespace-free
184+
// and parses to a non-file scheme with a non-empty body. The whitespace gate keeps
185+
// "see: this thing" out without us having to maintain a scheme allowlist.
186+
if (text.none { it.isWhitespace() }) {
187+
val parsed = runCatching { Uri.parse(text) }.getOrNull()
188+
val scheme = parsed?.scheme?.lowercase()
189+
if (scheme != null && scheme != "file" && !parsed.schemeSpecificPart.isNullOrBlank()) {
190+
return InlineShare.Kind.URL
191+
}
192+
}
193+
return InlineShare.Kind.TEXT
194+
}
195+
196+
// Chromium highlighted-text shares come through as `"<snippet>"\n <url>#:~:text=…`.
197+
// Receivers paste the blob verbatim; drop the URL tail and outer quotes.
198+
private fun stripChromeHighlightedTextShare(text: String): String {
199+
val urlLine = Regex("""(?m)^[ \t]*https?://\S*#:~:text=\S*[ \t]*$""").find(text) ?: return text
200+
val before = text.substring(0, urlLine.range.first).trimEnd()
201+
val unquoted = before.removeSurrounding("\"")
202+
return if (unquoted.isNotEmpty()) unquoted else text
203+
}
204+
147205
private fun generateFallbackName(uri: Uri): String {
148206
val randomId = Random.nextLong()
149207
val mimeType = contentResolver?.getType(uri)

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.InlineShare
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 inlineShare: InlineShare? = null
215217

216218
fun prepare(peerId: StableNodeID): OutgoingFile {
217219
val f = copy(ID = UUID.randomUUID().toString(), PeerID = peerId)
218220
f.uri = uri
221+
f.inlineShare = inlineShare
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.PendingInlineShare
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 _inlineShareInbox = MutableStateFlow<List<PendingInlineShare>>(emptyList())
63+
val inlineShareInbox: StateFlow<List<PendingInlineShare>> = _inlineShareInbox
64+
65+
fun appendInlineShare(p: PendingInlineShare) {
66+
_inlineShareInbox.update { it + p }
67+
}
68+
69+
fun removeInlineShare(id: String) {
70+
_inlineShareInbox.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: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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.InlineShareActionReceiver
16+
import com.tailscale.ipn.R
17+
import com.tailscale.ipn.util.InlineShare
18+
import com.tailscale.ipn.util.PendingInlineShare
19+
import com.tailscale.ipn.util.TSLog
20+
21+
object TaildropNotifier {
22+
private const val TAG = "TaildropNotifier"
23+
24+
fun cancel(context: Context, id: String) {
25+
NotificationManagerCompat.from(context).cancel(id.hashCode())
26+
}
27+
28+
fun notify(context: Context, pending: PendingInlineShare) {
29+
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
30+
PackageManager.PERMISSION_GRANTED) {
31+
TSLog.d(TAG, "POST_NOTIFICATIONS not granted; skipping inline share notification")
32+
return
33+
}
34+
35+
val title: String
36+
val body: String
37+
val subtitle: String
38+
when (pending.kind) {
39+
InlineShare.Kind.URL -> {
40+
title = context.getString(R.string.taildrop_link_received)
41+
body = pending.content
42+
subtitle = context.getString(R.string.taildrop_tap_to_open)
43+
}
44+
InlineShare.Kind.TEXT -> {
45+
title = context.getString(R.string.taildrop_text_received)
46+
body = pending.content.take(120).replace("\n", " ")
47+
subtitle = context.getString(R.string.taildrop_tap_to_copy)
48+
}
49+
}
50+
51+
val tapIntent =
52+
Intent(context, InlineShareActionReceiver::class.java).apply {
53+
action = InlineShareActionReceiver.ACTION_CONSUME
54+
putExtra(InlineShareActionReceiver.EXTRA_KIND, pending.kind.name.lowercase())
55+
putExtra(InlineShareActionReceiver.EXTRA_CONTENT, pending.content)
56+
putExtra(InlineShareActionReceiver.EXTRA_ID, pending.id)
57+
}
58+
59+
val pendingIntent =
60+
PendingIntent.getBroadcast(
61+
context,
62+
pending.id.hashCode(),
63+
tapIntent,
64+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
65+
66+
val notification =
67+
NotificationCompat.Builder(context, App.FILE_CHANNEL_ID)
68+
.setSmallIcon(R.drawable.ic_notification)
69+
.setContentTitle(title)
70+
.setContentText(body)
71+
.setSubText(subtitle)
72+
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
73+
.setAutoCancel(true)
74+
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
75+
.setContentIntent(pendingIntent)
76+
.build()
77+
78+
NotificationManagerCompat.from(context).notify(pending.id.hashCode(), notification)
79+
}
80+
}

0 commit comments

Comments
 (0)