Skip to content
Merged
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
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ The frontend API is designed to closely resemble the Web Share API, making it in

2. **Sharing Content**

Use the `share()` function with a `ShareData` object to trigger the native dialog. The files field requires an array of `File` objects, which the plugin automatically handles by converting them to Base64 and managing their lifecycle in the backend. At least one of `text`, `url`, or a non-empty `files` array must be provided.
Use the `share()` function with a `ShareData` object to trigger the native dialog. The `files` field requires an array of `File` objects, which the plugin automatically handles by converting them to Base64 and managing their lifecycle in the backend. For content that already exists on disk, use `filePaths` to share the file directly while preserving its original filename. On Android, paths inside a configured `FileProvider` root are shared as-is; paths outside a declared root are copied to the plugin's cache share directory and cleaned up automatically. At least one of `text`, `url`, a non-empty `files` array, or a non-empty `filePaths` array must be provided.
Note: on Android and Windows, the promise resolves when the app regains focus after the share UI closes (best-effort). On macOS, the share delegate is used to resolve when the share completes. On iOS, the promise is resolved using the native completion handler (`UIActivityViewController.completionWithItemsHandler`), which provides accurate resolution when sharing completes. We may expose a configuration option in the future to let developers choose the resolution behavior (immediate vs. on-focus vs. delayed).

```ts
Expand Down Expand Up @@ -108,6 +108,16 @@ The frontend API is designed to closely resemble the Web Share API, making it in
console.log("File shared successfully.");
}
}

// Share existing files on disk by path (preserves original filename)
async function shareByPath() {
if (await canShare()) {
await share({
title: "Saved Report",
filePaths: ["/Users/me/report.zip"],
});
}
}
```

3. Manual Cleanup
Expand Down Expand Up @@ -153,6 +163,26 @@ and that the active capability includes the plugin permission:

After changing plugin permissions or native Android code, fully rebuild and reinstall the Android app. A stale installed APK can keep showing this error even after the source code is corrected.

#### FileProvider and `filePaths`

The plugin registers a `FileProvider` with authority `<your-application-id>.fileprovider`. By default it only exposes the plugin's own cache directory (`cache/shares/`) for temporary files.

If you want `filePaths` to share files directly from your app's directories (without making a temporary copy), add those directories to your app's `file_paths.xml` resource. In a default Tauri v2 project this is `src-tauri/gen/android/app/src/main/res/xml/file_paths.xml`; the `xml` directory and file may need to be created, and the exact path changes if you have customized `TAURI_ANDROID_PROJECT_PATH`. The plugin will try to use the original file first and only copy it if the path is not under a declared `FileProvider` root.

```xml
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- Keep a cache-path so the plugin can still share its temporary files -->
<cache-path name="plugin_cache" path="shares/" />
<files-path name="movies" path="data/movies" />
<files-path name="music" path="data/music" />
</paths>
```

Make sure your custom `file_paths.xml` still contains a `cache-path` entry (for example `path="."` or `path="shares/"`) so that files created from the `files` array and fallback copies remain accessible.

If a `filePath` is outside any declared root, the plugin copies it to its cache share directory, shares the copy, and cleans it up automatically.

2. **Using the `ShareExt` Trait**

The `ShareExt` trait is provided for a more idiomatic way to access the plugin's functionalities directly from an `AppHandle` or `Window`.
Expand All @@ -169,6 +199,7 @@ After changing plugin permissions or native Android code, fully rebuild and rein
title: Some("Rust Share".to_string()),
url: None,
files: None,
file_paths: None,
anchor: None,
};

Expand Down
86 changes: 77 additions & 9 deletions android/src/main/java/SharePlugin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import app.tauri.plugin.Invoke
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
import java.io.IOException
import java.net.URLConnection
import java.util.UUID

@InvokeArg
Expand All @@ -42,6 +43,8 @@ class ShareOptions {
var title: String? = null
var url: String? = null
var files: List<SharedFile>? = null
/** A list of local file paths to share directly from disk, preserving the original filename. */
var filePaths: List<String>? = null
var anchor: Anchor? = null
}

Expand Down Expand Up @@ -77,7 +80,8 @@ class SharePlugin(private val activity: Activity): Plugin(activity) {
val args = invoke.parseArgs(ShareOptions::class.java)
ShareValidation.validateShareOptions(args)
val fileUris = ArrayList<Uri>()
var determinedMimeType = "text/plain"
val mimeTypes = ArrayList<String>()
val authority = "${activity.packageName}.fileprovider"

args.files?.let {
if (it.isNotEmpty()) {
Expand All @@ -90,16 +94,50 @@ class SharePlugin(private val activity: Activity): Plugin(activity) {
}
filesForShare.add(tempFile)

val authority = "${activity.packageName}.fileprovider"
fileUris.add(
FileProvider.getUriForFile(activity, authority, tempFile)
)
mimeTypes.add(file.mimeType)
}
}
}

args.filePaths?.let {
if (it.isNotEmpty()) {
for (path in it) {
val file = File(path)
if (!file.exists() || !file.isFile) {
throw SecurityException("File does not exist or is not a regular file: $path")
}

// Prefer sharing directly through the caller's FileProvider.
// If the file is outside a declared root, copy it to the plugin's
// cache share directory and share from there.
val shareFile = try {
FileProvider.getUriForFile(activity, authority, file)
file
} catch (e: IllegalArgumentException) {
val safeFile = copyToSafeShareDir(file)
filesForShare.add(safeFile)
safeFile
}

determinedMimeType = determineMimeType(it)
fileUris.add(
FileProvider.getUriForFile(activity, authority, shareFile)
)
mimeTypes.add(
URLConnection.guessContentTypeFromName(shareFile.name)
?: "application/octet-stream"
)
}
}
}

