notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit f73985aa11b20e21bba6ba3c299c1838879369da
parent e549c4db80e7c6d4512ad9cbd3781459cfede6b6
Author: William Casarin <jb55@jb55.com>
Date:   Wed, 18 Feb 2026 14:57:28 -0800

android: vibrate on NeedsInput status

Trigger a 200ms vibration on Android when auto-steal focus fires for
a NeedsInput agent session, so the user gets tactile feedback that
Dave needs attention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck/src/platform/android.rs | 24++++++++++++++++++++++++
Mcrates/notedeck/src/platform/mod.rs | 10++++++++++
Mcrates/notedeck_chrome/android/app/src/main/AndroidManifest.xml | 1+
Mcrates/notedeck_chrome/android/app/src/main/java/com/damus/notedeck/MainActivity.java | 11+++++++++++
Mcrates/notedeck_dave/src/focus_queue.rs | 9++++++++-
Mcrates/notedeck_dave/src/lib.rs | 7++++++-
6 files changed, 60 insertions(+), 2 deletions(-)

diff --git a/crates/notedeck/src/platform/android.rs b/crates/notedeck/src/platform/android.rs @@ -81,6 +81,30 @@ pub extern "C" fn Java_com_damus_notedeck_MainActivity_nativeOnFilePickedWithCon } } +pub fn vibrate(duration_ms: i64) -> std::result::Result<(), Box<dyn std::error::Error>> { + let vm = get_jvm(); + let mut env = vm.attach_current_thread()?; + let context = unsafe { JObject::from_raw(ndk_context::android_context().context().cast()) }; + env.call_method( + context, + "vibrate", + "(J)V", + &[jni::objects::JValue::Long(duration_ms)], + )?; + Ok(()) +} + +pub fn try_vibrate() { + match vibrate(200) { + Ok(()) => { + info!("Vibration triggered"); + } + Err(e) => { + error!("Failed to vibrate: {}", e); + } + } +} + pub fn try_open_file_picker() { match open_file_picker() { Ok(()) => { diff --git a/crates/notedeck/src/platform/mod.rs b/crates/notedeck/src/platform/mod.rs @@ -8,6 +8,16 @@ pub fn get_next_selected_file() -> Option<Result<SelectedMedia, Error>> { file::get_next_selected_file() } +/// Trigger a short vibration on Android. No-op on other platforms. +#[cfg(target_os = "android")] +pub fn try_vibrate() { + android::try_vibrate(); +} + +/// Trigger a short vibration on Android. No-op on other platforms. +#[cfg(not(target_os = "android"))] +pub fn try_vibrate() {} + const VIRT_HEIGHT: i32 = 400; #[cfg(target_os = "android")] diff --git a/crates/notedeck_chrome/android/app/src/main/AndroidManifest.xml b/crates/notedeck_chrome/android/app/src/main/AndroidManifest.xml @@ -34,6 +34,7 @@ android:required="true" android:version="1" /> + <uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> diff --git a/crates/notedeck_chrome/android/app/src/main/java/com/damus/notedeck/MainActivity.java b/crates/notedeck_chrome/android/app/src/main/java/com/damus/notedeck/MainActivity.java @@ -6,6 +6,8 @@ import android.database.Cursor; import android.net.Uri; import android.os.Bundle; import android.os.ParcelFileDescriptor; +import android.os.VibrationEffect; +import android.os.Vibrator; import android.provider.OpenableColumns; import android.util.Log; import android.view.MotionEvent; @@ -31,6 +33,15 @@ public class MainActivity extends GameActivity { private native void nativeOnFilePickedFailed(String uri, String e); private native void nativeOnFilePickedWithContent(Object[] uri_info, byte[] content); + public void vibrate(long durationMs) { + Vibrator vibrator = (Vibrator) getSystemService(VIBRATOR_SERVICE); + if (vibrator != null && vibrator.hasVibrator()) { + VibrationEffect effect = VibrationEffect.createOneShot( + durationMs, VibrationEffect.DEFAULT_AMPLITUDE); + vibrator.vibrate(effect); + } + } + public void openFilePicker() { Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); intent.setType("*/*"); diff --git a/crates/notedeck_dave/src/focus_queue.rs b/crates/notedeck_dave/src/focus_queue.rs @@ -274,21 +274,28 @@ impl FocusQueue { Some((self.current_position()?, self.len(), entry.priority)) } + /// Update focus queue based on current session statuses. + /// Returns true if any session transitioned to NeedsInput. pub fn update_from_statuses( &mut self, sessions: impl Iterator<Item = (SessionId, AgentStatus)>, - ) { + ) -> bool { + let mut has_new_needs_input = false; for (session_id, status) in sessions { let prev = self.previous_statuses.get(&session_id).copied(); if prev != Some(status) { if let Some(priority) = FocusPriority::from_status(status) { self.enqueue(session_id, priority); + if priority == FocusPriority::NeedsInput { + has_new_needs_input = true; + } } else { self.dequeue(session_id); } } self.previous_statuses.insert(session_id, status); } + has_new_needs_input } pub fn get_session_priority(&self, session_id: SessionId) -> Option<FocusPriority> { diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -2259,7 +2259,12 @@ impl notedeck::App for Dave { // Update focus queue based on status changes let status_iter = self.session_manager.iter().map(|s| (s.id, s.status())); - self.focus_queue.update_from_statuses(status_iter); + let new_needs_input = self.focus_queue.update_from_statuses(status_iter); + + // Vibrate on Android whenever a session transitions to NeedsInput + if new_needs_input { + notedeck::platform::try_vibrate(); + } // Suppress auto-steal while the user is typing (non-empty input) let user_is_typing = self