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
9 changes: 9 additions & 0 deletions .changes/file-association.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"tauri": minor:feat
"tauri-build": minor:feat
"tauri-plugin": minor:feat
"tauri-cli": minor:feat
"tauri-bundler": minor:feat
---

Implement file association for Android and iOS.
7 changes: 7 additions & 0 deletions .changes/mobile-file-associations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"tauri": minor:feat
"tauri-runtime": minor:feat
"tauri-runtime-wry": minor:feat
---

Trigger `RunEvent::Opened` on Android.
10 changes: 6 additions & 4 deletions Cargo.lock

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

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,5 @@ schemars_derive = { git = 'https://github.com/tauri-apps/schemars.git', branch =
tauri = { path = "./crates/tauri" }
tauri-plugin = { path = "./crates/tauri-plugin" }
tauri-utils = { path = "./crates/tauri-utils" }
wry = { git = "https://github.com/tauri-apps/wry", branch = "feat/mobile-multi-webview" }
tao = { git = "https://github.com/tauri-apps/tao", branch = "feat/mobile-multi-window" }
wry = { git = "https://github.com/tauri-apps/wry", branch = "feat/android-on-new-intent" }
tao = { git = "https://github.com/tauri-apps/tao", branch = "feat/opened-event-android" }
5 changes: 5 additions & 0 deletions crates/tauri-build/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,11 @@ pub fn try_build(attributes: Attributes) -> Result<()> {

if let Some(project_dir) = env::var_os("TAURI_ANDROID_PROJECT_PATH").map(PathBuf::from) {
mobile::generate_gradle_files(project_dir)?;

// Update Android manifest with file associations
if let Some(associations) = config.bundle.file_associations.as_ref() {
mobile::update_android_manifest_file_associations(associations)?;
}
}

cfg_alias("dev", is_dev());
Expand Down
121 changes: 120 additions & 1 deletion crates/tauri-build/src/mobile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,130 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT

use std::path::PathBuf;
use std::{collections::HashSet, path::PathBuf};

use anyhow::{Context, Result};
use tauri_utils::write_if_changed;

/// Updates the Android manifest to add file association intent filters
pub fn update_android_manifest_file_associations(
associations: &[tauri_utils::config::FileAssociation],
) -> Result<()> {
if associations.is_empty() {
return Ok(());
}

let intent_filters = generate_file_association_intent_filters(associations);
tauri_utils::build::update_android_manifest("tauri-file-associations", "activity", intent_filters)
}

fn generate_file_association_intent_filters(
associations: &[tauri_utils::config::FileAssociation],
) -> String {
let mut filters = String::new();

for association in associations {
// Get mime types - use explicit mime_type, or infer from extensions
let mut mime_types = HashSet::new();

if let Some(mime_type) = &association.mime_type {
mime_types.insert(mime_type.clone());
} else {
// Infer mime types from extensions
for ext in &association.ext {
if let Some(mime) = extension_to_mime_type(&ext.0) {
mime_types.insert(mime);
}
}
}

// If we have mime types, create intent filters
if !mime_types.is_empty() {
for mime_type in &mime_types {
filters.push_str("<intent-filter>\n");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tauri config has rank but this is not being used on Android.

{
  "ext": ["png"],
  "mimeType": "image/png",
  "rank": "Owner"  // ← This exists in config
}

Android can specify a priority on intent filters which is a signed integer between -1000 to 1000.

<intent-filter android:priority="1000">
    <action android:name="android.intent.action.VIEW" />
    <data android:mimeType="image/png" />
</intent-filter>

filters.push_str(" <action android:name=\"android.intent.action.SEND\" />\n");
filters.push_str(" <action android:name=\"android.intent.action.SEND_MULTIPLE\" />\n");
filters.push_str(" <category android:name=\"android.intent.category.DEFAULT\" />\n");
filters.push_str(" <category android:name=\"android.intent.category.BROWSABLE\" />\n");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are missing ACTION_VIEW e.g. user taps in file manager. Ideally, if would be good if we could define which actions we which to listen to.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From Android Docs:

BROWSABLE - The target activity allows itself to be started by a web browser to display data referenced by a link, such as an image or an e-mail message.

This is related to deep-linking not file association. Does this need to be handled differently? How will this interact with the deep-link plugin?

filters.push_str(&format!(
" <data android:mimeType=\"{}\" />\n",
mime_type
));

// Add file scheme and path patterns for extensions
if !association.ext.is_empty() {
// Create path patterns for each extension
// Android's pathPattern needs \\. (double backslash-dot) in XML to match a literal dot
let path_patterns: Vec<String> = association
.ext
.iter()
.map(|ext| format!(".*\\\\.{}", ext.0))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Android's pathPattern has known limitations e.g. Doesn't match paths with multiple dots (e.g., file.backup.png).

Workarounds:

  1. Use multiple patterns
<data android:pathPattern=".*\\.png" />
<data android:pathPattern=".*\\..*\\.png" />
<data android:pathPattern=".*\\..*\\..*\\.png" />
  1. Use MIME type only
<intent-filter>
    <action android:name="android.intent.action.SEND" />
    <data android:mimeType="image/png" />
    <!-- No pathPattern - rely on MIME type -->
</intent-filter>
  1. Scheme + MIME Type
<intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <data android:scheme="file" />
    <data android:scheme="content" />
    <data android:mimeType="image/png" />
</intent-filter

If filters are not generated properly, the only way forward is then to use a wildcard (*) filter which associates the app with all file/MIME types - this is not an ideal UX experience.

.collect();

for pattern in &path_patterns {
filters.push_str(&format!(
" <data android:pathPattern=\"{}\" />\n",
pattern
));
}
}

filters.push_str("</intent-filter>\n");
}
} else if !association.ext.is_empty() {
// If no mime type but we have extensions, use a generic approach
filters.push_str("<intent-filter>\n");
filters.push_str(" <action android:name=\"android.intent.action.VIEW\" />\n");
filters.push_str(" <category android:name=\"android.intent.category.DEFAULT\" />\n");
filters.push_str(" <category android:name=\"android.intent.category.BROWSABLE\" />\n");

for ext in &association.ext {
// Android's pathPattern needs \\. (double backslash-dot) in XML to match a literal dot
filters.push_str(&format!(
" <data android:pathPattern=\".*\\\\.{}\" />\n",
ext.0
));
}

filters.push_str("</intent-filter>\n");
}
}

filters
}

fn extension_to_mime_type(ext: &str) -> Option<String> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While this covers ~25 file types it is important to include support for custom file extensions/MIME types.

We need to be able to define these in tauri.conf.json for desktop/mobile so they are available to the builder in order to generate correct platform-specific metadata e.g. info.plist, AndroidManifest.xml.

Some(
match ext.to_lowercase().as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"bmp" => "image/bmp",
"webp" => "image/webp",
"svg" => "image/svg+xml",
"ico" => "image/x-icon",
"tiff" | "tif" => "image/tiff",
"heic" | "heif" => "image/heic",
"mp4" => "video/mp4",
"mov" => "video/quicktime",
"avi" => "video/x-msvideo",
"mkv" => "video/x-matroska",
"mp3" => "audio/mpeg",
"wav" => "audio/wav",
"aac" => "audio/aac",
"m4a" => "audio/mp4",
"pdf" => "application/pdf",
"txt" => "text/plain",
"html" | "htm" => "text/html",
"json" => "application/json",
"xml" => "application/xml",
"rtf" => "application/rtf",
_ => return None,
}
.to_string(),
)
}

pub fn generate_gradle_files(project_dir: PathBuf) -> Result<()> {
let gradle_settings_path = project_dir.join("tauri.settings.gradle");
let app_build_gradle_path = project_dir.join("app").join("tauri.build.gradle.kts");
Expand Down
103 changes: 8 additions & 95 deletions crates/tauri-bundler/src/bundle/macos/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,102 +268,15 @@ fn create_info_plist(
}

if let Some(associations) = settings.file_associations() {
let exported_associations = associations
.iter()
.filter_map(|association| {
association.exported_type.as_ref().map(|exported_type| {
let mut dict = plist::Dictionary::new();

dict.insert(
"UTTypeIdentifier".into(),
exported_type.identifier.clone().into(),
);
if let Some(description) = &association.description {
dict.insert("UTTypeDescription".into(), description.clone().into());
}
if let Some(conforms_to) = &exported_type.conforms_to {
dict.insert(
"UTTypeConformsTo".into(),
plist::Value::Array(conforms_to.iter().map(|s| s.clone().into()).collect()),
);
}

let mut specification = plist::Dictionary::new();
specification.insert(
"public.filename-extension".into(),
plist::Value::Array(
association
.ext
.iter()
.map(|s| s.to_string().into())
.collect(),
),
);
if let Some(mime_type) = &association.mime_type {
specification.insert("public.mime-type".into(), mime_type.clone().into());
}

dict.insert("UTTypeTagSpecification".into(), specification.into());

plist::Value::Dictionary(dict)
})
})
.collect::<Vec<_>>();

if !exported_associations.is_empty() {
plist.insert(
"UTExportedTypeDeclarations".into(),
plist::Value::Array(exported_associations),
);
if let Some(file_associations_plist) =
tauri_utils::config::file_associations_plist(associations)
{
if let Some(plist_dict) = file_associations_plist.as_dictionary() {
for (key, value) in plist_dict {
plist.insert(key.clone(), value.clone());
}
}
}

plist.insert(
"CFBundleDocumentTypes".into(),
plist::Value::Array(
associations
.iter()
.map(|association| {
let mut dict = plist::Dictionary::new();

if !association.ext.is_empty() {
dict.insert(
"CFBundleTypeExtensions".into(),
plist::Value::Array(
association
.ext
.iter()
.map(|ext| ext.to_string().into())
.collect(),
),
);
}

if let Some(content_types) = &association.content_types {
dict.insert(
"LSItemContentTypes".into(),
plist::Value::Array(content_types.iter().map(|s| s.to_string().into()).collect()),
);
}

dict.insert(
"CFBundleTypeName".into(),
association
.name
.as_ref()
.unwrap_or(&association.ext[0].0)
.to_string()
.into(),
);
dict.insert(
"CFBundleTypeRole".into(),
association.role.to_string().into(),
);
dict.insert("LSHandlerRank".into(), association.rank.to_string().into());
plist::Value::Dictionary(dict)
})
.collect(),
),
);
}

if let Some(protocols) = settings.deep_link_protocols() {
Expand Down
20 changes: 11 additions & 9 deletions crates/tauri-cli/src/mobile/ios/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,16 +237,18 @@ pub fn command(options: Options, noise_level: NoiseLevel) -> Result<BuiltApplica
if tauri_path.join("Info.ios.plist").exists() {
src_plists.push(tauri_path.join("Info.ios.plist").into());
}
if let Some(info_plist) = &tauri_config
.lock()
.unwrap()
.as_ref()
.unwrap()
.bundle
.ios
.info_plist
{
src_plists.push(info_plist.clone().into());
let tauri_config_guard = tauri_config.lock().unwrap();
let tauri_config = tauri_config_guard.as_ref().unwrap();

if let Some(info_plist) = &tauri_config.bundle.ios.info_plist {
src_plists.push(info_plist.clone().into());
}
if let Some(associations) = tauri_config.bundle.file_associations.as_ref() {
if let Some(file_associations) = tauri_utils::config::file_associations_plist(associations) {
src_plists.push(file_associations.into());
}
}
}
let merged_info_plist = merge_plist(src_plists)?;
merged_info_plist
Expand Down
20 changes: 11 additions & 9 deletions crates/tauri-cli/src/mobile/ios/dev.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,16 +227,18 @@ fn run_command(options: Options, noise_level: NoiseLevel) -> Result<()> {
if tauri_path.join("Info.ios.plist").exists() {
src_plists.push(tauri_path.join("Info.ios.plist").into());
}
if let Some(info_plist) = &tauri_config
.lock()
.unwrap()
.as_ref()
.unwrap()
.bundle
.ios
.info_plist
{
src_plists.push(info_plist.clone().into());
let tauri_config_guard = tauri_config.lock().unwrap();
let tauri_config = tauri_config_guard.as_ref().unwrap();

if let Some(info_plist) = &tauri_config.bundle.ios.info_plist {
src_plists.push(info_plist.clone().into());
}
if let Some(associations) = tauri_config.bundle.file_associations.as_ref() {
if let Some(file_associations) = tauri_utils::config::file_associations_plist(associations) {
src_plists.push(file_associations.into());
}
}
}
let merged_info_plist = merge_plist(src_plists)?;
merged_info_plist
Expand Down
Loading
Loading