var determinedMimeType = "text/plain"
if (mimeTypes.isNotEmpty()) {
determinedMimeType = determineMimeType(mimeTypes)
}

if (fileUris.isEmpty() && args.text.isNullOrEmpty() && args.url.isNullOrEmpty()) {
invoke.reject("No content provided to share.")
return
Expand Down Expand Up @@ -183,15 +221,15 @@ class SharePlugin(private val activity: Activity): Plugin(activity) {
}
}

private fun determineMimeType(files: List<SharedFile>): String {
if (files.isEmpty()) return "*/*"
val firstMimeType = files.first().mimeType
private fun determineMimeType(mimeTypes: List<String>): String {
if (mimeTypes.isEmpty()) return "*/*"
val firstMimeType = mimeTypes.first()
val firstGeneralType = firstMimeType.substringBefore('/')
val allSame = files.all { it.mimeType == firstMimeType }

val allSame = mimeTypes.all { it == firstMimeType }
if (allSame) return firstMimeType

val allSameGeneral = files.all { it.mimeType.startsWith(firstGeneralType) }
val allSameGeneral = mimeTypes.all { it.startsWith(firstGeneralType) }
if (allSameGeneral) return "$firstGeneralType/*"

return "*/*"
Expand Down Expand Up @@ -280,4 +318,34 @@ class SharePlugin(private val activity: Activity): Plugin(activity) {

return intendedFile
}

/**
* Copies an existing file into the dedicated share directory in the app's cache.
* A unique subdirectory is used so the original filename is preserved while
* avoiding name collisions, and the app's FileProvider (which only exposes
* cache/shares/) can grant URIs for the copy.
*/
@Throws(IOException::class, SecurityException::class)
private fun copyToSafeShareDir(source: File): File {
val safeDir = getSafeShareDir()
val safeDirCanonicalPath = safeDir.canonicalPath

// Use a unique subdirectory for each copied file to avoid collisions
// and to keep the original filename intact for target apps.
val uniqueDir = File(safeDir, UUID.randomUUID().toString())
if (!uniqueDir.mkdirs()) {
throw IOException("Failed to create share subdirectory.")
}

val dest = File(uniqueDir, source.name)

// CRITICAL: Path Traversal Check
// Ensure the final resolved path is still inside our secure directory.
if (!dest.canonicalPath.startsWith(safeDirCanonicalPath + File.separator)) {
throw SecurityException("Path Traversal Attack Detected. Malicious filename: '${source.name}'")
}

source.copyTo(dest, overwrite = true)
return dest
}
}
11 changes: 9 additions & 2 deletions android/src/main/java/ShareValidation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ object ShareValidation {
const val MAX_URL_BYTES = 4096
const val MAX_FILE_NAME_BYTES = 255
const val MAX_MIME_TYPE_BYTES = 255
const val MAX_FILE_PATH_BYTES = 4096

@Throws(SecurityException::class)
fun validateShareOptions(args: ShareOptions) {
Expand All @@ -24,8 +25,10 @@ object ShareValidation {
}
}

val files = args.files ?: return
if (files.size > MAX_FILES) {
val files = args.files ?: emptyList()
val filePaths = args.filePaths ?: emptyList()
val fileCount = files.size + filePaths.size
if (fileCount > MAX_FILES) {
throw SecurityException("Too many files provided. Maximum is $MAX_FILES.")
}

Expand All @@ -43,6 +46,10 @@ object ShareValidation {
throw SecurityException("Total shared file size exceeds the maximum of $MAX_TOTAL_FILE_BYTES bytes.")
}
}

for (path in filePaths) {
validateStringLength("file path", path, MAX_FILE_PATH_BYTES)
}
}

@Throws(SecurityException::class)
Expand Down
15 changes: 15 additions & 0 deletions android/src/test/java/ShareValidationUnitTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ class ShareValidationUnitTest {
ShareValidation.validateShareOptions(options(files = files))
}

val paths = (0..ShareValidation.MAX_FILES).map {
"/data/file-$it"
}
assertThrowsSecurity {
ShareValidation.validateShareOptions(options(filePaths = paths))
}

assertThrowsSecurity {
ShareValidation.validateShareOptions(options(text = "a".repeat(ShareValidation.MAX_TEXT_BYTES + 1)))
}
Expand All @@ -60,19 +67,27 @@ class ShareValidationUnitTest {
options(files = listOf(file("a".repeat(ShareValidation.MAX_FILE_NAME_BYTES + 1), "aGVsbG8=")))
)
}

assertThrowsSecurity {
ShareValidation.validateShareOptions(
options(filePaths = listOf("/path/".repeat(ShareValidation.MAX_FILE_PATH_BYTES)))
)
}
}

private fun options(
text: String? = null,
title: String? = null,
url: String? = null,
files: List<SharedFile>? = null,
filePaths: List<String>? = null,
): ShareOptions {
return ShareOptions().apply {
this.text = text
this.title = title
this.url = url
this.files = files
this.filePaths = filePaths
}
}

Expand Down
2 changes: 1 addition & 1 deletion api-iife.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading