diff --git a/aw-sync/src/android.rs b/aw-sync/src/android.rs index 651014e5..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,10 +111,72 @@ fn rust_string_to_jstring(env: &JNIEnv, s: String) -> jstring { output.into_raw() } -/// Helper function to get AwClient from port +/// 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"; - 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..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 @@ -16,7 +18,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()?; @@ -35,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()); + } +} 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"] { 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 }