1use crate::{LastPass, LocalFailure, PendingReason, PendingRecord, PendingSubject, StatusFailure};
12use coven_database::{DbError, RecordedLocation, RetrySync};
13use coven_storage::{CloudProvider, StorageFailure};
14use std::time::SystemTime;
15
16#[derive(Clone, Debug, PartialEq)]
20pub struct SyncStatus {
21 pub state: SyncState,
23 pub last_synced: Option<SystemTime>,
26}
27
28#[derive(Clone, Debug, PartialEq)]
34pub enum SyncState {
35 NotSynced,
37 Syncing,
40 WaitingToSync(SyncWait),
45 Synced,
49 Offline(SyncCause),
51 OfflineWithUnsentChanges(SyncCause),
53 SignedOut {
56 provider: CloudProvider,
58 cause: Option<SyncCause>,
60 },
61 NoAccess(SyncCause),
63 StorageFull(SyncCause),
65 FolderMissing(SyncCause),
67 ClockWrong(SyncCause),
69 UpdateNeeded(SyncCause),
71 Removed(SyncCause),
74 NeedsReset(SyncCause),
76 Failed(SyncCause),
79}
80
81#[derive(Clone, Copy, Debug, PartialEq, Eq)]
85pub enum SyncWait {
86 NotSyncing,
88 CoolingDown {
90 until: SystemTime,
92 },
93 Blocked,
96 Paused,
99 Metered,
104 Retrying {
107 at: SystemTime,
109 },
110 Queued,
113}
114
115#[derive(Clone, Debug, PartialEq)]
118pub enum SyncCause {
119 Pass(LastPass),
121 Pending(PendingRecord),
123}
124
125#[derive(Clone, Copy, Debug, PartialEq, Eq)]
127enum Named {
128 SignedOut,
129 NoAccess,
130 StorageFull,
131 FolderMissing,
132 ClockWrong,
133 UpdateNeeded,
134 Removed,
135 NeedsReset,
136 Failed,
137}
138
139impl Named {
140 fn rank(self) -> u8 {
142 match self {
143 Self::Failed => 3,
144 Self::StorageFull => 6,
145 Self::ClockWrong => 7,
146 Self::FolderMissing => 8,
147 Self::NoAccess => 9,
148 Self::SignedOut => 10,
149 Self::UpdateNeeded => 11,
150 Self::NeedsReset => 12,
151 Self::Removed => 13,
152 }
153 }
154
155 fn state(self, cause: SyncCause, provider: CloudProvider) -> SyncState {
157 match self {
158 Self::SignedOut => SyncState::SignedOut { provider, cause: Some(cause) },
159 Self::NoAccess => SyncState::NoAccess(cause),
160 Self::StorageFull => SyncState::StorageFull(cause),
161 Self::FolderMissing => SyncState::FolderMissing(cause),
162 Self::ClockWrong => SyncState::ClockWrong(cause),
163 Self::UpdateNeeded => SyncState::UpdateNeeded(cause),
164 Self::Removed => SyncState::Removed(cause),
165 Self::NeedsReset => SyncState::NeedsReset(cause),
166 Self::Failed => SyncState::Failed(cause),
167 }
168 }
169}
170
171impl SyncState {
172 fn rank(&self) -> u8 {
177 match self {
178 Self::Synced => 0,
179 Self::WaitingToSync(_) => 1,
180 Self::Syncing => 2,
181 Self::Failed(_) => Named::Failed.rank(),
182 Self::Offline(_) => 4,
183 Self::OfflineWithUnsentChanges(_) => 5,
184 Self::StorageFull(_) => Named::StorageFull.rank(),
185 Self::ClockWrong(_) => Named::ClockWrong.rank(),
186 Self::FolderMissing(_) => Named::FolderMissing.rank(),
187 Self::NoAccess(_) => Named::NoAccess.rank(),
188 Self::SignedOut { .. } => Named::SignedOut.rank(),
189 Self::UpdateNeeded(_) => Named::UpdateNeeded.rank(),
190 Self::NeedsReset(_) => Named::NeedsReset.rank(),
191 Self::Removed(_) => Named::Removed.rank(),
192 Self::NotSynced => 14,
193 }
194 }
195}
196
197#[deny(clippy::wildcard_enum_match_arm)]
201fn storage_names(failure: StorageFailure) -> Option<Named> {
202 use StorageFailure::*;
203 match failure {
204 Authentication => Some(Named::SignedOut),
205 PermissionDenied | AccessNotGranted { .. } => Some(Named::NoAccess),
206 QuotaExceeded => Some(Named::StorageFull),
207 ContainerNotFound => Some(Named::FolderMissing),
208 ClockSkew => Some(Named::ClockWrong),
209 Bug => Some(Named::Failed),
211 ProviderUnavailable
212 | AccessNotRegistered { .. }
213 | DropboxAppFolderNotShareable
214 | CatchUpRequired
215 | Network
216 | Corrupted
217 | NotFound
218 | AlreadyExists
219 | Conflict
220 | RegionMismatch
221 | StoreExists
222 | OwnLocationRequired
223 | RateLimited
224 | InvalidConfiguration
225 | Refused
226 | Protocol
227 | InvalidPath
228 | InvalidRange
229 | NotStoreOwner
230 | InviteeIsStoreOwner
231 | SharingRefused(_)
232 | AccountIdUnavailable
233 | InvitationMismatch
234 | SessionMismatch
235 | SessionExpired
236 | SessionIdentityUnknown
237 | InvalidPart
238 | SingleRequestTooLarge { .. }
239 | CopyTooLarge { .. }
240 | Encoding
241 | File
242 | MemberKeysMissing => None,
243 }
244}
245
246impl PendingReason {
247 #[deny(clippy::wildcard_enum_match_arm)]
258 fn names(&self) -> Option<Named> {
259 match self {
260 Self::Storage { failure, .. } => storage_names(*failure).or_else(|| {
261 (crate::pending::storage_clears(*failure) != crate::pending::Clears::ByItself)
262 .then_some(Named::Failed)
263 }),
264 Self::UpdateRequired(_) => Some(Named::UpdateNeeded),
265 Self::Removed | Self::NotAdmitted => Some(Named::Removed),
266 Self::MemberKeysReplaced { .. } => Some(Named::NeedsReset),
267 Self::Refused(_) | Self::FileUnavailable(_) | Self::Original { .. } => {
268 Some(Named::Failed)
269 }
270 Self::Failed(LocalFailure::Shutdown) => None,
271 Self::Failed(
272 LocalFailure::Database
273 | LocalFailure::DiskFull
274 | LocalFailure::DiskDenied
275 | LocalFailure::DiskOther
276 | LocalFailure::KeyCustody
277 | LocalFailure::Crypto
278 | LocalFailure::Operation
279 | LocalFailure::Bug,
280 ) => Some(Named::Failed),
281 Self::Cleanup { cleanup, .. } => cleanup.names(),
282 Self::Missing { .. }
283 | Self::Waits(_)
284 | Self::KeyUnavailable { .. }
285 | Self::NoStorage
286 | Self::WritesRefused { .. }
287 | Self::ProviderPending { .. }
288 | Self::AccountInUse { .. }
289 | Self::AccessRemains { .. }
290 | Self::Rejected(_)
291 | Self::Paused => None,
292 }
293 }
294}
295
296impl StatusFailure {
297 #[deny(clippy::wildcard_enum_match_arm)]
300 fn names(&self) -> Option<Named> {
301 match self {
302 Self::UpdateRequired(_) => Some(Named::UpdateNeeded),
303 Self::Removed | Self::NotAdmitted => Some(Named::Removed),
304 Self::DeviceIdInUse(_) => Some(Named::NeedsReset),
305 Self::Storage(failure) => storage_names(*failure)
306 .or_else(|| (*failure != StorageFailure::RateLimited).then_some(Named::Failed)),
307 Self::Operation(record) => Some(record.reason.names().unwrap_or(Named::Failed)),
308 Self::Other => Some(Named::Failed),
309 }
310 }
311}
312
313impl LastPass {
314 fn ended(&self, unsent: bool, held: Option<SyncWait>, provider: CloudProvider) -> SyncState {
320 let cause = || SyncCause::Pass(self.clone());
321 let waiting = || SyncState::WaitingToSync(held.unwrap_or(SyncWait::Queued));
322 match self {
323 Self::Synced => SyncState::Synced,
324 Self::Offline { .. } if unsent => SyncState::OfflineWithUnsentChanges(cause()),
325 Self::Offline { .. } => SyncState::Offline(cause()),
326 Self::Blocked { reason, .. } => {
327 reason.names().map_or_else(waiting, |named| named.state(cause(), provider))
328 }
329 Self::Failed { failure, .. } => {
330 failure.names().map_or_else(waiting, |named| named.state(cause(), provider))
331 }
332 }
333 }
334}
335
336#[derive(Clone, Copy, Debug, PartialEq, Eq)]
338pub(crate) enum Progress {
339 Moving,
341 Stored,
343 Queued,
345}
346
347#[derive(Clone, Debug, PartialEq)]
349pub(crate) struct Upload {
350 pub(crate) progress: Progress,
351 pub(crate) bound: bool,
353 pub(crate) retry_at: Option<SystemTime>,
355 pub(crate) record: Option<PendingRecord>,
357}
358
359#[derive(Clone, Debug, PartialEq)]
362pub(crate) struct Facts {
363 pub(crate) location: RecordedLocation<CloudProvider>,
364 pub(crate) last: Option<LastPass>,
368 pub(crate) moving: bool,
370 pub(crate) downloading: bool,
372 pub(crate) uploads: Vec<Upload>,
378 pub(crate) queued: bool,
380 pub(crate) unsent: bool,
382 pub(crate) records: Vec<PendingRecord>,
385 pub(crate) sync: RetrySync,
388 pub(crate) paused: bool,
390 pub(crate) unmetered_only: bool,
392 pub(crate) metered: bool,
394}
395
396enum Candidate<'a> {
398 State(SyncState),
399 Named(Named, &'a PendingRecord),
400}
401
402impl Candidate<'_> {
403 fn rank(&self) -> u8 {
404 match self {
405 Self::State(state) => state.rank(),
406 Self::Named(named, _) => named.rank(),
407 }
408 }
409
410 fn state(self, provider: CloudProvider) -> SyncState {
411 match self {
412 Self::State(state) => state,
413 Self::Named(named, record) => named.state(SyncCause::Pending(record.clone()), provider),
414 }
415 }
416}
417
418impl Facts {
419 fn unsent_work(&self) -> bool {
421 self.unsent || self.queued
422 }
423
424 fn held_all(&self) -> Option<SyncWait> {
428 match self.sync {
429 RetrySync::Stopped => Some(SyncWait::NotSyncing),
430 RetrySync::Started { cooling_until: Some(until) } => {
431 Some(SyncWait::CoolingDown { until })
432 }
433 RetrySync::Started { cooling_until: None } => {
434 matches!(self.last, Some(LastPass::Blocked { .. })).then_some(SyncWait::Blocked)
435 }
436 }
437 }
438
439 fn upload_wait(&self, upload: &Upload) -> SyncWait {
442 if let Some(held) = self.held_all() {
443 held
444 } else if self.paused && !upload.bound {
445 SyncWait::Paused
446 } else if self.unmetered_only && self.metered && crate::files::held_on_metered(upload.bound)
447 {
448 SyncWait::Metered
449 } else if let Some(at) = upload.retry_at {
450 SyncWait::Retrying { at }
451 } else {
452 SyncWait::Queued
453 }
454 }
455
456 fn upload<'a>(&self, upload: &'a Upload) -> Option<Candidate<'a>> {
459 match upload.progress {
460 Progress::Moving => Some(Candidate::State(SyncState::Syncing)),
461 Progress::Stored => None,
462 Progress::Queued => Some(
463 match upload
464 .record
465 .as_ref()
466 .and_then(|record| Some((record.reason.names()?, record)))
467 {
468 Some((named, record)) => Candidate::Named(named, record),
469 None => Candidate::State(SyncState::WaitingToSync(self.upload_wait(upload))),
470 },
471 ),
472 }
473 }
474
475 pub(crate) fn state(&self) -> SyncState {
483 let (provider, credentials) = match self.location {
484 RecordedLocation::None => return SyncState::NotSynced,
485 RecordedLocation::SignedOut(provider) => (provider, false),
486 RecordedLocation::Connected(provider) => (provider, true),
487 };
488 let signed_out = (!credentials)
489 .then_some(Candidate::State(SyncState::SignedOut { provider, cause: None }));
490 let looping = Candidate::State(if self.moving {
491 SyncState::Syncing
492 } else {
493 match &self.last {
494 None => SyncState::Synced,
495 Some(last) => last.ended(self.unsent_work(), self.held_all(), provider),
496 }
497 });
498 let records = self
499 .records
500 .iter()
501 .filter_map(|record| Some(Candidate::Named(record.reason.names()?, record)));
502 let downloading = self.downloading.then_some(Candidate::State(SyncState::Syncing));
503 let uploads = self.uploads.iter().filter_map(|upload| self.upload(upload));
504 let writes = self.unsent.then(|| {
507 Candidate::State(SyncState::WaitingToSync(self.held_all().unwrap_or(SyncWait::Queued)))
508 });
509 signed_out
510 .into_iter()
511 .chain([looping])
512 .chain(records)
513 .chain(downloading)
514 .chain(uploads)
515 .chain(writes)
516 .reduce(|shown, next| if next.rank() > shown.rank() { next } else { shown })
517 .expect("the loop's candidate is always one")
518 .state(provider)
519 }
520
521 fn read(
523 facts: coven_database::SyncFacts<PendingRecord>,
524 ) -> Result<(Self, Option<SystemTime>), DbError> {
525 let row = facts.row;
526 let location = match row.location {
527 RecordedLocation::None => RecordedLocation::None,
528 RecordedLocation::SignedOut(name) => {
529 RecordedLocation::SignedOut(named_provider(&name)?)
530 }
531 RecordedLocation::Connected(name) => {
532 RecordedLocation::Connected(named_provider(&name)?)
533 }
534 };
535 let last = row
536 .last_pass
537 .map(|bytes| serde_json::from_slice(&bytes).map_err(DbError::StateEncoding))
538 .transpose()?;
539 let uploads = facts
540 .uploads
541 .into_iter()
542 .map(|upload| Upload {
543 progress: if upload.sending {
544 Progress::Moving
545 } else if upload.stored {
546 Progress::Stored
547 } else {
548 Progress::Queued
549 },
550 bound: upload.bound,
551 retry_at: upload.retry_at,
552 record: upload.record,
553 })
554 .collect();
555 let facts = Self {
556 location,
557 last,
558 moving: row.moving,
559 downloading: facts.downloading,
560 uploads,
561 queued: facts.queued,
562 unsent: facts.unsent,
563 records: facts
564 .records
565 .into_iter()
566 .filter(|record| record.subject != PendingSubject::Connection)
567 .collect(),
568 sync: facts.retry.sync,
569 paused: facts.paused,
570 unmetered_only: facts.unmetered_only,
571 metered: facts.metered,
572 };
573 Ok((facts, row.last_synced))
574 }
575}
576
577impl SyncStatus {
578 pub fn computed(facts: coven_database::SyncFacts<PendingRecord>) -> Result<Self, DbError> {
582 let (facts, last_synced) = Facts::read(facts)?;
583 Ok(Self { state: facts.state(), last_synced })
584 }
585
586 pub fn closed(
593 facts: coven_database::SyncFacts<PendingRecord>,
594 location: RecordedLocation<CloudProvider>,
595 ) -> Result<Self, DbError> {
596 let (facts, last_synced) = Facts::read(facts)?;
597 let facts = Facts { location, moving: false, sync: RetrySync::Stopped, ..facts };
598 Ok(Self { state: facts.state(), last_synced })
599 }
600}
601
602pub async fn sync_status(database: &coven_database::Database) -> Result<SyncStatus, DbError> {
605 database.sync_facts(SyncStatus::computed).await
606}
607
608fn provider_name(provider: CloudProvider) -> String {
610 match provider {
611 CloudProvider::S3 => "S3",
612 CloudProvider::Dropbox => "Dropbox",
613 CloudProvider::OneDrive => "OneDrive",
614 CloudProvider::CloudKit => "CloudKit",
615 }
616 .into()
617}
618
619fn named_provider(name: &str) -> Result<CloudProvider, DbError> {
621 match name {
622 "S3" => Ok(CloudProvider::S3),
623 "Dropbox" => Ok(CloudProvider::Dropbox),
624 "OneDrive" => Ok(CloudProvider::OneDrive),
625 "CloudKit" => Ok(CloudProvider::CloudKit),
626 _ => Err(DbError::DamagedDatabase),
627 }
628}
629
630pub async fn record_opened(
635 database: &coven_database::Database,
636 location: RecordedLocation<CloudProvider>,
637 finished: Option<SystemTime>,
638) -> Result<(), DbError> {
639 database.record_opened(location.map(provider_name), finished).await
640}
641
642pub async fn record_location(
647 database: &coven_database::Database,
648 location: RecordedLocation<CloudProvider>,
649 finished: Option<SystemTime>,
650) -> Result<(), DbError> {
651 database.record_location(location.map(provider_name), finished).await
652}
653
654#[cfg(test)]
655#[path = "sync_status_tests.rs"]
656mod tests;