use crate::context::AppContext;
use crate::model::{CalendarListEntry, IcsAdapter, Task};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
#[cfg(not(target_os = "android"))]
use fs2::FileExt;
#[cfg(target_os = "android")]
use std::sync::Arc;
pub const LOCAL_CALENDAR_HREF: &str = "local://default";
pub const LOCAL_CALENDAR_NAME: &str = "Local";
pub const LOCAL_TRASH_HREF: &str = "local://trash";
pub const LOCAL_REGISTRY_FILENAME: &str = "local_calendars.json";
const LOCAL_STORAGE_VERSION: u32 = 8;
#[derive(Serialize, Deserialize)]
struct LocalStorageData {
#[serde(default)]
version: u32,
tasks: Vec<Task>,
}
#[cfg(target_os = "android")]
static ANDROID_FILE_LOCKS: OnceLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> = OnceLock::new();
static LOAD_STATE_MAP: OnceLock<Mutex<HashMap<String, LoadState>>> = OnceLock::new();
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LoadState {
Uninitialized,
Success,
Failed,
}
impl LoadState {
fn get(href: &str) -> LoadState {
let map = LOAD_STATE_MAP.get_or_init(|| Mutex::new(HashMap::new()));
*map.lock()
.unwrap()
.get(href)
.unwrap_or(&LoadState::Uninitialized)
}
fn set(href: &str, state: LoadState) {
let map = LOAD_STATE_MAP.get_or_init(|| Mutex::new(HashMap::new()));
map.lock().unwrap().insert(href.to_string(), state);
}
}
pub struct LocalCalendarRegistry;
impl LocalCalendarRegistry {
fn get_path(ctx: &dyn AppContext) -> Option<PathBuf> {
ctx.get_data_dir()
.ok()
.map(|p| p.join(LOCAL_REGISTRY_FILENAME))
}
pub fn load(ctx: &dyn AppContext) -> Result<Vec<CalendarListEntry>> {
let mut cals = vec![];
let default_cal = CalendarListEntry {
name: LOCAL_CALENDAR_NAME.to_string(),
href: LOCAL_CALENDAR_HREF.to_string(),
color: None,
};
if let Some(path) = Self::get_path(ctx)
&& path.exists()
&& let Ok(content) = LocalStorage::with_lock(&path, || Ok(fs::read_to_string(&path)?))
&& let Ok(registry) = serde_json::from_str::<Vec<CalendarListEntry>>(&content)
{
cals = registry;
}
if !cals.iter().any(|c| c.href == LOCAL_CALENDAR_HREF) {
cals.insert(0, default_cal);
}
Ok(cals)
}
pub fn save(ctx: &dyn AppContext, calendars: &[CalendarListEntry]) -> Result<()> {
if let Some(path) = Self::get_path(ctx) {
LocalStorage::with_lock(&path, || {
let json = serde_json::to_string_pretty(calendars)?;
LocalStorage::atomic_write(&path, json)?;
Ok(())
})?;
}
Ok(())
}
pub fn ensure_trash_calendar_exists(ctx: &dyn AppContext) -> Result<bool> {
let mut locals = Self::load(ctx)?;
if !locals.iter().any(|c| c.href == LOCAL_TRASH_HREF) {
locals.push(CalendarListEntry {
name: "Trash".to_string(),
href: LOCAL_TRASH_HREF.to_string(),
color: Some("#808080".to_string()),
});
Self::save(ctx, &locals)?;
return Ok(true);
}
Ok(false)
}
}
pub struct LocalStorage;
impl LocalStorage {
pub fn get_path_for_href(ctx: &dyn AppContext, href: &str) -> Option<PathBuf> {
if href == LOCAL_CALENDAR_HREF {
return ctx.get_local_task_path();
} else if href.starts_with("local://") {
let id = href.trim_start_matches("local://");
let safe_id: String = id
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-')
.collect();
return ctx
.get_data_dir()
.ok()
.map(|p| p.join(format!("local_{}.json", safe_id)));
}
None
}
pub fn import_from_ics(
ctx: &dyn AppContext,
calendar_href: &str,
ics_content: &str,
) -> Result<usize> {
let mut imported_tasks = Vec::new();
let normalized_content = ics_content.replace("\r\n", "\n").replace('\n', "\r\n");
let parts: Vec<&str> = normalized_content.split("BEGIN:VTODO").collect();
for component in parts.iter().skip(1) {
if !component.contains("END:VTODO") {
continue;
}
let vtodo_end = match component.find("END:VTODO") {
Some(pos) => pos + "END:VTODO".len(),
None => continue,
};
let vtodo_content = &component[..vtodo_end];
let vtodo = format!("BEGIN:VTODO{}", vtodo_content);
let full_ics = format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//cfait//cfait//EN\r\n{}\r\nEND:VCALENDAR",
vtodo
);
if let Ok(task) = IcsAdapter::from_ics(
&full_ics,
String::new(),
format!("{}.ics", uuid::Uuid::new_v4()),
calendar_href.to_string(),
) {
imported_tasks.push(task);
}
}
if imported_tasks.is_empty() {
anyhow::bail!("No valid tasks found in ICS file");
}
let count = imported_tasks.len();
Self::modify_for_href(ctx, calendar_href, |existing_tasks| {
for imported in imported_tasks {
if let Some(idx) = existing_tasks.iter().position(|t| t.uid == imported.uid) {
existing_tasks[idx] = imported;
} else {
existing_tasks.push(imported);
}
}
})?;
Ok(count)
}
pub fn to_ics_string(tasks: &[Task]) -> String {
let mut output =
String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Cfait//Export//EN\r\n");
for task in tasks {
let full_ics = IcsAdapter::to_ics(task);
if let Some(start) = full_ics.find("BEGIN:VTODO")
&& let Some(end_idx) = full_ics.rfind("END:VTODO")
{
let vtodo = &full_ics[start..end_idx + 9];
output.push_str(vtodo);
output.push_str("\r\n");
}
}
output.push_str("END:VCALENDAR");
output
}
#[cfg(not(target_os = "android"))]
fn get_lock_path(file_path: &Path) -> PathBuf {
let mut lock_path = file_path.to_path_buf();
if let Some(ext) = lock_path.extension() {
let mut new_ext = ext.to_os_string();
new_ext.push(".lock");
lock_path.set_extension(new_ext);
} else {
lock_path.set_extension("lock");
}
lock_path
}
#[cfg(not(target_os = "android"))]
pub fn with_lock<F, T>(file_path: &Path, f: F) -> Result<T>
where
F: FnOnce() -> Result<T>,
{
let lock_path = Self::get_lock_path(file_path);
let file = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)?;
file.lock_exclusive()?;
let result = f();
file.unlock()?;
result
}
#[cfg(target_os = "android")]
pub fn with_lock<F, T>(file_path: &Path, f: F) -> Result<T>
where
F: FnOnce() -> Result<T>,
{
let map_mutex = ANDROID_FILE_LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
let key = file_path.canonicalize().unwrap_or(file_path.to_path_buf());
let file_mutex = {
let mut map = map_mutex.lock().unwrap();
map.entry(key)
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
};
let _guard = file_mutex.lock().unwrap();
f()
}
pub fn atomic_write<P: AsRef<Path>, C: AsRef<[u8]>>(path: P, contents: C) -> Result<()> {
let path = path.as_ref();
let tmp_path = path.with_extension("tmp");
let mut file = fs::File::create(&tmp_path)?;
use std::io::Write;
file.write_all(contents.as_ref())?;
file.sync_all()?;
fs::rename(tmp_path, path)?;
Ok(())
}
fn load_internal(path: &Path, _href: &str) -> Result<(Vec<Task>, bool)> {
let json = fs::read_to_string(path)?;
let (mut tasks, mut needs_save) =
if let Ok(data) = serde_json::from_str::<LocalStorageData>(&json) {
if data.version == LOCAL_STORAGE_VERSION {
(data.tasks, false)
} else {
(Self::migrate_to_current(data.version, &json)?, true)
}
} else {
(Self::migrate_v1_to_v2(&json)?, true)
};
for t in tasks.iter_mut() {
if t.uid.trim().is_empty() {
t.uid = uuid::Uuid::new_v4().to_string();
needs_save = true;
}
}
let len_before = tasks.len();
let mut uid_to_index = HashMap::new();
for (i, t) in tasks.iter().enumerate() {
uid_to_index.insert(t.uid.clone(), i);
}
if uid_to_index.len() < len_before {
let mut indices: Vec<usize> = uid_to_index.into_values().collect();
indices.sort_unstable();
let mut deduped = Vec::with_capacity(indices.len());
for i in indices {
deduped.push(tasks[i].clone());
}
tasks = deduped;
needs_save = true;
}
Ok((tasks, needs_save))
}
fn save_internal(path: &Path, tasks: &[Task]) -> Result<()> {
let data = LocalStorageData {
version: LOCAL_STORAGE_VERSION,
tasks: tasks.to_vec(),
};
let json = serde_json::to_string_pretty(&data)?;
Self::atomic_write(path, json)
}
fn load_from_path(path: &Path, href: &str) -> Result<Vec<Task>> {
if !path.exists() {
LoadState::set(href, LoadState::Success);
return Ok(vec![]);
}
let result = Self::with_lock(path, || {
let (tasks, needs_save) = Self::load_internal(path, href)?;
if needs_save {
Self::save_internal(path, &tasks)?;
}
Ok(tasks)
});
match &result {
Ok(_) => LoadState::set(href, LoadState::Success),
Err(_) => LoadState::set(href, LoadState::Failed),
}
result
}
fn save_to_path(path: &Path, href: &str, tasks: &[Task]) -> Result<()> {
if !Self::can_save_href(href) {
return Err(anyhow::anyhow!(
"Cannot save {}: previous load failed.",
href
));
}
Self::with_lock(path, || Self::save_internal(path, tasks))
}
pub fn modify_for_href<F>(ctx: &dyn AppContext, href: &str, f: F) -> Result<()>
where
F: FnOnce(&mut Vec<Task>),
{
if !Self::can_save_href(href) {
return Err(anyhow::anyhow!(
"Cannot modify {}: previous load failed.",
href
));
}
if let Some(path) = Self::get_path_for_href(ctx, href) {
Self::with_lock(&path, || {
let mut tasks = if path.exists() {
let (t, _) = Self::load_internal(&path, href)?;
t
} else {
vec![]
};
f(&mut tasks);
Self::save_internal(&path, &tasks)?;
Ok(())
})
} else {
Err(anyhow::anyhow!("Invalid local href: {}", href))
}
}
pub fn load_for_href(ctx: &dyn AppContext, href: &str) -> Result<Vec<Task>> {
if let Some(path) = Self::get_path_for_href(ctx, href) {
Self::load_from_path(&path, href)
} else {
Ok(vec![])
}
}
pub fn save_for_href(ctx: &dyn AppContext, href: &str, tasks: &[Task]) -> Result<()> {
if let Some(path) = Self::get_path_for_href(ctx, href) {
Self::save_to_path(&path, href, tasks)
} else {
Err(anyhow::anyhow!("Invalid local href: {}", href))
}
}
pub fn can_save_href(href: &str) -> bool {
match LoadState::get(href) {
LoadState::Uninitialized | LoadState::Success => true,
LoadState::Failed => false,
}
}
fn migrate_v1_to_v2(json: &str) -> Result<Vec<Task>> {
serde_json::from_str::<Vec<Task>>(json)
.map_err(|e| anyhow::anyhow!("Failed to migrate v1 to v2: {}", e))
}
fn migrate_to_current(old_version: u32, json: &str) -> Result<Vec<Task>> {
if old_version > LOCAL_STORAGE_VERSION {
return Err(anyhow::anyhow!("Local storage version too new"));
}
let tasks = match old_version {
0 | 1 => Self::migrate_v1_to_v2(json)?,
2..=7 => {
let data: LocalStorageData = serde_json::from_str(json)?;
data.tasks
}
_ => return Err(anyhow::anyhow!("Unknown version {}", old_version)),
};
Ok(tasks)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_all_previous_versions_are_handled() {
for v in 0..LOCAL_STORAGE_VERSION {
let res = LocalStorage::migrate_to_current(v, "invalid_json");
if let Err(e) = res {
let err_msg = e.to_string();
assert!(
!err_msg.contains("Unknown version"),
"Version {} is not handled by migrate_to_current! Please update the match statement.",
v
);
} else {
panic!(
"Expected a parse error for version {}, but migration somehow succeeded.",
v
);
}
}
}
}
#[cfg(not(target_os = "android"))]
pub struct DaemonLock {
_file: std::fs::File,
}
#[cfg(not(target_os = "android"))]
pub struct PresenceLock {
_file: std::fs::File,
}
#[cfg(not(target_os = "android"))]
impl PresenceLock {
pub fn acquire_shared(ctx: &dyn AppContext) -> Result<Self> {
let path = ctx.get_data_dir()?.join("presence.lock");
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)?;
file.lock_shared()?;
Ok(Self { _file: file })
}
pub fn is_present(ctx: &dyn AppContext) -> bool {
let path = match ctx.get_data_dir() {
Ok(p) => p.join("presence.lock"),
Err(_) => return false,
};
if let Ok(file) = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)
{
match file.try_lock_exclusive() {
Ok(_) => {
let _ = file.unlock();
false
}
Err(e)
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::PermissionDenied =>
{
true
}
Err(_) => false,
}
} else {
false
}
}
}
#[cfg(not(target_os = "android"))]
impl DaemonLock {
pub fn acquire_shared(ctx: &dyn AppContext) -> Result<Self> {
let path = ctx.get_data_dir()?.join("daemon.lock");
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)?;
file.lock_shared()?;
Ok(Self { _file: file })
}
pub fn try_acquire_exclusive(ctx: &dyn AppContext) -> Result<Option<Self>> {
let path = ctx.get_data_dir()?.join("daemon.lock");
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)?;
match file.try_lock_exclusive() {
Ok(_) => Ok(Some(Self { _file: file })),
Err(e)
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::PermissionDenied =>
{
Ok(None)
}
Err(e) => Err(anyhow::anyhow!("Failed to acquire daemon lock: {}", e)),
}
}
}
#[cfg(test)]
#[cfg(not(target_os = "android"))]
mod lock_tests {
use super::*;
use crate::context::TestContext;
#[test]
fn test_daemon_locks_shared_vs_exclusive() {
let ctx = TestContext::new();
let shared1 = DaemonLock::acquire_shared(&ctx).unwrap();
let shared2 = DaemonLock::acquire_shared(&ctx).unwrap();
let excl1 = DaemonLock::try_acquire_exclusive(&ctx).unwrap();
assert!(
excl1.is_none(),
"Exclusive lock should fail when shared locks exist"
);
drop(shared1);
drop(shared2);
let excl2 = DaemonLock::try_acquire_exclusive(&ctx).unwrap();
assert!(
excl2.is_some(),
"Exclusive lock should succeed when no shared locks exist"
);
}
}