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

Added mobile app plugin to support exit and back button press event.
5 changes: 5 additions & 0 deletions .changes/back-button-press-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tauri-apps/api": minor:feat
---

Added `app > onBackButtonPress` for Android back button handling.
10 changes: 5 additions & 5 deletions Cargo.lock

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

2 changes: 1 addition & 1 deletion crates/tauri-runtime-wry/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ rustc-args = ["--cfg", "docsrs"]
rustdoc-args = ["--cfg", "docsrs"]

[dependencies]
wry = { version = "0.53.2", default-features = false, features = [
wry = { version = "0.53.4", default-features = false, features = [
"drag-drop",
"protocol",
"os-webview",
Expand Down
2 changes: 2 additions & 0 deletions crates/tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ const PLUGINS: &[(&str, &[(&str, bool)])] = &[
("set_app_theme", false),
("set_dock_visibility", false),
("bundle_type", true),
("register_listener", true),
("remove_listener", true),
],
),
(
Expand Down
1 change: 1 addition & 0 deletions crates/tauri/mobile/android-codegen/TauriActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import app.tauri.plugin.PluginManager

abstract class TauriActivity : WryActivity() {
var pluginManager: PluginManager = PluginManager(this)
override val handleBackNavigation: Boolean = true

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
Expand Down
54 changes: 54 additions & 0 deletions crates/tauri/mobile/android/src/main/java/app/tauri/AppPlugin.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT

package app.tauri

import android.app.Activity
import android.webkit.WebView
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import app.tauri.annotation.Command
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Plugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSObject

@TauriPlugin
class AppPlugin(private val activity: Activity): Plugin(activity) {
private val BACK_BUTTON_EVENT = "back-button"

private var webView: WebView? = null

override fun load(webView: WebView) {
this.webView = webView
}

init {
val callback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (!hasListener(BACK_BUTTON_EVENT)) {
if (this@AppPlugin.webView?.canGoBack() == true) {
this@AppPlugin.webView!!.goBack()
} else {
this.isEnabled = false
this@AppPlugin.activity.onBackPressed()
this.isEnabled = true
}
Comment on lines +33 to +37
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Android onBackPressed deprecated API level Android 13 OnBackPressedDispatcher

💡 Result:

Short answer

  • ComponentActivity.onBackPressed() is deprecated (AndroidX) — use OnBackPressedDispatcher with OnBackPressedCallback instead. [1][2][3]

Details

  • The Activity API was updated to route back events through an OnBackPressedDispatcher that invokes one or more OnBackPressedCallback instances; ComponentActivity.onBackPressed() is marked deprecated in favor of that dispatcher. [1][2]
  • On Android 13 (API 33) the activity support libraries also expose integration with the platform’s predictive back (OnBackInvokedDispatcher) via setOnBackInvokedDispatcher for proper behavior on SDK 33+. Use the androidx OnBackPressedDispatcher APIs (addCallback with a LifecycleOwner) to handle back navigation and lifecycle automatically. [2][1][3]

Example (Kotlin)

  • requireActivity().onBackPressedDispatcher.addCallback(this) { /* handle back */ } . [2][3]

Sources
[1] ComponentActivity (AndroidX) — deprecation note.
[2] OnBackPressedDispatcher (AndroidX) — usage and addCallback.
[3] Android Developers guide: Provide custom back navigation (recommendation to replace onBackPressed with OnBackPressedCallback).


🌐 Web query:

Android Activity onBackPressed() deprecated replacement best practices

💡 Result:

Short answer

  • Don’t override Activity.onBackPressed() (deprecated). Use the Jetpack OnBackPressedDispatcher + OnBackPressedCallback for most apps, and for Android 13+ (API 33+) use the platform OnBackInvokedDispatcher/OnBackInvokedCallback or the AndroidX bridge. Register callbacks lifecycle‑aware and enable/disable them instead of removing. [1][2][3]

Why and when

  • OnBackPressedDispatcher (androidx.activity) is lifecycle‑aware and is the recommended cross‑API solution (works with Fragments, ViewModels, avoids leaks). Use it by default. [1]
  • OnBackInvokedDispatcher / OnBackInvokedCallback (platform, API 33+) replaces Activity.onBackPressed at the OS level and provides priority/animation hooks; AndroidX can be wired to it so your code works across API levels. [2][3]

Best practices (concise)

  • Use OnBackPressedDispatcher.addCallback(owner, callback) so the callback follows the LifecycleOwner (use viewLifecycleOwner in Fragments). [1]
  • Prefer enabling/disabling an OnBackPressedCallback rather than removing it. [1]
  • If you target API 33+, either register an OnBackInvokedCallback for platform behavior or rely on the AndroidX dispatcher which can be set to use the platform dispatcher (AndroidX 1.6+). [1][2]
  • Keep back handling focused: let UI overlays (dialogs, drawers) register higher‑priority callbacks and return control to the default flow when appropriate. [2]

Minimal examples

  • Activity (Kotlin, Jetpack):
    val callback = object : OnBackPressedCallback(true) {
    override fun handleOnBackPressed() { /* custom */ }
    }
    onBackPressedDispatcher.addCallback(this, callback) // lifecycle-aware. [1]

  • Fragment (Kotlin):
    val cb = object : OnBackPressedCallback(true) {
    override fun handleOnBackPressed() { /* handle or call requireActivity().onBackPressedDispatcher.onBackPressed() */ }
    }
    requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, cb) // use viewLifecycleOwner. [1]

Docs / references

  • OnBackPressedDispatcher / OnBackPressedCallback (AndroidX). [1]
  • OnBackInvokedDispatcher (platform API level 33+) — priority and window-level callbacks. [2]
  • OnBackInvokedCallback (platform replacement for Activity.onBackPressed()). [3]

If you want, I can paste a copy‑pasteable Kotlin sample showing AndroidX + API‑33 bridging (setOnBackInvokedDispatcher) and explain version checks.


🏁 Script executed:

# Let's examine the full context of the code in question
cd /tmp && git clone --depth 1 https://github.com/tauri-apps/tauri.git 2>/dev/null || true
find . -name "AppPlugin.kt" -type f 2>/dev/null | head -5

Repository: qodo-benchmark/tauri-combined-coderabbit

Length of output: 156


🏁 Script executed:

# Get the full context of AppPlugin.kt around lines 33-37
cat -n /tmp/tauri/crates/tauri/mobile/android/src/main/java/app/tauri/AppPlugin.kt | head -60

Repository: qodo-benchmark/tauri-combined-coderabbit

Length of output: 2002


🏁 Script executed:

# Let's check if there are other methods or patterns used in the codebase for back press handling
rg "onBackPressed|OnBackPressedDispatcher|OnBackPressedCallback" crates/tauri/mobile/android --type kotlin -B2 -A2

Repository: qodo-benchmark/tauri-combined-coderabbit

Length of output: 2002


Replace deprecated onBackPressed() with OnBackPressedDispatcher.onBackPressed().

The Activity.onBackPressed() method is deprecated in AndroidX. The modern approach is to call onBackPressedDispatcher.onBackPressed(), which properly delegates to the next callback in the dispatcher queue or the system's default back handling. The disable/re-enable pattern is still needed here to prevent callback recursion.

🔧 Suggested fix
          } else {
            this.isEnabled = false
-           this@AppPlugin.activity.onBackPressed()
+           (this@AppPlugin.activity as AppCompatActivity).onBackPressedDispatcher.onBackPressed()
            this.isEnabled = true
          }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} else {
this.isEnabled = false
this@AppPlugin.activity.onBackPressed()
this.isEnabled = true
}
} else {
this.isEnabled = false
(this@AppPlugin.activity as AppCompatActivity).onBackPressedDispatcher.onBackPressed()
this.isEnabled = true
}
🤖 Prompt for AI Agents
In `@crates/tauri/mobile/android/src/main/java/app/tauri/AppPlugin.kt` around
lines 33 - 37, Replace the deprecated call to Activity.onBackPressed() in
AppPlugin with the Activity's OnBackPressedDispatcher by invoking
activity.onBackPressedDispatcher.onBackPressed(); keep the existing
disable/re-enable pattern around the call (this.isEnabled = false ...
this.isEnabled = true) to avoid recursion; locate the call site referencing
this@AppPlugin.activity.onBackPressed() and swap it to use
this@AppPlugin.activity.onBackPressedDispatcher.onBackPressed() while preserving
surrounding logic in the AppPlugin class.

} else {
val data = JSObject().apply {
put("canGoBack", this@AppPlugin.webView?.canGoBack() ?: false)
}
trigger(BACK_BUTTON_EVENT, data)
}
}
}
(activity as AppCompatActivity).onBackPressedDispatcher.addCallback(activity, callback)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Unsafe cast to AppCompatActivity may crash at runtime.

The cast (activity as AppCompatActivity) will throw ClassCastException if the activity is not an AppCompatActivity. Consider adding a type check or documenting this requirement.

🔧 Suggested defensive check
-    (activity as AppCompatActivity).onBackPressedDispatcher.addCallback(activity, callback)
+    if (activity is AppCompatActivity) {
+      activity.onBackPressedDispatcher.addCallback(activity, callback)
+    }

Note: If the callback isn't registered when activity is not AppCompatActivity, the back button behavior will fall back to the default system handling, which may be acceptable depending on the use case.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
(activity as AppCompatActivity).onBackPressedDispatcher.addCallback(activity, callback)
if (activity is AppCompatActivity) {
activity.onBackPressedDispatcher.addCallback(activity, callback)
}
🤖 Prompt for AI Agents
In `@crates/tauri/mobile/android/src/main/java/app/tauri/AppPlugin.kt` at line 46,
The current direct cast (activity as AppCompatActivity) before calling
onBackPressedDispatcher.addCallback is unsafe and can throw ClassCastException;
update AppPlugin.kt to perform a safe type check (e.g., use an
instanceof/is-check or a safe cast) against AppCompatActivity before calling
onBackPressedDispatcher.addCallback(activity, callback) and only register the
callback when the activity is an AppCompatActivity (otherwise skip registration
or log/handle gracefully) so the back-button behavior falls back to the system
when the activity is not AppCompatActivity.

}

