Skip to content
Merged
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,40 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause

package com.tailscale.ipn

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.notifier.TaildropNotifier
import com.tailscale.ipn.util.InlineShareConsumer

// Handles taps on Taildrop inline-share notifications: URL → browser, text → clipboard.
class InlineShareActionReceiver : BroadcastReceiver() {
companion object {
const val ACTION_CONSUME = "com.tailscale.ipn.INLINE_SHARE_CONSUME"
const val ACTION_DISMISS = "com.tailscale.ipn.INLINE_SHARE_DISMISS"
const val EXTRA_ID = "id"
}

// Looking the share up by id makes this idempotent: whichever of tap or swipe lands
// second finds no entry and does nothing.
override fun onReceive(context: Context, intent: Intent) {
val id = intent.getStringExtra(EXTRA_ID) ?: return
val share = Notifier.inlineShareInbox.value.find { it.id == id } ?: return
when (intent.action) {
ACTION_CONSUME -> {
// Leave the entry alone if nothing happened, so the user can retry.
if (!InlineShareConsumer.consume(context, share)) return
Notifier.removeInlineShare(id)
TaildropNotifier.cancel(context, id)
}
// Swiped away: the notification is already gone, so there's nothing to cancel.
ACTION_DISMISS -> {
InlineShareConsumer.dismiss(context, share)
Notifier.removeInlineShare(id)
}
}
}
}
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
19 changes: 19 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,14 @@ import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.model.NodeID
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.util.InlineShareInboxStore
import com.tailscale.ipn.util.PendingInlineShare
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
Expand Down Expand Up @@ -57,6 +60,22 @@ object Notifier {
val incomingFiles: StateFlow<List<Ipn.PartialFile>?> = MutableStateFlow(null)
val filesWaiting: StateFlow<Empty.Message?> = MutableStateFlow(null)

// Seeded from disk so pending shares survive process death, e.g. if the app is killed
// in the background before the user acts on a share that couldn't be surfaced as a
// notification because POST_NOTIFICATIONS was denied.
private val _inlineShareInbox = MutableStateFlow(InlineShareInboxStore.load())
val inlineShareInbox: StateFlow<List<PendingInlineShare>> = _inlineShareInbox

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

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

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

private lateinit var app: libtailscale.Application
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause

package com.tailscale.ipn.ui.notifier

import android.Manifest
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.tailscale.ipn.App
import com.tailscale.ipn.InlineShareActionReceiver
import com.tailscale.ipn.R
import com.tailscale.ipn.util.InlineShare
import com.tailscale.ipn.util.PendingInlineShare
import com.tailscale.ipn.util.TSLog

object TaildropNotifier {
private const val TAG = "TaildropNotifier"
// Notification extras cross a binder transaction; keep the preview well clear of its limit.
private const val PREVIEW_CHARS = 120

fun cancel(context: Context, id: String) {
NotificationManagerCompat.from(context).cancel(id.hashCode())
}

fun notify(context: Context, pending: PendingInlineShare) {
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED) {
TSLog.d(TAG, "POST_NOTIFICATIONS not granted; skipping inline share notification")
return
}

val title: String
val body: String
val subtitle: String
when (pending.kind) {
InlineShare.Kind.URL -> {
title = context.getString(R.string.taildrop_link_received)
body = pending.content
subtitle = context.getString(R.string.taildrop_tap_to_open)
}
InlineShare.Kind.TEXT -> {
title = context.getString(R.string.taildrop_text_received)
body = pending.content.take(120).replace("\n", " ")
subtitle = context.getString(R.string.taildrop_tap_to_copy)
}
}

val tapIntent =
Intent(context, InlineShareActionReceiver::class.java).apply {
action = InlineShareActionReceiver.ACTION_CONSUME
putExtra(InlineShareActionReceiver.EXTRA_ID, pending.id)
}

val pendingIntent =
PendingIntent.getBroadcast(
context,
pending.id.hashCode(),
tapIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)

// Without a delete intent a swipe-away is invisible to us, leaving the inbox entry
// (and, for URLs, the file) behind.
val dismissIntent =
Intent(context, InlineShareActionReceiver::class.java).apply {
action = InlineShareActionReceiver.ACTION_DISMISS
putExtra(InlineShareActionReceiver.EXTRA_ID, pending.id)
}

val dismissPendingIntent =
PendingIntent.getBroadcast(
context,
"dismiss-${pending.id}".hashCode(),
dismissIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)

val notification =
NotificationCompat.Builder(context, App.FILE_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
.setSubText(subtitle)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.setDeleteIntent(dismissPendingIntent)
.build()

NotificationManagerCompat.from(context).notify(pending.id.hashCode(), notification)
}
}
Loading
Loading