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
140 changes: 140 additions & 0 deletions aw-client-rust/src/classes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,53 @@ pub struct ClassSetting {
pub data: Option<serde_json::Value>,
}

/// 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::<Vec<ClassSetting>>(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::<serde_json::Value>(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![
Expand Down Expand Up @@ -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");
}
}
93 changes: 46 additions & 47 deletions aw-server/src/android/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -297,6 +295,36 @@ pub mod android {
}
}

/// Return a raw settings JSON value (the datastore body, matching GET /api/0/settings/<key>).
/// 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/<key>: 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,
Expand Down Expand Up @@ -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::<Vec<aw_models::Class>>(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()
}
};
Expand All @@ -413,7 +413,6 @@ RETURN = {{"events": events, "duration": duration, "cat_events": cat_events}};"#
build_android_canonical_events(&params)
);

let datastore = openDatastore();
let mut results = Vec::new();

for interval in &timeperiods {
Expand Down
2 changes: 2 additions & 0 deletions aw-server/src/endpoints/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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("/")]
Expand Down
56 changes: 49 additions & 7 deletions aw-server/src/endpoints/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,57 @@ use aw_datastore::DatastoreError;

use crate::endpoints::HttpErrorJson;

fn parse_key(key: String) -> Result<String, HttpErrorJson> {
let namespace: String = "settings.".to_string();
/// Map a settings API key to the datastore key (`settings.<key>`).
///
/// 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<String, &'static str> {
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<String, HttpErrorJson> {
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))
);
}
}

Expand Down
23 changes: 23 additions & 0 deletions aw-server/tests/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading