From b1a58b37d3f9f9a0175ea86eef4a6ed86e4ba223 Mon Sep 17 00:00:00 2001 From: Doug Melton Date: Tue, 12 Aug 2025 15:03:54 -0700 Subject: [PATCH 1/2] android: ShareActivity code cleanup Simplified `loadFiles` by: * Creating extension functions for the wordy getParcelable*Extra methods * Shoring up some nullability checks --- .../java/com/tailscale/ipn/ShareActivity.kt | 48 +++++++++++++------ 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/android/src/main/java/com/tailscale/ipn/ShareActivity.kt b/android/src/main/java/com/tailscale/ipn/ShareActivity.kt index 09d9665ee3..46b772aac8 100644 --- a/android/src/main/java/com/tailscale/ipn/ShareActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/ShareActivity.kt @@ -69,31 +69,30 @@ 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 { + 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 +106,7 @@ class ShareActivity : ComponentActivity() { null } } - } ?: emptyList() + } if (pendingFiles.isEmpty()) { TSLog.e(TAG, "Share failure - no files extracted from intent") @@ -123,3 +122,22 @@ 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() \ No newline at end of file From cef7bae00bc76d61c8f6ae516f58b2f101c1d649 Mon Sep 17 00:00:00 2001 From: Doug Melton Date: Tue, 12 Aug 2025 16:56:37 -0700 Subject: [PATCH 2/2] android: Added the ability to share text as a .txt file --- android/src/main/AndroidManifest.xml | 10 ++++ .../src/main/java/com/tailscale/ipn/App.kt | 18 +++++++ .../java/com/tailscale/ipn/ShareActivity.kt | 50 +++++++++++++++++-- android/src/main/res/xml/file_paths.xml | 4 ++ 4 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 android/src/main/res/xml/file_paths.xml 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 46b772aac8..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 @@ -76,6 +81,13 @@ class ShareActivity : ComponentActivity() { // 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() @@ -140,4 +152,36 @@ private fun Intent.versionSafeGetStreamUris(): List = getParcelableArrayListExtra(Intent.EXTRA_STREAM) } ?.filterNotNull() - ?: emptyList() \ No newline at end of file + ?: 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