@Command
fun exit(invoke: Invoke) {
activity.finish()
invoke.resolve()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.webkit.WebView
import androidx.activity.result.IntentSenderRequest
import androidx.core.app.ActivityCompat
Expand All @@ -22,7 +21,6 @@ import app.tauri.annotation.InvokeArg
import app.tauri.annotation.PermissionCallback
import app.tauri.annotation.TauriPlugin
import com.fasterxml.jackson.databind.ObjectMapper
import org.json.JSONException
import java.util.*
import java.util.concurrent.CopyOnWriteArrayList

Expand Down Expand Up @@ -148,6 +146,10 @@ abstract class Plugin(private val activity: Activity) {
}
}

fun hasListener(event: String): Boolean {
return !listeners[event].isNullOrEmpty()
}

@Command
open fun registerListener(invoke: Invoke) {
val args = invoke.parseArgs(RegisterListenerArgs::class.java)
Expand Down
54 changes: 54 additions & 0 deletions crates/tauri/permissions/app/autogenerated/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ Default permissions for the plugin.
- `allow-tauri-version`
- `allow-identifier`
- `allow-bundle-type`
- `allow-register-listener`
- `allow-remove-listener`

## Permission Table

Expand Down Expand Up @@ -204,6 +206,32 @@ Denies the name command without any pre-configured scope.
<tr>
<td>

`core:app:allow-register-listener`

</td>
<td>

Enables the register_listener command without any pre-configured scope.

</td>
</tr>

<tr>
<td>

`core:app:deny-register-listener`

</td>
<td>

Denies the register_listener command without any pre-configured scope.

</td>
</tr>

<tr>
<td>

`core:app:allow-remove-data-store`

</td>
Expand All @@ -230,6 +258,32 @@ Denies the remove_data_store command without any pre-configured scope.
<tr>
<td>

`core:app:allow-remove-listener`

</td>
<td>

Enables the remove_listener command without any pre-configured scope.

</td>
</tr>

<tr>
<td>

`core:app:deny-remove-listener`

</td>
<td>

Denies the remove_listener command without any pre-configured scope.

</td>
</tr>

<tr>
<td>

`core:app:allow-set-app-theme`

</td>
Expand Down
2 changes: 1 addition & 1 deletion crates/tauri/scripts/bundle.global.js

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions crates/tauri/src/app/plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,5 +132,17 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
set_dock_visibility,
bundle_type,
])
.setup(|_app, _api| {
#[cfg(target_os = "android")]
{
let handle = _api.register_android_plugin("app.tauri", "AppPlugin")?;
let handle_ref = &handle;
_app.manage(AppPlugin(*handle_ref));
}
Ok(())
})
Comment on lines +135 to +143
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for PluginHandle definition
fd "\.rs$" -x rg -l "PluginHandle" {} \; | head -10

