Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,16 @@
android:value="true" />
</service>

<provider
android:name="androidx.core.content.FileProvider"
android:authorities="com.tailscale.ipn.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>

<meta-data
android:name="android.content.APP_RESTRICTIONS"
android:resource="@xml/app_restrictions" />
Expand Down
18 changes: 18 additions & 0 deletions android/src/main/java/com/tailscale/ipn/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,16 @@ 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
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 {
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -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
Expand Down
96 changes: 79 additions & 17 deletions android/src/main/java/com/tailscale/ipn/ShareActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

package com.tailscale.ipn

import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
Expand All @@ -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
Expand All @@ -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() {
Expand Down Expand Up @@ -57,43 +60,51 @@ 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
}

val act = intent.action

val uris: List<Uri?>? =
val uris: List<Uri> =
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<Uri?>(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION") intent.getParcelableArrayListExtra<Uri?>(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<Ipn.OutgoingFile> =
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)
Expand All @@ -107,7 +118,7 @@ class ShareActivity : ComponentActivity() {
null
}
}
} ?: emptyList()
}

if (pendingFiles.isEmpty()) {
TSLog.e(TAG, "Share failure - no files extracted from intent")
Expand All @@ -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<Uri> =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelableArrayListExtra<Uri?>(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
getParcelableArrayListExtra<Uri?>(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
}
4 changes: 4 additions & 0 deletions android/src/main/res/xml/file_paths.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="shared" path="/" />
</paths>