diff --git a/aw-client-rust/src/classes.rs b/aw-client-rust/src/classes.rs index 1e6c2236..1bc09893 100644 --- a/aw-client-rust/src/classes.rs +++ b/aw-client-rust/src/classes.rs @@ -27,6 +27,53 @@ pub struct ClassSetting { pub data: Option, } +/// Parse categorization classes from a settings JSON value. +/// +/// Accepts the payload the webui writes to `settings.classes`: a JSON array of +/// `{name, rule, ...}` objects. `id` is optional (the webui assigns it in +/// memory). `null`, empty arrays, missing fields, or unparseable JSON fall +/// back to [`default_classes`]. +/// +/// A JSON *string* is treated as double-encoded payload (the localStorage → +/// server settings migration bug, ActivityWatch/activitywatch#1067) and decoded +/// once more before parsing. +pub fn classes_from_settings_json(value: &serde_json::Value) -> Vec<(CategoryId, CategorySpec)> { + let value = decode_maybe_double_encoded(value); + match value { + serde_json::Value::Null => default_classes(), + serde_json::Value::Array(ref arr) if arr.is_empty() => default_classes(), + other => match serde_json::from_value::>(other) { + Ok(classes) if classes.is_empty() => default_classes(), + Ok(classes) => classes.into_iter().map(|c| (c.name, c.rule)).collect(), + Err(e) => { + log::warn!("Failed to parse settings.classes, using defaults: {:?}", e); + default_classes() + } + }, + } +} + +/// Parse `settings.classes` from the raw datastore/HTTP body string. +pub fn classes_from_settings_str(raw: &str) -> Vec<(CategoryId, CategorySpec)> { + match serde_json::from_str::(raw.trim()) { + Ok(v) => classes_from_settings_json(&v), + Err(e) => { + log::warn!( + "settings.classes is not valid JSON, using defaults: {:?}", + e + ); + default_classes() + } + } +} + +fn decode_maybe_double_encoded(value: &serde_json::Value) -> serde_json::Value { + match value { + serde_json::Value::String(s) => serde_json::from_str(s).unwrap_or_else(|_| value.clone()), + _ => value.clone(), + } +} + /// Returns the default categorization classes pub fn default_classes() -> Vec<(CategoryId, CategorySpec)> { vec![ @@ -142,3 +189,96 @@ pub fn default_classes() -> Vec<(CategoryId, CategorySpec)> { ), ] } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn work_regex(classes: &[(CategoryId, CategorySpec)]) -> Option<&str> { + classes + .iter() + .find(|(name, _)| name == &vec!["Work".to_string()]) + .map(|(_, spec)| spec.regex.as_str()) + } + + #[test] + fn custom_classes_without_id_are_used() { + let value = json!([ + { + "name": ["Work"], + "rule": {"type": "regex", "regex": "Slack|Notion", "ignore_case": true} + }, + { + "name": ["Uncategorized", "Games"], + "rule": {"type": "regex", "regex": "Steam"} + } + ]); + let classes = classes_from_settings_json(&value); + assert_eq!(classes.len(), 2); + assert_eq!(work_regex(&classes), Some("Slack|Notion")); + assert!(classes[0].1.ignore_case); + assert_eq!( + classes[1].0, + vec!["Uncategorized".to_string(), "Games".to_string()] + ); + assert_eq!(classes[1].1.regex, "Steam"); + } + + #[test] + fn null_and_empty_fall_back_to_defaults() { + let defaults = default_classes(); + assert_eq!( + work_regex(&classes_from_settings_json(&json!(null))), + work_regex(&defaults) + ); + assert_eq!( + work_regex(&classes_from_settings_json(&json!([]))), + work_regex(&defaults) + ); + assert_eq!( + work_regex(&classes_from_settings_str("null")), + work_regex(&defaults) + ); + } + + #[test] + fn invalid_json_falls_back_to_defaults() { + let defaults = default_classes(); + assert_eq!( + work_regex(&classes_from_settings_str("not-json")), + work_regex(&defaults) + ); + assert_eq!( + work_regex(&classes_from_settings_json(&json!({"name": "Work"}))), + work_regex(&defaults) + ); + } + + #[test] + fn double_encoded_string_is_decoded() { + // ActivityWatch/activitywatch#1067: classes stored as a JSON string of JSON. + let inner = json!([ + {"name": ["Work"], "rule": {"type": "regex", "regex": "CustomApp"}} + ]); + let wrapped = serde_json::Value::String(inner.to_string()); + let classes = classes_from_settings_json(&wrapped); + assert_eq!(work_regex(&classes), Some("CustomApp")); + } + + #[test] + fn webui_shaped_payload_with_id_and_data_parses() { + let value = json!([ + { + "id": 0, + "name": ["Media", "Video"], + "rule": {"type": "regex", "regex": "YouTube|Plex", "ignore_case": false}, + "data": {"color": "#F33", "score": 0} + } + ]); + let classes = classes_from_settings_json(&value); + assert_eq!(classes.len(), 1); + assert_eq!(classes[0].0, vec!["Media".to_string(), "Video".to_string()]); + assert_eq!(classes[0].1.regex, "YouTube|Plex"); + } +} diff --git a/aw-server/src/android/mod.rs b/aw-server/src/android/mod.rs index b36d6c6c..58a50456 100644 --- a/aw-server/src/android/mod.rs +++ b/aw-server/src/android/mod.rs @@ -39,9 +39,7 @@ pub mod android { use crate::endpoints; use crate::endpoints::ServerState; - use aw_client_rust::blocking::AwClient; - use aw_client_rust::classes::default_classes; - use aw_client_rust::classes::{CategoryId, CategorySpec}; + use aw_client_rust::classes::{classes_from_settings_str, default_classes}; use aw_client_rust::queries::{ build_android_canonical_events, AndroidQueryParams, QueryParamsBase, }; @@ -297,6 +295,36 @@ pub mod android { } } + /// Return a raw settings JSON value (the datastore body, matching GET /api/0/settings/). + /// Missing or invalid keys return the JSON literal `null`. + /// + /// Widget/worker code must use this instead of unauthenticated HTTP: Android + /// enables API-key auth by default, so GET /api/0/settings/... from the + /// widget process 401s and silently falls back to defaults. + #[no_mangle] + pub unsafe extern "C" fn Java_net_activitywatch_android_RustInterface_getSetting( + env: JNIEnv, + _: JClass, + java_key: JString, + ) -> jstring { + let key = jstring_to_string(&env, java_key); + // Match GET /api/0/settings/: dots are valid (nested-looking + // keys like "foo.bar" store as settings.foo.bar). Reject empty keys + // and path/NUL bytes so JNI cannot smuggle a lookup the HTTP router + // would never pass through. + if key.is_empty() || key.contains('/') || key.contains('\\') || key.contains('\0') { + return string_to_jstring(&env, "null".to_string()); + } + let setting_key = match crate::endpoints::settings_datastore_key(&key) { + Ok(k) => k, + Err(_) => return string_to_jstring(&env, "null".to_string()), + }; + match openDatastore().get_key_value(&setting_key) { + Ok(value) => string_to_jstring(&env, value), + Err(_) => string_to_jstring(&env, "null".to_string()), + } + } + #[no_mangle] pub unsafe extern "C" fn Java_net_activitywatch_android_RustInterface_query( env: JNIEnv, @@ -346,50 +374,22 @@ pub mod android { // Hardcoded bucket ID let bid_android = "aw-watcher-android".to_string(); - // Get classes from server settings via HTTP API - let classes = match AwClient::new("127.0.0.1", 5600, "aw-android-query") { - Ok(client) => { - match client.get_setting("classes") { - Ok(classes_value) => { - // Parse the server-side classes from JSON value - match serde_json::from_value::>(classes_value) { - Ok(server_classes) => { - if server_classes.is_empty() { - info!("Server classes list is empty, using default classes"); - default_classes() - } else { - // Convert from aw_models::Class to CategorySpec format - server_classes - .iter() - .map(|c| { - let category_id: CategoryId = c.name.clone(); - let category_spec = CategorySpec { - spec_type: c.rule.rule_type.clone(), - regex: c.rule.regex.clone().unwrap_or_default(), - ignore_case: c.rule.ignore_case.unwrap_or(false), - }; - (category_id, category_spec) - }) - .collect() - } - } - Err(e) => { - warn!("Failed to parse server classes, using defaults: {:?}", e); - default_classes() - } - } - } - Err(e) => { - info!("Failed to get server classes, using defaults: {:?}", e); - default_classes() - } - } + // Read classes from the datastore directly. Do NOT fetch them over HTTP: + // Android enables API-key auth by default, and androidQuery runs from the + // widget process which does not send a Bearer token. The previous + // AwClient GET /api/0/settings/classes path 401'd (or failed if the + // HTTP server wasn't up) and silently fell back to default_classes(), + // which is why the homescreen widget disagreed with the Activity view + // on per-category time while totals still matched. + // See ActivityWatch/aw-android#142. + let datastore = openDatastore(); + let classes = match datastore.get_key_value("settings.classes") { + Ok(raw) => { + info!("Loaded classes from datastore settings.classes"); + classes_from_settings_str(&raw) } - Err(e) => { - warn!( - "Failed to create client for fetching classes, using defaults: {:?}", - e - ); + Err(_) => { + info!("settings.classes unset or unreadable, using default classes"); default_classes() } }; @@ -413,7 +413,6 @@ RETURN = {{"events": events, "duration": duration, "cat_events": cat_events}};"# build_android_canonical_events(¶ms) ); - let datastore = openDatastore(); let mut results = Vec::new(); for interval in &timeperiods { diff --git a/aw-server/src/endpoints/mod.rs b/aw-server/src/endpoints/mod.rs index eff321cf..8d098f7c 100644 --- a/aw-server/src/endpoints/mod.rs +++ b/aw-server/src/endpoints/mod.rs @@ -59,6 +59,8 @@ mod import; mod query; mod settings; +#[cfg(target_os = "android")] +pub(crate) use settings::settings_datastore_key; pub use util::HttpErrorJson; #[get("/")] diff --git a/aw-server/src/endpoints/settings.rs b/aw-server/src/endpoints/settings.rs index 5a2aeb5a..d678687b 100644 --- a/aw-server/src/endpoints/settings.rs +++ b/aw-server/src/endpoints/settings.rs @@ -8,15 +8,57 @@ use aw_datastore::DatastoreError; use crate::endpoints::HttpErrorJson; -fn parse_key(key: String) -> Result { - let namespace: String = "settings.".to_string(); +/// Map a settings API key to the datastore key (`settings.`). +/// +/// Dots are allowed: `GET /api/0/settings/foo.bar` stores and retrieves +/// `settings.foo.bar`. Keys of length >= 128 are rejected (same rule as the +/// HTTP handler). Android JNI `getSetting` uses this helper so native callers +/// cannot silently miss a value the HTTP API would return. +pub(crate) fn settings_datastore_key(key: &str) -> Result { if key.len() >= 128 { - Err(HttpErrorJson::new( - Status::BadRequest, - "Too long key".to_string(), - )) + Err("Too long key") } else { - Ok(namespace + key.as_str()) + Ok(format!("settings.{}", key)) + } +} + +fn parse_key(key: String) -> Result { + settings_datastore_key(&key) + .map_err(|msg| HttpErrorJson::new(Status::BadRequest, msg.to_string())) +} + +#[cfg(test)] +mod key_tests { + use super::settings_datastore_key; + + #[test] + fn simple_keys_are_namespaced() { + assert_eq!( + settings_datastore_key("classes").unwrap(), + "settings.classes" + ); + assert_eq!( + settings_datastore_key("startOfDay").unwrap(), + "settings.startOfDay" + ); + } + + #[test] + fn dotted_keys_are_namespaced_not_rejected() { + assert_eq!( + settings_datastore_key("foo.bar").unwrap(), + "settings.foo.bar" + ); + assert_eq!(settings_datastore_key("a.b.c").unwrap(), "settings.a.b.c"); + } + + #[test] + fn overly_long_keys_are_rejected() { + assert!(settings_datastore_key(&"a".repeat(128)).is_err()); + assert_eq!( + settings_datastore_key(&"a".repeat(127)).unwrap(), + format!("settings.{}", "a".repeat(127)) + ); } } diff --git a/aw-server/tests/api.rs b/aw-server/tests/api.rs index 59b641f2..e911ccdf 100644 --- a/aw-server/tests/api.rs +++ b/aw-server/tests/api.rs @@ -654,6 +654,29 @@ mod api_tests { assert_eq!(deserialized, value); } + #[test] + fn test_get_setting_dotted_key() { + // HTTP accepts dotted keys; JNI getSetting must use the same grammar + // (ActivityWatch/aw-server-rust#653 Greptile: don't reject '.'). + let server = setup_testserver(); + let client = Client::untracked(server).expect("valid instance"); + + let key = "nested.like.key"; + let value = json!("dotted_value"); + assert_eq!( + set_setting_request(&client, key, &value), + rocket::http::Status::Created + ); + + let res = client + .get(format!("/api/0/settings/{}", key)) + .header(Header::new("Host", "127.0.0.1:5600")) + .dispatch(); + assert_eq!(res.status(), rocket::http::Status::Ok); + let deserialized: Value = serde_json::from_str(&res.into_string().unwrap()).unwrap(); + assert_eq!(deserialized, value); + } + #[test] fn test_get_setting_list() { let server = setup_testserver();