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:
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