diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml
index 92cb0dea41..fa2815b387 100644
--- a/android/src/main/AndroidManifest.xml
+++ b/android/src/main/AndroidManifest.xml
@@ -131,6 +131,16 @@
android:value="true" />
+
+
+
+
diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt
index 7e4e514bdd..db13554354 100644
--- a/android/src/main/java/com/tailscale/ipn/App.kt
+++ b/android/src/main/java/com/tailscale/ipn/App.kt
@@ -45,6 +45,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import libtailscale.Libtailscale
@@ -52,6 +53,8 @@ import java.io.IOException
import java.net.NetworkInterface
import java.security.GeneralSecurityException
import java.util.Locale
+import kotlin.time.Duration.Companion.hours
+
class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
companion object {
@@ -178,6 +181,9 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
applicationScope.launch {
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
}
+ applicationScope.launch {
+ cleanupOldCacheFiles()
+ }
TSLog.init(this)
FeatureFlags.initialize(mapOf("enable_new_search" to true))
}
@@ -335,6 +341,18 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
fun notifyPolicyChanged() {
app.notifyPolicyChanged()
}
+
+ suspend fun cleanupOldCacheFiles() {
+ val maxAgeMs = 1.hours.inWholeMilliseconds
+ val cutoffTime = System.currentTimeMillis() - maxAgeMs
+ withContext(Dispatchers.IO) {
+ cacheDir.listFiles()?.forEach { file ->
+ if (file.name.startsWith("shared_text_") && file.lastModified() < cutoffTime) {
+ file.delete()
+ }
+ }
+ }
+ }
}
/**
* UninitializedApp contains all of the methods of App that can be used without having to initialize
diff --git a/android/src/main/java/com/tailscale/ipn/ShareActivity.kt b/android/src/main/java/com/tailscale/ipn/ShareActivity.kt
index 09d9665ee3..8fbf350017 100644
--- a/android/src/main/java/com/tailscale/ipn/ShareActivity.kt
+++ b/android/src/main/java/com/tailscale/ipn/ShareActivity.kt
@@ -3,6 +3,7 @@
package com.tailscale.ipn
+import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
@@ -14,6 +15,7 @@ import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
+import androidx.core.content.FileProvider
import androidx.lifecycle.lifecycleScope
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.theme.AppTheme
@@ -27,6 +29,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import java.io.File
// ShareActivity is the entry point for Taildrop share intents
class ShareActivity : ComponentActivity() {
@@ -57,11 +60,13 @@ class ShareActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
- loadFiles()
+ lifecycleScope.launch {
+ loadFiles()
+ }
}
// Loads the files from the intent.
- fun loadFiles() {
+ suspend fun loadFiles() {
if (intent == null) {
TSLog.e(TAG, "Share failure - No intent found")
return
@@ -69,31 +74,37 @@ class ShareActivity : ComponentActivity() {
val act = intent.action
- val uris: List? =
+ val uris: List =
when (act) {
Intent.ACTION_SEND -> {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- listOf(intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java))
- } else {
- @Suppress("DEPRECATION")
- listOf(intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri)
+ if (intent.extras?.containsKey(Intent.EXTRA_STREAM) == true) {
+ // If EXTRA_STREAM is present, get the single URI for that stream
+ listOfNotNull(intent.versionSafeGetStreamUri())
+ }
+ else if (intent.extras?.containsKey(Intent.EXTRA_TEXT) == true) {
+ // If EXTRA_TEXT is present, create a temporary file with the text content.
+ // This could be any shared text, like a URL or plain text from the clipboard.
+ val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?: ""
+ val uri = createTemporaryFile(text)
+ listOf(uri)
+ }
+ else {
+ TSLog.e(TAG, "No extras found in intent - nothing to share")
+ emptyList()
}
}
Intent.ACTION_SEND_MULTIPLE -> {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
- } else {
- @Suppress("DEPRECATION") intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
- }
+ // If ACTION_SEND_MULTIPLE, assume this is a list of files to share
+ intent.versionSafeGetStreamUris()
}
else -> {
- TSLog.e(TAG, "No extras found in intent - nothing to share")
- null
+ TSLog.e(TAG, "Unexpected intent action: $act. Expected ACTION_SEND or ACTION_SEND_MULTIPLE")
+ emptyList()
}
}
val pendingFiles: List =
- uris?.filterNotNull()?.mapNotNull { uri ->
+ uris.mapNotNull { uri ->
contentResolver?.query(uri, null, null, null, null)?.use { cursor ->
val nameCol = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeCol = cursor.getColumnIndex(OpenableColumns.SIZE)
@@ -107,7 +118,7 @@ class ShareActivity : ComponentActivity() {
null
}
}
- } ?: emptyList()
+ }
if (pendingFiles.isEmpty()) {
TSLog.e(TAG, "Share failure - no files extracted from intent")
@@ -123,3 +134,54 @@ class ShareActivity : ComponentActivity() {
return if (extension != null) "$randomId.$extension" else randomId.toString()
}
}
+
+private fun Intent.versionSafeGetStreamUri(): Uri? =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
+ }
+ else {
+ @Suppress("DEPRECATION")
+ getParcelableExtra(Intent.EXTRA_STREAM) as? Uri
+ }
+
+private fun Intent.versionSafeGetStreamUris(): List =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
+ } else {
+ @Suppress("DEPRECATION")
+ getParcelableArrayListExtra(Intent.EXTRA_STREAM)
+ }
+ ?.filterNotNull()
+ ?: emptyList()
+
+/**
+ * Creates a temporary txt file in the app's cache directory with the given content.
+ * Then grants temporary read permission to the file using FileProvider and returns its URI.
+ */
+private suspend fun Context.createTemporaryFile(
+ content: String,
+ dir: File = cacheDir,
+ fileName: String = "shared_text_${System.currentTimeMillis()}.txt",
+): Uri {
+ // Create temporary file in cache directory
+ val tempFile = File(dir, fileName)
+ withContext(Dispatchers.IO) {
+ tempFile.writeText(content)
+ }
+
+ // Get content URI using FileProvider
+ val uri = FileProvider.getUriForFile(
+ this,
+ "${applicationContext.packageName}.fileprovider",
+ tempFile
+ )
+
+ // Grant temporary read permission
+ grantUriPermission(
+ packageName,
+ uri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION
+ )
+
+ return uri
+}
\ No newline at end of file
diff --git a/android/src/main/res/xml/file_paths.xml b/android/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000000..0bf97348c4
--- /dev/null
+++ b/android/src/main/res/xml/file_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file