From 6499119bf52f4960121c56c88223212270e6dd0a Mon Sep 17 00:00:00 2001 From: Lukas Kreussel <65088241+LLukas22@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:47:41 +0200 Subject: [PATCH 01/10] stub out haptics --- Cargo.toml | 1 + packages/haptics/Cargo.toml | 26 ++++++++++++++++ packages/haptics/README.md | 26 ++++++++++++++++ packages/haptics/src/lib.rs | 59 +++++++++++++++++++++++++++++++++++++ packages/sdk/Cargo.toml | 2 ++ packages/sdk/src/lib.rs | 5 ++++ 6 files changed, 119 insertions(+) create mode 100644 packages/haptics/Cargo.toml create mode 100644 packages/haptics/README.md create mode 100644 packages/haptics/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 61dbf0f..709e40e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ dioxus-sdk-notification = { path = "packages/notification", version = "0.7.0" } dioxus-sdk-sync = { path = "packages/sync", version = "0.7.0" } dioxus-sdk-util = { path = "packages/util", version = "0.7.0" } dioxus-sdk-window = { path = "packages/window", version = "0.7.0" } +dioxus-sdk-haptics = { path = "packages/haptics", version = "0.7.0" } # Dioxus dioxus = "0.7.0" diff --git a/packages/haptics/Cargo.toml b/packages/haptics/Cargo.toml new file mode 100644 index 0000000..911adfe --- /dev/null +++ b/packages/haptics/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "dioxus-sdk-haptics" +version = "0.7.0" + +description = "Haptics utilities for Dioxus." +readme = "./README.md" +keywords = ["gui", "dioxus"] +categories = ["gui", "android", "ios"] + +edition.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[dependencies] +dioxus = { workspace = true } +cfg-if = { workspace = true } +serde = { workspace = true } + + +[target.'cfg(target_family = "wasm")'.dependencies] +wasm-bindgen = { workspace = true } +js-sys = { workspace = true } + +[target.'cfg(windows)'.dependencies] diff --git a/packages/haptics/README.md b/packages/haptics/README.md new file mode 100644 index 0000000..711a2b0 --- /dev/null +++ b/packages/haptics/README.md @@ -0,0 +1,26 @@ +# Dioxus Haptics +Haptics utilities for Dioxus. + +Heavily inspired by [tauri-plugin-haptics](https://github.com/tauri-apps/plugins-workspace/tree/v2/plugins/haptics) + +### Supports +- [ ] Web +- [ ] Windows +- [ ] Mac +- [ ] Linux +- [x] Android +- [x] iOs + +## Usage +Add `dioxus-sdk-haptics` to your `Cargo.toml`: +```toml +[dependencies] +dioxus-sdk-haptics = "0.7" +``` + +Example: +```rs +use dioxus::prelude::*; + +//TODO +``` diff --git a/packages/haptics/src/lib.rs b/packages/haptics/src/lib.rs new file mode 100644 index 0000000..22f1e51 --- /dev/null +++ b/packages/haptics/src/lib.rs @@ -0,0 +1,59 @@ +use serde::{Deserialize, Serialize}; + +use std::{ + error::Error, + fmt::{self, Display}, +}; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ImpactFeedbackStyle { + Light, + #[default] + Medium, + Heavy, + Soft, + Rigid, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum NotificationFeedbackType { + #[default] + Success, + Warning, + Error, +} + +/// Represents errors when utilizing the haptics abstraction. +#[derive(Debug)] +pub enum HapticsError { + /// Haptics are unsupported on this platform. + Unsupported, + /// Failure to show a notification. + FailedToVibrate(Box), +} + +impl Error for HapticsError {} +impl Display for HapticsError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Unsupported => write!(f, "haptics are not supported on this platform"), + Self::FailedToVibrate(err) => write!(f, "failed to vibrate: {err}"), + } + } +} + +pub fn vibrate(duration: u32) -> Result<(), HapticsError> { + Ok(()) +} + +pub fn impact_feedback(style: ImpactFeedbackStyle) -> Result<(), HapticsError> { + Ok(()) +} + +pub fn notification_feedback(style: NotificationFeedbackType) -> Result<(), HapticsError> { + Ok(()) +} + +pub fn selection_feedback() -> Result<(), HapticsError> { + Ok(()) +} diff --git a/packages/sdk/Cargo.toml b/packages/sdk/Cargo.toml index e029a87..942cffb 100644 --- a/packages/sdk/Cargo.toml +++ b/packages/sdk/Cargo.toml @@ -21,6 +21,7 @@ dioxus-sdk-sync = { workspace = true, optional = true } dioxus-sdk-time = { workspace = true, optional = true } dioxus-sdk-util = { workspace = true, optional = true } dioxus-sdk-window = { workspace = true, optional = true } +dioxus-sdk-haptics = { workspace = true, optional = true } [features] geolocation = ["dep:dioxus-sdk-geolocation"] @@ -30,6 +31,7 @@ sync = ["dep:dioxus-sdk-sync"] time = ["dep:dioxus-sdk-time"] util = ["dep:dioxus-sdk-util"] window = ["dep:dioxus-sdk-window"] +haptics = ["dep:dioxus-sdk-haptics"] [package.metadata.docs.rs] all-features = true diff --git a/packages/sdk/src/lib.rs b/packages/sdk/src/lib.rs index 4afa8eb..79eabc3 100644 --- a/packages/sdk/src/lib.rs +++ b/packages/sdk/src/lib.rs @@ -20,6 +20,7 @@ //! | [`dioxus-sdk-notification`] | Send notifications. | `notification` | //! | [`dioxus-sdk-sync`] | Synchronization primities for Dioxus. | `sync` | //! | [`dioxus-sdk-util`] | Misc utilities for Dioxus. | `util` | +//! | [`dioxus-sdk-haptics`] | Haptics utilities for Dioxus. | `haptics` | //! //! [`dioxus-sdk-geolocation`]: https://crates.io/crates/dioxus-sdk-geolocation //! [`dioxus-sdk-storage`]: https://crates.io/crates/dioxus-sdk-storage @@ -28,6 +29,7 @@ //! [`dioxus-sdk-notification`]: https://crates.io/crates/dioxus-sdk-notification //! [`dioxus-sdk-sync`]: https://crates.io/crates/dioxus-sdk-sync //! [`dioxus-sdk-util`]: https://crates.io/crates/dioxus-sdk-util +//! [`dioxus-sdk-haptics`]: https://crates.io/crates/dioxus-sdk-haptics #[cfg(feature = "geolocation")] pub use dioxus_sdk_geolocation as geolocation; @@ -49,3 +51,6 @@ pub use dioxus_sdk_util as util; #[cfg(feature = "window")] pub use dioxus_sdk_window as window; + +#[cfg(feature = "haptics")] +pub use dioxus_sdk_haptics as haptics; From 48ad23178c9937bcaf314839229b861b67fcc571 Mon Sep 17 00:00:00 2001 From: Lukas Kreussel <65088241+LLukas22@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:56:09 +0200 Subject: [PATCH 02/10] copy over tauri files --- packages/haptics/android/.gitignore | 1 + packages/haptics/android/build.gradel.kts | 45 ++++++ .../haptics/android/main/AndroidManifest.xml | 4 + .../android/main/java/HapticsPlugin.kt | 150 ++++++++++++++++++ .../android/main/java/patterns/Impact.kt | 35 ++++ .../main/java/patterns/Notification.kt | 23 +++ .../android/main/java/patterns/Pattern.kt | 11 ++ .../android/main/java/patterns/Selection.kt | 11 ++ 8 files changed, 280 insertions(+) create mode 100644 packages/haptics/android/.gitignore create mode 100644 packages/haptics/android/build.gradel.kts create mode 100644 packages/haptics/android/main/AndroidManifest.xml create mode 100644 packages/haptics/android/main/java/HapticsPlugin.kt create mode 100644 packages/haptics/android/main/java/patterns/Impact.kt create mode 100644 packages/haptics/android/main/java/patterns/Notification.kt create mode 100644 packages/haptics/android/main/java/patterns/Pattern.kt create mode 100644 packages/haptics/android/main/java/patterns/Selection.kt diff --git a/packages/haptics/android/.gitignore b/packages/haptics/android/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/packages/haptics/android/.gitignore @@ -0,0 +1 @@ +/build diff --git a/packages/haptics/android/build.gradel.kts b/packages/haptics/android/build.gradel.kts new file mode 100644 index 0000000..88c2f82 --- /dev/null +++ b/packages/haptics/android/build.gradel.kts @@ -0,0 +1,45 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "app.tauri.haptics" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + + implementation("androidx.core:core-ktx:1.9.0") + implementation("androidx.appcompat:appcompat:1.6.0") + implementation("com.google.android.material:material:1.7.0") + implementation("com.fasterxml.jackson.core:jackson-databind:2.15.3") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + implementation(project(":tauri-android")) +} diff --git a/packages/haptics/android/main/AndroidManifest.xml b/packages/haptics/android/main/AndroidManifest.xml new file mode 100644 index 0000000..d2a9891 --- /dev/null +++ b/packages/haptics/android/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/packages/haptics/android/main/java/HapticsPlugin.kt b/packages/haptics/android/main/java/HapticsPlugin.kt new file mode 100644 index 0000000..81b12a6 --- /dev/null +++ b/packages/haptics/android/main/java/HapticsPlugin.kt @@ -0,0 +1,150 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.haptics + +import android.app.Activity +import android.content.Context +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import app.tauri.Logger +import app.tauri.annotation.Command +import app.tauri.annotation.InvokeArg +import app.tauri.annotation.TauriPlugin +import app.tauri.haptics.patterns.ImpactPatternHeavy +import app.tauri.haptics.patterns.ImpactPatternLight +import app.tauri.haptics.patterns.ImpactPatternMedium +import app.tauri.haptics.patterns.ImpactPatternRigid +import app.tauri.haptics.patterns.ImpactPatternSoft +import app.tauri.haptics.patterns.NotificationPatternError +import app.tauri.haptics.patterns.NotificationPatternSuccess +import app.tauri.haptics.patterns.NotificationPatternWarning +import app.tauri.haptics.patterns.Pattern +import app.tauri.haptics.patterns.SelectionPattern +import app.tauri.plugin.Invoke +import app.tauri.plugin.Plugin +import com.fasterxml.jackson.annotation.JsonProperty + +@InvokeArg +class HapticsOptions { + var duration: Long = 300 +} + +@InvokeArg +class NotificationFeedbackArgs { + val type: NotificationFeedbackType = NotificationFeedbackType.Success +} + +@InvokeArg +enum class NotificationFeedbackType { + @JsonProperty("success") + Success, + + @JsonProperty("warning") + Warning, + + @JsonProperty("error") + Error; + + fun into(): Pattern { + return when (this) { + Success -> NotificationPatternSuccess + Warning -> NotificationPatternWarning + Error -> NotificationPatternError + } + } +} + +@InvokeArg +class ImpactFeedbackArgs { + val style: ImpactFeedbackStyle = ImpactFeedbackStyle.Medium +} + +@InvokeArg +enum class ImpactFeedbackStyle { + @JsonProperty("light") + Light, + + @JsonProperty("medium") + Medium, + + @JsonProperty("heavy") + Heavy, + + @JsonProperty("soft") + Soft, + + @JsonProperty("rigid") + Rigid; + + fun into(): Pattern { + return when (this) { + Light -> ImpactPatternLight + Medium -> ImpactPatternMedium + Heavy -> ImpactPatternHeavy + Soft -> ImpactPatternSoft + Rigid -> ImpactPatternRigid + } + } +} + +@TauriPlugin +class HapticsPlugin(private val activity: Activity) : Plugin(activity) { + private val vibrator: Vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibManager = + activity.applicationContext.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager + vibManager.defaultVibrator + } else { + @Suppress("DEPRECATION") + activity.applicationContext.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + } + + // + // TAURI COMMANDS + // + + @Command + fun vibrate(invoke: Invoke) { + val args = invoke.parseArgs(HapticsOptions::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createOneShot(args.duration, VibrationEffect.DEFAULT_AMPLITUDE)) + } else { + vibrator.vibrate(args.duration) + } + invoke.resolve() + } + + @Command + fun impactFeedback(invoke: Invoke) { + val args = invoke.parseArgs(ImpactFeedbackArgs::class.java) + vibratePattern(args.style.into()) + invoke.resolve() + } + + @Command + fun notificationFeedback(invoke: Invoke) { + val args = invoke.parseArgs(NotificationFeedbackArgs::class.java) + vibratePattern(args.type.into()) + invoke.resolve() + } + + // TODO: Consider breaking this up into Start,Change,End like capacitor + @Command + fun selectionFeedback(invoke: Invoke) { + vibratePattern(SelectionPattern) + invoke.resolve() + } + + // INTERNAL FUNCTIONS + + private fun vibratePattern(pattern: Pattern) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createWaveform(pattern.timings, pattern.amplitudes, -1)) + } else { + vibrator.vibrate(pattern.oldSDKPattern, -1) + } + } +} diff --git a/packages/haptics/android/main/java/patterns/Impact.kt b/packages/haptics/android/main/java/patterns/Impact.kt new file mode 100644 index 0000000..1a61060 --- /dev/null +++ b/packages/haptics/android/main/java/patterns/Impact.kt @@ -0,0 +1,35 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.haptics.patterns + +val ImpactPatternLight = Pattern( + longArrayOf(0, 50), + intArrayOf(0, 30), + longArrayOf(0, 20) +) + +val ImpactPatternMedium = Pattern( + longArrayOf(0, 43), + intArrayOf(0, 50), + longArrayOf(0, 43) +) + +val ImpactPatternHeavy = Pattern( + longArrayOf(0, 60), + intArrayOf(0, 70), + longArrayOf(0, 61) +) + +val ImpactPatternSoft = Pattern( + longArrayOf(0, 50), + intArrayOf(0, 30), + longArrayOf(0, 20) +) + +val ImpactPatternRigid = Pattern( + longArrayOf(0, 43), + intArrayOf(0, 50), + longArrayOf(0, 43) +) diff --git a/packages/haptics/android/main/java/patterns/Notification.kt b/packages/haptics/android/main/java/patterns/Notification.kt new file mode 100644 index 0000000..2f70c23 --- /dev/null +++ b/packages/haptics/android/main/java/patterns/Notification.kt @@ -0,0 +1,23 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.haptics.patterns + +val NotificationPatternSuccess = Pattern( + longArrayOf(0, 40, 100, 40), + intArrayOf(0, 50, 0, 60), + longArrayOf(0, 40, 100, 40) +) + +val NotificationPatternWarning = Pattern( + longArrayOf(0, 40, 120, 60), + intArrayOf(0, 40, 0, 60), + longArrayOf(0, 40, 120, 60) +) + +val NotificationPatternError = Pattern( + longArrayOf(0, 60, 100, 40, 80, 50), + intArrayOf(0, 50, 0, 40, 0, 50), + longArrayOf(0, 60, 100, 40, 80, 50) +) diff --git a/packages/haptics/android/main/java/patterns/Pattern.kt b/packages/haptics/android/main/java/patterns/Pattern.kt new file mode 100644 index 0000000..234b31a --- /dev/null +++ b/packages/haptics/android/main/java/patterns/Pattern.kt @@ -0,0 +1,11 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.haptics.patterns + +class Pattern( + val timings: LongArray, + val amplitudes: IntArray, + val oldSDKPattern: LongArray +) {} diff --git a/packages/haptics/android/main/java/patterns/Selection.kt b/packages/haptics/android/main/java/patterns/Selection.kt new file mode 100644 index 0000000..2b9912e --- /dev/null +++ b/packages/haptics/android/main/java/patterns/Selection.kt @@ -0,0 +1,11 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package app.tauri.haptics.patterns + +val SelectionPattern = Pattern( + timings = longArrayOf(0, 50), + amplitudes = intArrayOf(0, 30), + oldSDKPattern = longArrayOf(0, 70) +) From 1e43880a153b6078d851b5dbc3337ed0053fc0fd Mon Sep 17 00:00:00 2001 From: Lukas Kreussel <65088241+LLukas22@users.noreply.github.com> Date: Thu, 2 Apr 2026 10:45:09 +0200 Subject: [PATCH 03/10] Wire up HapticsPlugin.kt --- .zed/settings.json | 16 ++ examples/haptics/Cargo.toml | 15 ++ examples/haptics/README.md | 11 ++ examples/haptics/justfile | 33 ++++ examples/haptics/src/main.rs | 111 +++++++++++++ packages/haptics/Cargo.toml | 3 + packages/haptics/android/.gitignore | 1 - packages/haptics/android/build.gradel.kts | 45 ------ packages/haptics/android/build.gradle.kts | 24 +++ .../android/main/java/HapticsPlugin.kt | 150 ------------------ .../{ => src}/main/AndroidManifest.xml | 2 +- .../dev/dioxus/sdk/haptics/HapticsPlugin.kt | 73 +++++++++ .../dioxus/sdk/haptics}/patterns/Impact.kt | 2 +- .../sdk/haptics}/patterns/Notification.kt | 2 +- .../dioxus/sdk/haptics}/patterns/Pattern.kt | 2 +- .../dioxus/sdk/haptics}/patterns/Selection.kt | 2 +- packages/haptics/src/android.rs | 111 +++++++++++++ packages/haptics/src/lib.rs | 74 +++++++-- 18 files changed, 465 insertions(+), 212 deletions(-) create mode 100644 .zed/settings.json create mode 100644 examples/haptics/Cargo.toml create mode 100644 examples/haptics/README.md create mode 100644 examples/haptics/justfile create mode 100644 examples/haptics/src/main.rs delete mode 100644 packages/haptics/android/.gitignore delete mode 100644 packages/haptics/android/build.gradel.kts create mode 100644 packages/haptics/android/build.gradle.kts delete mode 100644 packages/haptics/android/main/java/HapticsPlugin.kt rename packages/haptics/android/{ => src}/main/AndroidManifest.xml (78%) create mode 100644 packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/HapticsPlugin.kt rename packages/haptics/android/{main/java => src/main/java/dev/dioxus/sdk/haptics}/patterns/Impact.kt (94%) rename packages/haptics/android/{main/java => src/main/java/dev/dioxus/sdk/haptics}/patterns/Notification.kt (93%) rename packages/haptics/android/{main/java => src/main/java/dev/dioxus/sdk/haptics}/patterns/Pattern.kt (86%) rename packages/haptics/android/{main/java => src/main/java/dev/dioxus/sdk/haptics}/patterns/Selection.kt (87%) create mode 100644 packages/haptics/src/android.rs diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..8bbfd53 --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,16 @@ +{ + "lsp": { + "rust-analyzer": { + "initialization_options": { + "cargo": { + "features": "all", + "target": "aarch64-linux-android", + }, + "check": { + "features": "all", + "allTargets": true, + }, + }, + }, + }, +} diff --git a/examples/haptics/Cargo.toml b/examples/haptics/Cargo.toml new file mode 100644 index 0000000..d3bdb33 --- /dev/null +++ b/examples/haptics/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "haptics-example" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +dioxus = { workspace = true } +dioxus-sdk-haptics = { workspace = true } + +[features] +default = ["android"] +android = [] +web = ["dioxus/web"] +desktop = ["dioxus/desktop"] diff --git a/examples/haptics/README.md b/examples/haptics/README.md new file mode 100644 index 0000000..b7451b9 --- /dev/null +++ b/examples/haptics/README.md @@ -0,0 +1,11 @@ +# haptics + +Learn how to use the `haptics` abstraction. + +Run: + +```dx serve --platorm android``` + +Or use the provided justfile to easily test on a real android device connected via ADB: + +```just android-haptics``` diff --git a/examples/haptics/justfile b/examples/haptics/justfile new file mode 100644 index 0000000..729bb8f --- /dev/null +++ b/examples/haptics/justfile @@ -0,0 +1,33 @@ +default: + @just --list + +android-haptics device='': + #!/usr/bin/env bash + set -euo pipefail + + serial='{{device}}' + + adb start-server >/dev/null + + if [[ -z "$serial" ]]; then + mapfile -t devices < <(adb devices | awk 'NR > 1 && $2 == "device" { print $1 }') + + if [[ ${#devices[@]} -eq 0 ]]; then + echo 'No adb device is connected.' >&2 + echo 'Connect an Android device over USB and make sure USB debugging is enabled.' >&2 + exit 1 + fi + + if [[ ${#devices[@]} -gt 1 ]]; then + echo 'Multiple adb devices are connected. Pass a serial explicitly:' >&2 + echo ' just android-haptics ' >&2 + adb devices -l >&2 + exit 1 + fi + + serial="${devices[0]}" + fi + + echo "Using adb device: $serial" + adb -s "$serial" wait-for-device + dx run --platform android --device "$serial" diff --git a/examples/haptics/src/main.rs b/examples/haptics/src/main.rs new file mode 100644 index 0000000..8778557 --- /dev/null +++ b/examples/haptics/src/main.rs @@ -0,0 +1,111 @@ +use dioxus::prelude::*; +use dioxus_sdk_haptics::{ + impact_feedback, notification_feedback, selection_feedback, vibrate, ImpactFeedbackStyle, + NotificationFeedbackType, +}; + +fn main() { + launch(App); +} + +#[component] +fn App() -> Element { + let status = use_signal(|| "Tap a button to test haptics.".to_string()); + + rsx! { + div { + style: "max-width: 720px; margin: 0 auto; padding: 24px; font-family: sans-serif;", + h1 { "Dioxus Haptics Example" } + p { "Run this on an mobile device to exercise the haptics bridge." } + p { strong { "Status: " } "{status}" } + + section { + style: "margin-top: 24px;", + h2 { "Vibrate" } + div { + style: "display: flex; gap: 12px; flex-wrap: wrap;", + button { + onclick: move |_| run_action(status, "vibrate 25ms", map_result(vibrate(25))), + "25 ms" + } + button { + onclick: move |_| run_action(status, "vibrate 75ms", map_result(vibrate(75))), + "75 ms" + } + button { + onclick: move |_| run_action(status, "vibrate 150ms", map_result(vibrate(150))), + "150 ms" + } + } + } + + section { + style: "margin-top: 24px;", + h2 { "Impact Feedback" } + div { + style: "display: flex; gap: 12px; flex-wrap: wrap;", + button { + onclick: move |_| run_action(status, "impact light", map_result(impact_feedback(ImpactFeedbackStyle::Light))), + "Light" + } + button { + onclick: move |_| run_action(status, "impact medium", map_result(impact_feedback(ImpactFeedbackStyle::Medium))), + "Medium" + } + button { + onclick: move |_| run_action(status, "impact heavy", map_result(impact_feedback(ImpactFeedbackStyle::Heavy))), + "Heavy" + } + button { + onclick: move |_| run_action(status, "impact soft", map_result(impact_feedback(ImpactFeedbackStyle::Soft))), + "Soft" + } + button { + onclick: move |_| run_action(status, "impact rigid", map_result(impact_feedback(ImpactFeedbackStyle::Rigid))), + "Rigid" + } + } + } + + section { + style: "margin-top: 24px;", + h2 { "Notification Feedback" } + div { + style: "display: flex; gap: 12px; flex-wrap: wrap;", + button { + onclick: move |_| run_action(status, "notification success", map_result(notification_feedback(NotificationFeedbackType::Success))), + "Success" + } + button { + onclick: move |_| run_action(status, "notification warning", map_result(notification_feedback(NotificationFeedbackType::Warning))), + "Warning" + } + button { + onclick: move |_| run_action(status, "notification error", map_result(notification_feedback(NotificationFeedbackType::Error))), + "Error" + } + } + } + + section { + style: "margin-top: 24px;", + h2 { "Selection" } + button { + onclick: move |_| run_action(status, "selection feedback", map_result(selection_feedback())), + "Selection" + } + } + } + } +} + +fn run_action(mut status: Signal, label: &'static str, result: Result<(), String>) { + match result { + Ok(()) => status.set(format!("Triggered {label}.")), + Err(err) => status.set(format!("{label} failed: {err}")), + } +} + +fn map_result(result: Result<(), dioxus_sdk_haptics::HapticsError>) -> Result<(), String> { + result.map_err(|err| err.to_string()) +} diff --git a/packages/haptics/Cargo.toml b/packages/haptics/Cargo.toml index 911adfe..5b2a7db 100644 --- a/packages/haptics/Cargo.toml +++ b/packages/haptics/Cargo.toml @@ -23,4 +23,7 @@ serde = { workspace = true } wasm-bindgen = { workspace = true } js-sys = { workspace = true } +[target.'cfg(target_os = "android")'.dependencies] +manganis = "0.7.4" + [target.'cfg(windows)'.dependencies] diff --git a/packages/haptics/android/.gitignore b/packages/haptics/android/.gitignore deleted file mode 100644 index 796b96d..0000000 --- a/packages/haptics/android/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build diff --git a/packages/haptics/android/build.gradel.kts b/packages/haptics/android/build.gradel.kts deleted file mode 100644 index 88c2f82..0000000 --- a/packages/haptics/android/build.gradel.kts +++ /dev/null @@ -1,45 +0,0 @@ -plugins { - id("com.android.library") - id("org.jetbrains.kotlin.android") -} - -android { - namespace = "app.tauri.haptics" - compileSdk = 36 - - defaultConfig { - minSdk = 24 - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles("consumer-rules.pro") - } - - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = "1.8" - } -} - -dependencies { - - implementation("androidx.core:core-ktx:1.9.0") - implementation("androidx.appcompat:appcompat:1.6.0") - implementation("com.google.android.material:material:1.7.0") - implementation("com.fasterxml.jackson.core:jackson-databind:2.15.3") - testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test.ext:junit:1.1.5") - androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") - implementation(project(":tauri-android")) -} diff --git a/packages/haptics/android/build.gradle.kts b/packages/haptics/android/build.gradle.kts new file mode 100644 index 0000000..4417796 --- /dev/null +++ b/packages/haptics/android/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "dev.dioxus.sdk.haptics" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies {} diff --git a/packages/haptics/android/main/java/HapticsPlugin.kt b/packages/haptics/android/main/java/HapticsPlugin.kt deleted file mode 100644 index 81b12a6..0000000 --- a/packages/haptics/android/main/java/HapticsPlugin.kt +++ /dev/null @@ -1,150 +0,0 @@ -// Copyright 2019-2023 Tauri Programme within The Commons Conservancy -// SPDX-License-Identifier: Apache-2.0 -// SPDX-License-Identifier: MIT - -package app.tauri.haptics - -import android.app.Activity -import android.content.Context -import android.os.Build -import android.os.VibrationEffect -import android.os.Vibrator -import android.os.VibratorManager -import app.tauri.Logger -import app.tauri.annotation.Command -import app.tauri.annotation.InvokeArg -import app.tauri.annotation.TauriPlugin -import app.tauri.haptics.patterns.ImpactPatternHeavy -import app.tauri.haptics.patterns.ImpactPatternLight -import app.tauri.haptics.patterns.ImpactPatternMedium -import app.tauri.haptics.patterns.ImpactPatternRigid -import app.tauri.haptics.patterns.ImpactPatternSoft -import app.tauri.haptics.patterns.NotificationPatternError -import app.tauri.haptics.patterns.NotificationPatternSuccess -import app.tauri.haptics.patterns.NotificationPatternWarning -import app.tauri.haptics.patterns.Pattern -import app.tauri.haptics.patterns.SelectionPattern -import app.tauri.plugin.Invoke -import app.tauri.plugin.Plugin -import com.fasterxml.jackson.annotation.JsonProperty - -@InvokeArg -class HapticsOptions { - var duration: Long = 300 -} - -@InvokeArg -class NotificationFeedbackArgs { - val type: NotificationFeedbackType = NotificationFeedbackType.Success -} - -@InvokeArg -enum class NotificationFeedbackType { - @JsonProperty("success") - Success, - - @JsonProperty("warning") - Warning, - - @JsonProperty("error") - Error; - - fun into(): Pattern { - return when (this) { - Success -> NotificationPatternSuccess - Warning -> NotificationPatternWarning - Error -> NotificationPatternError - } - } -} - -@InvokeArg -class ImpactFeedbackArgs { - val style: ImpactFeedbackStyle = ImpactFeedbackStyle.Medium -} - -@InvokeArg -enum class ImpactFeedbackStyle { - @JsonProperty("light") - Light, - - @JsonProperty("medium") - Medium, - - @JsonProperty("heavy") - Heavy, - - @JsonProperty("soft") - Soft, - - @JsonProperty("rigid") - Rigid; - - fun into(): Pattern { - return when (this) { - Light -> ImpactPatternLight - Medium -> ImpactPatternMedium - Heavy -> ImpactPatternHeavy - Soft -> ImpactPatternSoft - Rigid -> ImpactPatternRigid - } - } -} - -@TauriPlugin -class HapticsPlugin(private val activity: Activity) : Plugin(activity) { - private val vibrator: Vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val vibManager = - activity.applicationContext.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager - vibManager.defaultVibrator - } else { - @Suppress("DEPRECATION") - activity.applicationContext.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator - } - - // - // TAURI COMMANDS - // - - @Command - fun vibrate(invoke: Invoke) { - val args = invoke.parseArgs(HapticsOptions::class.java) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - vibrator.vibrate(VibrationEffect.createOneShot(args.duration, VibrationEffect.DEFAULT_AMPLITUDE)) - } else { - vibrator.vibrate(args.duration) - } - invoke.resolve() - } - - @Command - fun impactFeedback(invoke: Invoke) { - val args = invoke.parseArgs(ImpactFeedbackArgs::class.java) - vibratePattern(args.style.into()) - invoke.resolve() - } - - @Command - fun notificationFeedback(invoke: Invoke) { - val args = invoke.parseArgs(NotificationFeedbackArgs::class.java) - vibratePattern(args.type.into()) - invoke.resolve() - } - - // TODO: Consider breaking this up into Start,Change,End like capacitor - @Command - fun selectionFeedback(invoke: Invoke) { - vibratePattern(SelectionPattern) - invoke.resolve() - } - - // INTERNAL FUNCTIONS - - private fun vibratePattern(pattern: Pattern) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - vibrator.vibrate(VibrationEffect.createWaveform(pattern.timings, pattern.amplitudes, -1)) - } else { - vibrator.vibrate(pattern.oldSDKPattern, -1) - } - } -} diff --git a/packages/haptics/android/main/AndroidManifest.xml b/packages/haptics/android/src/main/AndroidManifest.xml similarity index 78% rename from packages/haptics/android/main/AndroidManifest.xml rename to packages/haptics/android/src/main/AndroidManifest.xml index d2a9891..042e61a 100644 --- a/packages/haptics/android/main/AndroidManifest.xml +++ b/packages/haptics/android/src/main/AndroidManifest.xml @@ -1,4 +1,4 @@ - + diff --git a/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/HapticsPlugin.kt b/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/HapticsPlugin.kt new file mode 100644 index 0000000..c520c6d --- /dev/null +++ b/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/HapticsPlugin.kt @@ -0,0 +1,73 @@ +package dev.dioxus.sdk.haptics + +import android.app.Activity +import android.content.Context +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import dev.dioxus.sdk.haptics.patterns.ImpactPatternHeavy +import dev.dioxus.sdk.haptics.patterns.ImpactPatternLight +import dev.dioxus.sdk.haptics.patterns.ImpactPatternMedium +import dev.dioxus.sdk.haptics.patterns.ImpactPatternRigid +import dev.dioxus.sdk.haptics.patterns.ImpactPatternSoft +import dev.dioxus.sdk.haptics.patterns.NotificationPatternError +import dev.dioxus.sdk.haptics.patterns.NotificationPatternSuccess +import dev.dioxus.sdk.haptics.patterns.NotificationPatternWarning +import dev.dioxus.sdk.haptics.patterns.Pattern +import dev.dioxus.sdk.haptics.patterns.SelectionPattern + +class HapticsPlugin(private val activity: Activity) { + private val vibrator: Vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = + activity.applicationContext.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager + vibratorManager.defaultVibrator + } else { + @Suppress("DEPRECATION") + activity.applicationContext.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + } + + fun vibrate(duration: Long) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createOneShot(duration, VibrationEffect.DEFAULT_AMPLITUDE)) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(duration) + } + } + + fun impactFeedback(style: String) { + vibratePattern( + when (style.lowercase()) { + "light" -> ImpactPatternLight + "heavy" -> ImpactPatternHeavy + "soft" -> ImpactPatternSoft + "rigid" -> ImpactPatternRigid + else -> ImpactPatternMedium + } + ) + } + + fun notificationFeedback(kind: String) { + vibratePattern( + when (kind.lowercase()) { + "warning" -> NotificationPatternWarning + "error" -> NotificationPatternError + else -> NotificationPatternSuccess + } + ) + } + + fun selectionFeedback() { + vibratePattern(SelectionPattern) + } + + private fun vibratePattern(pattern: Pattern) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createWaveform(pattern.timings, pattern.amplitudes, -1)) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(pattern.oldSDKPattern, -1) + } + } +} diff --git a/packages/haptics/android/main/java/patterns/Impact.kt b/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/patterns/Impact.kt similarity index 94% rename from packages/haptics/android/main/java/patterns/Impact.kt rename to packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/patterns/Impact.kt index 1a61060..9c3a935 100644 --- a/packages/haptics/android/main/java/patterns/Impact.kt +++ b/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/patterns/Impact.kt @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -package app.tauri.haptics.patterns +package dev.dioxus.sdk.haptics.patterns val ImpactPatternLight = Pattern( longArrayOf(0, 50), diff --git a/packages/haptics/android/main/java/patterns/Notification.kt b/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/patterns/Notification.kt similarity index 93% rename from packages/haptics/android/main/java/patterns/Notification.kt rename to packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/patterns/Notification.kt index 2f70c23..59acb1e 100644 --- a/packages/haptics/android/main/java/patterns/Notification.kt +++ b/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/patterns/Notification.kt @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -package app.tauri.haptics.patterns +package dev.dioxus.sdk.haptics.patterns val NotificationPatternSuccess = Pattern( longArrayOf(0, 40, 100, 40), diff --git a/packages/haptics/android/main/java/patterns/Pattern.kt b/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/patterns/Pattern.kt similarity index 86% rename from packages/haptics/android/main/java/patterns/Pattern.kt rename to packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/patterns/Pattern.kt index 234b31a..9e69e2d 100644 --- a/packages/haptics/android/main/java/patterns/Pattern.kt +++ b/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/patterns/Pattern.kt @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -package app.tauri.haptics.patterns +package dev.dioxus.sdk.haptics.patterns class Pattern( val timings: LongArray, diff --git a/packages/haptics/android/main/java/patterns/Selection.kt b/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/patterns/Selection.kt similarity index 87% rename from packages/haptics/android/main/java/patterns/Selection.kt rename to packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/patterns/Selection.kt index 2b9912e..fc91574 100644 --- a/packages/haptics/android/main/java/patterns/Selection.kt +++ b/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/patterns/Selection.kt @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -package app.tauri.haptics.patterns +package dev.dioxus.sdk.haptics.patterns val SelectionPattern = Pattern( timings = longArrayOf(0, 50), diff --git a/packages/haptics/src/android.rs b/packages/haptics/src/android.rs new file mode 100644 index 0000000..f8616cb --- /dev/null +++ b/packages/haptics/src/android.rs @@ -0,0 +1,111 @@ +use manganis::{ + android::with_activity, + jni::{ + JNIEnv, + objects::{JClass, JObject}, + }, +}; + +use crate::{ImpactFeedbackStyle, NotificationFeedbackType}; + +const PLUGIN_CLASS: &str = "dev.dioxus.sdk.haptics.HapticsPlugin"; + +// Bundle the Android Gradle module into the generated Dioxus app. +#[manganis::ffi("/android")] +unsafe extern "Kotlin" {} + +fn find_plugin_class<'env>( + env: &mut JNIEnv<'env>, + activity: &JObject<'_>, +) -> Result, String> { + let class_name = env + .new_string(PLUGIN_CLASS) + .map_err(|err| format!("failed to build plugin class name: {err:?}"))?; + + env.call_method( + activity, + "getAppClass", + "(Ljava/lang/String;)Ljava/lang/Class;", + &[(&class_name).into()], + ) + .and_then(|class| class.l()) + .map(Into::into) + .map_err(|err| format!("failed to load {PLUGIN_CLASS}: {err:?}")) +} + +fn with_plugin( + f: impl FnOnce(&mut JNIEnv<'_>, &JObject<'_>) -> Result, +) -> Result { + let result = with_activity(|env, activity| { + let class = match find_plugin_class(env, activity) { + Ok(class) => class, + Err(err) => return Some(Err(err)), + }; + + let plugin = match env.new_object(&class, "(Landroid/app/Activity;)V", &[activity.into()]) { + Ok(plugin) => plugin, + Err(err) => { + return Some(Err(format!("failed to create HapticsPlugin: {err:?}"))); + } + }; + + Some(f(env, &plugin)) + }); + + match result { + Some(result) => result, + None => Err("failed to access Android activity".to_string()), + } +} + +pub fn vibrate(duration: u32) -> Result<(), String> { + with_plugin(|env, plugin| { + env.call_method(plugin, "vibrate", "(J)V", &[i64::from(duration).into()]) + .map_err(|err| format!("failed to vibrate: {err:?}"))?; + Ok(()) + }) +} + +pub fn impact_feedback(style: ImpactFeedbackStyle) -> Result<(), String> { + with_plugin(|env, plugin| { + let style = env + .new_string(style.to_string()) + .map_err(|err| format!("failed to build impact style string: {err:?}"))?; + + env.call_method( + plugin, + "impactFeedback", + "(Ljava/lang/String;)V", + &[(&style).into()], + ) + .map_err(|err| format!("failed to run impact feedback: {err:?}"))?; + + Ok(()) + }) +} + +pub fn notification_feedback(kind: NotificationFeedbackType) -> Result<(), String> { + with_plugin(|env, plugin| { + let kind = env + .new_string(kind.to_string()) + .map_err(|err| format!("failed to build notification type string: {err:?}"))?; + + env.call_method( + plugin, + "notificationFeedback", + "(Ljava/lang/String;)V", + &[(&kind).into()], + ) + .map_err(|err| format!("failed to run notification feedback: {err:?}"))?; + + Ok(()) + }) +} + +pub fn selection_feedback() -> Result<(), String> { + with_plugin(|env, plugin| { + env.call_method(plugin, "selectionFeedback", "()V", &[]) + .map_err(|err| format!("failed to run selection feedback: {err:?}"))?; + Ok(()) + }) +} diff --git a/packages/haptics/src/lib.rs b/packages/haptics/src/lib.rs index 22f1e51..9a6d4f2 100644 --- a/packages/haptics/src/lib.rs +++ b/packages/haptics/src/lib.rs @@ -6,6 +6,7 @@ use std::{ }; #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum ImpactFeedbackStyle { Light, #[default] @@ -15,7 +16,21 @@ pub enum ImpactFeedbackStyle { Rigid, } +impl fmt::Display for ImpactFeedbackStyle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + ImpactFeedbackStyle::Light => "light", + ImpactFeedbackStyle::Medium => "medium", + ImpactFeedbackStyle::Heavy => "heavy", + ImpactFeedbackStyle::Soft => "soft", + ImpactFeedbackStyle::Rigid => "rigid", + }; + write!(f, "{s}") + } +} + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum NotificationFeedbackType { #[default] Success, @@ -23,6 +38,17 @@ pub enum NotificationFeedbackType { Error, } +impl fmt::Display for NotificationFeedbackType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Success => "success", + Self::Warning => "warning", + Self::Error => "error", + }; + f.write_str(s) + } +} + /// Represents errors when utilizing the haptics abstraction. #[derive(Debug)] pub enum HapticsError { @@ -42,18 +68,44 @@ impl Display for HapticsError { } } -pub fn vibrate(duration: u32) -> Result<(), HapticsError> { - Ok(()) -} +cfg_if::cfg_if! { + if #[cfg(target_os = "android")] { + mod android; -pub fn impact_feedback(style: ImpactFeedbackStyle) -> Result<(), HapticsError> { - Ok(()) -} + fn failed_to_vibrate(err: String) -> HapticsError { + HapticsError::FailedToVibrate(std::io::Error::other(err).into()) + } -pub fn notification_feedback(style: NotificationFeedbackType) -> Result<(), HapticsError> { - Ok(()) -} + pub fn vibrate(duration: u32) -> Result<(), HapticsError> { + android::vibrate(duration).map_err(failed_to_vibrate) + } + + pub fn impact_feedback(style: ImpactFeedbackStyle) -> Result<(), HapticsError> { + android::impact_feedback(style).map_err(failed_to_vibrate) + } + + pub fn notification_feedback(style: NotificationFeedbackType) -> Result<(), HapticsError> { + android::notification_feedback(style).map_err(failed_to_vibrate) + } + + pub fn selection_feedback() -> Result<(), HapticsError> { + android::selection_feedback().map_err(failed_to_vibrate) + } + } else { + pub fn vibrate(_duration: u32) -> Result<(), HapticsError> { + Err(HapticsError::Unsupported) + } -pub fn selection_feedback() -> Result<(), HapticsError> { - Ok(()) + pub fn impact_feedback(_style: ImpactFeedbackStyle) -> Result<(), HapticsError> { + Err(HapticsError::Unsupported) + } + + pub fn notification_feedback(_style: NotificationFeedbackType) -> Result<(), HapticsError> { + Err(HapticsError::Unsupported) + } + + pub fn selection_feedback() -> Result<(), HapticsError> { + Err(HapticsError::Unsupported) + } + } } From 0ff0c20159a13f0eaa91d9999302f9cf19a20e51 Mon Sep 17 00:00:00 2001 From: Lukas Kreussel <65088241+LLukas22@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:13:09 +0200 Subject: [PATCH 04/10] Wire up ios --- packages/haptics/Cargo.toml | 8 +- packages/haptics/ios/.gitignore | 1 + packages/haptics/ios/Package.swift | 22 ++++ .../Sources/HapticsPlugin/HapticsPlugin.swift | 112 ++++++++++++++++++ packages/haptics/src/ios.rs | 38 ++++++ packages/haptics/src/lib.rs | 27 ++++- 6 files changed, 198 insertions(+), 10 deletions(-) create mode 100644 packages/haptics/ios/.gitignore create mode 100644 packages/haptics/ios/Package.swift create mode 100644 packages/haptics/ios/Sources/HapticsPlugin/HapticsPlugin.swift create mode 100644 packages/haptics/src/ios.rs diff --git a/packages/haptics/Cargo.toml b/packages/haptics/Cargo.toml index 5b2a7db..139f2de 100644 --- a/packages/haptics/Cargo.toml +++ b/packages/haptics/Cargo.toml @@ -18,12 +18,8 @@ dioxus = { workspace = true } cfg-if = { workspace = true } serde = { workspace = true } - -[target.'cfg(target_family = "wasm")'.dependencies] -wasm-bindgen = { workspace = true } -js-sys = { workspace = true } - [target.'cfg(target_os = "android")'.dependencies] manganis = "0.7.4" -[target.'cfg(windows)'.dependencies] +[target.'cfg(target_os = "ios")'.dependencies] +manganis = "0.7.4" diff --git a/packages/haptics/ios/.gitignore b/packages/haptics/ios/.gitignore new file mode 100644 index 0000000..24e5b0a --- /dev/null +++ b/packages/haptics/ios/.gitignore @@ -0,0 +1 @@ +.build diff --git a/packages/haptics/ios/Package.swift b/packages/haptics/ios/Package.swift new file mode 100644 index 0000000..2a07644 --- /dev/null +++ b/packages/haptics/ios/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version:5.7 + +import PackageDescription + +let package = Package( + name: "haptics-plugin", + platforms: [ + .iOS(.v13) + ], + products: [ + .library( + name: "HapticsPlugin", + targets: ["HapticsPlugin"] + ) + ], + targets: [ + .target( + name: "HapticsPlugin", + path: "Sources/HapticsPlugin" + ) + ] +) diff --git a/packages/haptics/ios/Sources/HapticsPlugin/HapticsPlugin.swift b/packages/haptics/ios/Sources/HapticsPlugin/HapticsPlugin.swift new file mode 100644 index 0000000..57f85bd --- /dev/null +++ b/packages/haptics/ios/Sources/HapticsPlugin/HapticsPlugin.swift @@ -0,0 +1,112 @@ +import AudioToolbox +import CoreHaptics +import Dispatch +import Foundation +import UIKit + +@objc(HapticsPlugin) +public final class HapticsPlugin: NSObject { + @objc public func vibrate(_ duration: Int64) { + runOnMain { + self.vibrateOnMain(duration) + } + } + + @objc public func impactFeedback(_ style: String) { + runOnMain { + let generator = UIImpactFeedbackGenerator(style: self.impactStyle(from: style)) + generator.prepare() + generator.impactOccurred() + } + } + + @objc public func notificationFeedback(_ kind: String) { + runOnMain { + let generator = UINotificationFeedbackGenerator() + generator.prepare() + generator.notificationOccurred(self.notificationType(from: kind)) + } + } + + @objc public func selectionFeedback() { + runOnMain { + let generator = UISelectionFeedbackGenerator() + generator.prepare() + generator.selectionChanged() + } + } + + private func runOnMain(_ action: @escaping () -> Void) { + if Thread.isMainThread { + action() + } else { + DispatchQueue.main.sync(execute: action) + } + } + + private func vibrateOnMain(_ duration: Int64) { + if #available(iOS 13.0, *), CHHapticEngine.capabilitiesForHardware().supportsHaptics { + do { + let engine = try CHHapticEngine() + try engine.start() + + engine.resetHandler = { + do { + try engine.start() + } catch { + AudioServicesPlayAlertSound(kSystemSoundID_Vibrate) + } + } + + let intensity = CHHapticEventParameter( + parameterID: .hapticIntensity, + value: 1.0 + ) + let sharpness = CHHapticEventParameter( + parameterID: .hapticSharpness, + value: 1.0 + ) + let event = CHHapticEvent( + eventType: .hapticContinuous, + parameters: [intensity, sharpness], + relativeTime: 0.0, + duration: Double(duration) / 1000.0 + ) + let pattern = try CHHapticPattern(events: [event], parameters: []) + let player = try engine.makePlayer(with: pattern) + + try player.start(atTime: 0) + } catch { + AudioServicesPlayAlertSound(kSystemSoundID_Vibrate) + } + } else { + AudioServicesPlayAlertSound(kSystemSoundID_Vibrate) + } + } + + private func impactStyle(from style: String) -> UIImpactFeedbackGenerator.FeedbackStyle { + switch style { + case "light": + return .light + case "heavy": + return .heavy + case "soft": + return .soft + case "rigid": + return .rigid + default: + return .medium + } + } + + private func notificationType(from kind: String) -> UINotificationFeedbackGenerator.FeedbackType { + switch kind { + case "warning": + return .warning + case "error": + return .error + default: + return .success + } + } +} diff --git a/packages/haptics/src/ios.rs b/packages/haptics/src/ios.rs new file mode 100644 index 0000000..12f29ad --- /dev/null +++ b/packages/haptics/src/ios.rs @@ -0,0 +1,38 @@ +use crate::{ImpactFeedbackStyle, NotificationFeedbackType}; + +mod ffi { + #[allow(non_snake_case)] + #[manganis::ffi("/ios")] + unsafe extern "Swift" { + pub type HapticsPlugin; + + pub fn vibrate(this: &HapticsPlugin, duration: i64); + pub fn impact_feedback(this: &HapticsPlugin, style: &str); + pub fn notification_feedback(this: &HapticsPlugin, kind: &str); + pub fn selection_feedback(this: &HapticsPlugin); + } +} + +fn plugin() -> Result { + ffi::HapticsPlugin::new() +} + +pub fn vibrate(duration: u32) -> Result<(), &'static str> { + let plugin = plugin()?; + ffi::vibrate(&plugin, i64::from(duration)) +} + +pub fn impact_feedback(style: ImpactFeedbackStyle) -> Result<(), &'static str> { + let plugin = plugin()?; + ffi::impact_feedback(&plugin, &style.to_string()) +} + +pub fn notification_feedback(kind: NotificationFeedbackType) -> Result<(), &'static str> { + let plugin = plugin()?; + ffi::notification_feedback(&plugin, &kind.to_string()) +} + +pub fn selection_feedback() -> Result<(), &'static str> { + let plugin = plugin()?; + ffi::selection_feedback(&plugin) +} diff --git a/packages/haptics/src/lib.rs b/packages/haptics/src/lib.rs index 9a6d4f2..776e298 100644 --- a/packages/haptics/src/lib.rs +++ b/packages/haptics/src/lib.rs @@ -68,14 +68,15 @@ impl Display for HapticsError { } } +#[cfg(any(target_os = "android", target_os = "ios"))] +fn failed_to_vibrate(err: impl Display) -> HapticsError { + HapticsError::FailedToVibrate(std::io::Error::other(err.to_string()).into()) +} + cfg_if::cfg_if! { if #[cfg(target_os = "android")] { mod android; - fn failed_to_vibrate(err: String) -> HapticsError { - HapticsError::FailedToVibrate(std::io::Error::other(err).into()) - } - pub fn vibrate(duration: u32) -> Result<(), HapticsError> { android::vibrate(duration).map_err(failed_to_vibrate) } @@ -91,6 +92,24 @@ cfg_if::cfg_if! { pub fn selection_feedback() -> Result<(), HapticsError> { android::selection_feedback().map_err(failed_to_vibrate) } + } else if #[cfg(target_os = "ios")] { + mod ios; + + pub fn vibrate(duration: u32) -> Result<(), HapticsError> { + ios::vibrate(duration).map_err(failed_to_vibrate) + } + + pub fn impact_feedback(style: ImpactFeedbackStyle) -> Result<(), HapticsError> { + ios::impact_feedback(style).map_err(failed_to_vibrate) + } + + pub fn notification_feedback(style: NotificationFeedbackType) -> Result<(), HapticsError> { + ios::notification_feedback(style).map_err(failed_to_vibrate) + } + + pub fn selection_feedback() -> Result<(), HapticsError> { + ios::selection_feedback().map_err(failed_to_vibrate) + } } else { pub fn vibrate(_duration: u32) -> Result<(), HapticsError> { Err(HapticsError::Unsupported) From 8c73406a58dc4d7f0991cc5da5ba0184a395a7d5 Mon Sep 17 00:00:00 2001 From: Lukas Kreussel <65088241+LLukas22@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:28:59 +0200 Subject: [PATCH 05/10] Dont reconstruct native plugins on every call --- .../dev/dioxus/sdk/haptics/HapticsPlugin.kt | 9 +- .../Sources/HapticsPlugin/HapticsPlugin.swift | 110 +++++++++++------- packages/haptics/src/android.rs | 73 +++++++++--- packages/haptics/src/ios.rs | 10 +- 4 files changed, 138 insertions(+), 64 deletions(-) diff --git a/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/HapticsPlugin.kt b/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/HapticsPlugin.kt index c520c6d..48bd098 100644 --- a/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/HapticsPlugin.kt +++ b/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/HapticsPlugin.kt @@ -1,6 +1,5 @@ package dev.dioxus.sdk.haptics -import android.app.Activity import android.content.Context import android.os.Build import android.os.VibrationEffect @@ -17,14 +16,16 @@ import dev.dioxus.sdk.haptics.patterns.NotificationPatternWarning import dev.dioxus.sdk.haptics.patterns.Pattern import dev.dioxus.sdk.haptics.patterns.SelectionPattern -class HapticsPlugin(private val activity: Activity) { +class HapticsPlugin(context: Context) { + private val appContext = context.applicationContext + private val vibrator: Vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val vibratorManager = - activity.applicationContext.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager + appContext.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager vibratorManager.defaultVibrator } else { @Suppress("DEPRECATION") - activity.applicationContext.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + appContext.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator } fun vibrate(duration: Long) { diff --git a/packages/haptics/ios/Sources/HapticsPlugin/HapticsPlugin.swift b/packages/haptics/ios/Sources/HapticsPlugin/HapticsPlugin.swift index 57f85bd..4986ab8 100644 --- a/packages/haptics/ios/Sources/HapticsPlugin/HapticsPlugin.swift +++ b/packages/haptics/ios/Sources/HapticsPlugin/HapticsPlugin.swift @@ -6,6 +6,15 @@ import UIKit @objc(HapticsPlugin) public final class HapticsPlugin: NSObject { + private lazy var lightImpactGenerator = UIImpactFeedbackGenerator(style: .light) + private lazy var mediumImpactGenerator = UIImpactFeedbackGenerator(style: .medium) + private lazy var heavyImpactGenerator = UIImpactFeedbackGenerator(style: .heavy) + private lazy var softImpactGenerator = UIImpactFeedbackGenerator(style: .soft) + private lazy var rigidImpactGenerator = UIImpactFeedbackGenerator(style: .rigid) + private lazy var notificationGenerator = UINotificationFeedbackGenerator() + private lazy var selectionGenerator = UISelectionFeedbackGenerator() + private lazy var hapticEngine: CHHapticEngine? = makeHapticEngine() + @objc public func vibrate(_ duration: Int64) { runOnMain { self.vibrateOnMain(duration) @@ -14,7 +23,7 @@ public final class HapticsPlugin: NSObject { @objc public func impactFeedback(_ style: String) { runOnMain { - let generator = UIImpactFeedbackGenerator(style: self.impactStyle(from: style)) + let generator = self.impactGenerator(from: style) generator.prepare() generator.impactOccurred() } @@ -22,17 +31,15 @@ public final class HapticsPlugin: NSObject { @objc public func notificationFeedback(_ kind: String) { runOnMain { - let generator = UINotificationFeedbackGenerator() - generator.prepare() - generator.notificationOccurred(self.notificationType(from: kind)) + self.notificationGenerator.prepare() + self.notificationGenerator.notificationOccurred(self.notificationType(from: kind)) } } @objc public func selectionFeedback() { runOnMain { - let generator = UISelectionFeedbackGenerator() - generator.prepare() - generator.selectionChanged() + self.selectionGenerator.prepare() + self.selectionGenerator.selectionChanged() } } @@ -45,57 +52,70 @@ public final class HapticsPlugin: NSObject { } private func vibrateOnMain(_ duration: Int64) { - if #available(iOS 13.0, *), CHHapticEngine.capabilitiesForHardware().supportsHaptics { - do { - let engine = try CHHapticEngine() - try engine.start() + guard let engine = hapticEngine else { + AudioServicesPlayAlertSound(kSystemSoundID_Vibrate) + return + } - engine.resetHandler = { - do { - try engine.start() - } catch { - AudioServicesPlayAlertSound(kSystemSoundID_Vibrate) - } - } + do { + try engine.start() - let intensity = CHHapticEventParameter( - parameterID: .hapticIntensity, - value: 1.0 - ) - let sharpness = CHHapticEventParameter( - parameterID: .hapticSharpness, - value: 1.0 - ) - let event = CHHapticEvent( - eventType: .hapticContinuous, - parameters: [intensity, sharpness], - relativeTime: 0.0, - duration: Double(duration) / 1000.0 - ) - let pattern = try CHHapticPattern(events: [event], parameters: []) - let player = try engine.makePlayer(with: pattern) + let intensity = CHHapticEventParameter( + parameterID: .hapticIntensity, + value: 1.0 + ) + let sharpness = CHHapticEventParameter( + parameterID: .hapticSharpness, + value: 1.0 + ) + let event = CHHapticEvent( + eventType: .hapticContinuous, + parameters: [intensity, sharpness], + relativeTime: 0.0, + duration: Double(duration) / 1000.0 + ) + let pattern = try CHHapticPattern(events: [event], parameters: []) + let player = try engine.makePlayer(with: pattern) - try player.start(atTime: 0) - } catch { - AudioServicesPlayAlertSound(kSystemSoundID_Vibrate) - } - } else { + try player.start(atTime: 0) + } catch { AudioServicesPlayAlertSound(kSystemSoundID_Vibrate) } } - private func impactStyle(from style: String) -> UIImpactFeedbackGenerator.FeedbackStyle { + private func makeHapticEngine() -> CHHapticEngine? { + guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { + return nil + } + + do { + let engine = try CHHapticEngine() + engine.resetHandler = { + do { + try engine.start() + } catch { + AudioServicesPlayAlertSound(kSystemSoundID_Vibrate) + } + } + try engine.start() + return engine + } catch { + return nil + } + } + + private func impactGenerator(from style: String) -> UIImpactFeedbackGenerator { switch style { case "light": - return .light + return lightImpactGenerator case "heavy": - return .heavy + return heavyImpactGenerator case "soft": - return .soft + return softImpactGenerator case "rigid": - return .rigid + return rigidImpactGenerator default: - return .medium + return mediumImpactGenerator } } diff --git a/packages/haptics/src/android.rs b/packages/haptics/src/android.rs index f8616cb..d9fd06a 100644 --- a/packages/haptics/src/android.rs +++ b/packages/haptics/src/android.rs @@ -2,13 +2,15 @@ use manganis::{ android::with_activity, jni::{ JNIEnv, - objects::{JClass, JObject}, + objects::{GlobalRef, JClass, JObject}, }, }; +use std::sync::OnceLock; use crate::{ImpactFeedbackStyle, NotificationFeedbackType}; const PLUGIN_CLASS: &str = "dev.dioxus.sdk.haptics.HapticsPlugin"; +static PLUGIN: OnceLock> = OnceLock::new(); // Bundle the Android Gradle module into the generated Dioxus app. #[manganis::ffi("/android")] @@ -33,23 +35,44 @@ fn find_plugin_class<'env>( .map_err(|err| format!("failed to load {PLUGIN_CLASS}: {err:?}")) } -fn with_plugin( - f: impl FnOnce(&mut JNIEnv<'_>, &JObject<'_>) -> Result, -) -> Result { +fn create_plugin() -> Result { let result = with_activity(|env, activity| { let class = match find_plugin_class(env, activity) { Ok(class) => class, Err(err) => return Some(Err(err)), }; - let plugin = match env.new_object(&class, "(Landroid/app/Activity;)V", &[activity.into()]) { - Ok(plugin) => plugin, + let context = match env + .call_method( + activity, + "getApplicationContext", + "()Landroid/content/Context;", + &[], + ) + .and_then(|context| context.l()) + { + Ok(context) => context, Err(err) => { - return Some(Err(format!("failed to create HapticsPlugin: {err:?}"))); + return Some(Err(format!( + "failed to access application context: {err:?}" + ))); } }; - Some(f(env, &plugin)) + let plugin = + match env.new_object(&class, "(Landroid/content/Context;)V", &[(&context).into()]) { + Ok(plugin) => plugin, + Err(err) => { + return Some(Err(format!("failed to create HapticsPlugin: {err:?}"))); + } + }; + + match env.new_global_ref(&plugin) { + Ok(plugin) => Some(Ok(plugin)), + Err(err) => Some(Err(format!( + "failed to create global plugin reference: {err:?}" + ))), + } }); match result { @@ -58,10 +81,34 @@ fn with_plugin( } } +fn plugin() -> Result<&'static GlobalRef, String> { + PLUGIN + .get_or_init(create_plugin) + .as_ref() + .map_err(Clone::clone) +} + +fn with_plugin( + f: impl FnOnce(&mut JNIEnv<'_>, &GlobalRef) -> Result, +) -> Result { + let plugin = plugin()?; + let result = with_activity(|env, _activity| Some(f(env, plugin))); + + match result { + Some(result) => result, + None => Err("failed to access Android activity".to_string()), + } +} + pub fn vibrate(duration: u32) -> Result<(), String> { with_plugin(|env, plugin| { - env.call_method(plugin, "vibrate", "(J)V", &[i64::from(duration).into()]) - .map_err(|err| format!("failed to vibrate: {err:?}"))?; + env.call_method( + plugin.as_obj(), + "vibrate", + "(J)V", + &[i64::from(duration).into()], + ) + .map_err(|err| format!("failed to vibrate: {err:?}"))?; Ok(()) }) } @@ -73,7 +120,7 @@ pub fn impact_feedback(style: ImpactFeedbackStyle) -> Result<(), String> { .map_err(|err| format!("failed to build impact style string: {err:?}"))?; env.call_method( - plugin, + plugin.as_obj(), "impactFeedback", "(Ljava/lang/String;)V", &[(&style).into()], @@ -91,7 +138,7 @@ pub fn notification_feedback(kind: NotificationFeedbackType) -> Result<(), Strin .map_err(|err| format!("failed to build notification type string: {err:?}"))?; env.call_method( - plugin, + plugin.as_obj(), "notificationFeedback", "(Ljava/lang/String;)V", &[(&kind).into()], @@ -104,7 +151,7 @@ pub fn notification_feedback(kind: NotificationFeedbackType) -> Result<(), Strin pub fn selection_feedback() -> Result<(), String> { with_plugin(|env, plugin| { - env.call_method(plugin, "selectionFeedback", "()V", &[]) + env.call_method(plugin.as_obj(), "selectionFeedback", "()V", &[]) .map_err(|err| format!("failed to run selection feedback: {err:?}"))?; Ok(()) }) diff --git a/packages/haptics/src/ios.rs b/packages/haptics/src/ios.rs index 12f29ad..3b95235 100644 --- a/packages/haptics/src/ios.rs +++ b/packages/haptics/src/ios.rs @@ -1,4 +1,5 @@ use crate::{ImpactFeedbackStyle, NotificationFeedbackType}; +use std::sync::OnceLock; mod ffi { #[allow(non_snake_case)] @@ -13,8 +14,13 @@ mod ffi { } } -fn plugin() -> Result { - ffi::HapticsPlugin::new() +static PLUGIN: OnceLock> = OnceLock::new(); + +fn plugin() -> Result<&'static ffi::HapticsPlugin, &'static str> { + PLUGIN + .get_or_init(ffi::HapticsPlugin::new) + .as_ref() + .map_err(|err| *err) } pub fn vibrate(duration: u32) -> Result<(), &'static str> { From e7295027bac0752a501fd6446eac0d7e273b89b9 Mon Sep 17 00:00:00 2001 From: Lukas Kreussel <65088241+LLukas22@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:38:24 +0200 Subject: [PATCH 06/10] README --- packages/haptics/README.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/haptics/README.md b/packages/haptics/README.md index 711a2b0..afe613d 100644 --- a/packages/haptics/README.md +++ b/packages/haptics/README.md @@ -21,6 +21,23 @@ dioxus-sdk-haptics = "0.7" Example: ```rs use dioxus::prelude::*; +use dioxus_sdk_haptics::vibrate; -//TODO +#[component] +fn App() -> Element { + let mut status = use_signal(|| "Tap the button to vibrate.".to_string()); + + rsx! { + div { + p { strong { "Status: " } "{status}" } + button { + onclick: move |_| match vibrate(25).map_err(|err| err.to_string()){ + Ok(()) => status.set(format!("Triggered 25ms.")), + Err(err) => status.set(format!("25ms failed: {err}")), + }, + "25 ms" + } + } + } +} ``` From 15f1f8f118ff40cbe8f94b269417e363c39717b2 Mon Sep 17 00:00:00 2001 From: Lukas Kreussel <65088241+LLukas22@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:40:17 +0200 Subject: [PATCH 07/10] Other README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 69971a8..56de0da 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ - [x] Channels - `dioxus-sdk-util` - [x] `use_root_scroll` +- `dioxus-sdk-haptics` - Android & iOS - [ ] Camera - [ ] WiFi - [ ] Bluetooth From 7b342dccf6591ee1270c1f55216ff447081cb173 Mon Sep 17 00:00:00 2001 From: Lukas Kreussel <65088241+LLukas22@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:44:30 +0200 Subject: [PATCH 08/10] Throw in some dokstrings --- packages/haptics/src/lib.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/haptics/src/lib.rs b/packages/haptics/src/lib.rs index 776e298..76d7872 100644 --- a/packages/haptics/src/lib.rs +++ b/packages/haptics/src/lib.rs @@ -1,3 +1,5 @@ +//! Trigger native haptics feedback on mobile devices. + use serde::{Deserialize, Serialize}; use std::{ @@ -5,14 +7,20 @@ use std::{ fmt::{self, Display}, }; +/// Represents impact feedback intensity. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ImpactFeedbackStyle { + /// A light impact. Light, + /// A medium impact. #[default] Medium, + /// A heavy impact. Heavy, + /// A soft impact. Soft, + /// A rigid impact. Rigid, } @@ -29,12 +37,16 @@ impl fmt::Display for ImpactFeedbackStyle { } } +/// Represents notification feedback kind. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum NotificationFeedbackType { + /// A success notification. #[default] Success, + /// A warning notification. Warning, + /// An error notification. Error, } @@ -54,7 +66,7 @@ impl fmt::Display for NotificationFeedbackType { pub enum HapticsError { /// Haptics are unsupported on this platform. Unsupported, - /// Failure to show a notification. + /// Failure to trigger the vibration. FailedToVibrate(Box), } @@ -77,52 +89,64 @@ cfg_if::cfg_if! { if #[cfg(target_os = "android")] { mod android; + /// Trigger a simple vibration. pub fn vibrate(duration: u32) -> Result<(), HapticsError> { android::vibrate(duration).map_err(failed_to_vibrate) } + /// Trigger impact feedback. pub fn impact_feedback(style: ImpactFeedbackStyle) -> Result<(), HapticsError> { android::impact_feedback(style).map_err(failed_to_vibrate) } + /// Trigger notification feedback. pub fn notification_feedback(style: NotificationFeedbackType) -> Result<(), HapticsError> { android::notification_feedback(style).map_err(failed_to_vibrate) } + /// Trigger selection feedback. pub fn selection_feedback() -> Result<(), HapticsError> { android::selection_feedback().map_err(failed_to_vibrate) } } else if #[cfg(target_os = "ios")] { mod ios; + /// Trigger a simple vibration. pub fn vibrate(duration: u32) -> Result<(), HapticsError> { ios::vibrate(duration).map_err(failed_to_vibrate) } + /// Trigger impact feedback. pub fn impact_feedback(style: ImpactFeedbackStyle) -> Result<(), HapticsError> { ios::impact_feedback(style).map_err(failed_to_vibrate) } + /// Trigger notification feedback. pub fn notification_feedback(style: NotificationFeedbackType) -> Result<(), HapticsError> { ios::notification_feedback(style).map_err(failed_to_vibrate) } + /// Trigger selection feedback. pub fn selection_feedback() -> Result<(), HapticsError> { ios::selection_feedback().map_err(failed_to_vibrate) } } else { + /// Trigger a simple vibration. pub fn vibrate(_duration: u32) -> Result<(), HapticsError> { Err(HapticsError::Unsupported) } + /// Trigger impact feedback. pub fn impact_feedback(_style: ImpactFeedbackStyle) -> Result<(), HapticsError> { Err(HapticsError::Unsupported) } + /// Trigger notification feedback. pub fn notification_feedback(_style: NotificationFeedbackType) -> Result<(), HapticsError> { Err(HapticsError::Unsupported) } + /// Trigger selection feedback. pub fn selection_feedback() -> Result<(), HapticsError> { Err(HapticsError::Unsupported) } From aaf329e9d155f4ae995536a3baa69e9734ec2431 Mon Sep 17 00:00:00 2001 From: Lukas Kreussel <65088241+LLukas22@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:48:42 +0200 Subject: [PATCH 09/10] remove zed config --- .zed/settings.json | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 .zed/settings.json diff --git a/.zed/settings.json b/.zed/settings.json deleted file mode 100644 index 8bbfd53..0000000 --- a/.zed/settings.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "lsp": { - "rust-analyzer": { - "initialization_options": { - "cargo": { - "features": "all", - "target": "aarch64-linux-android", - }, - "check": { - "features": "all", - "allTargets": true, - }, - }, - }, - }, -} From a272d88bba620d52aa2ade203bb9a83867b65ef8 Mon Sep 17 00:00:00 2001 From: Lukas Kreussel <65088241+LLukas22@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:37:44 +0200 Subject: [PATCH 10/10] Update to manganis 0.7.6 --- packages/haptics/Cargo.toml | 4 +- .../dev/dioxus/sdk/haptics/HapticsPlugin.kt | 5 +- packages/haptics/src/android.rs | 162 +++--------------- 3 files changed, 32 insertions(+), 139 deletions(-) diff --git a/packages/haptics/Cargo.toml b/packages/haptics/Cargo.toml index 139f2de..64ec098 100644 --- a/packages/haptics/Cargo.toml +++ b/packages/haptics/Cargo.toml @@ -19,7 +19,7 @@ cfg-if = { workspace = true } serde = { workspace = true } [target.'cfg(target_os = "android")'.dependencies] -manganis = "0.7.4" +manganis = "0.7.6" [target.'cfg(target_os = "ios")'.dependencies] -manganis = "0.7.4" +manganis = "0.7.6" diff --git a/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/HapticsPlugin.kt b/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/HapticsPlugin.kt index 48bd098..7769874 100644 --- a/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/HapticsPlugin.kt +++ b/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/HapticsPlugin.kt @@ -1,5 +1,6 @@ package dev.dioxus.sdk.haptics +import android.app.Activity import android.content.Context import android.os.Build import android.os.VibrationEffect @@ -16,8 +17,8 @@ import dev.dioxus.sdk.haptics.patterns.NotificationPatternWarning import dev.dioxus.sdk.haptics.patterns.Pattern import dev.dioxus.sdk.haptics.patterns.SelectionPattern -class HapticsPlugin(context: Context) { - private val appContext = context.applicationContext +class HapticsPlugin(activity: Activity) { + private val appContext = activity.applicationContext private val vibrator: Vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val vibratorManager = diff --git a/packages/haptics/src/android.rs b/packages/haptics/src/android.rs index d9fd06a..965c6e1 100644 --- a/packages/haptics/src/android.rs +++ b/packages/haptics/src/android.rs @@ -1,158 +1,50 @@ -use manganis::{ - android::with_activity, - jni::{ - JNIEnv, - objects::{GlobalRef, JClass, JObject}, - }, -}; -use std::sync::OnceLock; +#![allow(non_snake_case)] use crate::{ImpactFeedbackStyle, NotificationFeedbackType}; +use std::sync::OnceLock; -const PLUGIN_CLASS: &str = "dev.dioxus.sdk.haptics.HapticsPlugin"; -static PLUGIN: OnceLock> = OnceLock::new(); - -// Bundle the Android Gradle module into the generated Dioxus app. -#[manganis::ffi("/android")] -unsafe extern "Kotlin" {} - -fn find_plugin_class<'env>( - env: &mut JNIEnv<'env>, - activity: &JObject<'_>, -) -> Result, String> { - let class_name = env - .new_string(PLUGIN_CLASS) - .map_err(|err| format!("failed to build plugin class name: {err:?}"))?; - - env.call_method( - activity, - "getAppClass", - "(Ljava/lang/String;)Ljava/lang/Class;", - &[(&class_name).into()], - ) - .and_then(|class| class.l()) - .map(Into::into) - .map_err(|err| format!("failed to load {PLUGIN_CLASS}: {err:?}")) -} - -fn create_plugin() -> Result { - let result = with_activity(|env, activity| { - let class = match find_plugin_class(env, activity) { - Ok(class) => class, - Err(err) => return Some(Err(err)), - }; - - let context = match env - .call_method( - activity, - "getApplicationContext", - "()Landroid/content/Context;", - &[], - ) - .and_then(|context| context.l()) - { - Ok(context) => context, - Err(err) => { - return Some(Err(format!( - "failed to access application context: {err:?}" - ))); - } - }; - - let plugin = - match env.new_object(&class, "(Landroid/content/Context;)V", &[(&context).into()]) { - Ok(plugin) => plugin, - Err(err) => { - return Some(Err(format!("failed to create HapticsPlugin: {err:?}"))); - } - }; - - match env.new_global_ref(&plugin) { - Ok(plugin) => Some(Ok(plugin)), - Err(err) => Some(Err(format!( - "failed to create global plugin reference: {err:?}" - ))), - } - }); +mod ffi { + #[cfg(target_os = "android")] + #[manganis::ffi("android")] + extern "Kotlin" { + pub type HapticsPlugin; - match result { - Some(result) => result, - None => Err("failed to access Android activity".to_string()), + pub fn vibrate(this: &HapticsPlugin, duration: i64); + pub fn impactFeedback(this: &HapticsPlugin, style: String); + pub fn notificationFeedback(this: &HapticsPlugin, kind: String); + pub fn selectionFeedback(this: &HapticsPlugin); } } -fn plugin() -> Result<&'static GlobalRef, String> { +static PLUGIN: OnceLock> = OnceLock::new(); + +fn plugin() -> Result<&'static ffi::HapticsPlugin, String> { PLUGIN - .get_or_init(create_plugin) + .get_or_init(ffi::HapticsPlugin::new) .as_ref() .map_err(Clone::clone) } -fn with_plugin( - f: impl FnOnce(&mut JNIEnv<'_>, &GlobalRef) -> Result, -) -> Result { - let plugin = plugin()?; - let result = with_activity(|env, _activity| Some(f(env, plugin))); - - match result { - Some(result) => result, - None => Err("failed to access Android activity".to_string()), - } -} - pub fn vibrate(duration: u32) -> Result<(), String> { - with_plugin(|env, plugin| { - env.call_method( - plugin.as_obj(), - "vibrate", - "(J)V", - &[i64::from(duration).into()], - ) - .map_err(|err| format!("failed to vibrate: {err:?}"))?; - Ok(()) - }) + let plugin = plugin()?; + let _ = ffi::vibrate(plugin, duration as i64)?; + Ok(()) } pub fn impact_feedback(style: ImpactFeedbackStyle) -> Result<(), String> { - with_plugin(|env, plugin| { - let style = env - .new_string(style.to_string()) - .map_err(|err| format!("failed to build impact style string: {err:?}"))?; - - env.call_method( - plugin.as_obj(), - "impactFeedback", - "(Ljava/lang/String;)V", - &[(&style).into()], - ) - .map_err(|err| format!("failed to run impact feedback: {err:?}"))?; - - Ok(()) - }) + let plugin = plugin()?; + let _ = ffi::impactFeedback(plugin, style.to_string())?; + Ok(()) } pub fn notification_feedback(kind: NotificationFeedbackType) -> Result<(), String> { - with_plugin(|env, plugin| { - let kind = env - .new_string(kind.to_string()) - .map_err(|err| format!("failed to build notification type string: {err:?}"))?; - - env.call_method( - plugin.as_obj(), - "notificationFeedback", - "(Ljava/lang/String;)V", - &[(&kind).into()], - ) - .map_err(|err| format!("failed to run notification feedback: {err:?}"))?; - - Ok(()) - }) + let plugin = plugin()?; + let _ = ffi::notificationFeedback(plugin, kind.to_string())?; + Ok(()) } pub fn selection_feedback() -> Result<(), String> { - with_plugin(|env, plugin| { - env.call_method(plugin.as_obj(), "selectionFeedback", "()V", &[]) - .map_err(|err| format!("failed to run selection feedback: {err:?}"))?; - Ok(()) - }) + let plugin = plugin()?; + let _ = ffi::selectionFeedback(plugin)?; + Ok(()) }