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 <willh@tailscale.com>
This commit is contained in:
@@ -102,6 +102,10 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".InlineShareActionReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".MDMSettingsChangedReceiver"
|
||||
android:exported="false">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PendingTaildropViewModel.PendingTaildropItem>): 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<PendingTaildropViewModel.PendingTaildropItem>): 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<PendingTaildropViewModel.PendingTaildropItem>): 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)
|
||||
}
|
||||
}
|
||||
@@ -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,40 +95,50 @@ fun TaildropView(
|
||||
|
||||
@Composable
|
||||
fun FileSharePeerList(
|
||||
peers: List<Tailcfg.Node>,
|
||||
recent: List<Tailcfg.Node>,
|
||||
other: List<Tailcfg.Node>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
false -> {
|
||||
LazyColumn {
|
||||
peers.forEach { peer ->
|
||||
item {
|
||||
PeerView(
|
||||
peer = peer,
|
||||
onClick = { onShare(peer) },
|
||||
subtitle = { peer.Hostinfo.OS ?: "" },
|
||||
trailingContent = { stateViewGenerator(peer.StableID) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
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
|
||||
@@ -153,35 +168,46 @@ fun FileShareHeader(fileTransfers: List<Ipn.OutgoingFile>, 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<Ipn.OutgoingFile>) {
|
||||
// (jonathan) TODO: Thumbnails?
|
||||
@@ -193,12 +219,24 @@ fun IconForTransfer(transfers: List<Ipn.OutgoingFile>) {
|
||||
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),
|
||||
)
|
||||
|
||||
@@ -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<Boolean> = appViewModel.vpnPrepared
|
||||
|
||||
val isVpnActive: StateFlow<Boolean> = appViewModel.vpnActive
|
||||
|
||||
@@ -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<Set<String>>(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<List<Ipn.PartialFile>>(emptyList())
|
||||
|
||||
val pendingFiles: StateFlow<List<Ipn.PartialFile>> =
|
||||
_pendingFiles
|
||||
.combine(consumedFileIds) { files, consumed ->
|
||||
files.filter { PendingTaildropItem.File(it).id !in consumed }
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
val pendingItems: StateFlow<List<PendingTaildropItem>> =
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Set<StableNodeID>> = MutableStateFlow(emptySet())
|
||||
// Set of OutgoingFile.IDs that we're currently transferring.
|
||||
private val currentTransferIDs: StateFlow<Set<String>> = 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<List<Tailcfg.Node>> = MutableStateFlow(emptyList())
|
||||
// Recently shared-to devices (capped at 3) and everything else.
|
||||
val recentPeers: StateFlow<List<Tailcfg.Node>> = MutableStateFlow(emptyList())
|
||||
val otherPeers: StateFlow<List<Tailcfg.Node>> = MutableStateFlow(emptyList())
|
||||
|
||||
// Non null if there's an error to be rendered.
|
||||
val showDialog: StateFlow<ErrorDialogType?> = 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<Tailcfg.Node> { 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
// <md5-of-content-hex>.txt raw UTF-8 text
|
||||
// <md5-of-content-hex>.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: `<name>.partial`, `<name>.<clientID>.partial`,
|
||||
// `<name>.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(),
|
||||
)
|
||||
@@ -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<String, String>()
|
||||
|
||||
private val inlineShareCacheFiles = ConcurrentHashMap<String, File>()
|
||||
|
||||
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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Tailcfg.Node>,
|
||||
): Pair<List<Tailcfg.Node>, List<Tailcfg.Node>> {
|
||||
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<Tailcfg.Node>,
|
||||
): List<Tailcfg.Node> = sortPeers(peers, loadAll(context)[userID.toString()].orEmpty())
|
||||
|
||||
private fun sortPeers(
|
||||
peers: List<Tailcfg.Node>,
|
||||
lastUsed: Map<String, Long>,
|
||||
): List<Tailcfg.Node> =
|
||||
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<String, Map<String, Long>> {
|
||||
val raw = prefs(context).getString(KEY, null) ?: return emptyMap()
|
||||
return runCatching { Json.decodeFromString(serializer, raw) }.getOrDefault(emptyMap())
|
||||
}
|
||||
|
||||
private fun saveAll(context: Context, data: Map<String, Map<String, Long>>) {
|
||||
prefs(context).edit().putString(KEY, Json.encodeToString(serializer, data)).apply()
|
||||
}
|
||||
|
||||
private fun prefs(context: Context) =
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
@@ -257,6 +257,8 @@
|
||||
<string name="file_count">%1$s files</string>
|
||||
<string name="connect_to_your_tailnet_to_share_files">Connect to your tailnet to share files.</string>
|
||||
<string name="my_devices">My devices</string>
|
||||
<string name="taildrop_recently_used">Recently used</string>
|
||||
<string name="taildrop_all_devices">All devices</string>
|
||||
<string name="no_devices_to_share_with">There are no devices on your tailnet to share to</string>
|
||||
<string name="taildrop_share_failed">Taildrop failed. Some files were not shared. Please try again.</string>
|
||||
<string name="taildrop_sending">Sending</string>
|
||||
@@ -374,6 +376,20 @@
|
||||
<string name="taildrop_directory_picker_info">What is taildrop?</string>
|
||||
<string name="taildrop_directory_picker_button">Open Directory Picker</string>
|
||||
|
||||
<!-- Strings for Taildrop receive notifications and in-app banner/sheet -->
|
||||
<string name="taildrop_link_received">Taildrop link received</string>
|
||||
<string name="taildrop_text_received">Taildrop text received</string>
|
||||
<string name="taildrop_file_received">Taildrop file received</string>
|
||||
<string name="taildrop_tap_to_open">Tap to open</string>
|
||||
<string name="taildrop_tap_to_copy">Tap to copy</string>
|
||||
<string name="taildrop_saved_in_directory">Saved in Taildrop directory</string>
|
||||
<string name="taildrop_received_sheet_title">Received via Taildrop</string>
|
||||
<string name="taildrop_pending_count">Received %1$d Taildrop items</string>
|
||||
<string name="taildrop_copied_to_clipboard">Copied to clipboard</string>
|
||||
<string name="taildrop_done">Done</string>
|
||||
<string name="taildrop_dismiss">Dismiss</string>
|
||||
<string name="taildrop_open_folder">Open Taildrop folder</string>
|
||||
|
||||
<!-- Strings for Hardware Attestation MDM setting -->
|
||||
<string name="enable_hardware_attestation">Enable hardware attestation</string>
|
||||
<string name="use_hardware_backed_keys_to_bind_node_identity_to_the_device">Use hardware-backed keys to bind node identity to the device</string>
|
||||
|
||||
Reference in New Issue
Block a user