Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@
</intent-filter>
</receiver>

<receiver
android:name=".InlineShareActionReceiver"
android:exported="false" />

<receiver
android:name=".MDMSettingsChangedReceiver"
android:exported="false">
Expand Down
2 changes: 1 addition & 1 deletion android/src/main/java/com/tailscale/ipn/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
86 changes: 72 additions & 14 deletions android/src/main/java/com/tailscale/ipn/ShareActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -120,22 +122,29 @@ class ShareActivity : ComponentActivity() {
}
}

val pendingFiles: List<Ipn.OutgoingFile> =
uris?.filterNotNull()?.mapNotNull { uri ->
contentResolver?.query(uri, null, null, null, null)?.use { cursor ->
val nameCol = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeCol = cursor.getColumnIndex(OpenableColumns.SIZE)
val pendingFiles: MutableList<Ipn.OutgoingFile> =
uris
?.filterNotNull()
?.mapNotNull { uri ->
contentResolver?.query(uri, null, null, null, null)?.use { cursor ->
val nameCol = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeCol = cursor.getColumnIndex(OpenableColumns.SIZE)

if (cursor.moveToFirst()) {
val name: String = cursor.getString(nameCol) ?: generateFallbackName(uri)
val size: Long = cursor.getLong(sizeCol)
Ipn.OutgoingFile(Name = name, DeclaredSize = size).apply { this.uri = uri }
} else {
TSLog.e(TAG, "Cursor is empty for URI: $uri")
null
if (cursor.moveToFirst()) {
val name: String = cursor.getString(nameCol) ?: generateFallbackName(uri)
val size: Long = cursor.getLong(sizeCol)
Ipn.OutgoingFile(Name = name, DeclaredSize = size).apply { this.uri = uri }
} else {
TSLog.e(TAG, "Cursor is empty for URI: $uri")
null
}
}
}
}
} ?: emptyList()
?.toMutableList() ?: mutableListOf()

if (pendingFiles.isEmpty() && act == Intent.ACTION_SEND) {
inlineShareFromIntent(intent)?.let { pendingFiles.add(it) }
}

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

private fun inlineShareFromIntent(intent: Intent): Ipn.OutgoingFile? {
val raw = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()?.trim().orEmpty()
if (raw.isEmpty()) return null

val text = stripChromeHighlightedTextShare(raw)
val kind = classifyKind(text)

return try {
val file = InlineShare.writeToCache(applicationContext, kind, text)
Ipn.OutgoingFile(Name = file.name, DeclaredSize = file.length()).apply {
this.uri = Uri.fromFile(file)
this.inlineShare = InlineShare(kind = kind, content = text)
}
} catch (e: Exception) {
TSLog.e(TAG, "Failed to write inline share: $e")
null
}
}

private fun classifyKind(text: String): InlineShare.Kind {
// Patterns.WEB_URL covers http(s)://, ftp://, www., bare hostnames. Use .matches()
// so a URL embedded in a sentence stays TEXT.
if (Patterns.WEB_URL.matcher(text).matches()) {
val scheme = runCatching { Uri.parse(text) }.getOrNull()?.scheme
if (scheme != null && scheme != "file") return InlineShare.Kind.URL
}
// Opaque schemes (tel:, sms:, mailto:, ssh:, magnet:, geo:, tailscale:, …) have
// no `//` so Patterns.WEB_URL misses them. Accept anything that's whitespace-free
// and parses to a non-file scheme with a non-empty body. The whitespace gate keeps
// "see: this thing" out without us having to maintain a scheme allowlist.
if (text.none { it.isWhitespace() }) {
val parsed = runCatching { Uri.parse(text) }.getOrNull()
val scheme = parsed?.scheme?.lowercase()
if (scheme != null && scheme != "file" && !parsed.schemeSpecificPart.isNullOrBlank()) {
return InlineShare.Kind.URL
}
}
return InlineShare.Kind.TEXT
}

// Chromium highlighted-text shares come through as `"<snippet>"\n <url>#:~:text=…`.
// Receivers paste the blob verbatim; drop the URL tail and outer quotes.
private fun stripChromeHighlightedTextShare(text: String): String {
val urlLine = Regex("""(?m)^[ \t]*https?://\S*#:~:text=\S*[ \t]*$""").find(text) ?: return text
val before = text.substring(0, urlLine.range.first).trimEnd()
val unquoted = before.removeSurrounding("\"")
return if (unquoted.isNotEmpty()) unquoted else text
}

private fun generateFallbackName(uri: Uri): String {
val randomId = Random.nextLong()
val mimeType = contentResolver?.getType(uri)
Expand Down
3 changes: 3 additions & 0 deletions android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
Expand Down
13 changes: 13 additions & 0 deletions android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -57,6 +59,17 @@ object Notifier {
val incomingFiles: StateFlow<List<Ipn.PartialFile>?> = MutableStateFlow(null)
val filesWaiting: StateFlow<Empty.Message?> = MutableStateFlow(null)

private val _inlineShareInbox = MutableStateFlow<List<PendingInlineShare>>(emptyList())
val inlineShareInbox: StateFlow<List<PendingInlineShare>> = _inlineShareInbox

fun appendInlineShare(p: PendingInlineShare) {
_inlineShareInbox.update { it + p }
}

fun removeInlineShare(id: String) {
_inlineShareInbox.update { list -> list.filterNot { it.id == id } }
}

private val userProfiles = mutableMapOf<String, Tailcfg.UserProfile>()

private lateinit var app: libtailscale.Application
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading