Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions docs/android.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,11 +228,12 @@ runaway token burn events:
- runaway events set Vimichi into a visible alarm state in the Android UI;
- idle/recovery events clear the alarm state;
- Android 8+ receives a `vimit-agent-alerts` notification channel;
- Android 13+ still requires the user to grant notification permission at
runtime before notifications are visible.
- Android 13+ requests `POST_NOTIFICATIONS` at runtime before relying on
system notification delivery.

Runtime permission UX for Android 13+ should be implemented as a separate
Android task before relying on notifications as the only alert channel.
If the user declines the Android 13+ notification permission, the app must not
crash. System notifications may be hidden by Android, but vibration and the
visible in-app Vimichi alarm remain available as fallback signals.

The Android alert bridge consumes `AgentBurnEvent` from the shared core
detector. The foreground/background polling source for those events is tracked
Expand Down
90 changes: 89 additions & 1 deletion src/android.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,26 @@ use std::path::PathBuf;
#[cfg(all(target_os = "android", feature = "android-gui"))]
use std::sync::Mutex;

const ANDROID_NOTIFICATION_RUNTIME_PERMISSION_SDK: i32 = 33;
#[cfg(all(target_os = "android", feature = "android-gui"))]
const ANDROID_NOTIFICATION_PERMISSION_GRANTED: i32 = 0;

#[cfg(all(target_os = "android", feature = "android-gui"))]
const ANDROID_POST_NOTIFICATIONS_PERMISSION: &str = "android.permission.POST_NOTIFICATIONS";
#[cfg(all(target_os = "android", feature = "android-gui"))]
const ANDROID_NOTIFICATION_PERMISSION_REQUEST_CODE: i32 = 113;

#[cfg(all(target_os = "android", feature = "android-gui"))]
#[unsafe(no_mangle)]
pub fn android_main(app: slint::android::AndroidApp) {
let data_dir = app.internal_data_path();
slint::android::init(app).expect("cannot initialize Android backend");
slint::android::init(app.clone()).expect("cannot initialize Android backend");
let window = AppWindow::new().expect("cannot initialize Slint window");
window.set_is_android(true);
window.set_needs_setup(false);
if let Err(error) = android_request_notification_permission_if_needed(&app) {
window.set_source_text(format!("Android notification permission: {error}").into());
}

let key_path = android_key_path(data_dir);
let saved_key = android_load_api_key(&key_path).unwrap_or_default();
Expand Down Expand Up @@ -269,6 +281,14 @@ pub fn android_runaway_alert_text() -> AndroidAlertText {
}
}

pub fn android_needs_notification_runtime_permission(sdk: i32) -> bool {
sdk >= ANDROID_NOTIFICATION_RUNTIME_PERMISSION_SDK
}

pub fn android_should_request_notification_permission(sdk: i32, granted: bool) -> bool {
android_needs_notification_runtime_permission(sdk) && !granted
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AndroidBurnUiState {
pub visible: bool,
Expand Down Expand Up @@ -466,6 +486,12 @@ fn android_show_notification(
}

let sdk = android_sdk_int(env)?;
if android_needs_notification_runtime_permission(sdk)
&& !android_has_notification_permission(env, activity)?
{
return Ok(());
}

if sdk >= 26 {
android_create_notification_channel(env, &manager, channel_id)?;
}
Expand Down Expand Up @@ -549,6 +575,54 @@ fn android_show_notification(
})
}

#[cfg(all(target_os = "android", feature = "android-gui"))]
fn android_request_notification_permission_if_needed(
app: &slint::android::AndroidApp,
) -> Result<(), String> {
let app = app.clone();
android_run_on_java_main_thread(&app, move |env, activity| {
let sdk = android_sdk_int(env)?;
if !android_should_request_notification_permission(
sdk,
android_has_notification_permission(env, activity)?,
) {
return Ok(());
}

let permission = env.new_string(ANDROID_POST_NOTIFICATIONS_PERMISSION)?;
let permissions =
jni::objects::JObjectArray::<jni::objects::JString>::new(env, 1, &permission)?;
env.call_method(
activity,
jni::jni_str!("requestPermissions"),
jni::jni_sig!("([Ljava/lang/String;I)V"),
&[
jni::objects::JValue::Object(&permissions),
jni::objects::JValue::Int(ANDROID_NOTIFICATION_PERMISSION_REQUEST_CODE),
],
)?;
Ok(())
})
}

#[cfg(all(target_os = "android", feature = "android-gui"))]
fn android_has_notification_permission(
env: &mut jni::Env,
activity: &jni::objects::JObject,
) -> jni::errors::Result<bool> {
let permission = env.new_string(ANDROID_POST_NOTIFICATIONS_PERMISSION)?;
let permission = jni::objects::JObject::from(permission);
let grant = env
.call_method(
activity,
jni::jni_str!("checkSelfPermission"),
jni::jni_sig!("(Ljava/lang/String;)I"),
&[jni::objects::JValue::Object(&permission)],
)?
.i()?;
Ok(grant == ANDROID_NOTIFICATION_PERMISSION_GRANTED)
}

#[cfg(all(target_os = "android", feature = "android-gui"))]
fn android_create_notification_channel(
env: &mut jni::Env,
Expand Down Expand Up @@ -672,6 +746,20 @@ mod tests {
assert!(!combined.contains("c:\\"));
}

#[test]
fn android_notification_permission_is_runtime_only_on_android_13_plus() {
assert!(!android_needs_notification_runtime_permission(32));
assert!(android_needs_notification_runtime_permission(33));
assert!(android_needs_notification_runtime_permission(34));
}

#[test]
fn android_notification_permission_request_skips_when_already_granted() {
assert!(!android_should_request_notification_permission(32, false));
assert!(!android_should_request_notification_permission(33, true));
assert!(android_should_request_notification_permission(33, false));
}

#[test]
fn android_burn_ui_state_marks_runaway_as_vimichi_alarm() {
let event = AgentBurnEvent {
Expand Down
7 changes: 7 additions & 0 deletions ui/app.slint
Original file line number Diff line number Diff line change
Expand Up @@ -764,6 +764,13 @@ export component AppWindow inherits Window {
height: 46px;
clicked => { root.settings-open = !root.settings-open; }
}

Text {
text: "Android 13+ спросит разрешение на уведомления. Если отказать, системные alerts могут не появиться, но вибрация и in-app тревога останутся.";
color: Theme.text-muted;
font-size: 11px;
wrap: word-wrap;
}
}
}

Expand Down
Loading