From cef7bae00bc76d61c8f6ae516f58b2f101c1d649 Mon Sep 17 00:00:00 2001 From: Doug Melton Date: Tue, 12 Aug 2025 16:56:37 -0700 Subject: [PATCH] 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 92cb0de..fa2815b 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 7e4e514..db13554 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 46b772a..8fbf350 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 0000000..0bf9734 --- /dev/null +++ b/android/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file