Repository: qodo-benchmark/tauri-combined-coderabbit

Length of output: 224


🏁 Script executed:

#!/bin/bash
# Get the structure and trait impls for PluginHandle
rg -n "struct PluginHandle|enum PluginHandle" --type rust -A 10 | head -50

Repository: qodo-benchmark/tauri-combined-coderabbit

Length of output: 843


🏁 Script executed:

#!/bin/bash
# Search for Copy/Clone implementations for PluginHandle
rg -n "impl.*Copy.*PluginHandle|impl.*Clone.*PluginHandle|#\[derive\(.*Copy.*\)|#\[derive\(.*Clone.*\)" --type rust -B 3 -A 5 | grep -A 5 -B 5 "PluginHandle"

Repository: qodo-benchmark/tauri-combined-coderabbit

Length of output: 816


🏁 Script executed:

#!/bin/bash
# Read the specific lines mentioned in the review
sed -n '135,143p' crates/tauri/src/app/plugin.rs

Repository: qodo-benchmark/tauri-combined-coderabbit

Length of output: 338


🏁 Script executed:

#!/bin/bash
# Get more context around those lines
sed -n '130,150p' crates/tauri/src/app/plugin.rs

Repository: qodo-benchmark/tauri-combined-coderabbit

Length of output: 562


