From 270634dfa247692214e067577541b74e061fdc75 Mon Sep 17 00:00:00 2001 From: Bob Date: Mon, 31 Aug 2026 13:51:46 +0000 Subject: [PATCH 1/3] fix(aw-sync): forward API key on Android JNI client Android stores [auth].api_key in filesDir/config.toml. The JNI get_client() path still used AwClient::new() without that key, so GET /api/0/buckets returned 401 and sync wrote nothing (ActivityWatch/aw-android#247). --- aw-sync/src/android.rs | 20 ++++++++++++++++++-- aw-sync/src/dirs.rs | 3 ++- aw-sync/src/util.rs | 28 +++++++++++++++++++++++++--- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/aw-sync/src/android.rs b/aw-sync/src/android.rs index 651014e5..13bdaacb 100644 --- a/aw-sync/src/android.rs +++ b/aw-sync/src/android.rs @@ -110,10 +110,26 @@ fn rust_string_to_jstring(env: &JNIEnv, s: String) -> jstring { output.into_raw() } -/// Helper function to get AwClient from port +/// Helper function to get AwClient from port. +/// +/// Android enables API-key auth whenever `config.toml` has `[auth].api_key`. +/// The desktop CLI path (`main.rs`) already forwards that key; this JNI path +/// used `AwClient::new()` and 401'd on `GET /api/0/buckets` (aw-android#247). fn get_client(port: i32) -> Result { let host = "127.0.0.1"; - AwClient::new(host, port as u16, "aw-sync-android") + let api_key = match crate::util::get_server_config(false, None) { + Ok(cfg) => { + if cfg.api_key.is_some() { + info!("using API key from config.toml for local client"); + } + cfg.api_key + } + Err(e) => { + warn!("failed to read server config for API key: {}", e); + None + } + }; + AwClient::new_with_api_key(host, port as u16, "aw-sync-android", api_key) .map_err(|e| format!("Failed to create client: {}", e)) } diff --git a/aw-sync/src/dirs.rs b/aw-sync/src/dirs.rs index 1e072ae0..7c8d0df2 100644 --- a/aw-sync/src/dirs.rs +++ b/aw-sync/src/dirs.rs @@ -16,7 +16,8 @@ pub fn get_config_dir() -> Result> { Ok(dir) } -#[cfg(not(target_os = "android"))] +/// Path to the embedded/local aw-server config. On Android this is +/// `filesDir/config.toml` (same file ConfigManager and the server use). #[allow(dead_code)] pub fn get_server_config_path(testing: bool) -> Result { let dir = aw_server::dirs::get_config_dir()?; diff --git a/aw-sync/src/util.rs b/aw-sync/src/util.rs index bd3f9b80..e82d588a 100644 --- a/aw-sync/src/util.rs +++ b/aw-sync/src/util.rs @@ -6,13 +6,11 @@ use std::io::Read; use std::net::IpAddr; use std::path::{Path, PathBuf}; -#[cfg(not(target_os = "android"))] pub struct ServerConfig { pub port: u16, pub api_key: Option, } -#[cfg(not(target_os = "android"))] impl ServerConfig { pub fn default_for(testing: bool) -> Self { Self { @@ -23,7 +21,10 @@ impl ServerConfig { } /// Returns the settings aw-sync needs from the selected aw-server config. -#[cfg(not(target_os = "android"))] +/// +/// Also used on Android: the embedded server writes `config.toml` under +/// `filesDir`, and `get_client()` in `android.rs` must send the same +/// `[auth].api_key` or `/api/0/buckets` returns 401 (aw-android#247). pub fn get_server_config( testing: bool, config_override: Option<&Path>, @@ -120,6 +121,27 @@ mod tests { assert!(testing.api_key.is_none()); } + #[test] + fn commented_or_empty_api_key_is_absent() { + let config_path = std::env::temp_dir().join(format!( + "aw-sync-config-commented-{}-{}.toml", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::write( + &config_path, + "port = 5600\n[auth]\n#api_key = \"secret\"\napi_key = \"\"\n", + ) + .unwrap(); + + let config = get_server_config(false, Some(&config_path)).unwrap(); + fs::remove_file(config_path).unwrap(); + assert!(config.api_key.is_none()); + } + #[test] fn recognizes_only_loopback_hosts() { for host in ["127.0.0.1", "127.0.0.2", "::1", "localhost", "LOCALHOST"] { From f4b4145428fda80d8d6fe8d298396be8be7154da Mon Sep 17 00:00:00 2001 From: Bob Date: Mon, 31 Aug 2026 14:37:16 +0000 Subject: [PATCH 2/3] fix(aw-sync): point Android JNI client at the app filesDir libaw_sync.so keeps its own ANDROID_DATA_DIR static, so RustInterface.setDataDir on libaw_server.so never updates it. The hardcoded default only matches the release user-0 filesDir; debug (.debug suffix) and work-profile installs still 401. Derive filesDir from XDG_DATA_HOME ($filesDir/data, already set by SyncInterface.kt) and add SyncInterface.setDataDir JNI. --- aw-sync/src/android.rs | 47 ++++++++++++++++++++++++++++++++++++++++ aw-sync/src/dirs.rs | 49 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/aw-sync/src/android.rs b/aw-sync/src/android.rs index 13bdaacb..c6e6e0b8 100644 --- a/aw-sync/src/android.rs +++ b/aw-sync/src/android.rs @@ -1,6 +1,7 @@ use std::ffi::CString; use std::os::raw::{c_char, c_int, c_void}; use std::panic; +use std::path::Path; use std::sync::Once; use aw_client_rust::blocking::AwClient; @@ -110,12 +111,58 @@ fn rust_string_to_jstring(env: &JNIEnv, s: String) -> jstring { output.into_raw() } +/// Point this library's `ANDROID_DATA_DIR` at the app filesDir. +/// +/// `libaw_sync.so` and `libaw_server.so` are separate cdylibs, so +/// `RustInterface.setDataDir` does not update the copy compiled into this +/// `.so`. SyncInterface.kt already sets `XDG_DATA_HOME=$filesDir/data`, which +/// is the path that works for debug (`applicationIdSuffix ".debug"`) and +/// work-profile installs — the hardcoded default is only the release user-0 +/// path. +fn apply_android_data_dir_from_env() { + let Ok(xdg_data) = std::env::var("XDG_DATA_HOME") else { + return; + }; + let Some(files_dir) = crate::dirs::files_dir_from_xdg_data_home(Path::new(&xdg_data)) else { + warn!( + "XDG_DATA_HOME={} is not $filesDir/data; leaving android data dir unchanged", + xdg_data + ); + return; + }; + let path = files_dir.to_string_lossy(); + info!("android data dir from XDG_DATA_HOME: {}", path); + aw_server::dirs::set_android_data_dir(&path); +} + +/// Mirror of `RustInterface.setDataDir`. Prefer this explicit path; the XDG +/// fallback in `get_client` covers current Kotlin that does not call it. +#[no_mangle] +pub extern "C" fn Java_net_activitywatch_android_SyncInterface_setDataDir( + mut env: JNIEnv, + _class: JClass, + java_dir: JString, +) { + init_android_logging(); + match env.get_string(&java_dir) { + Ok(s) => { + let path: String = s.into(); + info!("Setting android data dir as {}", path); + aw_server::dirs::set_android_data_dir(&path); + } + Err(e) => { + error!("setDataDir: failed to read path: {}", e); + } + } +} + /// Helper function to get AwClient from port. /// /// Android enables API-key auth whenever `config.toml` has `[auth].api_key`. /// The desktop CLI path (`main.rs`) already forwards that key; this JNI path /// used `AwClient::new()` and 401'd on `GET /api/0/buckets` (aw-android#247). fn get_client(port: i32) -> Result { + apply_android_data_dir_from_env(); let host = "127.0.0.1"; let api_key = match crate::util::get_server_config(false, None) { Ok(cfg) => { diff --git a/aw-sync/src/dirs.rs b/aw-sync/src/dirs.rs index 7c8d0df2..2835ea75 100644 --- a/aw-sync/src/dirs.rs +++ b/aw-sync/src/dirs.rs @@ -1,6 +1,8 @@ use dirs::home_dir; use std::error::Error; use std::fs; +#[cfg(any(target_os = "android", test))] +use std::path::Path; use std::path::PathBuf; // TODO: This could be refactored to share logic with aw-server/src/dirs.rs @@ -36,3 +38,50 @@ pub fn get_sync_dir() -> Result> { let home_dir = home_dir().ok_or("Unable to read home_dir")?; Ok(home_dir.join("ActivityWatchSync")) } + +/// SyncInterface.kt sets `XDG_DATA_HOME=$filesDir/data` before `loadLibrary`. +/// `libaw_sync.so` is a separate cdylib from `libaw_server.so`, so +/// `RustInterface.setDataDir` does not update this library's `ANDROID_DATA_DIR`. +/// Parent of that env var is the app filesDir (release, `.debug`, work profile). +#[cfg(any(target_os = "android", test))] +pub(crate) fn files_dir_from_xdg_data_home(xdg_data_home: &Path) -> Option { + if xdg_data_home.file_name() != Some(std::ffi::OsStr::new("data")) { + return None; + } + let parent = xdg_data_home.parent()?; + if parent.as_os_str().is_empty() || parent == Path::new("/") { + return None; + } + Some(parent.to_path_buf()) +} + +#[cfg(test)] +mod tests { + use super::files_dir_from_xdg_data_home; + use std::path::Path; + + #[test] + fn xdg_data_home_parent_is_files_dir() { + let debug = Path::new("/data/user/0/net.activitywatch.android.debug/files/data"); + assert_eq!( + files_dir_from_xdg_data_home(debug).as_deref(), + Some(Path::new( + "/data/user/0/net.activitywatch.android.debug/files" + )) + ); + + let work_profile = Path::new("/data/user/10/net.activitywatch.android/files/data"); + assert_eq!( + files_dir_from_xdg_data_home(work_profile).as_deref(), + Some(Path::new("/data/user/10/net.activitywatch.android/files")) + ); + } + + #[test] + fn rejects_non_data_leaf_and_root() { + assert!(files_dir_from_xdg_data_home(Path::new("/data")).is_none()); + assert!(files_dir_from_xdg_data_home(Path::new("/")).is_none()); + assert!(files_dir_from_xdg_data_home(Path::new("data")).is_none()); + assert!(files_dir_from_xdg_data_home(Path::new("/tmp/config")).is_none()); + } +} From 0500a9a7118b06271cbe32932a90a34a3b673f6a Mon Sep 17 00:00:00 2001 From: Bob Date: Mon, 31 Aug 2026 14:51:08 +0000 Subject: [PATCH 3/3] test(aw-sync): unique tmp_db paths for parallel roundtrip tests Windows CI failed on test_push_does_not_reexport_synced_buckets with SQLITE_BUSY / database is locked. Two parallel tests in this binary both call round_trip() and can land on the same PID+name+nanosecond path when the clock tick is coarser than 1 ns (common on Windows). Same uniqueness shape as ActivityWatch/aw-server-rust#655: per-process atomic counter for within-run uniqueness, timestamp for PID-reuse across runs. Test-only, no production change. --- aw-sync/tests/sync_roundtrip.rs | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/aw-sync/tests/sync_roundtrip.rs b/aw-sync/tests/sync_roundtrip.rs index ee4733f2..b48a08f0 100644 --- a/aw-sync/tests/sync_roundtrip.rs +++ b/aw-sync/tests/sync_roundtrip.rs @@ -10,21 +10,33 @@ /// /// Both copies then render in /timeline, so every event is shown twice. use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; use aw_datastore::Datastore; use aw_models::{Bucket, BucketMetadata}; use aw_sync::{sync_datastores, AccessMethod, SyncSpec}; +// Tests in this binary run in parallel. Use a monotonic counter to guarantee +// each datastore gets a unique path even when two tests start within the same +// clock tick (Windows/macOS SystemTime resolution can be coarser than 1 ns, +// causing path collisions: "duplicate column name" on macOS, "database is +// locked" on Windows). +static DB_COUNTER: AtomicU64 = AtomicU64::new(0); + fn tmp_db(name: &str) -> PathBuf { let mut p = std::env::temp_dir(); + // Counter: within-run uniqueness. Timestamp: cross-run uniqueness when + // the PID is reused and the counter resets to 0. Same as #655. + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); p.push(format!( - "aw-sync-roundtrip-{}-{}-{}.db", + "aw-sync-roundtrip-{}-{}-{}-{}.db", std::process::id(), name, - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() + DB_COUNTER.fetch_add(1, Ordering::Relaxed), + ts, )); p }