🏁 Script executed:

#!/bin/bash
# Search for AppPlugin struct definition
rg -n "struct AppPlugin|impl.*AppPlugin" --type rust -A 5 | head -30

Repository: qodo-benchmark/tauri-combined-coderabbit

Length of output: 379


This code does not compile as written.

PluginHandle implements Clone but not Copy. The pattern let handle_ref = &handle; _app.manage(AppPlugin(*handle_ref)); violates Rust's move semantics—you cannot move a non-Copy value out of a reference. This is a compilation error that must be fixed.

Remove the intermediate reference and pass the owned handle directly:

♻️ Corrected code
     .setup(|_app, _api| {
       #[cfg(target_os = "android")]
       {
         let handle = _api.register_android_plugin("app.tauri", "AppPlugin")?;
-        let handle_ref = &handle;
-        _app.manage(AppPlugin(*handle_ref));
+        _app.manage(AppPlugin(handle));
       }
       Ok(())
     })
🤖 Prompt for AI Agents
In `@crates/tauri/src/app/plugin.rs` around lines 135 - 143, The setup closure is
attempting to dereference a reference to a PluginHandle which is not Copy;
remove the intermediate reference and pass the owned handle (or clone it)
directly into AppPlugin. Locate the call to register_android_plugin and the
AppPlugin construction inside the .setup closure and replace the pattern let
handle = _api.register_android_plugin(...)?; let handle_ref = &handle;
_app.manage(AppPlugin(*handle_ref)); with a direct move or clone into
_app.manage (e.g., _app.manage(AppPlugin(handle)) or
_app.manage(AppPlugin(handle.clone()))), since PluginHandle implements Clone but
not Copy.

.build()
}

#[cfg(target_os = "android")]
pub(crate) struct AppPlugin<R: Runtime>(pub crate::plugin::PluginHandle<R>);
5 changes: 1 addition & 4 deletions crates/tauri/src/path/android.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@

use super::Result;
use crate::{plugin::PluginHandle, Runtime};
use std::{
ffi::OsStr,
path::{Path, PathBuf},
};
use std::path::{Path, PathBuf};

/// A helper class to access the mobile path APIs.
pub struct PathResolver<R: Runtime>(pub(crate) PluginHandle<R>);
Expand Down
28 changes: 26 additions & 2 deletions packages/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT

import { invoke } from './core'
import { addPluginListener, invoke, PluginListener } from './core'
import { Image } from './image'
import { Theme } from './window'

Expand Down Expand Up @@ -252,6 +252,28 @@ async function getBundleType(): Promise<BundleType> {
return invoke('plugin:app|bundle_type')
}

/**
* Payload for the onBackButtonPress event.
*/
type OnBackButtonPressPayload = {
/** Whether the webview canGoBack property is true. */
canGoBack: boolean
}

/**
* Listens to the backButton event on Android.
* @param handler
*/
async function onBackButtonPress(
handler: (payload: OnBackButtonPressPayload) => void
): Promise<PluginListener> {
return addPluginListener<OnBackButtonPressPayload>(
'app',
'back-button',
handler
)
}

export {
getName,
getVersion,
Expand All @@ -264,5 +286,7 @@ export {
fetchDataStoreIdentifiers,
removeDataStore,
setDockVisibility,
getBundleType
getBundleType,
type OnBackButtonPressPayload,
onBackButtonPress
}