[ios] Base implementation of iCloud Synchronization #7641

Merged
root merged 11 commits from ios/ab-icloud-continuous-sync into master 2024-05-30 07:15:34 +00:00
84 changed files with 4874 additions and 48 deletions

View file

@ -30388,3 +30388,424 @@
vi = Xem thực đơn
zh-Hans = 查看菜单
zh-Hant = 查看選單
[enable_icloud_synchronization_title]
comment = Title for the "Enable iCloud Syncronization" alert.
tags = ios
en = Enable iCloud Syncronization
af = Aktiveer iCloud-sinchronisasie
ar = تمكين مزامنة iCloud
az = iCloud Sinxronizasiyasını aktivləşdirin
be = Уключыць сінхранізацыю з iCloud
bg = Активиране на синхронизацията с iCloud
ca = Activa la sincronització d'iCloud
cs = Povolení synchronizace s iCloudem
da = Aktivér iCloud-synkronisering
de = Aktiviere die iCloud-Syncronisierung
el = Ενεργοποίηση συγχρονισμού iCloud
es = Activar la sincronización con iCloud
es-MX = Habilitar la sincronización de iCloud
et = Lubage iCloudi sünkroniseerimine
eu = Gaitu iCloud sinkronizazioa
fa = همگام سازی iCloud را فعال کنید
fi = Ota iCloud-synkronointi käyttöön
fr = Activer la synchronisation iCloud
he = הפעל סנכרון iCloud
hi = iCloud सिंक्रोनाइज़ेशन सक्षम करें
hu = iCloud-szinkronizálás engedélyezése
id = Mengaktifkan Sinkronisasi iCloud
it = Abilita la sincronizzazione con iCloud
ja = iCloud同期を有効にする
ko = iCloud 동기화 활성화
lt = "iCloud" sinchronizavimo įjungimas
mr = iCloud सिंक्रोनाइझेशन सक्षम करा
nb = Aktiver iCloud-synkronisering
nl = iCloud-synchronisatie inschakelen
pl = Włącz synchronizację iCloud
pt = Ativar a sincronização do iCloud
pt-BR = Ativar a sincronização do iCloud
ro = Activați Sincronizarea iCloud
ru = Включить синхронизацию с iCloud
sk = Povolenie synchronizácie iCloud
sv = Aktivera iCloud-synkronisering
sw = Washa Usawazishaji wa iCloud
th = เปิดใช้งานการซิงโครไนซ์ iCloud
tr = iCloud Senkronizasyonunu Etkinleştir
uk = Увімкнути синхронізацію з iCloud
vi = Kích hoạt đồng bộ hóa iCloud
zh-Hans = 启用 iCloud 同步
zh-Hant = 啟用 iCloud 同步
[enable_icloud_synchronization_message]
comment = Message for the "Enable iCloud Syncronization" alert.
tags = ios
en = iCloud synchronization is an experimental feature under development. Make sure that you have made a backup of all your bookmarks and tracks.
af = iCloud-sinchronisasie is 'n eksperimentele kenmerk wat ontwikkel word. Maak seker dat jy 'n rugsteun van al jou boekmerke en snitte gemaak het.
ar = تعد مزامنة iCloud ميزة تجريبية قيد التطوير. تأكد من عمل نسخة احتياطية لجميع الإشارات المرجعية والمسارات الخاصة بك.
az = iCloud sinxronizasiyası inkişaf mərhələsində olan eksperimental xüsusiyyətdir. Bütün əlfəcinlərinizin və treklərinizin ehtiyat nüsxəsini yaratdığınızdan əmin olun.
be = Сінхранізацыя з iCloud - гэта эксперыментальная функцыя, якая знаходзіцца ў стадыі распрацоўкі. Пераканайцеся, што вы зрабілі рэзервовую копію ўсіх сваіх закладак і трэкаў.
bg = Синхронизацията в iCloud е експериментална функция в процес на разработка. Уверете се, че сте направили резервно копие на всичките си отметки и песни.
ca = La sincronització d'iCloud és una característica experimental en desenvolupament. Assegureu-vos que heu fet una còpia de seguretat de tots els vostres marcadors i pistes.
cs = Synchronizace iCloudu je experimentální funkce, která se vyvíjí. Ujistěte se, že jste si vytvořili zálohu všech svých záložek a skladeb.
da = iCloud-synkronisering er en eksperimentel funktion under udvikling. Sørg for, at du har lavet en backup af alle dine bogmærker og spor.
de = Die iCloud-Synchronisierung ist eine experimentelle Funktion, die noch entwickelt wird. Vergewissere dich, dass du eine Sicherungskopie all deiner Lesezeichen und Titel erstellt hast.
el = Ο συγχρονισμός iCloud είναι μια πειραματική λειτουργία υπό ανάπτυξη. Βεβαιωθείτε ότι έχετε δημιουργήσει αντίγραφο ασφαλείας όλων των σελιδοδεικτών και των κομματιών σας.
es = La sincronización con iCloud es una función experimental en desarrollo. Asegúrate de haber hecho una copia de seguridad de todos tus marcadores y pistas.
es-MX = La sincronización de iCloud es una función experimental en desarrollo. Asegúrese de haber realizado una copia de seguridad de todos sus marcadores y pistas.
et = iCloudi sünkroniseerimine on eksperimentaalne funktsioon, mis on arendamisel. Veenduge, et olete teinud varukoopia kõigist oma järjehoidjatest ja lugudest.
eu = iCloud sinkronizazioa garatzen ari den ezaugarri esperimental bat da. Ziurtatu zure laster-marka eta ibilbide guztien babeskopia egin duzula.
fa = همگام سازی iCloud یک ویژگی آزمایشی در دست توسعه است. مطمئن شوید که از تمام نشانکu200cها و آهنگu200cهای خود یک نسخه پشتیبان تهیه کردهu200cاید.
fi = iCloud-synkronointi on kehitteillä oleva kokeellinen ominaisuus. Varmista, että olet tehnyt varmuuskopion kaikista kirjanmerkeistäsi ja kappaleistasi.
fr = La synchronisation iCloud est une fonctionnalité expérimentale en cours de développement. Assure-toi d'avoir fait une sauvegarde de tous tes signets et pistes.
he = סנכרון iCloud הוא תכונה ניסיונית בפיתוח. ודא שעשית גיבוי של כל הסימניות והרצועות שלך.
hi = iCloud सिंक्रोनाइज़ेशन विकासाधीन एक प्रायोगिक सुविधा है। सुनिश्चित करें कि आपने अपने सभी बुकमार्क और ट्रैक का बैकअप बना लिया है।
hu = Az iCloud-szinkronizálás egy fejlesztés alatt álló kísérleti funkció. Győződjön meg róla, hogy készített biztonsági másolatot az összes könyvjelzőjéről és zeneszámáról.
id = Sinkronisasi iCloud adalah fitur eksperimental yang sedang dikembangkan. Pastikan Anda telah membuat cadangan semua penanda dan lagu Anda.
it = La sincronizzazione con iCloud è una funzione sperimentale in fase di sviluppo. Assicurati di aver fatto un backup di tutti i tuoi segnalibri e tracce.
ja = iCloud同期は開発中の実験的機能である。すべてのブックマークとトラックのバックアップをとっておくこと。
ko = iCloud 동기화는 개발 중인 실험적 기능입니다. 모든 북마크와 트랙을 백업해 두었는지 확인하세요.
lt = "iCloud" sinchronizavimas yra kuriama eksperimentinė funkcija. Įsitikinkite, kad padarėte atsarginę visų savo žymeklių ir takelių kopiją.
mr = iCloud सिंक्रोनाइझेशन हे विकासाधीन प्रायोगिक वैशिष्ट्य आहे. तुम्ही तुमच्या सर्व बुकमार्क्स आणि ट्रॅकचा बॅकअप घेतला असल्याची खात्री करा.
nb = iCloud-synkronisering er en eksperimentell funksjon under utvikling. Sørg for at du har tatt en sikkerhetskopi av alle bokmerkene og sporene dine.
nl = iCloud synchronisatie is een experimentele functie in ontwikkeling. Zorg ervoor dat je een back-up hebt gemaakt van al je bladwijzers en tracks.
pl = Synchronizacja iCloud jest eksperymentalną funkcją w fazie rozwoju. Upewnij się, że wykonałeś kopię zapasową wszystkich zakładek i utworów.
pt = A sincronização com o iCloud é uma funcionalidade experimental em desenvolvimento. Certifica-te de que fizeste uma cópia de segurança de todos os teus marcadores e faixas.
pt-BR = A sincronização do iCloud é um recurso experimental em desenvolvimento. Certifique-se de que você tenha feito um backup de todos os seus favoritos e faixas.
ro = Sincronizarea iCloud este o funcție experimentală aflată în curs de dezvoltare. Asigurați-vă că ați făcut o copie de rezervă a tuturor marcajelor și pieselor dvs.
ru = Синхронизация с iCloud - это экспериментальная функция, которая находится в стадии разработки. Убедитесь, что вы сделали резервную копию всех своих закладок и треков.
sk = Synchronizácia s iCloudom je experimentálna funkcia, ktorá je vo vývoji. Uistite sa, že ste si vytvorili zálohu všetkých svojich záložiek a skladieb.
sv = iCloud-synkronisering är en experimentell funktion under utveckling. Se till att du har gjort en säkerhetskopia av alla dina bokmärken och spår.
sw = Usawazishaji wa iCloud ni kipengele cha majaribio kinachoendelezwa. Hakikisha kuwa umefanya nakala rudufu ya alamisho na nyimbo zako zote.
th = การซิงโครไนซ์ iCloud เป็นคุณสมบัติทดลองที่อยู่ระหว่างการพัฒนา ตรวจสอบให้แน่ใจว่าคุณได้สำรองข้อมูลบุ๊กมาร์กและแทร็กทั้งหมดของคุณแล้ว
tr = iCloud senkronizasyonu geliştirme aşamasında olan deneysel bir özelliktir. Tüm yer imlerinizin ve parçalarınızın yedeğini aldığınızdan emin olun.
uk = Синхронізація з iCloud - це експериментальна функція, яка перебуває на стадії розробки. Переконайтеся, що ви зробили резервну копію всіх ваших закладок і треків.
vi = Đồng bộ hóa iCloud là một tính năng thử nghiệm đang được phát triển. Đảm bảo rằng bạn đã tạo bản sao lưu tất cả dấu trang và bản nhạc của mình.
zh-Hans = iCloud 同步是一项正在开发的实验性功能。请确保备份了所有书签和曲目。
zh-Hant = iCloud 同步是一項正在開發中的實驗性功能。確保您已備份所有書籤和曲目。
[icloud_disabled_title]
comment = Title for the "iCloud is Disabled" alert.
tags = ios
en = iCloud is Disabled
af = iCloud is gedeaktiveer
ar = تم تعطيل iCloud
az = iCloud Deaktivdir
be = iCloud адключаны
bg = iCloud е деактивиран
ca = iCloud està desactivat
cs = Služba iCloud je vypnutá
da = iCloud er deaktiveret
de = iCloud ist deaktiviert
el = Το iCloud είναι απενεργοποιημένο
es = iCloud está desactivado
es-MX = iCloud está desactivado
et = iCloud on välja lülitatud
eu = iCloud desgaituta dago
fa = iCloud غیرفعال است
fi = iCloud on poistettu käytöstä
fr = iCloud est désactivé
he = iCloud מושבת
hi = iCloud अक्षम है
hu = Az iCloud letiltva
id = iCloud Dinonaktifkan
it = iCloud è disabilitato
ja = iCloudが無効になっている
ko = iCloud가 비활성화됨
lt = "iCloud" yra išjungtas
mr = iCloud अक्षम आहे
nb = iCloud er deaktivert
nl = iCloud is uitgeschakeld
pl = iCloud jest wyłączony
pt = O iCloud está desativado
ro = iCloud este dezactivat
ru = iCloud отключен
sk = Služba iCloud je vypnutá
sv = iCloud är inaktiverat
sw = iCloud Imezimwa
th = iCloud ถูกปิดใช้งาน
tr = iCloud Devre Dışı
uk = iCloud вимкнено
vi = iCloud bị vô hiệu hóa
zh-Hans = iCloud 已禁用
zh-Hant = iCloud 已停用
[icloud_disabled_message]
comment = Message for the "iCloud is Disabled" alert.
tags = ios
en = Please enable iCloud in your device's settings to use this feature.
af = Aktiveer asseblief iCloud in jou toestel se instellings om hierdie kenmerk te gebruik.
ar = يرجى تمكين iCloud في إعدادات جهازك لاستخدام هذه الميزة.
az = Bu funksiyadan istifadə etmək üçün cihazınızın parametrlərində iCloud-u aktiv edin.
be = Каб выкарыстоўваць гэту функцыю, уключыце iCloud у наладах прылады.
bg = За да използвате тази функция, разрешете iCloud в настройките на устройството си.
ca = Activa iCloud a la configuració del teu dispositiu per utilitzar aquesta funció.
cs = Chcete-li tuto funkci používat, povolte v nastavení zařízení službu iCloud.
da = Aktivér iCloud i din enheds indstillinger for at bruge denne funktion.
de = Bitte aktiviere iCloud in den Einstellungen deines Geräts, um diese Funktion zu nutzen.
el = Ενεργοποιήστε το iCloud στις ρυθμίσεις της συσκευής σας για να χρησιμοποιήσετε αυτή τη λειτουργία.
es = Activa iCloud en los ajustes de tu dispositivo para utilizar esta función.
es-MX = Activa iCloud en los ajustes de tu dispositivo para utilizar esta función.
et = Selle funktsiooni kasutamiseks lubage seadme seadetes iCloud.
eu = Mesedez, gaitu iCloud zure gailuaren ezarpenetan eginbide hau erabiltzeko.
fa = لطفاً iCloud را در تنظیمات دستگاه خود فعال کنید تا از این ویژگی استفاده کنید.
fi = Ota iCloud käyttöön laitteesi asetuksissa, jotta voit käyttää tätä ominaisuutta.
fr = Active iCloud dans les paramètres de ton appareil pour utiliser cette fonctionnalité.
he = אנא הפעל את iCloud בהגדרות המכשיר שלך כדי להשתמש בתכונה זו.
hi = कृपया इस सुविधा का उपयोग करने के लिए अपने डिवाइस की सेटिंग में iCloud सक्षम करें।
hu = A funkció használatához engedélyezze az iCloudot a készülék beállításaiban.
id = Aktifkan iCloud di pengaturan perangkat Anda untuk menggunakan fitur ini.
it = Per utilizzare questa funzione, attiva iCloud nelle impostazioni del tuo dispositivo.
ja = この機能を使うには、デバイスの設定でiCloudを有効にしてほしい。
ko = 이 기능을 사용하려면 기기 설정에서 iCloud를 활성화하세요.
lt = Jei norite naudotis šia funkcija, įjunkite "iCloud" įrenginio nustatymuose.
mr = कृपया हे वैशिष्ट्य वापरण्यासाठी तुमच्या डिव्हाइसच्या सेटिंग्जमध्ये iCloud सक्षम करा.
nb = Aktiver iCloud i enhetens innstillinger for å bruke denne funksjonen.
nl = Schakel iCloud in bij de instellingen van je apparaat om deze functie te gebruiken.
pl = Aby korzystać z tej funkcji, włącz usługę iCloud w ustawieniach urządzenia.
pt = Para utilizar esta funcionalidade, ativa o iCloud nas definições do teu dispositivo.
pt-BR = Ative o iCloud nas configurações do seu dispositivo para usar esse recurso.
ro = Vă rugăm să activați iCloud în setările dispozitivului dvs. pentru a utiliza această funcție.
ru = Чтобы воспользоваться этой функцией, включите iCloud в настройках своего устройства.
sk = Ak chcete túto funkciu používať, povoľte iCloud v nastaveniach zariadenia.
sv = Aktivera iCloud i enhetens inställningar för att kunna använda denna funktion.
sw = Tafadhali wezesha iCloud katika mipangilio ya kifaa chako ili kutumia kipengele hiki.
th = โปรดเปิดใช้งาน iCloud ในการตั้งค่าอุปกรณ์ของคุณเพื่อใช้คุณสมบัตินี้
tr = Bu özelliği kullanmak için lütfen cihazınızın ayarlarında iCloud'u etkinleştirin.
uk = Щоб скористатися цією функцією, увімкніть iCloud у налаштуваннях вашого пристрою.
vi = Vui lòng bật iCloud trong cài đặt thiết bị của bạn để sử dụng tính năng này.
zh-Hans = 请在设备设置中启用 iCloud以使用此功能。
zh-Hant = 請在您的裝置設定中啟用 iCloud 以使用此功能。
[enable]
comment = Title for the "Enable iCloud Syncronization" alert's "Enable" action button.
tags = ios
en = Enable
af = Aktiveer
ar = يُمكَِن
az = Aktivləşdirin
be = Уключыць
bg = Активиране на
ca = Activa
cs = Povolit
da = Aktivér
de = Aktiviere
el = Ενεργοποίηση
es = Activa
et = Lubage
eu = Gaitu
fa = فعال کردن
fi = Ota käyttöön
fr = Activer
he = לְאַפשֵׁר
hi = सक्षम
hu = Engedélyezze a
id = Aktifkan
it = Abilitazione
ja = 有効にする
ko = 사용
lt = Įjungti
mr = सक्षम करा
nb = Aktivere
nl = inschakelen
pl = Włącz
pt = Ativar
pt-BR = Ativar
ro = Activați
ru = Включить
sk = Povolenie stránky
sv = Aktivera
sw = Wezesha
th = เปิดใช้งาน
tr = Etkinleştir
uk = Увімкнути
vi = Cho phép
zh-Hans = 启用
zh-Hant = 使能夠
[backup]
comment = Title for the "Enable iCloud Syncronization" alert's "Backup" action button.
tags = ios
en = Backup
af = Ondersteuning
ar = دعم
az = Yedəkləmə
be = Рэзервовае капіраванне
bg = Резервно копие
ca = Còpia de seguretat
cs = Záloha
da = Sikkerhedskopiering
de = Sicherung
el = Δημιουργία αντιγράφων ασφαλείας
es = Copia de seguridad
es-MX = Respaldo
et = Varukoopia
eu = Babeskopia
fa = پشتیبان گیری
fi = Varmuuskopiointi
fr = Sauvegarde
he = גיבוי
hi = बैकअप
hu = Biztonsági mentés
id = Cadangan
it = Backup
ja = バックアップ
ko = 백업
lt = Atsarginė kopija
mr = बॅकअप
nb = Sikkerhetskopiering
nl = Back-up
pl = Kopia zapasowa
pt = Cópia de segurança
pt-BR = Cópia de segurança
ro = Backup
ru = Резервная копия
sk = Zálohovanie
sv = Säkerhetskopiering
sw = Hifadhi nakala
th = สำรองข้อมูล
tr = Yedekleme
uk = Резервне копіювання
vi = Hỗ trợ
zh-Hans = 备份
zh-Hant = 备份
[icloud_synchronization_error_connection_error]
comment = iCloud error message: Failed to synchronize due to connection error
tags = ios
en = Error: Failed to synchronize due to connection error
af = Fout: Kon nie sinchroniseer nie weens verbindingsfout
ar = خطأ: فشل المزامنة بسبب خطأ في الاتصال
az = Xəta: Bağlantı xətası səbəbindən sinxronizasiya alınmadı
be = Памылка: не ўдалося сінхранізаваць з-за памылкі злучэння
bg = Грешка: Не успя да се синхронизира поради грешка при свързването
ca = Error: no s'ha pogut sincronitzar a causa d'un error de connexió
cs = Chyba: Nepodařilo se synchronizovat z důvodu chyby připojení
da = Fejl: Kunne ikke synkronisere på grund af forbindelsesfejl
de = Fehler: Synchronisierung aufgrund eines Verbindungsfehlers fehlgeschlagen
el = Σφάλμα: Αποτυχία συγχρονισμού λόγω σφάλματος σύνδεσης
es = Error: No se ha podido sincronizar debido a un error de conexión
es-MX = Error: No se ha podido sincronizar debido a un error de conexión
et = Viga: Sünkroniseerimine ebaõnnestus ühenduse vea tõttu.
eu = Errorea: Ezin izan da sinkronizatu konexio-errore baten ondorioz
fa = خطا: به دلیل خطای اتصال همگام سازی نشد
fi = Virhe: Synkronointi epäonnistui yhteysvirheen vuoksi.
fr = Erreur : La synchronisation a échoué en raison d'une erreur de connexion
he = שגיאה: הסנכרון נכשל עקב שגיאת חיבור
hi = त्रुटि: कनेक्शन त्रुटि के कारण सिंक्रनाइज़ करने में विफल
hu = Hiba: A szinkronizálás sikertelen volt a kapcsolat hibája miatt.
id = Kesalahan: Gagal menyinkronkan karena kesalahan koneksi
it = Errore: Impossibile sincronizzare a causa di un errore di connessione
ja = エラー:接続エラーにより同期に失敗した
ko = 오류입니다: 연결 오류로 인해 동기화하지 못했습니다.
lt = Klaida: Nepavyko sinchronizuoti dėl ryšio klaidos
mr = त्रुटी: कनेक्शन त्रुटीमुळे सिंक्रोनाइझ करण्यात अयशस्वी
nb = Feil: Kunne ikke synkronisere på grunn av tilkoblingsfeil
nl = Fout: Synchronisatie mislukt door verbindingsfout
pl = Błąd: Nie udało się zsynchronizować z powodu błędu połączenia
pt = Erro: Falha na sincronização devido a um erro de ligação
pt-BR = Erro: Falha na sincronização devido a erro de conexão
ro = Eroare: Nu s-a reușit sincronizarea din cauza unei erori de conexiune
ru = Ошибка: Не удалось выполнить синхронизацию из-за ошибки подключения
sk = Chyba: Nepodarilo sa synchronizovať z dôvodu chyby pripojenia
sv = Fel: Synkronisering misslyckades på grund av anslutningsfel
sw = Hitilafu: Imeshindwa kusawazisha kwa sababu ya hitilafu ya muunganisho
th = ข้อผิดพลาด: ไม่สามารถซิงโครไนซ์ได้เนื่องจากข้อผิดพลาดในการเชื่อมต่อ
tr = Hata oluştu: Bağlantı hatası nedeniyle senkronizasyon başarısız oldu
uk = Помилка: Не вдалося синхронізувати через помилку з'єднання
vi = Lỗi: Không đồng bộ được do lỗi kết nối
zh-Hans = 错误:由于连接错误,同步失败
zh-Hant = 錯誤:由於連線錯誤而無法同步
[icloud_synchronization_error_quota_exceeded]
comment = iCloud error message: Failed to synchronize due to iCloud quota exceeded
tags = ios
en = Error: Failed to synchronize due to iCloud quota exceeded
af = Fout: Kon nie sinchroniseer nie weens iCloud-kwota oorskry
ar = خطأ: فشل في المزامنة بسبب تجاوز حصة iCloud النسبية
az = Xəta: iCloud kvotasını keçdiyinə görə sinxronizasiya alınmadı
be = Памылка: не ўдалося сінхранізаваць з-за перавышэння квоты iCloud
bg = Грешка: Неуспешно синхронизиране поради превишена квота на iCloud
ca = Error: no s'ha pogut sincronitzar perquè s'ha superat la quota d'iCloud
cs = Chyba: Nepodařilo se synchronizovat z důvodu překročení kvóty iCloudu
da = Fejl: Kunne ikke synkronisere på grund af overskredet iCloud-kvote
de = Fehler: Synchronisierung fehlgeschlagen, da iCloud-Kontingent überschritten
el = Σφάλμα: iCloud: Απέτυχε ο συγχρονισμός λόγω υπέρβασης της ποσόστωσης iCloud
es = Error: No se ha podido sincronizar porque se ha superado la cuota de iCloud
es-MX = Error: No se ha podido sincronizar porque se ha superado la cuota de iCloud
et = Viga: iCloudi kvoodi ületamise tõttu ei õnnestunud sünkroniseerimine.
eu = Errorea: Ezin izan da sinkronizatu iCloud kuota gainditu delako
fa = خطا: به دلیل فراتر رفتن از سهمیه iCloud، همگام سازی انجام نشد
fi = Virhe: iCloud-kiintiön ylittymisen vuoksi synkronointi epäonnistui.
fr = Erreur : Échec de la synchronisation en raison du dépassement du quota iCloud.
he = שגיאה: הסנכרון נכשל עקב חריגה ממכסת iCloud
hi = त्रुटि: iCloud कोटा पार हो जाने के कारण सिंक्रनाइज़ करने में विफल
hu = Hiba: iCloud-kvóta túllépése miatt nem sikerült szinkronizálni.
id = Kesalahan: Gagal menyinkronkan karena kuota iCloud terlampaui
it = Errore: Impossibile sincronizzare a causa del superamento della quota iCloud
ja = エラーiCloudのクォータが超過したため、同期に失敗しました。
ko = 오류입니다: iCloud 할당량이 초과되어 동기화하지 못했습니다.
lt = Klaida: Nepavyko sinchronizuoti dėl viršytos "iCloud" kvotos
mr = त्रुटी: iCloud कोटा ओलांडल्यामुळे सिंक्रोनाइझ करण्यात अयशस्वी
nb = Feil: Kunne ikke synkronisere på grunn av overskredet iCloud-kvote
nl = Fout: Synchronisatie mislukt vanwege overschrijding iCloud-quota
pl = Błąd: Nie udało się zsynchronizować z powodu przekroczenia limitu iCloud
pt = Erro: Falha na sincronização devido à quota do iCloud ter sido excedida
pt-BR = Erro: Falha ao sincronizar devido à cota do iCloud excedida
ro = Eroare: Nu s-a reușit sincronizarea din cauza depășirii cotei iCloud
ru = Ошибка: Не удалось выполнить синхронизацию из-за превышения квоты iCloud
sk = Chyba: Nepodarilo sa synchronizovať z dôvodu prekročenia kvóty iCloudu
sv = Fel: Synkronisering misslyckades på grund av att iCloud-kvoten överskreds
sw = Hitilafu: Imeshindwa kusawazisha kwa sababu ya mgao wa iCloud uliozidishwa
th = ข้อผิดพลาด: ไม่สามารถซิงโครไนซ์ได้เนื่องจากเกินโควต้า iCloud
tr = Hata: iCloud kotasııldığı için senkronize edilemedi
uk = Помилка: Не вдалося синхронізувати через перевищення квоти iCloud
vi = Lỗi: Không thể đồng bộ hóa do vượt quá hạn ngạch iCloud
zh-Hans = 错误:由于超过 iCloud 配额,同步失败
zh-Hant = 錯誤:由於超出 iCloud 配額而無法同步
[icloud_synchronization_error_cloud_is_unavailable]
comment = iCloud error message: iCloud is not available
tags = ios
en = Error: iCloud is not available
af = Fout: iCloud is nie beskikbaar nie
ar = خطأ: iCloud غير متوفر
az = Xəta: iCloud mövcud deyil
be = Памылка: iCloud недаступны
bg = Грешка: iCloud не е наличен
ca = Error: iCloud no està disponible
cs = Chyba: iCloud není k dispozici
da = Fejl: iCloud er ikke tilgængelig
de = Fehler: iCloud ist nicht verfügbar
el = Σφάλμα: Το iCloud δεν είναι διαθέσιμο
es = Error: iCloud no está disponible
es-MX = Error: iCloud no está disponible
et = Viga: iCloud ei ole saadaval
eu = Errorea: iCloud ez dago erabilgarri
fa = خطا: iCloud در دسترس نیست
fi = Virhe: iCloud ei ole käytettävissä
fr = Erreur : iCloud n'est pas disponible
he = שגיאה: iCloud אינו זמין
hi = त्रुटि: iCloud उपलब्ध नहीं है
hu = Hiba: Az iCloud nem elérhető
id = Kesalahan: iCloud tidak tersedia
it = Errore: iCloud non è disponibile
ja = エラーiCloudが利用できない
ko = 오류: iCloud를 사용할 수 없습니다.
lt = Klaida: "iCloud" nepasiekiamas
mr = त्रुटी: iCloud उपलब्ध नाही
nb = Feil: iCloud er ikke tilgjengelig
nl = Fout: iCloud is niet beschikbaar
pl = Błąd: usługa iCloud jest niedostępna
pt = Erro: o iCloud não está disponível
pt-BR = Erro: o iCloud não está disponível
ro = Eroare: iCloud nu este disponibil
ru = Ошибка: iCloud недоступен
sk = Chyba: iCloud nie je k dispozícii
sv = Fel: iCloud är inte tillgängligt
sw = Hitilafu: iCloud haipatikani
th = ข้อผิดพลาด: iCloud ไม่พร้อมใช้งาน
tr = Hata: iCloud kullanılamıyor
uk = Помилка: iCloud недоступний
vi = Lỗi: iCloud không khả dụng
zh-Hans = 错误iCloud 不可用
zh-Hant = 錯誤iCloud 不可用

View file

@ -36,6 +36,8 @@ NS_SWIFT_NAME(BookmarksManager)
- (BOOL)areBookmarksLoaded;
- (void)loadBookmarks;
- (void)reloadCategoryAtFilePath:(NSString *)filePath;
- (void)deleteCategoryAtFilePath:(NSString *)filePath;
- (BOOL)areAllCategoriesEmpty;
- (BOOL)isCategoryEmpty:(MWMMarkGroupID)groupId;
@ -61,7 +63,9 @@ NS_SWIFT_NAME(BookmarksManager)
- (void)setUserCategoriesVisible:(BOOL)isVisible;
- (void)deleteCategory:(MWMMarkGroupID)groupId;
- (BOOL)checkCategoryName:(NSString *)name;
- (BOOL)hasCategory:(MWMMarkGroupID)groupId;
- (BOOL)hasBookmark:(MWMMarkID)bookmarkId;
- (BOOL)hasTrack:(MWMTrackID)trackId;
- (NSArray<NSNumber *> *)availableSortingTypes:(MWMMarkGroupID)groupId hasMyPosition:(BOOL)hasMyPosition;
- (void)sortBookmarks:(MWMMarkGroupID)groupId
sortingType:(MWMBookmarksSortingType)sortingType

View file

@ -203,6 +203,18 @@ static KmlFileType convertFileTypeToCore(MWMKmlFileType fileType) {
self.bm.LoadBookmarks();
}
- (void)reloadCategoryAtFilePath:(NSString *)filePath
{
self.bm.ReloadBookmark(filePath.UTF8String);
}
- (void)deleteCategoryAtFilePath:(NSString *)filePath
{
auto const groupId = self.bm.GetCategoryByFileName(filePath.UTF8String);
if (groupId)
[self deleteCategory:groupId];
}
#pragma mark - Categories
- (BOOL)areAllCategoriesEmpty
@ -350,11 +362,21 @@ static KmlFileType convertFileTypeToCore(MWMKmlFileType fileType) {
return !self.bm.IsUsedCategoryName(name.UTF8String);
}
- (BOOL)hasCategory:(MWMMarkGroupID)groupId
{
return self.bm.HasBmCategory(groupId);
}
- (BOOL)hasBookmark:(MWMMarkID)bookmarkId
{
return self.bm.HasBookmark(bookmarkId);
}
- (BOOL)hasTrack:(MWMTrackID)trackId
{
return self.bm.HasTrack(trackId);
}
- (NSArray<NSNumber *> *)availableSortingTypes:(MWMMarkGroupID)groupId hasMyPosition:(BOOL)hasMyPosition{
auto const availableTypes = self.bm.GetAvailableSortingTypes(groupId, hasMyPosition);
NSMutableArray *result = [NSMutableArray array];

View file

@ -33,6 +33,15 @@ import UIKit
state = .closed
}
@objc func goBack() {
switch state {
case .opened:
navigationController?.popViewController(animated: true)
case .hidden, .closed:
close()
}
}
@objc func hide(categoryId: MWMMarkGroupID) {
state = .hidden(categoryId: categoryId)
}

View file

@ -15,12 +15,21 @@ extension BookmarksListSortingType {
}
}
final class BookmarksListInteractor {
private let markGroupId: MWMMarkGroupID
private var bookmarksManager: BookmarksManager { BookmarksManager.shared() }
final class BookmarksListInteractor: NSObject {
private var markGroupId: MWMMarkGroupID
private var bookmarksManager: BookmarksManager
var onCategoryReload: ((GroupReloadingResult) -> Void)?
init(markGroupId: MWMMarkGroupID) {
self.markGroupId = markGroupId
self.bookmarksManager = BookmarksManager.shared()
super.init()
self.addToBookmarksManagerObserverList()
}
deinit {
removeFromBookmarksManagerObserverList()
}
}
@ -151,4 +160,27 @@ extension BookmarksListInteractor: IBookmarksListInteractor {
func finishExportFile() {
bookmarksManager.finishShareCategory()
}
func addToBookmarksManagerObserverList() {
bookmarksManager.add(self)
}
func removeFromBookmarksManagerObserverList() {
bookmarksManager.remove(self)
}
func reloadCategory() {
onCategoryReload?(bookmarksManager.hasCategory(markGroupId) ? .success : .notFound)
}
}
// MARK: - BookmarksObserver
extension BookmarksListInteractor: BookmarksObserver {
func onBookmarksLoadFinished() {
reloadCategory()
}
func onBookmarksCategoryDeleted(_ groupId: MWMMarkGroupID) {
reloadCategory()
}
}

View file

@ -11,6 +11,11 @@ enum BookmarkToolbarButtonSource {
case more
}
enum GroupReloadingResult {
case success
case notFound
}
protocol IBookmarksListSectionViewModel {
var numberOfItems: Int { get }
var sectionTitle: String { get }
@ -89,6 +94,8 @@ enum BookmarksListSortingType {
}
protocol IBookmarksListInteractor {
var onCategoryReload: ((GroupReloadingResult) -> Void)? { get set }
func getBookmarkGroup() -> BookmarkGroup
func hasDescription() -> Bool
func prepareForSearch()
@ -125,6 +132,7 @@ protocol IBookmarksListRouter {
delegate: SelectBookmarkGroupViewControllerDelegate?)
func editBookmark(bookmarkId: MWMMarkID, completion: @escaping (Bool) -> Void)
func editTrack(trackId: MWMTrackID, completion: @escaping (Bool) -> Void)
func goBack()
}
protocol IBookmarksListInfoViewModel {

View file

@ -5,12 +5,12 @@ protocol BookmarksListDelegate: AnyObject {
final class BookmarksListPresenter {
private unowned let view: IBookmarksListView
private let router: IBookmarksListRouter
private let interactor: IBookmarksListInteractor
private var interactor: IBookmarksListInteractor
private weak var delegate: BookmarksListDelegate?
private let distanceFormatter = MeasurementFormatter()
private let imperialUnits: Bool
private let bookmarkGroup: BookmarkGroup
private var bookmarkGroup: BookmarkGroup
private enum EditableItem {
case bookmark(MWMMarkID)
@ -28,9 +28,22 @@ final class BookmarksListPresenter {
self.delegate = delegate
self.interactor = interactor
self.imperialUnits = imperialUnits
self.bookmarkGroup = interactor.getBookmarkGroup()
self.distanceFormatter.unitOptions = [.providedUnit]
self.subscribeOnGroupReloading()
}
bookmarkGroup = interactor.getBookmarkGroup()
distanceFormatter.unitOptions = [.providedUnit]
private func subscribeOnGroupReloading() {
interactor.onCategoryReload = { [weak self] result in
guard let self else { return }
switch result {
case .notFound:
self.router.goBack()
case .success:
self.bookmarkGroup = self.interactor.getBookmarkGroup()
self.reload()
}
}
}
private func reload() {

View file

@ -79,4 +79,8 @@ extension BookmarksListRouter: IBookmarksListRouter {
let editTrackController = EditTrackViewController(trackId: trackId, editCompletion: completion)
mapViewController.navigationController?.pushViewController(editTrackController, animated: true)
}
func goBack() {
coordinator?.goBack()
}
}

View file

@ -45,17 +45,11 @@ final class BMCViewController: MWMViewController {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// Disable all notifications in BM on appearance of this view.
// It allows to significantly improve performance in case of bookmarks
// modification. All notifications will be sent on controller's disappearance.
viewModel.setNotificationsEnabled(false)
viewModel.addToObserverList()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
// Allow to send all notifications in BM.
viewModel.setNotificationsEnabled(true)
viewModel.removeFromObserverList()
}

View file

@ -371,7 +371,7 @@ NSString *const kPP2BookmarkEditingSegue = @"PP2BookmarkEditing";
- (void)viewDidLayoutSubviews {
[super viewDidLayoutSubviews];
if (!self.mapView.drapeEngineCreated && !MapsAppDelegate.isDrapeDisabled)
if (!self.mapView.drapeEngineCreated && !MapsAppDelegate.isTestsEnvironment)
[self.mapView createDrapeEngine];
}

View file

@ -33,7 +33,7 @@ NS_ASSUME_NONNULL_BEGIN
- (NSUInteger)badgeNumber;
+ (BOOL)isDrapeDisabled;
+ (BOOL)isTestsEnvironment;
@end

View file

@ -121,6 +121,9 @@ using namespace osm_auth_ios;
}
[self enableTTSForTheFirstTime];
if (![MapsAppDelegate isTestsEnvironment])
[[CloudStorageManager shared] start];
[[DeepLinkHandler shared] applicationDidFinishLaunching:launchOptions];
// application:openUrl:options is called later for deep links if YES is returned.
return YES;
@ -225,8 +228,8 @@ using namespace osm_auth_ios;
LOG(LINFO, ("applicationDidBecomeActive - end"));
}
// TODO: Drape enabling is skipped during the test run due to the app crashing in teardown. This is a temporary solution. Drape should be properly disabled instead of merely skipping the enabling process.
+ (BOOL)isDrapeDisabled {
// TODO: Drape enabling and iCloud sync are skipped during the test run due to the app crashing in teardown. This is a temporary solution. Drape should be properly disabled instead of merely skipping the enabling process.
+ (BOOL)isTestsEnvironment {
NSProcessInfo * processInfo = [NSProcessInfo processInfo];
NSArray<NSString *> * launchArguments = [processInfo arguments];
BOOL isTests = [launchArguments containsObject:@"-IsTests"];

View file

@ -34,4 +34,7 @@ NS_SWIFT_NAME(Settings)
+ (NSString *)donateUrl;
+ (BOOL)isNY;
+ (BOOL)iCLoudSynchronizationEnabled;
+ (void)setICLoudSynchronizationEnabled:(BOOL)iCLoudSyncEnabled;
@end

View file

@ -18,6 +18,7 @@ NSString * const kUDAutoNightModeOff = @"AutoNightModeOff";
NSString * const kThemeMode = @"ThemeMode";
NSString * const kSpotlightLocaleLanguageId = @"SpotlightLocaleLanguageId";
NSString * const kUDTrackWarningAlertWasShown = @"TrackWarningAlertWasShown";
NSString * const kiCLoudSynchronizationEnabledKey = @"iCLoudSynchronizationEnabled";
} // namespace
@implementation MWMSettings
@ -156,4 +157,14 @@ NSString * const kUDTrackWarningAlertWasShown = @"TrackWarningAlertWasShown";
return settings::Get("NY", isNY) ? isNY : false;
}
+ (BOOL)iCLoudSynchronizationEnabled
{
return [NSUserDefaults.standardUserDefaults boolForKey:kiCLoudSynchronizationEnabledKey];
}
+ (void)setICLoudSynchronizationEnabled:(BOOL)iCLoudSyncEnabled
{
[NSUserDefaults.standardUserDefaults setBool:iCLoudSyncEnabled forKey:kiCLoudSynchronizationEnabledKey];
[NSNotificationCenter.defaultCenter postNotificationName:NSNotification.iCloudSynchronizationDidChangeEnabledState object:nil];
}
@end

View file

@ -0,0 +1,338 @@
enum VoidResult {
case success
case failure(Error)
}
enum WritingResult {
case success
case reloadCategoriesAtURLs([URL])
case deleteCategoriesAtURLs([URL])
case failure(Error)
}
typealias VoidResultCompletionHandler = (VoidResult) -> Void
typealias WritingResultCompletionHandler = (WritingResult) -> Void
// TODO: Remove this type and use custom UTTypeIdentifier that is registered into the Info.plist after updating to the iOS >= 14.0.
struct FileType {
let fileExtension: String
let typeIdentifier: String
}
extension FileType {
static let kml = FileType(fileExtension: "kml", typeIdentifier: "com.google.earth.kml")
}
let kTrashDirectoryName = ".Trash"
private let kBookmarksDirectoryName = "bookmarks"
private let kICloudSynchronizationDidChangeEnabledStateNotificationName = "iCloudSynchronizationDidChangeEnabledStateNotification"
private let kUDDidFinishInitialCloudSynchronization = "kUDDidFinishInitialCloudSynchronization"
@objc @objcMembers final class CloudStorageManager: NSObject {
fileprivate struct Observation {
weak var observer: AnyObject?
var onErrorCompletionHandler: ((NSError?) -> Void)?
}
let fileManager: FileManager
private let localDirectoryMonitor: LocalDirectoryMonitor
private let cloudDirectoryMonitor: CloudDirectoryMonitor
private let settings: Settings.Type
private let bookmarksManager: BookmarksManager
private let synchronizationStateManager: SynchronizationStateManager
private var fileWriter: SynchronizationFileWriter?
private var observers = [ObjectIdentifier: CloudStorageManager.Observation]()
private var synchronizationError: SynchronizationError? {
didSet { notifyObserversOnSynchronizationError(synchronizationError) }
}
static private var isInitialSynchronization: Bool {
return !UserDefaults.standard.bool(forKey: kUDDidFinishInitialCloudSynchronization)
}
static let shared: CloudStorageManager = {
let fileManager = FileManager.default
let fileType = FileType.kml
let cloudDirectoryMonitor = iCloudDocumentsDirectoryMonitor(fileManager: fileManager, fileType: fileType)
let synchronizationStateManager = DefaultSynchronizationStateManager(isInitialSynchronization: CloudStorageManager.isInitialSynchronization)
do {
let localDirectoryMonitor = try DefaultLocalDirectoryMonitor(fileManager: fileManager, directory: fileManager.bookmarksDirectoryUrl, fileType: fileType)
let clodStorageManager = try CloudStorageManager(fileManager: fileManager,
settings: Settings.self,
bookmarksManager: BookmarksManager.shared(),
cloudDirectoryMonitor: cloudDirectoryMonitor,
localDirectoryMonitor: localDirectoryMonitor,
synchronizationStateManager: synchronizationStateManager)
return clodStorageManager
} catch {
fatalError("Failed to create shared iCloud storage manager with error: \(error)")
}
}()
// MARK: - Initialization
init(fileManager: FileManager,
settings: Settings.Type,
bookmarksManager: BookmarksManager,
cloudDirectoryMonitor: CloudDirectoryMonitor,
localDirectoryMonitor: LocalDirectoryMonitor,
synchronizationStateManager: SynchronizationStateManager) throws {
guard fileManager === cloudDirectoryMonitor.fileManager, fileManager === localDirectoryMonitor.fileManager else {
throw NSError(domain: "CloudStorageManger", code: 0, userInfo: [NSLocalizedDescriptionKey: "File managers should be the same."])
}
self.fileManager = fileManager
self.settings = settings
self.bookmarksManager = bookmarksManager
self.cloudDirectoryMonitor = cloudDirectoryMonitor
self.localDirectoryMonitor = localDirectoryMonitor
self.synchronizationStateManager = synchronizationStateManager
super.init()
}
// MARK: - Public
@objc func start() {
subscribeToSettingsNotifications()
subscribeToApplicationLifecycleNotifications()
cloudDirectoryMonitor.delegate = self
localDirectoryMonitor.delegate = self
}
}
// MARK: - Private
private extension CloudStorageManager {
// MARK: - Synchronization Lifecycle
func startSynchronization() {
LOG(.debug, "Start synchronization...")
switch cloudDirectoryMonitor.state {
case .started:
LOG(.debug, "Synchronization is already started")
return
case .paused:
resumeSynchronization()
case .stopped:
cloudDirectoryMonitor.start { [weak self] result in
guard let self else { return }
switch result {
case .failure(let error):
self.stopSynchronization()
self.processError(error)
case .success(let cloudDirectoryUrl):
self.localDirectoryMonitor.start { result in
switch result {
case .failure(let error):
self.stopSynchronization()
self.processError(error)
case .success(let localDirectoryUrl):
self.fileWriter = SynchronizationFileWriter(fileManager: self.fileManager,
localDirectoryUrl: localDirectoryUrl,
cloudDirectoryUrl: cloudDirectoryUrl)
LOG(.debug, "Synchronization is started successfully")
}
}
}
}
}
}
func stopSynchronization() {
LOG(.debug, "Stop synchronization")
localDirectoryMonitor.stop()
cloudDirectoryMonitor.stop()
synchronizationError = nil
fileWriter = nil
synchronizationStateManager.resetState()
}
func pauseSynchronization() {
LOG(.debug, "Pause synchronization")
localDirectoryMonitor.pause()
cloudDirectoryMonitor.pause()
}
func resumeSynchronization() {
LOG(.debug, "Resume synchronization")
localDirectoryMonitor.resume()
cloudDirectoryMonitor.resume()
}
// MARK: - App Lifecycle
func subscribeToApplicationLifecycleNotifications() {
NotificationCenter.default.addObserver(self, selector: #selector(appWillEnterForeground), name: UIApplication.didBecomeActiveNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(appDidEnterBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)
}
func unsubscribeFromApplicationLifecycleNotifications() {
NotificationCenter.default.removeObserver(self, name: UIApplication.didBecomeActiveNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil)
}
func subscribeToSettingsNotifications() {
NotificationCenter.default.addObserver(self, selector: #selector(didChangeEnabledState), name: NSNotification.iCloudSynchronizationDidChangeEnabledState, object: nil)
}
@objc func appWillEnterForeground() {
guard settings.iCLoudSynchronizationEnabled() else { return }
startSynchronization()
}
@objc func appDidEnterBackground() {
guard settings.iCLoudSynchronizationEnabled() else { return }
pauseSynchronization()
}
@objc func didChangeEnabledState() {
settings.iCLoudSynchronizationEnabled() ? startSynchronization() : stopSynchronization()
}
}
// MARK: - iCloudStorageManger + LocalDirectoryMonitorDelegate
extension CloudStorageManager: LocalDirectoryMonitorDelegate {
func didFinishGathering(contents: LocalContents) {
let events = synchronizationStateManager.resolveEvent(.didFinishGatheringLocalContents(contents))
processEvents(events)
}
func didUpdate(contents: LocalContents) {
let events = synchronizationStateManager.resolveEvent(.didUpdateLocalContents(contents))
processEvents(events)
}
func didReceiveLocalMonitorError(_ error: Error) {
processError(error)
}
}
// MARK: - iCloudStorageManger + CloudDirectoryMonitorDelegate
extension CloudStorageManager: CloudDirectoryMonitorDelegate {
func didFinishGathering(contents: CloudContents) {
let events = synchronizationStateManager.resolveEvent(.didFinishGatheringCloudContents(contents))
processEvents(events)
}
func didUpdate(contents: CloudContents) {
let events = synchronizationStateManager.resolveEvent(.didUpdateCloudContents(contents))
processEvents(events)
}
func didReceiveCloudMonitorError(_ error: Error) {
processError(error)
}
}
// MARK: - Private methods
private extension CloudStorageManager {
func processEvents(_ events: [OutgoingEvent]) {
guard !events.isEmpty else {
synchronizationError = nil
return
}
LOG(.debug, "Start processing events...")
events.forEach { [weak self] event in
LOG(.debug, "Processing event: \(event)")
guard let self, let fileWriter else { return }
fileWriter.processEvent(event, completion: writingResultHandler(for: event))
}
}
func writingResultHandler(for event: OutgoingEvent) -> WritingResultCompletionHandler {
return { [weak self] result in
guard let self else { return }
DispatchQueue.main.async {
switch result {
case .success:
// Mark that initial synchronization is finished.
if case .didFinishInitialSynchronization = event {
UserDefaults.standard.set(true, forKey: kUDDidFinishInitialCloudSynchronization)
}
case .reloadCategoriesAtURLs(let urls):
urls.forEach { self.bookmarksManager.reloadCategory(atFilePath: $0.path) }
case .deleteCategoriesAtURLs(let urls):
urls.forEach { self.bookmarksManager.deleteCategory(atFilePath: $0.path) }
case .failure(let error):
self.processError(error)
}
}
}
}
// MARK: - Error handling
func processError(_ error: Error) {
if let synchronizationError = error as? SynchronizationError {
LOG(.debug, "Synchronization error: \(error.localizedDescription)")
switch synchronizationError {
case .fileUnavailable: break
case .fileNotUploadedDueToQuota: break
case .ubiquityServerNotAvailable: break
case .iCloudIsNotAvailable: fallthrough
case .failedToOpenLocalDirectoryFileDescriptor: fallthrough
case .failedToRetrieveLocalDirectoryContent: fallthrough
case .containerNotFound:
stopSynchronization()
}
self.synchronizationError = synchronizationError
} else {
// TODO: Handle non-synchronization errors
LOG(.debug, "Non-synchronization error: \(error.localizedDescription)")
}
}
}
// MARK: - CloudStorageManger Observing
extension CloudStorageManager {
func addObserver(_ observer: AnyObject, onErrorCompletionHandler: @escaping (NSError?) -> Void) {
let id = ObjectIdentifier(observer)
observers[id] = Observation(observer: observer, onErrorCompletionHandler:onErrorCompletionHandler)
// Notify the new observer immediately to handle initial state.
observers[id]?.onErrorCompletionHandler?(synchronizationError as NSError?)
}
func removeObserver(_ observer: AnyObject) {
let id = ObjectIdentifier(observer)
observers.removeValue(forKey: id)
}
private func notifyObserversOnSynchronizationError(_ error: SynchronizationError?) {
self.observers.removeUnreachable().forEach { _, observable in
DispatchQueue.main.async {
observable.onErrorCompletionHandler?(error as NSError?)
}
}
}
}
// MARK: - FileManager + Directories
extension FileManager {
var bookmarksDirectoryUrl: URL {
urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent(kBookmarksDirectoryName, isDirectory: true)
}
func trashDirectoryUrl(for baseDirectoryUrl: URL) throws -> URL {
let trashDirectory = baseDirectoryUrl.appendingPathComponent(kTrashDirectoryName, isDirectory: true)
if !fileExists(atPath: trashDirectory.path) {
try createDirectory(at: trashDirectory, withIntermediateDirectories: true)
}
return trashDirectory
}
}
// MARK: - Notification + iCloudSynchronizationDidChangeEnabledState
extension Notification.Name {
static let iCloudSynchronizationDidChangeEnabledStateNotification = Notification.Name(kICloudSynchronizationDidChangeEnabledStateNotificationName)
}
@objc extension NSNotification {
public static let iCloudSynchronizationDidChangeEnabledState = Notification.Name.iCloudSynchronizationDidChangeEnabledStateNotification
}
// MARK: - Dictionary + RemoveUnreachable
private extension Dictionary where Key == ObjectIdentifier, Value == CloudStorageManager.Observation {
mutating func removeUnreachable() -> Self {
for (id, observation) in self {
if observation.observer == nil {
removeValue(forKey: id)
}
}
return self
}
}

View file

@ -0,0 +1,216 @@
enum DirectoryMonitorState: CaseIterable, Equatable {
case started
case stopped
case paused
}
protocol DirectoryMonitor: AnyObject {
var state: DirectoryMonitorState { get }
func start(completion: ((Result<URL, Error>) -> Void)?)
func stop()
func pause()
func resume()
}
protocol LocalDirectoryMonitor: DirectoryMonitor {
var fileManager: FileManager { get }
var directory: URL { get }
var delegate: LocalDirectoryMonitorDelegate? { get set }
}
protocol LocalDirectoryMonitorDelegate : AnyObject {
func didFinishGathering(contents: LocalContents)
func didUpdate(contents: LocalContents)
func didReceiveLocalMonitorError(_ error: Error)
}
final class DefaultLocalDirectoryMonitor: LocalDirectoryMonitor {
typealias Delegate = LocalDirectoryMonitorDelegate
fileprivate enum DispatchSourceDebounceState {
case stopped
case started(source: DispatchSourceFileSystemObject)
case debounce(source: DispatchSourceFileSystemObject, timer: Timer)
}
let fileManager: FileManager
let fileType: FileType
private let resourceKeys: [URLResourceKey] = [.nameKey]
private var dispatchSource: DispatchSourceFileSystemObject?
private var dispatchSourceDebounceState: DispatchSourceDebounceState = .stopped
private var dispatchSourceIsSuspended = false
private var dispatchSourceIsResumed = false
private var didFinishGatheringIsCalled = false
// MARK: - Public properties
let directory: URL
private(set) var state: DirectoryMonitorState = .stopped
weak var delegate: Delegate?
init(fileManager: FileManager, directory: URL, fileType: FileType = .kml) throws {
self.fileManager = fileManager
self.directory = directory
self.fileType = fileType
try fileManager.createDirectoryIfNeeded(at: directory)
}
// MARK: - Public methods
func start(completion: ((Result<URL, Error>) -> Void)? = nil) {
guard state != .started else { return }
let nowTimer = Timer.scheduledTimer(withTimeInterval: .zero, repeats: false) { [weak self] _ in
LOG(.debug, "LocalMonitor: Initial timer firing...")
self?.debounceTimerDidFire()
}
if let dispatchSource {
dispatchSourceDebounceState = .debounce(source: dispatchSource, timer: nowTimer)
resume()
completion?(.success(directory))
return
}
do {
let source = try fileManager.source(for: directory)
source.setEventHandler { [weak self] in
self?.queueDidFire()
}
dispatchSourceDebounceState = .debounce(source: source, timer: nowTimer)
source.activate()
dispatchSource = source
state = .started
completion?(.success(directory))
} catch {
stop()
completion?(.failure(error))
}
}
func stop() {
guard state == .started else { return }
LOG(.debug, "LocalMonitor: Stop.")
suspendDispatchSource()
didFinishGatheringIsCalled = false
dispatchSourceDebounceState = .stopped
state = .stopped
}
func pause() {
guard state == .started else { return }
LOG(.debug, "LocalMonitor: Pause.")
suspendDispatchSource()
state = .paused
}
func resume() {
guard state != .started else { return }
LOG(.debug, "LocalMonitor: Resume.")
resumeDispatchSource()
state = .started
}
// MARK: - Private
private func queueDidFire() {
LOG(.debug, "LocalMonitor: Queue did fire.")
let debounceTimeInterval = 0.5
switch dispatchSourceDebounceState {
case .started(let source):
let timer = Timer.scheduledTimer(withTimeInterval: debounceTimeInterval, repeats: false) { [weak self] _ in
self?.debounceTimerDidFire()
}
dispatchSourceDebounceState = .debounce(source: source, timer: timer)
case .debounce(_, let timer):
timer.fireDate = Date(timeIntervalSinceNow: debounceTimeInterval)
// Stay in the `.debounce` state.
case .stopped:
// This can happen if the read source fired and enqueued a block on the
// main queue but, before the main queue got to service that block, someone
// called `stop()`. The correct response is to just do nothing.
break
}
}
private func debounceTimerDidFire() {
LOG(.debug, "LocalMonitor: Debounce timer did fire.")
guard state == .started else {
LOG(.debug, "LocalMonitor: State is not started. Skip iteration.")
return
}
guard case .debounce(let source, let timer) = dispatchSourceDebounceState else { fatalError() }
timer.invalidate()
dispatchSourceDebounceState = .started(source: source)
do {
let files = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: [], options: [.skipsHiddenFiles], fileExtension: fileType.fileExtension)
let contents = files.compactMap { url in
do {
let metadataItem = try LocalMetadataItem(fileUrl: url)
return metadataItem
} catch {
delegate?.didReceiveLocalMonitorError(error)
return nil
}
}
let contentMetadataItems = LocalContents(contents)
if !didFinishGatheringIsCalled {
didFinishGatheringIsCalled = true
LOG(.debug, "LocalMonitor: didFinishGathering called.")
LOG(.debug, "LocalMonitor: contentMetadataItems count: \(contentMetadataItems.count)")
delegate?.didFinishGathering(contents: contentMetadataItems)
} else {
LOG(.debug, "LocalMonitor: didUpdate called.")
LOG(.debug, "LocalMonitor: contentMetadataItems count: \(contentMetadataItems.count)")
delegate?.didUpdate(contents: contentMetadataItems)
}
} catch {
LOG(.debug, "\(error)")
delegate?.didReceiveLocalMonitorError(SynchronizationError.failedToRetrieveLocalDirectoryContent)
}
}
private func suspendDispatchSource() {
if !dispatchSourceIsSuspended {
LOG(.debug, "LocalMonitor: Suspend dispatch source.")
dispatchSource?.suspend()
dispatchSourceIsSuspended = true
dispatchSourceIsResumed = false
}
}
private func resumeDispatchSource() {
if !dispatchSourceIsResumed {
LOG(.debug, "LocalMonitor: Resume dispatch source.")
dispatchSource?.resume()
dispatchSourceIsResumed = true
dispatchSourceIsSuspended = false
}
}
}
private extension FileManager {
func source(for directory: URL) throws -> DispatchSourceFileSystemObject {
let directoryFileDescriptor = open(directory.path, O_EVTONLY)
biodranik commented 2024-05-01 20:17:12 +00:00 (Migrated from github.com)
Review

This code monitor only directory changes, not file changes. E.g. if you edit/overwrite some file in-place, it won't fire. If you add a new file, delete, change file name, then it fires.

Our bookmarks saving code should create a new temporary file, and move it to overwrite the old file atomically, that's why it works.

However, there could be some other cases when files are just overwritten/appended, without modifying the directory.

Another case to test: what if user uploads some good/bad kml files (and other types too) using Finder/File Sharing into OM's monitored folder?

This code monitor only directory changes, not file changes. E.g. if you edit/overwrite some file in-place, it won't fire. If you add a new file, delete, change file name, then it fires. Our bookmarks saving code should create a new temporary file, and move it to overwrite the old file atomically, that's why it works. However, there could be some other cases when files are just overwritten/appended, without modifying the directory. Another case to test: what if user uploads some good/bad kml files (and other types too) using Finder/File Sharing into OM's monitored folder?
Review

This code is triggered on every write operation into the directory, not only the creation/renaming/removal because I've set up the source with the write eventMask:
https://developer.apple.com/documentation/dispatch/dispatchsource/filesystemevent/1780646-write

let dispatchSource = DispatchSource.makeFileSystemObjectSource(fileDescriptor: directoryFileDescriptor, eventMask: [.write], queue: DispatchQueue.main)

I wrote the test case to check the file contents updating without replacing isolated from the bookmark manager:

image

Another case to test: what if user uploads some good/bad kml files (and other types too) using Finder/File Sharing into OM's monitored folder?

We can only add files to the iCloud local container.

  • Good kmls will be parsed and displayed immediately
  • Bad kmls will not be displayed because they will not be parsed - I mentioned in todo that we should add an alert to handle this situation and notify a user.
  • Other files (not *.kml) will not be synced (until we implement the support of the nested files/directories).
This code is triggered on _every write operation_ into the directory, not only the creation/renaming/removal because I've set up the source with the `write` eventMask: https://developer.apple.com/documentation/dispatch/dispatchsource/filesystemevent/1780646-write ```swift let dispatchSource = DispatchSource.makeFileSystemObjectSource(fileDescriptor: directoryFileDescriptor, eventMask: [.write], queue: DispatchQueue.main) ``` I wrote the test case to check the file contents _updating without replacing_ isolated from the bookmark manager: <img width="806" alt="image" src="https://github.com/organicmaps/organicmaps/assets/79797627/feef7adb-0639-4fd8-940c-0175e11e365d"> > Another case to test: what if user uploads some good/bad kml files (and other types too) using Finder/File Sharing into OM's monitored folder? We can only add files to the iCloud local container. - Good kmls will be parsed and displayed immediately - Bad kmls will not be displayed because they will not be parsed - I mentioned in todo that we should add an alert to handle this situation and notify a user. - Other files (not *.kml) will not be synced (until we implement the support of the nested files/directories).
biodranik commented 2024-05-02 07:48:23 +00:00 (Migrated from github.com)
Review

Is any appending write to the existing file also catched? Can a test for append write be added?

Is it possible to avoid detection of appending data?

My Files app shows non-kml files. Is it a bug that they are synced to iCloud?

image

Is any appending write to the existing file also catched? Can a test for append write be added? Is it possible to avoid detection of appending data? My Files app shows non-kml files. Is it a bug that they are synced to iCloud? ![image](https://github.com/organicmaps/organicmaps/assets/170263/db5055d0-ffd6-4f88-9d8a-91ac5880eb9d)
Review

My Files app shows non-kml files. Is it a bug that they are synced to iCloud?

It seems that these are temporary kmls created by the core...
I'll add filtering to these files.

> My Files app shows non-kml files. Is it a bug that they are synced to iCloud? It seems that these are temporary kmls created by the core... I'll add filtering to these files.
biodranik commented 2024-05-02 16:00:20 +00:00 (Migrated from github.com)
Review

Another question is why these temporary files are not deleted...

Another question is why these temporary files are not deleted...
guard directoryFileDescriptor >= 0 else {
throw SynchronizationError.failedToOpenLocalDirectoryFileDescriptor
}
let dispatchSource = DispatchSource.makeFileSystemObjectSource(fileDescriptor: directoryFileDescriptor, eventMask: [.write], queue: DispatchQueue.main)
dispatchSource.setCancelHandler {
close(directoryFileDescriptor)
}
return dispatchSource
}
func createDirectoryIfNeeded(at url: URL) throws {
if !fileExists(atPath: url.path) {
try createDirectory(at: url, withIntermediateDirectories: true)
}
}
func contentsOfDirectory(at url: URL, includingPropertiesForKeys keys: [URLResourceKey]?, options: FileManager.DirectoryEnumerationOptions, fileExtension: String) throws -> [URL] {
let files = try contentsOfDirectory(at: url, includingPropertiesForKeys: keys, options: options)
return files.filter { $0.pathExtension == fileExtension }
}
}

View file

@ -0,0 +1,156 @@
protocol MetadataItem: Equatable, Hashable {
var fileName: String { get }
var fileUrl: URL { get }
var fileSize: Int { get }
var contentType: String { get }
var creationDate: TimeInterval { get }
var lastModificationDate: TimeInterval { get }
}
struct LocalMetadataItem: MetadataItem {
let fileName: String
let fileUrl: URL
let fileSize: Int
let contentType: String
let creationDate: TimeInterval
let lastModificationDate: TimeInterval
}
struct CloudMetadataItem: MetadataItem {
let fileName: String
let fileUrl: URL
let fileSize: Int
let contentType: String
var isDownloaded: Bool
let creationDate: TimeInterval
var lastModificationDate: TimeInterval
var isRemoved: Bool
let downloadingError: NSError?
let uploadingError: NSError?
let hasUnresolvedConflicts: Bool
}
extension LocalMetadataItem {
init(metadataItem: NSMetadataItem) throws {
guard let fileName = metadataItem.value(forAttribute: NSMetadataItemFSNameKey) as? String,
let fileUrl = metadataItem.value(forAttribute: NSMetadataItemURLKey) as? URL,
let fileSize = metadataItem.value(forAttribute: NSMetadataItemFSSizeKey) as? Int,
let contentType = metadataItem.value(forAttribute: NSMetadataItemContentTypeKey) as? String,
let creationDate = (metadataItem.value(forAttribute: NSMetadataItemFSCreationDateKey) as? Date)?.timeIntervalSince1970.rounded(.down),
let lastModificationDate = (metadataItem.value(forAttribute: NSMetadataItemFSContentChangeDateKey) as? Date)?.timeIntervalSince1970.rounded(.down) else {
throw NSError(domain: "LocalMetadataItem", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to initialize LocalMetadataItem from NSMetadataItem"])
}
self.fileName = fileName
self.fileUrl = fileUrl
self.fileSize = fileSize
self.contentType = contentType
self.creationDate = creationDate
self.lastModificationDate = lastModificationDate
}
init(fileUrl: URL) throws {
let resources = try fileUrl.resourceValues(forKeys: [.fileSizeKey, .typeIdentifierKey, .contentModificationDateKey, .creationDateKey])
guard let fileSize = resources.fileSize,
let contentType = resources.typeIdentifier,
let creationDate = resources.creationDate?.timeIntervalSince1970.rounded(.down),
let lastModificationDate = resources.contentModificationDate?.timeIntervalSince1970.rounded(.down) else {
throw NSError(domain: "LocalMetadataItem", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to initialize LocalMetadataItem from URL"])
}
self.fileName = fileUrl.lastPathComponent
self.fileUrl = fileUrl
self.fileSize = fileSize
self.contentType = contentType
self.creationDate = creationDate
self.lastModificationDate = lastModificationDate
}
func fileData() throws -> Data {
try Data(contentsOf: fileUrl)
}
}
extension CloudMetadataItem {
init(metadataItem: NSMetadataItem) throws {
guard let fileName = metadataItem.value(forAttribute: NSMetadataItemFSNameKey) as? String,
let fileUrl = metadataItem.value(forAttribute: NSMetadataItemURLKey) as? URL,
let fileSize = metadataItem.value(forAttribute: NSMetadataItemFSSizeKey) as? Int,
let contentType = metadataItem.value(forAttribute: NSMetadataItemContentTypeKey) as? String,
let downloadStatus = metadataItem.value(forAttribute: NSMetadataUbiquitousItemDownloadingStatusKey) as? String,
let creationDate = (metadataItem.value(forAttribute: NSMetadataItemFSCreationDateKey) as? Date)?.timeIntervalSince1970.rounded(.down),
let lastModificationDate = (metadataItem.value(forAttribute: NSMetadataItemFSContentChangeDateKey) as? Date)?.timeIntervalSince1970.rounded(.down),
let hasUnresolvedConflicts = metadataItem.value(forAttribute: NSMetadataUbiquitousItemHasUnresolvedConflictsKey) as? Bool else {
throw NSError(domain: "CloudMetadataItem", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to initialize CloudMetadataItem from NSMetadataItem"])
}
self.fileName = fileName
self.fileUrl = fileUrl
self.fileSize = fileSize
self.contentType = contentType
self.isDownloaded = downloadStatus == NSMetadataUbiquitousItemDownloadingStatusCurrent
self.creationDate = creationDate
self.lastModificationDate = lastModificationDate
self.isRemoved = CloudMetadataItem.isInTrash(fileUrl)
self.hasUnresolvedConflicts = hasUnresolvedConflicts
self.downloadingError = metadataItem.value(forAttribute: NSMetadataUbiquitousItemDownloadingErrorKey) as? NSError
self.uploadingError = metadataItem.value(forAttribute: NSMetadataUbiquitousItemUploadingErrorKey) as? NSError
}
init(fileUrl: URL) throws {
let resources = try fileUrl.resourceValues(forKeys: [.nameKey, .fileSizeKey, .typeIdentifierKey, .contentModificationDateKey, .creationDateKey, .ubiquitousItemDownloadingStatusKey, .ubiquitousItemHasUnresolvedConflictsKey, .ubiquitousItemDownloadingErrorKey, .ubiquitousItemUploadingErrorKey])
guard let fileSize = resources.fileSize,
let contentType = resources.typeIdentifier,
let creationDate = resources.creationDate?.timeIntervalSince1970.rounded(.down),
let downloadStatus = resources.ubiquitousItemDownloadingStatus,
let lastModificationDate = resources.contentModificationDate?.timeIntervalSince1970.rounded(.down),
let hasUnresolvedConflicts = resources.ubiquitousItemHasUnresolvedConflicts else {
throw NSError(domain: "CloudMetadataItem", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to initialize CloudMetadataItem from NSMetadataItem"])
}
self.fileName = fileUrl.lastPathComponent
self.fileUrl = fileUrl
self.fileSize = fileSize
self.contentType = contentType
self.isDownloaded = downloadStatus.rawValue == NSMetadataUbiquitousItemDownloadingStatusCurrent
self.creationDate = creationDate
self.lastModificationDate = lastModificationDate
self.isRemoved = CloudMetadataItem.isInTrash(fileUrl)
self.hasUnresolvedConflicts = hasUnresolvedConflicts
self.downloadingError = resources.ubiquitousItemDownloadingError
self.uploadingError = resources.ubiquitousItemUploadingError
}
static func isInTrash(_ fileUrl: URL) -> Bool {
fileUrl.pathComponents.contains(kTrashDirectoryName)
}
func relatedLocalItemUrl(to localContainer: URL) -> URL {
localContainer.appendingPathComponent(fileName)
}
}
extension LocalMetadataItem {
func relatedCloudItemUrl(to cloudContainer: URL) -> URL {
cloudContainer.appendingPathComponent(fileName)
}
}
extension Array where Element: MetadataItem {
func containsByName(_ item: any MetadataItem) -> Bool {
return contains(where: { $0.fileName == item.fileName })
}
func firstByName(_ item: any MetadataItem) -> Element? {
return first(where: { $0.fileName == item.fileName })
}
}
extension Array where Element == CloudMetadataItem {
var downloaded: Self {
filter { $0.isDownloaded }
}
var notDownloaded: Self {
filter { !$0.isDownloaded }
}
func withUnresolvedConflicts(_ hasUnresolvedConflicts: Bool) -> Self {
filter { $0.hasUnresolvedConflicts == hasUnresolvedConflicts }
}
}

View file

@ -0,0 +1,45 @@
@objc enum SynchronizationError: Int, Error {
case fileUnavailable
case fileNotUploadedDueToQuota
case ubiquityServerNotAvailable
case iCloudIsNotAvailable
case containerNotFound
case failedToOpenLocalDirectoryFileDescriptor
case failedToRetrieveLocalDirectoryContent
}
extension SynchronizationError: LocalizedError {
var errorDescription: String? {
switch self {
case .fileUnavailable, .ubiquityServerNotAvailable:
return L("icloud_synchronization_error_connection_error")
case .fileNotUploadedDueToQuota:
return L("icloud_synchronization_error_quota_exceeded")
case .iCloudIsNotAvailable, .containerNotFound:
return L("icloud_synchronization_error_cloud_is_unavailable")
case .failedToOpenLocalDirectoryFileDescriptor:
return "Failed to open local directory file descriptor"
case .failedToRetrieveLocalDirectoryContent:
return "Failed to retrieve local directory content"
}
}
}
extension SynchronizationError {
static func fromError(_ error: Error) -> SynchronizationError? {
let nsError = error as NSError
switch nsError.code {
// NSURLUbiquitousItemDownloadingErrorKey contains an error with this code when the item has not been uploaded to iCloud by the other devices yet
case NSUbiquitousFileUnavailableError:
return .fileUnavailable
// NSURLUbiquitousItemUploadingErrorKey contains an error with this code when the item has not been uploaded to iCloud because it would make the account go over-quota
case NSUbiquitousFileNotUploadedDueToQuotaError:
return .fileNotUploadedDueToQuota
// NSURLUbiquitousItemDownloadingErrorKey and NSURLUbiquitousItemUploadingErrorKey contain an error with this code when connecting to the iCloud servers failed
case NSUbiquitousFileUbiquityServerNotAvailable:
return .ubiquityServerNotAvailable
default:
return nil
}
}
}

View file

@ -0,0 +1,273 @@
final class SynchronizationFileWriter {
private let fileManager: FileManager
private let backgroundQueue = DispatchQueue(label: "iCloud.app.organicmaps.backgroundQueue", qos: .background)
private let fileCoordinator: NSFileCoordinator
private let localDirectoryUrl: URL
private let cloudDirectoryUrl: URL
init(fileManager: FileManager = .default,
fileCoordinator: NSFileCoordinator = NSFileCoordinator(),
localDirectoryUrl: URL,
cloudDirectoryUrl: URL) {
self.fileManager = fileManager
self.fileCoordinator = fileCoordinator
self.localDirectoryUrl = localDirectoryUrl
self.cloudDirectoryUrl = cloudDirectoryUrl
}
func processEvent(_ event: OutgoingEvent, completion: @escaping WritingResultCompletionHandler) {
backgroundQueue.async { [weak self] in
guard let self else { return }
switch event {
case .createLocalItem(let cloudMetadataItem): self.createInLocalContainer(cloudMetadataItem, completion: completion)
case .updateLocalItem(let cloudMetadataItem): self.updateInLocalContainer(cloudMetadataItem, completion: completion)
case .removeLocalItem(let cloudMetadataItem): self.removeFromLocalContainer(cloudMetadataItem, completion: completion)
case .startDownloading(let cloudMetadataItem): self.startDownloading(cloudMetadataItem, completion: completion)
case .createCloudItem(let localMetadataItem): self.createInCloudContainer(localMetadataItem, completion: completion)
case .updateCloudItem(let localMetadataItem): self.updateInCloudContainer(localMetadataItem, completion: completion)
case .removeCloudItem(let localMetadataItem): self.removeFromCloudContainer(localMetadataItem, completion: completion)
case .resolveVersionsConflict(let cloudMetadataItem): self.resolveVersionsConflict(cloudMetadataItem, completion: completion)
case .resolveInitialSynchronizationConflict(let localMetadataItem): self.resolveInitialSynchronizationConflict(localMetadataItem, completion: completion)
case .didFinishInitialSynchronization: completion(.success)
case .didReceiveError(let error): completion(.failure(error))
}
}
}
// MARK: - Read/Write/Downloading/Uploading
private func startDownloading(_ cloudMetadataItem: CloudMetadataItem, completion: WritingResultCompletionHandler) {
do {
LOG(.debug, "Start downloading file: \(cloudMetadataItem.fileName)...")
try fileManager.startDownloadingUbiquitousItem(at: cloudMetadataItem.fileUrl)
completion(.success)
} catch {
completion(.failure(error))
}
}
private func createInLocalContainer(_ cloudMetadataItem: CloudMetadataItem, completion: @escaping WritingResultCompletionHandler) {
let targetLocalFileUrl = cloudMetadataItem.relatedLocalItemUrl(to: localDirectoryUrl)
guard !fileManager.fileExists(atPath: targetLocalFileUrl.path) else {
LOG(.debug, "File \(cloudMetadataItem.fileName) already exists in the local iCloud container.")
completion(.success)
return
}
writeToLocalContainer(cloudMetadataItem, completion: completion)
}
private func updateInLocalContainer(_ cloudMetadataItem: CloudMetadataItem, completion: @escaping WritingResultCompletionHandler) {
writeToLocalContainer(cloudMetadataItem, completion: completion)
}
private func writeToLocalContainer(_ cloudMetadataItem: CloudMetadataItem, completion: @escaping WritingResultCompletionHandler) {
var coordinationError: NSError?
let targetLocalFileUrl = cloudMetadataItem.relatedLocalItemUrl(to: localDirectoryUrl)
LOG(.debug, "File \(cloudMetadataItem.fileName) is downloaded to the local iCloud container. Start coordinating and writing file...")
fileCoordinator.coordinate(readingItemAt: cloudMetadataItem.fileUrl, writingItemAt: targetLocalFileUrl, error: &coordinationError) { readingUrl, writingUrl in
do {
let cloudFileData = try Data(contentsOf: readingUrl)
try cloudFileData.write(to: writingUrl, options: .atomic, lastModificationDate: cloudMetadataItem.lastModificationDate)
LOG(.debug, "File \(cloudMetadataItem.fileName) is copied to local directory successfully. Start reloading bookmarks...")
completion(.reloadCategoriesAtURLs([writingUrl]))
} catch {
completion(.failure(error))
}
return
}
if let coordinationError {
completion(.failure(coordinationError))
}
}
private func removeFromLocalContainer(_ cloudMetadataItem: CloudMetadataItem, completion: @escaping WritingResultCompletionHandler) {
LOG(.debug, "Start removing file \(cloudMetadataItem.fileName) from the local directory...")
let targetLocalFileUrl = cloudMetadataItem.relatedLocalItemUrl(to: localDirectoryUrl)
guard fileManager.fileExists(atPath: targetLocalFileUrl.path) else {
LOG(.debug, "File \(cloudMetadataItem.fileName) doesn't exist in the local directory and cannot be removed.")
completion(.success)
return
}
completion(.deleteCategoriesAtURLs([targetLocalFileUrl]))
LOG(.debug, "File \(cloudMetadataItem.fileName) is removed from the local directory successfully.")
}
private func createInCloudContainer(_ localMetadataItem: LocalMetadataItem, completion: @escaping WritingResultCompletionHandler) {
let targetCloudFileUrl = localMetadataItem.relatedCloudItemUrl(to: cloudDirectoryUrl)
guard !fileManager.fileExists(atPath: targetCloudFileUrl.path) else {
LOG(.debug, "File \(localMetadataItem.fileName) already exists in the cloud directory.")
completion(.success)
return
}
writeToCloudContainer(localMetadataItem, completion: completion)
}
private func updateInCloudContainer(_ localMetadataItem: LocalMetadataItem, completion: @escaping WritingResultCompletionHandler) {
writeToCloudContainer(localMetadataItem, completion: completion)
}
private func writeToCloudContainer(_ localMetadataItem: LocalMetadataItem, completion: @escaping WritingResultCompletionHandler) {
LOG(.debug, "Start writing file \(localMetadataItem.fileName) to the cloud directory...")
let targetCloudFileUrl = localMetadataItem.relatedCloudItemUrl(to: cloudDirectoryUrl)
var coordinationError: NSError?
fileCoordinator.coordinate(readingItemAt: localMetadataItem.fileUrl, writingItemAt: targetCloudFileUrl, error: &coordinationError) { readingUrl, writingUrl in
do {
let fileData = try localMetadataItem.fileData()
try fileData.write(to: writingUrl, lastModificationDate: localMetadataItem.lastModificationDate)
LOG(.debug, "File \(localMetadataItem.fileName) is copied to the cloud directory successfully.")
completion(.success)
} catch {
completion(.failure(error))
}
return
}
if let coordinationError {
completion(.failure(coordinationError))
}
}
private func removeFromCloudContainer(_ localMetadataItem: LocalMetadataItem, completion: @escaping WritingResultCompletionHandler) {
LOG(.debug, "Start trashing file \(localMetadataItem.fileName)...")
do {
let targetCloudFileUrl = localMetadataItem.relatedCloudItemUrl(to: cloudDirectoryUrl)
try removeDuplicatedFileFromTrashDirectoryIfNeeded(cloudDirectoryUrl: cloudDirectoryUrl, fileName: localMetadataItem.fileName)
try self.fileManager.trashItem(at: targetCloudFileUrl, resultingItemURL: nil)
LOG(.debug, "File \(localMetadataItem.fileName) was trashed successfully.")
completion(.success)
} catch {
completion(.failure(error))
}
}
// Remove duplicated file from iCloud's .Trash directory if needed.
// It's important to avoid the duplicating of names in the trash because we can't control the name of the trashed item.
private func removeDuplicatedFileFromTrashDirectoryIfNeeded(cloudDirectoryUrl: URL, fileName: String) throws {
// There are no ways to retrieve the content of iCloud's .Trash directory on macOS.
if #available(iOS 14.0, *), ProcessInfo.processInfo.isiOSAppOnMac {
return
}
LOG(.debug, "Checking if the file \(fileName) is already in the trash directory...")
let trashDirectoryUrl = try fileManager.trashDirectoryUrl(for: cloudDirectoryUrl)
let fileInTrashDirectoryUrl = trashDirectoryUrl.appendingPathComponent(fileName)
let trashDirectoryContent = try fileManager.contentsOfDirectory(at: trashDirectoryUrl,
includingPropertiesForKeys: [],
options: [.skipsPackageDescendants, .skipsSubdirectoryDescendants])
if trashDirectoryContent.contains(fileInTrashDirectoryUrl) {
LOG(.debug, "File \(fileName) is already in the trash directory. Removing it...")
try fileManager.removeItem(at: fileInTrashDirectoryUrl)
LOG(.debug, "File \(fileName) was removed from the trash directory successfully.")
}
}
// MARK: - Merge conflicts resolving
private func resolveVersionsConflict(_ cloudMetadataItem: CloudMetadataItem, completion: @escaping WritingResultCompletionHandler) {
LOG(.debug, "Start resolving version conflict for file \(cloudMetadataItem.fileName)...")
guard let versionsInConflict = NSFileVersion.unresolvedConflictVersionsOfItem(at: cloudMetadataItem.fileUrl), !versionsInConflict.isEmpty,
let currentVersion = NSFileVersion.currentVersionOfItem(at: cloudMetadataItem.fileUrl) else {
LOG(.debug, "No versions in conflict found for file \(cloudMetadataItem.fileName).")
completion(.success)
return
}
let sortedVersions = versionsInConflict.sorted { version1, version2 in
guard let date1 = version1.modificationDate, let date2 = version2.modificationDate else {
return false
}
return date1 > date2
}
guard let latestVersionInConflict = sortedVersions.first else {
LOG(.debug, "No latest version in conflict found for file \(cloudMetadataItem.fileName).")
completion(.success)
return
}
let targetCloudFileCopyUrl = generateNewFileUrl(for: cloudMetadataItem.fileUrl)
var coordinationError: NSError?
fileCoordinator.coordinate(writingItemAt: currentVersion.url,
options: [.forReplacing],
writingItemAt: targetCloudFileCopyUrl,
options: [],
error: &coordinationError) { currentVersionUrl, copyVersionUrl in
// Check that during the coordination block, the current version of the file have not been already resolved by another process.
guard let unresolvedVersions = NSFileVersion.unresolvedConflictVersionsOfItem(at: currentVersionUrl), !unresolvedVersions.isEmpty else {
LOG(.debug, "File \(cloudMetadataItem.fileName) was already resolved.")
completion(.success)
return
}
do {
// Check if the file was already resolved by another process. The in-memory versions should be marked as resolved.
guard !fileManager.fileExists(atPath: copyVersionUrl.path) else {
LOG(.debug, "File \(cloudMetadataItem.fileName) was already resolved.")
try NSFileVersion.removeOtherVersionsOfItem(at: currentVersionUrl)
completion(.success)
return
}
LOG(.debug, "Duplicate file \(cloudMetadataItem.fileName)...")
try latestVersionInConflict.replaceItem(at: copyVersionUrl)
// The modification date should be updated to mark files that was involved into the resolving process.
try currentVersionUrl.setResourceModificationDate(Date())
try copyVersionUrl.setResourceModificationDate(Date())
unresolvedVersions.forEach { $0.isResolved = true }
try NSFileVersion.removeOtherVersionsOfItem(at: currentVersionUrl)
LOG(.debug, "File \(cloudMetadataItem.fileName) was successfully resolved.")
completion(.success)
return
} catch {
completion(.failure(error))
return
}
}
if let coordinationError {
completion(.failure(coordinationError))
}
}
private func resolveInitialSynchronizationConflict(_ localMetadataItem: LocalMetadataItem, completion: @escaping WritingResultCompletionHandler) {
LOG(.debug, "Start resolving initial sync conflict for file \(localMetadataItem.fileName) by copying with a new name...")
do {
let newFileUrl = generateNewFileUrl(for: localMetadataItem.fileUrl, addDeviceName: true)
try fileManager.copyItem(at: localMetadataItem.fileUrl, to: newFileUrl)
LOG(.debug, "File \(localMetadataItem.fileName) was successfully resolved.")
completion(.reloadCategoriesAtURLs([newFileUrl]))
} catch {
completion(.failure(error))
}
}
// MARK: - Helper methods
// Generate a new file URL with a new name for the file with the same name.
// This method should generate the same name for the same file on different devices during the simultaneous conflict resolving.
private func generateNewFileUrl(for fileUrl: URL, addDeviceName: Bool = false) -> URL {
let baseName = fileUrl.deletingPathExtension().lastPathComponent
let fileExtension = fileUrl.pathExtension
let newBaseName = baseName + "_1"
let deviceName = addDeviceName ? "_\(UIDevice.current.name)" : ""
let newFileName = newBaseName + deviceName + "." + fileExtension
let newFileUrl = fileUrl.deletingLastPathComponent().appendingPathComponent(newFileName)
return newFileUrl
}
}
// MARK: - URL + ResourceValues
private extension URL {
func setResourceModificationDate(_ date: Date) throws {
var url = self
var resource = try resourceValues(forKeys:[.contentModificationDateKey])
resource.contentModificationDate = date
try url.setResourceValues(resource)
}
}
private extension Data {
func write(to url: URL, options: Data.WritingOptions = .atomic, lastModificationDate: TimeInterval? = nil) throws {
var url = url
try write(to: url, options: options)
if let lastModificationDate {
try url.setResourceModificationDate(Date(timeIntervalSince1970: lastModificationDate))
}
}
}

View file

@ -0,0 +1,260 @@
typealias MetadataItemName = String
typealias LocalContents = [LocalMetadataItem]
typealias CloudContents = [CloudMetadataItem]
protocol SynchronizationStateManager {
var currentLocalContents: LocalContents { get }
var currentCloudContents: CloudContents { get }
var localContentsGatheringIsFinished: Bool { get }
var cloudContentGatheringIsFinished: Bool { get }
@discardableResult
func resolveEvent(_ event: IncomingEvent) -> [OutgoingEvent]
func resetState()
}
enum IncomingEvent {
case didFinishGatheringLocalContents(LocalContents)
case didFinishGatheringCloudContents(CloudContents)
case didUpdateLocalContents(LocalContents)
case didUpdateCloudContents(CloudContents)
}
enum OutgoingEvent {
case createLocalItem(CloudMetadataItem)
case updateLocalItem(CloudMetadataItem)
case removeLocalItem(CloudMetadataItem)
case startDownloading(CloudMetadataItem)
case createCloudItem(LocalMetadataItem)
case updateCloudItem(LocalMetadataItem)
case removeCloudItem(LocalMetadataItem)
case didReceiveError(SynchronizationError)
case resolveVersionsConflict(CloudMetadataItem)
case resolveInitialSynchronizationConflict(LocalMetadataItem)
case didFinishInitialSynchronization
}
final class DefaultSynchronizationStateManager: SynchronizationStateManager {
// MARK: - Public properties
private(set) var localContentsGatheringIsFinished = false
private(set) var cloudContentGatheringIsFinished = false
private(set) var currentLocalContents: LocalContents = []
private(set) var currentCloudContents: CloudContents = [] {
didSet {
updateFilteredCloudContents()
}
}
// Cached derived arrays
private var trashedCloudContents: [CloudMetadataItem] = []
private var notTrashedCloudContents: [CloudMetadataItem] = []
private var downloadedCloudContents: [CloudMetadataItem] = []
private var notDownloadedCloudContents: [CloudMetadataItem] = []
private var isInitialSynchronization: Bool
init(isInitialSynchronization: Bool) {
self.isInitialSynchronization = isInitialSynchronization
}
// MARK: - Public methods
@discardableResult
func resolveEvent(_ event: IncomingEvent) -> [OutgoingEvent] {
let outgoingEvents: [OutgoingEvent]
switch event {
case .didFinishGatheringLocalContents(let contents):
localContentsGatheringIsFinished = true
outgoingEvents = resolveDidFinishGathering(localContents: contents, cloudContents: currentCloudContents)
case .didFinishGatheringCloudContents(let contents):
cloudContentGatheringIsFinished = true
outgoingEvents = resolveDidFinishGathering(localContents: currentLocalContents, cloudContents: contents)
case .didUpdateLocalContents(let contents):
outgoingEvents = resolveDidUpdateLocalContents(contents)
case .didUpdateCloudContents(let contents):
outgoingEvents = resolveDidUpdateCloudContents(contents)
}
return outgoingEvents
}
func resetState() {
currentLocalContents.removeAll()
currentCloudContents.removeAll()
localContentsGatheringIsFinished = false
cloudContentGatheringIsFinished = false
}
// MARK: - Private methods
private func updateFilteredCloudContents() {
trashedCloudContents = currentCloudContents.filter { $0.isRemoved }
notTrashedCloudContents = currentCloudContents.filter { !$0.isRemoved }
}
private func resolveDidFinishGathering(localContents: LocalContents, cloudContents: CloudContents) -> [OutgoingEvent] {
currentLocalContents = localContents
currentCloudContents = cloudContents
guard localContentsGatheringIsFinished, cloudContentGatheringIsFinished else { return [] }
var outgoingEvents: [OutgoingEvent]
switch (localContents.isEmpty, cloudContents.isEmpty) {
case (true, true):
outgoingEvents = []
case (true, false):
outgoingEvents = notTrashedCloudContents.map { .createLocalItem($0) }
case (false, true):
outgoingEvents = localContents.map { .createCloudItem($0) }
case (false, false):
var events = [OutgoingEvent]()
if isInitialSynchronization {
events.append(contentsOf: resolveInitialSynchronizationConflicts(localContents: localContents, cloudContents: cloudContents))
}
events.append(contentsOf: resolveDidUpdateCloudContents(cloudContents))
events.append(contentsOf: resolveDidUpdateLocalContents(localContents))
outgoingEvents = events
}
if isInitialSynchronization {
outgoingEvents.append(.didFinishInitialSynchronization)
isInitialSynchronization = false
}
return outgoingEvents
}
private func resolveDidUpdateLocalContents(_ localContents: LocalContents) -> [OutgoingEvent] {
let itemsToRemoveFromCloudContainer = Self.getItemsToRemoveFromCloudContainer(currentLocalContents: currentLocalContents,
newLocalContents: localContents)
let itemsToCreateInCloudContainer = Self.getItemsToCreateInCloudContainer(notTrashedCloudContents: notTrashedCloudContents,
trashedCloudContents: trashedCloudContents,
localContents: localContents)
let itemsToUpdateInCloudContainer = Self.getItemsToUpdateInCloudContainer(notTrashedCloudContents: notTrashedCloudContents,
localContents: localContents,
isInitialSynchronization: isInitialSynchronization)
var outgoingEvents = [OutgoingEvent]()
itemsToRemoveFromCloudContainer.forEach { outgoingEvents.append(.removeCloudItem($0)) }
itemsToCreateInCloudContainer.forEach { outgoingEvents.append(.createCloudItem($0)) }
itemsToUpdateInCloudContainer.forEach { outgoingEvents.append(.updateCloudItem($0)) }
currentLocalContents = localContents
return outgoingEvents
}
private func resolveDidUpdateCloudContents(_ cloudContents: CloudContents) -> [OutgoingEvent] {
var outgoingEvents = [OutgoingEvent]()
currentCloudContents = cloudContents
// 1. Handle errors
let errors = Self.getItemsWithErrors(cloudContents)
errors.forEach { outgoingEvents.append(.didReceiveError($0)) }
// 2. Handle merge conflicts
let itemsWithUnresolvedConflicts = Self.getItemsToResolveConflicts(notTrashedCloudContents: notTrashedCloudContents)
itemsWithUnresolvedConflicts.forEach { outgoingEvents.append(.resolveVersionsConflict($0)) }
// Merge conflicts should be resolved at first.
guard itemsWithUnresolvedConflicts.isEmpty else {
return outgoingEvents
}
let itemsToRemoveFromLocalContainer = Self.getItemsToRemoveFromLocalContainer(notTrashedCloudContents: notTrashedCloudContents,
trashedCloudContents: trashedCloudContents,
localContents: currentLocalContents)
let itemsToCreateInLocalContainer = Self.getItemsToCreateInLocalContainer(notTrashedCloudContents: notTrashedCloudContents,
localContents: currentLocalContents)
let itemsToUpdateInLocalContainer = Self.getItemsToUpdateInLocalContainer(notTrashedCloudContents: notTrashedCloudContents,
localContents: currentLocalContents,
isInitialSynchronization: isInitialSynchronization)
// 3. Handle not downloaded items
itemsToCreateInLocalContainer.notDownloaded.forEach { outgoingEvents.append(.startDownloading($0)) }
itemsToUpdateInLocalContainer.notDownloaded.forEach { outgoingEvents.append(.startDownloading($0)) }
// 4. Handle downloaded items
itemsToRemoveFromLocalContainer.forEach { outgoingEvents.append(.removeLocalItem($0)) }
itemsToCreateInLocalContainer.downloaded.forEach { outgoingEvents.append(.createLocalItem($0)) }
itemsToUpdateInLocalContainer.downloaded.forEach { outgoingEvents.append(.updateLocalItem($0)) }
return outgoingEvents
}
private func resolveInitialSynchronizationConflicts(localContents: LocalContents, cloudContents: CloudContents) -> [OutgoingEvent] {
let itemsInInitialConflict = localContents.filter { cloudContents.containsByName($0) }
guard !itemsInInitialConflict.isEmpty else {
return []
}
return itemsInInitialConflict.map { .resolveInitialSynchronizationConflict($0) }
}
private static func getItemsToRemoveFromCloudContainer(currentLocalContents: LocalContents, newLocalContents: LocalContents) -> LocalContents {
currentLocalContents.filter { !newLocalContents.containsByName($0) }
}
private static func getItemsToCreateInCloudContainer(notTrashedCloudContents: CloudContents, trashedCloudContents: CloudContents, localContents: LocalContents) -> LocalContents {
localContents.reduce(into: LocalContents()) { result, localItem in
if !notTrashedCloudContents.containsByName(localItem) && !trashedCloudContents.containsByName(localItem) {
result.append(localItem)
} else if !notTrashedCloudContents.containsByName(localItem),
let trashedCloudItem = trashedCloudContents.firstByName(localItem),
trashedCloudItem.lastModificationDate < localItem.lastModificationDate {
// If Cloud .Trash contains item and it's last modification date is less than the local item's last modification date than file should be recreated.
result.append(localItem)
}
}
}
private static func getItemsToUpdateInCloudContainer(notTrashedCloudContents: CloudContents, localContents: LocalContents, isInitialSynchronization: Bool) -> LocalContents {
guard !isInitialSynchronization else { return [] }
// Due to the initial sync all conflicted local items will be duplicated with different name and replaced by the cloud items to avoid a data loss.
return localContents.reduce(into: LocalContents()) { result, localItem in
if let cloudItem = notTrashedCloudContents.firstByName(localItem),
localItem.lastModificationDate > cloudItem.lastModificationDate {
result.append(localItem)
}
}
}
private static func getItemsWithErrors(_ cloudContents: CloudContents) -> [SynchronizationError] {
cloudContents.reduce(into: [SynchronizationError](), { partialResult, cloudItem in
if let downloadingError = cloudItem.downloadingError, let synchronizationError = SynchronizationError.fromError(downloadingError) {
partialResult.append(synchronizationError)
}
if let uploadingError = cloudItem.uploadingError, let synchronizationError = SynchronizationError.fromError(uploadingError) {
partialResult.append(synchronizationError)
}
})
}
private static func getItemsToRemoveFromLocalContainer(notTrashedCloudContents: CloudContents, trashedCloudContents: CloudContents, localContents: LocalContents) -> CloudContents {
trashedCloudContents.reduce(into: CloudContents()) { result, cloudItem in
// Items shouldn't be removed if newer version of the item isn't in the trash.
if let notTrashedCloudItem = notTrashedCloudContents.firstByName(cloudItem), notTrashedCloudItem.lastModificationDate > cloudItem.lastModificationDate {
return
}
if let localItemValue = localContents.firstByName(cloudItem),
cloudItem.lastModificationDate >= localItemValue.lastModificationDate {
result.append(cloudItem)
}
}
}
private static func getItemsToCreateInLocalContainer(notTrashedCloudContents: CloudContents, localContents: LocalContents) -> CloudContents {
notTrashedCloudContents.withUnresolvedConflicts(false).filter { !localContents.containsByName($0) }
}
private static func getItemsToUpdateInLocalContainer(notTrashedCloudContents: CloudContents, localContents: LocalContents, isInitialSynchronization: Bool) -> CloudContents {
notTrashedCloudContents.withUnresolvedConflicts(false).reduce(into: CloudContents()) { result, cloudItem in
if let localItemValue = localContents.firstByName(cloudItem) {
// Due to the initial sync all conflicted local items will be duplicated with different name and replaced by the cloud items to avoid a data loss.
if isInitialSynchronization {
result.append(cloudItem)
} else if cloudItem.lastModificationDate > localItemValue.lastModificationDate {
result.append(cloudItem)
}
}
}
}
private static func getItemsToResolveConflicts(notTrashedCloudContents: CloudContents) -> CloudContents {
notTrashedCloudContents.withUnresolvedConflicts(true)
}
}

View file

@ -0,0 +1,269 @@
protocol CloudDirectoryMonitor: DirectoryMonitor {
var fileManager: FileManager { get }
var ubiquitousDocumentsDirectory: URL? { get }
var delegate: CloudDirectoryMonitorDelegate? { get set }
func fetchUbiquityDirectoryUrl(completion: ((Result<URL, Error>) -> Void)?)
func isCloudAvailable() -> Bool
}
protocol CloudDirectoryMonitorDelegate : AnyObject {
func didFinishGathering(contents: CloudContents)
func didUpdate(contents: CloudContents)
func didReceiveCloudMonitorError(_ error: Error)
}
private let kUDCloudIdentityKey = "com.apple.organicmaps.UbiquityIdentityToken"
private let kDocumentsDirectoryName = "Documents"
class iCloudDocumentsDirectoryMonitor: NSObject, CloudDirectoryMonitor {
static let sharedContainerIdentifier: String = {
var identifier = "iCloud.app.organicmaps"
#if DEBUG
identifier.append(".debug")
#endif
return identifier
}()
let containerIdentifier: String
let fileManager: FileManager
private let fileType: FileType // TODO: Should be removed when the nested directory support will be implemented
private(set) var metadataQuery: NSMetadataQuery?
private(set) var ubiquitousDocumentsDirectory: URL?
// MARK: - Public properties
private(set) var state: DirectoryMonitorState = .stopped
weak var delegate: CloudDirectoryMonitorDelegate?
init(fileManager: FileManager = .default, cloudContainerIdentifier: String = iCloudDocumentsDirectoryMonitor.sharedContainerIdentifier, fileType: FileType) {
self.fileManager = fileManager
self.containerIdentifier = cloudContainerIdentifier
self.fileType = fileType
super.init()
fetchUbiquityDirectoryUrl()
subscribeOnMetadataQueryNotifications()
subscribeOnCloudAvailabilityNotifications()
}
// MARK: - Public methods
func start(completion: ((Result<URL, Error>) -> Void)? = nil) {
guard isCloudAvailable() else {
completion?(.failure(SynchronizationError.iCloudIsNotAvailable))
return
}
fetchUbiquityDirectoryUrl { [weak self] result in
guard let self else { return }
switch result {
case .failure(let error):
completion?(.failure(error))
case .success(let url):
LOG(.debug, "iCloudMonitor: Start")
self.startQuery()
self.state = .started
completion?(.success(url))
}
}
}
func stop() {
guard state != .stopped else { return }
LOG(.debug, "iCloudMonitor: Stop")
stopQuery()
state = .stopped
}
func resume() {
guard state != .started else { return }
LOG(.debug, "iCloudMonitor: Resume")
metadataQuery?.enableUpdates()
state = .started
}
func pause() {
guard state != .paused else { return }
LOG(.debug, "iCloudMonitor: Pause")
metadataQuery?.disableUpdates()
state = .paused
}
func fetchUbiquityDirectoryUrl(completion: ((Result<URL, Error>) -> Void)? = nil) {
if let ubiquitousDocumentsDirectory {
completion?(.success(ubiquitousDocumentsDirectory))
return
}
DispatchQueue.global().async {
guard let containerUrl = self.fileManager.url(forUbiquityContainerIdentifier: self.containerIdentifier) else {
LOG(.debug, "iCloudMonitor: Failed to retrieve container's URL for:\(self.containerIdentifier)")
completion?(.failure(SynchronizationError.containerNotFound))
return
}
let documentsContainerUrl = containerUrl.appendingPathComponent(kDocumentsDirectoryName)
if !self.fileManager.fileExists(atPath: documentsContainerUrl.path) {
LOG(.debug, "iCloudMonitor: Creating directory at path: \(documentsContainerUrl.path)")
do {
try self.fileManager.createDirectory(at: documentsContainerUrl, withIntermediateDirectories: true)
} catch {
completion?(.failure(SynchronizationError.containerNotFound))
}
}
LOG(.debug, "iCloudMonitor: Ubiquity directory URL: \(documentsContainerUrl)")
self.ubiquitousDocumentsDirectory = documentsContainerUrl
completion?(.success(documentsContainerUrl))
}
}
func isCloudAvailable() -> Bool {
let cloudToken = fileManager.ubiquityIdentityToken
guard let cloudToken else {
UserDefaults.standard.removeObject(forKey: kUDCloudIdentityKey)
LOG(.debug, "iCloudMonitor: Cloud is not available. Cloud token is nil.")
return false
}
do {
let data = try NSKeyedArchiver.archivedData(withRootObject: cloudToken, requiringSecureCoding: true)
UserDefaults.standard.set(data, forKey: kUDCloudIdentityKey)
return true
} catch {
UserDefaults.standard.removeObject(forKey: kUDCloudIdentityKey)
LOG(.debug, "iCloudMonitor: Failed to archive cloud token: \(error)")
return false
}
}
class func buildMetadataQuery(for fileType: FileType) -> NSMetadataQuery {
let metadataQuery = NSMetadataQuery()
metadataQuery.notificationBatchingInterval = 1
metadataQuery.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope]
metadataQuery.predicate = NSPredicate(format: "%K LIKE %@", NSMetadataItemFSNameKey, "*.\(fileType.fileExtension)")
metadataQuery.sortDescriptors = [NSSortDescriptor(key: NSMetadataItemFSNameKey, ascending: true)]
return metadataQuery
}
class func getContentsFromNotification(_ notification: Notification, _ onError: (Error) -> Void) -> CloudContents {
guard let metadataQuery = notification.object as? NSMetadataQuery,
let metadataItems = metadataQuery.results as? [NSMetadataItem] else {
return []
}
let cloudMetadataItems = CloudContents(metadataItems.compactMap { item in
do {
return try CloudMetadataItem(metadataItem: item)
} catch {
onError(error)
return nil
}
})
return cloudMetadataItems
}
// There are no ways to retrieve the content of iCloud's .Trash directory on the macOS because it uses different file system and place trashed content in the /Users/<user_name>/.Trash which cannot be observed without access.
// When we get a new notification and retrieve the metadata from the object the actual list of items in iOS contains both current and deleted files (which is in .Trash/ directory now) but on macOS we only have absence of the file. So there are no way to get list of deleted items on macOS on didFinishGathering state.
// Due to didUpdate state we can get the list of deleted items on macOS from the userInfo property but cannot get their new url.
class func getTrashContentsFromNotification(_ notification: Notification, _ onError: (Error) -> Void) -> CloudContents {
guard let removedItems = notification.userInfo?[NSMetadataQueryUpdateRemovedItemsKey] as? [NSMetadataItem] else { return [] }
return CloudContents(removedItems.compactMap { metadataItem in
do {
var item = try CloudMetadataItem(metadataItem: metadataItem)
// on macOS deleted file will not be in the ./Trash directory, but it doesn't mean that it is not removed because it is placed in the NSMetadataQueryUpdateRemovedItems array.
item.isRemoved = true
return item
} catch {
onError(error)
return nil
}
})
}
class func getTrashedContentsFromTrashDirectory(fileManager: FileManager, ubiquitousDocumentsDirectory: URL?, onError: (Error) -> Void) -> CloudContents {
// There are no ways to retrieve the content of iCloud's .Trash directory on macOS.
if #available(iOS 14.0, *), ProcessInfo.processInfo.isiOSAppOnMac {
return []
}
// On iOS we can get the list of deleted items from the .Trash directory but only when iCloud is enabled.
guard let ubiquitousDocumentsDirectory,
let trashDirectoryUrl = try? fileManager.trashDirectoryUrl(for: ubiquitousDocumentsDirectory),
let removedItems = try? fileManager.contentsOfDirectory(at: trashDirectoryUrl,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsPackageDescendants, .skipsSubdirectoryDescendants]) else {
return []
}
let removedCloudMetadataItems = CloudContents(removedItems.compactMap { url in
do {
var item = try CloudMetadataItem(fileUrl: url)
item.isRemoved = true
return item
} catch {
onError(error)
return nil
}
})
return removedCloudMetadataItems
}
}
// MARK: - Private
private extension iCloudDocumentsDirectoryMonitor {
func subscribeOnCloudAvailabilityNotifications() {
NotificationCenter.default.addObserver(self, selector: #selector(cloudAvailabilityChanged(_:)), name: .NSUbiquityIdentityDidChange, object: nil)
}
// TODO: - Actually this notification was never called. If user disable the iCloud for the current app during the active state the app will be relaunched. Needs to investigate additional cases when this notification can be sent.
@objc func cloudAvailabilityChanged(_ notification: Notification) {
LOG(.debug, "iCloudMonitor: Cloud availability changed to : \(isCloudAvailable())")
isCloudAvailable() ? startQuery() : stopQuery()
}
// MARK: - MetadataQuery
func subscribeOnMetadataQueryNotifications() {
NotificationCenter.default.addObserver(self, selector: #selector(queryDidFinishGathering(_:)), name: NSNotification.Name.NSMetadataQueryDidFinishGathering, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(queryDidUpdate(_:)), name: NSNotification.Name.NSMetadataQueryDidUpdate, object: nil)
}
func startQuery() {
metadataQuery = Self.buildMetadataQuery(for: fileType)
guard let metadataQuery, !metadataQuery.isStarted else { return }
LOG(.debug, "iCloudMonitor: Start metadata query")
metadataQuery.start()
}
func stopQuery() {
LOG(.debug, "iCloudMonitor: Stop metadata query")
metadataQuery?.stop()
metadataQuery = nil
}
@objc func queryDidFinishGathering(_ notification: Notification) {
guard isCloudAvailable() else { return }
metadataQuery?.disableUpdates()
LOG(.debug, "iCloudMonitor: Query did finish gathering")
let contents = Self.getContentsFromNotification(notification, metadataQueryErrorHandler)
let trashedContents = Self.getTrashedContentsFromTrashDirectory(fileManager: fileManager,
ubiquitousDocumentsDirectory: ubiquitousDocumentsDirectory,
onError: metadataQueryErrorHandler)
LOG(.debug, "iCloudMonitor: Cloud contents count: \(contents.count)")
LOG(.debug, "iCloudMonitor: Trashed contents count: \(trashedContents.count)")
delegate?.didFinishGathering(contents: contents + trashedContents)
metadataQuery?.enableUpdates()
}
@objc func queryDidUpdate(_ notification: Notification) {
guard isCloudAvailable() else { return }
metadataQuery?.disableUpdates()
LOG(.debug, "iCloudMonitor: Query did update")
let contents = Self.getContentsFromNotification(notification, metadataQueryErrorHandler)
let trashedContents = Self.getTrashContentsFromNotification(notification, metadataQueryErrorHandler)
LOG(.debug, "iCloudMonitor: Cloud contents count: \(contents.count)")
LOG(.debug, "iCloudMonitor: Trashed contents count: \(trashedContents.count)")
delegate?.didUpdate(contents: contents + trashedContents)
metadataQuery?.enableUpdates()
}
private var metadataQueryErrorHandler: (Error) -> Void {
{ [weak self] error in
self?.delegate?.didReceiveCloudMonitorError(error)
}
}
}

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "رابط القائمة";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "تمكين مزامنة iCloud";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "تعد مزامنة iCloud ميزة تجريبية قيد التطوير. تأكد من عمل نسخة احتياطية لجميع الإشارات المرجعية والمسارات الخاصة بك.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "تم تعطيل iCloud";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "يرجى تمكين iCloud في إعدادات جهازك لاستخدام هذه الميزة.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "يُمكَِن";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "دعم";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "خطأ: فشل المزامنة بسبب خطأ في الاتصال";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "خطأ: فشل في المزامنة بسبب تجاوز حصة iCloud النسبية";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "خطأ: iCloud غير متوفر";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Menyu keçidi";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "iCloud Sinxronizasiyasını aktivləşdirin";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "iCloud sinxronizasiyası inkişaf mərhələsində olan eksperimental xüsusiyyətdir. Bütün əlfəcinlərinizin və treklərinizin ehtiyat nüsxəsini yaratdığınızdan əmin olun.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud Deaktivdir";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Bu funksiyadan istifadə etmək üçün cihazınızın parametrlərində iCloud-u aktiv edin.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Aktivləşdirin";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Yedəkləmə";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Xəta: Bağlantı xətası səbəbindən sinxronizasiya alınmadı";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Xəta: iCloud kvotasını keçdiyinə görə sinxronizasiya alınmadı";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Xəta: iCloud mövcud deyil";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Спасылка на меню";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "Уключыць сінхранізацыю з iCloud";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "Сінхранізацыя з iCloud - гэта эксперыментальная функцыя, якая знаходзіцца ў стадыі распрацоўкі. Пераканайцеся, што вы зрабілі рэзервовую копію ўсіх сваіх закладак і трэкаў.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud адключаны";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Каб выкарыстоўваць гэту функцыю, уключыце iCloud у наладах прылады.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Уключыць";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Рэзервовае капіраванне";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Памылка: не ўдалося сінхранізаваць з-за памылкі злучэння";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Памылка: не ўдалося сінхранізаваць з-за перавышэння квоты iCloud";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Памылка: iCloud недаступны";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Връзка към менюто";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "Активиране на синхронизацията с iCloud";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "Синхронизацията в iCloud е експериментална функция в процес на разработка. Уверете се, че сте направили резервно копие на всичките си отметки и песни.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud е деактивиран";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "За да използвате тази функция, разрешете iCloud в настройките на устройството си.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Активиране на";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Резервно копие";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Грешка: Не успя да се синхронизира поради грешка при свързването";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Грешка: Неуспешно синхронизиране поради превишена квота на iCloud";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Грешка: iCloud не е наличен";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Enllaç del menú";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "Activa la sincronització d'iCloud";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "La sincronització d'iCloud és una característica experimental en desenvolupament. Assegureu-vos que heu fet una còpia de seguretat de tots els vostres marcadors i pistes.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud està desactivat";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Activa iCloud a la configuració del teu dispositiu per utilitzar aquesta funció.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Activa";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Còpia de seguretat";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Error: no s'ha pogut sincronitzar a causa d'un error de connexió";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Error: no s'ha pogut sincronitzar perquè s'ha superat la quota d'iCloud";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Error: iCloud no està disponible";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Odkaz na menu";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "Povolení synchronizace s iCloudem";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "Synchronizace iCloudu je experimentální funkce, která se vyvíjí. Ujistěte se, že jste si vytvořili zálohu všech svých záložek a skladeb.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "Služba iCloud je vypnutá";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Chcete-li tuto funkci používat, povolte v nastavení zařízení službu iCloud.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Povolit";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Záloha";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Chyba: Nepodařilo se synchronizovat z důvodu chyby připojení";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Chyba: Nepodařilo se synchronizovat z důvodu překročení kvóty iCloudu";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Chyba: iCloud není k dispozici";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Link til menu";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "Aktivér iCloud-synkronisering";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "iCloud-synkronisering er en eksperimentel funktion under udvikling. Sørg for, at du har lavet en backup af alle dine bogmærker og spor.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud er deaktiveret";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Aktivér iCloud i din enheds indstillinger for at bruge denne funktion.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Aktivér";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Sikkerhedskopiering";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Fejl: Kunne ikke synkronisere på grund af forbindelsesfejl";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Fejl: Kunne ikke synkronisere på grund af overskredet iCloud-kvote";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Fejl: iCloud er ikke tilgængelig";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Link zur Speisekarte";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "Aktiviere die iCloud-Syncronisierung";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "Die iCloud-Synchronisierung ist eine experimentelle Funktion, die noch entwickelt wird. Vergewissere dich, dass du eine Sicherungskopie all deiner Lesezeichen und Titel erstellt hast.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud ist deaktiviert";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Bitte aktiviere iCloud in den Einstellungen deines Geräts, um diese Funktion zu nutzen.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Aktiviere";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Sicherung";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Fehler: Synchronisierung aufgrund eines Verbindungsfehlers fehlgeschlagen";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Fehler: Synchronisierung fehlgeschlagen, da iCloud-Kontingent überschritten";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Fehler: iCloud ist nicht verfügbar";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Σύνδεσμος μενού";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "Ενεργοποίηση συγχρονισμού iCloud";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "Ο συγχρονισμός iCloud είναι μια πειραματική λειτουργία υπό ανάπτυξη. Βεβαιωθείτε ότι έχετε δημιουργήσει αντίγραφο ασφαλείας όλων των σελιδοδεικτών και των κομματιών σας.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "Το iCloud είναι απενεργοποιημένο";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Ενεργοποιήστε το iCloud στις ρυθμίσεις της συσκευής σας για να χρησιμοποιήσετε αυτή τη λειτουργία.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Ενεργοποίηση";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Δημιουργία αντιγράφων ασφαλείας";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Σφάλμα: Αποτυχία συγχρονισμού λόγω σφάλματος σύνδεσης";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Σφάλμα: iCloud: Απέτυχε ο συγχρονισμός λόγω υπέρβασης της ποσόστωσης iCloud";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Σφάλμα: Το iCloud δεν είναι διαθέσιμο";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Menu Link";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "Enable iCloud Syncronization";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "iCloud synchronization is an experimental feature under development. Make sure that you have made a backup of all your bookmarks and tracks.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud is Disabled";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Please enable iCloud in your device's settings to use this feature.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Enable";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Backup";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Error: Failed to synchronize due to connection error";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Error: Failed to synchronize due to iCloud quota exceeded";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Error: iCloud is not available";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Menu Link";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "Enable iCloud Syncronization";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "iCloud synchronization is an experimental feature under development. Make sure that you have made a backup of all your bookmarks and tracks.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud is Disabled";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Please enable iCloud in your device's settings to use this feature.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Enable";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Backup";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Error: Failed to synchronize due to connection error";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Error: Failed to synchronize due to iCloud quota exceeded";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Error: iCloud is not available";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Enlace al menú";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "Habilitar la sincronización de iCloud";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "La sincronización de iCloud es una función experimental en desarrollo. Asegúrese de haber realizado una copia de seguridad de todos sus marcadores y pistas.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud está desactivado";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Activa iCloud en los ajustes de tu dispositivo para utilizar esta función.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Activa";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Respaldo";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Error: No se ha podido sincronizar debido a un error de conexión";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Error: No se ha podido sincronizar porque se ha superado la cuota de iCloud";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Error: iCloud no está disponible";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Enlace al menú";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "Activar la sincronización con iCloud";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "La sincronización con iCloud es una función experimental en desarrollo. Asegúrate de haber hecho una copia de seguridad de todos tus marcadores y pistas.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud está desactivado";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Activa iCloud en los ajustes de tu dispositivo para utilizar esta función.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Activa";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Copia de seguridad";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Error: No se ha podido sincronizar debido a un error de conexión";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Error: No se ha podido sincronizar porque se ha superado la cuota de iCloud";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Error: iCloud no está disponible";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Menüü link";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "Lubage iCloudi sünkroniseerimine";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "iCloudi sünkroniseerimine on eksperimentaalne funktsioon, mis on arendamisel. Veenduge, et olete teinud varukoopia kõigist oma järjehoidjatest ja lugudest.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud on välja lülitatud";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Selle funktsiooni kasutamiseks lubage seadme seadetes iCloud.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Lubage";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Varukoopia";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Viga: Sünkroniseerimine ebaõnnestus ühenduse vea tõttu.";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Viga: iCloudi kvoodi ületamise tõttu ei õnnestunud sünkroniseerimine.";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Viga: iCloud ei ole saadaval";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Menuaren esteka";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "Gaitu iCloud sinkronizazioa";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "iCloud sinkronizazioa garatzen ari den ezaugarri esperimental bat da. Ziurtatu zure laster-marka eta ibilbide guztien babeskopia egin duzula.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud desgaituta dago";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Mesedez, gaitu iCloud zure gailuaren ezarpenetan eginbide hau erabiltzeko.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Gaitu";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Babeskopia";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Errorea: Ezin izan da sinkronizatu konexio-errore baten ondorioz";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Errorea: Ezin izan da sinkronizatu iCloud kuota gainditu delako";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Errorea: iCloud ez dago erabilgarri";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "لینک منو";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "همگام سازی iCloud را فعال کنید";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "همگام سازی iCloud یک ویژگی آزمایشی در دست توسعه است. مطمئن شوید که از تمام نشانکu200cها و آهنگu200cهای خود یک نسخه پشتیبان تهیه کردهu200cاید.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud غیرفعال است";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "لطفاً iCloud را در تنظیمات دستگاه خود فعال کنید تا از این ویژگی استفاده کنید.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "فعال کردن";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "پشتیبان گیری";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "خطا: به دلیل خطای اتصال همگام سازی نشد";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "خطا: به دلیل فراتر رفتن از سهمیه iCloud، همگام سازی انجام نشد";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "خطا: iCloud در دسترس نیست";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Valikkolinkki";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "Ota iCloud-synkronointi käyttöön";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "iCloud-synkronointi on kehitteillä oleva kokeellinen ominaisuus. Varmista, että olet tehnyt varmuuskopion kaikista kirjanmerkeistäsi ja kappaleistasi.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud on poistettu käytöstä";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Ota iCloud käyttöön laitteesi asetuksissa, jotta voit käyttää tätä ominaisuutta.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Ota käyttöön";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Varmuuskopiointi";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Virhe: Synkronointi epäonnistui yhteysvirheen vuoksi.";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Virhe: iCloud-kiintiön ylittymisen vuoksi synkronointi epäonnistui.";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Virhe: iCloud ei ole käytettävissä";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Lien vers le menu";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "Activer la synchronisation iCloud";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "La synchronisation iCloud est une fonctionnalité expérimentale en cours de développement. Assure-toi d'avoir fait une sauvegarde de tous tes signets et pistes.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud est désactivé";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Active iCloud dans les paramètres de ton appareil pour utiliser cette fonctionnalité.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Activer";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Sauvegarde";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Erreur : La synchronisation a échoué en raison d'une erreur de connexion";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Erreur : Échec de la synchronisation en raison du dépassement du quota iCloud.";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Erreur : iCloud n'est pas disponible";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "קישור לתפריט";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "הפעל סנכרון iCloud";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "סנכרון iCloud הוא תכונה ניסיונית בפיתוח. ודא שעשית גיבוי של כל הסימניות והרצועות שלך.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud מושבת";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "אנא הפעל את iCloud בהגדרות המכשיר שלך כדי להשתמש בתכונה זו.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "לְאַפשֵׁר";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "גיבוי";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "שגיאה: הסנכרון נכשל עקב שגיאת חיבור";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "שגיאה: הסנכרון נכשל עקב חריגה ממכסת iCloud";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "שגיאה: iCloud אינו זמין";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "मेनू लिंक";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "iCloud सिंक्रोनाइज़ेशन सक्षम करें";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "iCloud सिंक्रोनाइज़ेशन विकासाधीन एक प्रायोगिक सुविधा है। सुनिश्चित करें कि आपने अपने सभी बुकमार्क और ट्रैक का बैकअप बना लिया है।";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud अक्षम है";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "कृपया इस सुविधा का उपयोग करने के लिए अपने डिवाइस की सेटिंग में iCloud सक्षम करें।";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "सक्षम";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "बैकअप";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "त्रुटि: कनेक्शन त्रुटि के कारण सिंक्रनाइज़ करने में विफल";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "त्रुटि: iCloud कोटा पार हो जाने के कारण सिंक्रनाइज़ करने में विफल";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "त्रुटि: iCloud उपलब्ध नहीं है";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Menü link";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "iCloud-szinkronizálás engedélyezése";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "Az iCloud-szinkronizálás egy fejlesztés alatt álló kísérleti funkció. Győződjön meg róla, hogy készített biztonsági másolatot az összes könyvjelzőjéről és zeneszámáról.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "Az iCloud letiltva";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "A funkció használatához engedélyezze az iCloudot a készülék beállításaiban.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Engedélyezze a";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Biztonsági mentés";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Hiba: A szinkronizálás sikertelen volt a kapcsolat hibája miatt.";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Hiba: iCloud-kvóta túllépése miatt nem sikerült szinkronizálni.";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Hiba: Az iCloud nem elérhető";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Tautan menu";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "Mengaktifkan Sinkronisasi iCloud";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "Sinkronisasi iCloud adalah fitur eksperimental yang sedang dikembangkan. Pastikan Anda telah membuat cadangan semua penanda dan lagu Anda.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud Dinonaktifkan";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Aktifkan iCloud di pengaturan perangkat Anda untuk menggunakan fitur ini.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Aktifkan";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Cadangan";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Kesalahan: Gagal menyinkronkan karena kesalahan koneksi";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Kesalahan: Gagal menyinkronkan karena kuota iCloud terlampaui";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Kesalahan: iCloud tidak tersedia";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Link al menu";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "Abilita la sincronizzazione con iCloud";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "La sincronizzazione con iCloud è una funzione sperimentale in fase di sviluppo. Assicurati di aver fatto un backup di tutti i tuoi segnalibri e tracce.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud è disabilitato";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Per utilizzare questa funzione, attiva iCloud nelle impostazioni del tuo dispositivo.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Abilitazione";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Backup";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Errore: Impossibile sincronizzare a causa di un errore di connessione";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Errore: Impossibile sincronizzare a causa del superamento della quota iCloud";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Errore: iCloud non è disponibile";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "メニューリンク";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "iCloud同期を有効にする";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "iCloud同期は開発中の実験的機能である。すべてのブックマークとトラックのバックアップをとっておくこと。";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloudが無効になっている";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "この機能を使うには、デバイスの設定でiCloudを有効にしてほしい。";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "有効にする";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "バックアップ";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "エラー:接続エラーにより同期に失敗した";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "エラーiCloudのクォータが超過したため、同期に失敗しました。";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "エラーiCloudが利用できない";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "메뉴 링크";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "iCloud 동기화 활성화";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "iCloud 동기화는 개발 중인 실험적 기능입니다. 모든 북마크와 트랙을 백업해 두었는지 확인하세요.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud가 비활성화됨";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "이 기능을 사용하려면 기기 설정에서 iCloud를 활성화하세요.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "사용";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "백업";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "오류입니다: 연결 오류로 인해 동기화하지 못했습니다.";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "오류입니다: iCloud 할당량이 초과되어 동기화하지 못했습니다.";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "오류: iCloud를 사용할 수 없습니다.";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "मेनू लिंक";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "iCloud सिंक्रोनाइझेशन सक्षम करा";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "iCloud सिंक्रोनाइझेशन हे विकासाधीन प्रायोगिक वैशिष्ट्य आहे. तुम्ही तुमच्या सर्व बुकमार्क्स आणि ट्रॅकचा बॅकअप घेतला असल्याची खात्री करा.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud अक्षम आहे";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "कृपया हे वैशिष्ट्य वापरण्यासाठी तुमच्या डिव्हाइसच्या सेटिंग्जमध्ये iCloud सक्षम करा.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "सक्षम करा";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "बॅकअप";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "त्रुटी: कनेक्शन त्रुटीमुळे सिंक्रोनाइझ करण्यात अयशस्वी";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "त्रुटी: iCloud कोटा ओलांडल्यामुळे सिंक्रोनाइझ करण्यात अयशस्वी";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "त्रुटी: iCloud उपलब्ध नाही";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Lenke til menyen";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "Aktiver iCloud-synkronisering";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "iCloud-synkronisering er en eksperimentell funksjon under utvikling. Sørg for at du har tatt en sikkerhetskopi av alle bokmerkene og sporene dine.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud er deaktivert";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Aktiver iCloud i enhetens innstillinger for å bruke denne funksjonen.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Aktivere";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Sikkerhetskopiering";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Feil: Kunne ikke synkronisere på grunn av tilkoblingsfeil";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Feil: Kunne ikke synkronisere på grunn av overskredet iCloud-kvote";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Feil: iCloud er ikke tilgjengelig";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Menulink";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "iCloud-synchronisatie inschakelen";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "iCloud synchronisatie is een experimentele functie in ontwikkeling. Zorg ervoor dat je een back-up hebt gemaakt van al je bladwijzers en tracks.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud is uitgeschakeld";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Schakel iCloud in bij de instellingen van je apparaat om deze functie te gebruiken.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "inschakelen";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Back-up";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Fout: Synchronisatie mislukt door verbindingsfout";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Fout: Synchronisatie mislukt vanwege overschrijding iCloud-quota";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Fout: iCloud is niet beschikbaar";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Link do menu";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "Włącz synchronizację iCloud";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "Synchronizacja iCloud jest eksperymentalną funkcją w fazie rozwoju. Upewnij się, że wykonałeś kopię zapasową wszystkich zakładek i utworów.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud jest wyłączony";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Aby korzystać z tej funkcji, włącz usługę iCloud w ustawieniach urządzenia.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Włącz";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Kopia zapasowa";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Błąd: Nie udało się zsynchronizować z powodu błędu połączenia";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Błąd: Nie udało się zsynchronizować z powodu przekroczenia limitu iCloud";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Błąd: usługa iCloud jest niedostępna";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Link do cardápio";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "Ativar a sincronização do iCloud";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "A sincronização do iCloud é um recurso experimental em desenvolvimento. Certifique-se de que você tenha feito um backup de todos os seus favoritos e faixas.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "O iCloud está desativado";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Ative o iCloud nas configurações do seu dispositivo para usar esse recurso.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Ativar";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Cópia de segurança";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Erro: Falha na sincronização devido a erro de conexão";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Erro: Falha ao sincronizar devido à cota do iCloud excedida";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Erro: o iCloud não está disponível";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Link do menu";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "Ativar a sincronização do iCloud";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "A sincronização com o iCloud é uma funcionalidade experimental em desenvolvimento. Certifica-te de que fizeste uma cópia de segurança de todos os teus marcadores e faixas.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "O iCloud está desativado";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Para utilizar esta funcionalidade, ativa o iCloud nas definições do teu dispositivo.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Ativar";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Cópia de segurança";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Erro: Falha na sincronização devido a um erro de ligação";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Erro: Falha na sincronização devido à quota do iCloud ter sido excedida";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Erro: o iCloud não está disponível";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Legătura de meniu";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "Activați Sincronizarea iCloud";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "Sincronizarea iCloud este o funcție experimentală aflată în curs de dezvoltare. Asigurați-vă că ați făcut o copie de rezervă a tuturor marcajelor și pieselor dvs.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud este dezactivat";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Vă rugăm să activați iCloud în setările dispozitivului dvs. pentru a utiliza această funcție.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Activați";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Backup";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Eroare: Nu s-a reușit sincronizarea din cauza unei erori de conexiune";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Eroare: Nu s-a reușit sincronizarea din cauza depășirii cotei iCloud";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Eroare: iCloud nu este disponibil";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Ссылка на меню";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "Включить синхронизацию с iCloud";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "Синхронизация с iCloud - это экспериментальная функция, которая находится в стадии разработки. Убедитесь, что вы сделали резервную копию всех своих закладок и треков.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud отключен";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Чтобы воспользоваться этой функцией, включите iCloud в настройках своего устройства.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Включить";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Резервная копия";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Ошибка: Не удалось выполнить синхронизацию из-за ошибки подключения";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Ошибка: Не удалось выполнить синхронизацию из-за превышения квоты iCloud";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Ошибка: iCloud недоступен";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Odkaz na menu";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "Povolenie synchronizácie iCloud";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "Synchronizácia s iCloudom je experimentálna funkcia, ktorá je vo vývoji. Uistite sa, že ste si vytvorili zálohu všetkých svojich záložiek a skladieb.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "Služba iCloud je vypnutá";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Ak chcete túto funkciu používať, povoľte iCloud v nastaveniach zariadenia.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Povolenie stránky";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Zálohovanie";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Chyba: Nepodarilo sa synchronizovať z dôvodu chyby pripojenia";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Chyba: Nepodarilo sa synchronizovať z dôvodu prekročenia kvóty iCloudu";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Chyba: iCloud nie je k dispozícii";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Länk till meny";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "Aktivera iCloud-synkronisering";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "iCloud-synkronisering är en experimentell funktion under utveckling. Se till att du har gjort en säkerhetskopia av alla dina bokmärken och spår.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud är inaktiverat";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Aktivera iCloud i enhetens inställningar för att kunna använda denna funktion.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Aktivera";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Säkerhetskopiering";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Fel: Synkronisering misslyckades på grund av anslutningsfel";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Fel: Synkronisering misslyckades på grund av att iCloud-kvoten överskreds";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Fel: iCloud är inte tillgängligt";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Kiungo cha menyu";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "Washa Usawazishaji wa iCloud";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "Usawazishaji wa iCloud ni kipengele cha majaribio kinachoendelezwa. Hakikisha kuwa umefanya nakala rudufu ya alamisho na nyimbo zako zote.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud Imezimwa";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Tafadhali wezesha iCloud katika mipangilio ya kifaa chako ili kutumia kipengele hiki.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Wezesha";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Hifadhi nakala";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Hitilafu: Imeshindwa kusawazisha kwa sababu ya hitilafu ya muunganisho";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Hitilafu: Imeshindwa kusawazisha kwa sababu ya mgao wa iCloud uliozidishwa";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Hitilafu: iCloud haipatikani";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "ลิงค์เมนู";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "เปิดใช้งานการซิงโครไนซ์ iCloud";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "การซิงโครไนซ์ iCloud เป็นคุณสมบัติทดลองที่อยู่ระหว่างการพัฒนา ตรวจสอบให้แน่ใจว่าคุณได้สำรองข้อมูลบุ๊กมาร์กและแทร็กทั้งหมดของคุณแล้ว";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud ถูกปิดใช้งาน";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "โปรดเปิดใช้งาน iCloud ในการตั้งค่าอุปกรณ์ของคุณเพื่อใช้คุณสมบัตินี้";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "เปิดใช้งาน";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "สำรองข้อมูล";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "ข้อผิดพลาด: ไม่สามารถซิงโครไนซ์ได้เนื่องจากข้อผิดพลาดในการเชื่อมต่อ";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "ข้อผิดพลาด: ไม่สามารถซิงโครไนซ์ได้เนื่องจากเกินโควต้า iCloud";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "ข้อผิดพลาด: iCloud ไม่พร้อมใช้งาน";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Menü bağlantısı";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "iCloud Senkronizasyonunu Etkinleştir";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "iCloud senkronizasyonu geliştirme aşamasında olan deneysel bir özelliktir. Tüm yer imlerinizin ve parçalarınızın yedeğini aldığınızdan emin olun.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud Devre Dışı";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Bu özelliği kullanmak için lütfen cihazınızın ayarlarında iCloud'u etkinleştirin.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Etkinleştir";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Yedekleme";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Hata oluştu: Bağlantı hatası nedeniyle senkronizasyon başarısız oldu";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Hata: iCloud kotasııldığı için senkronize edilemedi";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Hata: iCloud kullanılamıyor";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Посилання на меню";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "Увімкнути синхронізацію з iCloud";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "Синхронізація з iCloud - це експериментальна функція, яка перебуває на стадії розробки. Переконайтеся, що ви зробили резервну копію всіх ваших закладок і треків.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud вимкнено";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Щоб скористатися цією функцією, увімкніть iCloud у налаштуваннях вашого пристрою.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Увімкнути";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Резервне копіювання";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Помилка: Не вдалося синхронізувати через помилку з'єднання";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Помилка: Не вдалося синхронізувати через перевищення квоти iCloud";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Помилка: iCloud недоступний";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "Liên kết thực đơn";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "Kích hoạt đồng bộ hóa iCloud";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "Đồng bộ hóa iCloud là một tính năng thử nghiệm đang được phát triển. Đảm bảo rằng bạn đã tạo bản sao lưu tất cả dấu trang và bản nhạc của mình.";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud bị vô hiệu hóa";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "Vui lòng bật iCloud trong cài đặt thiết bị của bạn để sử dụng tính năng này.";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "Cho phép";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "Hỗ trợ";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "Lỗi: Không đồng bộ được do lỗi kết nối";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "Lỗi: Không thể đồng bộ hóa do vượt quá hạn ngạch iCloud";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "Lỗi: iCloud không khả dụng";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "菜单链接";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "启用 iCloud 同步";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "iCloud 同步是一项正在开发的实验性功能。请确保备份了所有书签和曲目。";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud 已禁用";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "请在设备设置中启用 iCloud以使用此功能。";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "启用";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "备份";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "错误:由于连接错误,同步失败";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "错误:由于超过 iCloud 配额,同步失败";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "错误iCloud 不可用";
/********** Types **********/

View file

@ -1298,6 +1298,33 @@
/* Restaurant or other food place's menu URL field in the Editor */
"website_menu" = "選單連結";
/* Title for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_title" = "啟用 iCloud 同步";
/* Message for the "Enable iCloud Syncronization" alert. */
"enable_icloud_synchronization_message" = "iCloud 同步是一項正在開發中的實驗性功能。確保您已備份所有書籤和曲目。";
/* Title for the "iCloud is Disabled" alert. */
"icloud_disabled_title" = "iCloud 已停用";
/* Message for the "iCloud is Disabled" alert. */
"icloud_disabled_message" = "請在您的裝置設定中啟用 iCloud 以使用此功能。";
/* Title for the "Enable iCloud Syncronization" alert's "Enable" action button. */
"enable" = "使能夠";
/* Title for the "Enable iCloud Syncronization" alert's "Backup" action button. */
"backup" = "备份";
/* iCloud error message: Failed to synchronize due to connection error */
"icloud_synchronization_error_connection_error" = "錯誤:由於連線錯誤而無法同步";
/* iCloud error message: Failed to synchronize due to iCloud quota exceeded */
"icloud_synchronization_error_quota_exceeded" = "錯誤:由於超出 iCloud 配額而無法同步";
/* iCloud error message: iCloud is not available */
"icloud_synchronization_error_cloud_is_unavailable" = "錯誤iCloud 不可用";
/********** Types **********/

View file

@ -471,8 +471,15 @@
ED1263AB2B6F99F900AD99F3 /* UIView+AddSeparator.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED1263AA2B6F99F900AD99F3 /* UIView+AddSeparator.swift */; };
ED1ADA332BC6B1B40029209F /* CarPlayServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED1ADA322BC6B1B40029209F /* CarPlayServiceTests.swift */; };
ED3EAC202B03C88100220A4A /* BottomTabBarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED3EAC1F2B03C88100220A4A /* BottomTabBarButton.swift */; };
ED63CEB92BDF8F9D006155C4 /* SettingsTableViewiCloudSwitchCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED63CEB62BDF8F9C006155C4 /* SettingsTableViewiCloudSwitchCell.swift */; };
ED79A5AB2BD7AA9C00952D1F /* LoadingOverlayViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED79A5AA2BD7AA9C00952D1F /* LoadingOverlayViewController.swift */; };
ED79A5AD2BD7BA0F00952D1F /* UIApplication+LoadingOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED79A5AC2BD7BA0F00952D1F /* UIApplication+LoadingOverlay.swift */; };
ED79A5D32BDF8D6100952D1F /* CloudStorageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED79A5CB2BDF8D6100952D1F /* CloudStorageManager.swift */; };
ED79A5D42BDF8D6100952D1F /* MetadataItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED79A5CC2BDF8D6100952D1F /* MetadataItem.swift */; };
ED79A5D52BDF8D6100952D1F /* SynchronizationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED79A5CD2BDF8D6100952D1F /* SynchronizationError.swift */; };
ED79A5D62BDF8D6100952D1F /* iCloudDocumentsDirectoryMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED79A5CE2BDF8D6100952D1F /* iCloudDocumentsDirectoryMonitor.swift */; };
ED79A5D72BDF8D6100952D1F /* SynchronizationStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED79A5CF2BDF8D6100952D1F /* SynchronizationStateManager.swift */; };
ED79A5D82BDF8D6100952D1F /* DefaultLocalDirectoryMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED79A5D02BDF8D6100952D1F /* DefaultLocalDirectoryMonitor.swift */; };
ED9966802B94FBC20083CE55 /* ColorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED99667D2B94FBC20083CE55 /* ColorPicker.swift */; };
EDBD68072B625724005DD151 /* LocationServicesDisabledAlert.xib in Resources */ = {isa = PBXBuildFile; fileRef = EDBD68062B625724005DD151 /* LocationServicesDisabledAlert.xib */; };
EDBD680B2B62572E005DD151 /* LocationServicesDisabledAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDBD680A2B62572E005DD151 /* LocationServicesDisabledAlert.swift */; };
@ -480,6 +487,14 @@
EDE243DD2B6D2E640057369B /* AboutController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDE243D52B6CF3980057369B /* AboutController.swift */; };
EDE243E52B6D3F400057369B /* OSMView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDE243E42B6D3F400057369B /* OSMView.swift */; };
EDE243E72B6D55610057369B /* InfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDE243E02B6D3EA00057369B /* InfoView.swift */; };
EDF838842C00B640007E4E67 /* SynchronizationFileWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDF838812C00B640007E4E67 /* SynchronizationFileWriter.swift */; };
EDF838BE2C00B9D0007E4E67 /* LocalDirectoryMonitorDelegateMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDF838AD2C00B9C7007E4E67 /* LocalDirectoryMonitorDelegateMock.swift */; };
EDF838BF2C00B9D0007E4E67 /* SynchronizationStateManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDF838AF2C00B9C7007E4E67 /* SynchronizationStateManagerTests.swift */; };
EDF838C02C00B9D0007E4E67 /* DefaultLocalDirectoryMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDF838AE2C00B9C7007E4E67 /* DefaultLocalDirectoryMonitorTests.swift */; };
EDF838C12C00B9D6007E4E67 /* iCloudDirectoryMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDF838B22C00B9C7007E4E67 /* iCloudDirectoryMonitorTests.swift */; };
EDF838C22C00B9D6007E4E67 /* MetadataItemStubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDF838B42C00B9C7007E4E67 /* MetadataItemStubs.swift */; };
EDF838C32C00B9D6007E4E67 /* UbiquitousDirectoryMonitorDelegateMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDF838B12C00B9C7007E4E67 /* UbiquitousDirectoryMonitorDelegateMock.swift */; };
EDF838C42C00B9D6007E4E67 /* FileManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDF838B32C00B9C7007E4E67 /* FileManagerMock.swift */; };
EDFDFB462B7139490013A44C /* AboutInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDFDFB452B7139490013A44C /* AboutInfo.swift */; };
EDFDFB482B7139670013A44C /* SocialMedia.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDFDFB472B7139670013A44C /* SocialMedia.swift */; };
EDFDFB4A2B722A310013A44C /* SocialMediaCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDFDFB492B722A310013A44C /* SocialMediaCollectionViewCell.swift */; };
@ -1364,8 +1379,15 @@
ED3EAC1F2B03C88100220A4A /* BottomTabBarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomTabBarButton.swift; sourceTree = "<group>"; };
ED48BBB817C2B1E2003E7E92 /* CircleView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CircleView.h; sourceTree = "<group>"; };
ED48BBB917C2B1E2003E7E92 /* CircleView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CircleView.m; sourceTree = "<group>"; };
ED63CEB62BDF8F9C006155C4 /* SettingsTableViewiCloudSwitchCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsTableViewiCloudSwitchCell.swift; sourceTree = "<group>"; };
ED79A5AA2BD7AA9C00952D1F /* LoadingOverlayViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingOverlayViewController.swift; sourceTree = "<group>"; };
ED79A5AC2BD7BA0F00952D1F /* UIApplication+LoadingOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+LoadingOverlay.swift"; sourceTree = "<group>"; };
ED79A5CB2BDF8D6100952D1F /* CloudStorageManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudStorageManager.swift; sourceTree = "<group>"; };
ED79A5CC2BDF8D6100952D1F /* MetadataItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MetadataItem.swift; sourceTree = "<group>"; };
ED79A5CD2BDF8D6100952D1F /* SynchronizationError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SynchronizationError.swift; sourceTree = "<group>"; };
ED79A5CE2BDF8D6100952D1F /* iCloudDocumentsDirectoryMonitor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = iCloudDocumentsDirectoryMonitor.swift; sourceTree = "<group>"; };
ED79A5CF2BDF8D6100952D1F /* SynchronizationStateManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SynchronizationStateManager.swift; sourceTree = "<group>"; };
ED79A5D02BDF8D6100952D1F /* DefaultLocalDirectoryMonitor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DefaultLocalDirectoryMonitor.swift; sourceTree = "<group>"; };
ED99667D2B94FBC20083CE55 /* ColorPicker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ColorPicker.swift; sourceTree = "<group>"; };
EDBD68062B625724005DD151 /* LocationServicesDisabledAlert.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = LocationServicesDisabledAlert.xib; sourceTree = "<group>"; };
EDBD680A2B62572E005DD151 /* LocationServicesDisabledAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationServicesDisabledAlert.swift; sourceTree = "<group>"; };
@ -1373,6 +1395,14 @@
EDE243D52B6CF3980057369B /* AboutController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutController.swift; sourceTree = "<group>"; };
EDE243E02B6D3EA00057369B /* InfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoView.swift; sourceTree = "<group>"; };
EDE243E42B6D3F400057369B /* OSMView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSMView.swift; sourceTree = "<group>"; };
EDF838812C00B640007E4E67 /* SynchronizationFileWriter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SynchronizationFileWriter.swift; sourceTree = "<group>"; };
EDF838AD2C00B9C7007E4E67 /* LocalDirectoryMonitorDelegateMock.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalDirectoryMonitorDelegateMock.swift; sourceTree = "<group>"; };
EDF838AE2C00B9C7007E4E67 /* DefaultLocalDirectoryMonitorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DefaultLocalDirectoryMonitorTests.swift; sourceTree = "<group>"; };
EDF838AF2C00B9C7007E4E67 /* SynchronizationStateManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SynchronizationStateManagerTests.swift; sourceTree = "<group>"; };
EDF838B12C00B9C7007E4E67 /* UbiquitousDirectoryMonitorDelegateMock.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UbiquitousDirectoryMonitorDelegateMock.swift; sourceTree = "<group>"; };
EDF838B22C00B9C7007E4E67 /* iCloudDirectoryMonitorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = iCloudDirectoryMonitorTests.swift; sourceTree = "<group>"; };
EDF838B32C00B9C7007E4E67 /* FileManagerMock.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileManagerMock.swift; sourceTree = "<group>"; };
EDF838B42C00B9C7007E4E67 /* MetadataItemStubs.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MetadataItemStubs.swift; sourceTree = "<group>"; };
EDFDFB452B7139490013A44C /* AboutInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutInfo.swift; sourceTree = "<group>"; };
EDFDFB472B7139670013A44C /* SocialMedia.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialMedia.swift; sourceTree = "<group>"; };
EDFDFB492B722A310013A44C /* SocialMediaCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialMediaCollectionViewCell.swift; sourceTree = "<group>"; };
@ -1912,6 +1942,7 @@
340475281E081A4600C92850 /* Core */ = {
isa = PBXGroup;
children = (
ED79A5CA2BDF8D6100952D1F /* iCloud */,
994AEBE323AB763C0079B81F /* Theme */,
993F54F6237C622700545511 /* DeepLink */,
CDCA27822245090900167D87 /* EventListening */,
@ -2616,6 +2647,7 @@
4B4153B62BF9709100EE4B02 /* Core */ = {
isa = PBXGroup;
children = (
EDF838AB2C00B9C7007E4E67 /* iCloudTests */,
4B4153B72BF970A000EE4B02 /* TextToSpeech */,
);
path = Core;
@ -3025,6 +3057,20 @@
path = LoadingOverlay;
sourceTree = "<group>";
};
ED79A5CA2BDF8D6100952D1F /* iCloud */ = {
isa = PBXGroup;
children = (
ED79A5CB2BDF8D6100952D1F /* CloudStorageManager.swift */,
EDF838812C00B640007E4E67 /* SynchronizationFileWriter.swift */,
ED79A5CF2BDF8D6100952D1F /* SynchronizationStateManager.swift */,
ED79A5CE2BDF8D6100952D1F /* iCloudDocumentsDirectoryMonitor.swift */,
ED79A5D02BDF8D6100952D1F /* DefaultLocalDirectoryMonitor.swift */,
ED79A5CC2BDF8D6100952D1F /* MetadataItem.swift */,
ED79A5CD2BDF8D6100952D1F /* SynchronizationError.swift */,
);
path = iCloud;
sourceTree = "<group>";
};
ED99667C2B94FBC20083CE55 /* ColorPicker */ = {
isa = PBXGroup;
children = (
@ -3033,6 +3079,36 @@
path = ColorPicker;
sourceTree = "<group>";
};
EDF838AB2C00B9C7007E4E67 /* iCloudTests */ = {
isa = PBXGroup;
children = (
EDF838AC2C00B9C7007E4E67 /* DefaultLocalDirectoryMonitorTests */,
EDF838AF2C00B9C7007E4E67 /* SynchronizationStateManagerTests.swift */,
EDF838B02C00B9C7007E4E67 /* iCloudDirectoryMonitorTests */,
EDF838B42C00B9C7007E4E67 /* MetadataItemStubs.swift */,
);
path = iCloudTests;
sourceTree = "<group>";
};
EDF838AC2C00B9C7007E4E67 /* DefaultLocalDirectoryMonitorTests */ = {
isa = PBXGroup;
children = (
EDF838AD2C00B9C7007E4E67 /* LocalDirectoryMonitorDelegateMock.swift */,
EDF838AE2C00B9C7007E4E67 /* DefaultLocalDirectoryMonitorTests.swift */,
);
path = DefaultLocalDirectoryMonitorTests;
sourceTree = "<group>";
};
EDF838B02C00B9C7007E4E67 /* iCloudDirectoryMonitorTests */ = {
isa = PBXGroup;
children = (
EDF838B12C00B9C7007E4E67 /* UbiquitousDirectoryMonitorDelegateMock.swift */,
EDF838B22C00B9C7007E4E67 /* iCloudDirectoryMonitorTests.swift */,
EDF838B32C00B9C7007E4E67 /* FileManagerMock.swift */,
);
path = iCloudDirectoryMonitorTests;
sourceTree = "<group>";
};
EDFDFB412B7108090013A44C /* AboutController */ = {
isa = PBXGroup;
children = (
@ -3628,6 +3704,7 @@
F6E2FD371E097BA00083EBEC /* Cells */ = {
isa = PBXGroup;
children = (
ED63CEB62BDF8F9C006155C4 /* SettingsTableViewiCloudSwitchCell.swift */,
F6E2FD381E097BA00083EBEC /* SettingsTableViewLinkCell.swift */,
F6E2FD391E097BA00083EBEC /* SettingsTableViewSelectableCell.swift */,
F6E2FD3A1E097BA00083EBEC /* SettingsTableViewSwitchCell.swift */,
@ -4037,7 +4114,7 @@
6741A9981BF340DE002C974C /* resources-xhdpi_light in Resources */,
6741A9611BF340DE002C974C /* resources-xhdpi_dark in Resources */,
6741A94D1BF340DE002C974C /* resources-xxhdpi_light in Resources */,
3404F49E2028A2430090E401 /* BMCActionsCreateCell.xib in Resources */,
3404F49E2028A2430090E401 /* BMCActionsCell.xib in Resources */,
6741A9551BF340DE002C974C /* resources-xxhdpi_dark in Resources */,
340E1EF51E2F614400CE49BF /* SearchFilters.storyboard in Resources */,
340E1EF81E2F614400CE49BF /* Settings.storyboard in Resources */,
@ -4174,6 +4251,7 @@
F6E2FF631E097BA00083EBEC /* MWMTTSLanguageViewController.mm in Sources */,
4715273524907F8200E91BBA /* BookmarkColorViewController.swift in Sources */,
47E3C7292111E614008B3B27 /* FadeInAnimatedTransitioning.swift in Sources */,
ED79A5D42BDF8D6100952D1F /* MetadataItem.swift in Sources */,
34AB667D1FC5AA330078E451 /* MWMRoutePreview.mm in Sources */,
993DF11B23F6BDB100AC231A /* UIViewRenderer.swift in Sources */,
99C964302428C27A00E41723 /* PlacePageHeaderView.swift in Sources */,
@ -4199,9 +4277,11 @@
3454D7D41E07F045004AF2AD /* UIImageView+Coloring.m in Sources */,
993DF11D23F6BDB100AC231A /* UIToolbarRenderer.swift in Sources */,
99A906E923F6F7030005872B /* WikiDescriptionViewController.swift in Sources */,
ED79A5D62BDF8D6100952D1F /* iCloudDocumentsDirectoryMonitor.swift in Sources */,
EDFDFB522B726F1A0013A44C /* ButtonsStackView.swift in Sources */,
993DF11023F6BDB100AC231A /* MWMButtonRenderer.swift in Sources */,
3463BA671DE81DB90082417F /* MWMTrafficButtonViewController.mm in Sources */,
ED79A5D52BDF8D6100952D1F /* SynchronizationError.swift in Sources */,
993DF10323F6BDB100AC231A /* MainTheme.swift in Sources */,
34AB66051FC5AA320078E451 /* MWMNavigationDashboardManager+Entity.mm in Sources */,
993DF12A23F6BDB100AC231A /* Style.swift in Sources */,
@ -4231,6 +4311,7 @@
99A906E523F6F7030005872B /* ActionBarViewController.swift in Sources */,
47B9065421C7FA400079C85E /* UIImageView+WebImage.m in Sources */,
F6E2FF481E097BA00083EBEC /* SettingsTableViewSelectableCell.swift in Sources */,
ED63CEB92BDF8F9D006155C4 /* SettingsTableViewiCloudSwitchCell.swift in Sources */,
47CA68D4250043C000671019 /* BookmarksListPresenter.swift in Sources */,
F6E2FF451E097BA00083EBEC /* SettingsTableViewLinkCell.swift in Sources */,
34C9BD0A1C6DBCDA000DC38D /* MWMNavigationController.m in Sources */,
@ -4340,6 +4421,7 @@
F6E2FF661E097BA00083EBEC /* MWMTTSSettingsViewController.mm in Sources */,
3454D7C21E07F045004AF2AD /* NSString+Categories.m in Sources */,
34E7761F1F14DB48003040B3 /* PlacePageArea.swift in Sources */,
ED79A5D82BDF8D6100952D1F /* DefaultLocalDirectoryMonitor.swift in Sources */,
4728F69322CF89A400E00028 /* GradientView.swift in Sources */,
F6381BF61CD12045004CA943 /* LocaleTranslator.mm in Sources */,
9917D17F2397B1D600A7E06E /* IPadModalPresentationController.swift in Sources */,
@ -4411,6 +4493,7 @@
998927402449ECC200260CE2 /* BottomMenuItemCell.swift in Sources */,
F6E2FEE21E097BA00083EBEC /* MWMSearchManager.mm in Sources */,
F6E2FE221E097BA00083EBEC /* MWMOpeningHoursEditorViewController.mm in Sources */,
ED79A5D72BDF8D6100952D1F /* SynchronizationStateManager.swift in Sources */,
999FC12B23ABB4B800B0E6F9 /* FontStyleSheet.swift in Sources */,
47CA68DA2500469400671019 /* BookmarksListBuilder.swift in Sources */,
34D3AFE21E376F7E004100F9 /* UITableView+Updates.swift in Sources */,
@ -4440,6 +4523,7 @@
340475711E081A4600C92850 /* MWMSettings.mm in Sources */,
33046832219C57180041F3A8 /* CategorySettingsViewController.swift in Sources */,
3404756E1E081A4600C92850 /* MWMSearch.mm in Sources */,
EDF838842C00B640007E4E67 /* SynchronizationFileWriter.swift in Sources */,
6741AA191BF340DE002C974C /* MWMDownloaderDialogCell.m in Sources */,
993DF10823F6BDB100AC231A /* IColors.swift in Sources */,
4707E4B12372FE860017DF6E /* PlacePageViewController.swift in Sources */,
@ -4451,6 +4535,7 @@
6741AA1C1BF340DE002C974C /* MWMRoutingDisclaimerAlert.m in Sources */,
34D3B0481E389D05004100F9 /* MWMNoteCell.m in Sources */,
CD9AD967228067F500EC174A /* MapInfo.swift in Sources */,
ED79A5D32BDF8D6100952D1F /* CloudStorageManager.swift in Sources */,
6741AA1D1BF340DE002C974C /* MWMDownloadTransitMapAlert.mm in Sources */,
471A7BBE2481A3D000A0D4C1 /* EditBookmarkViewController.swift in Sources */,
993DF0C923F6BD0600AC231A /* ElevationDetailsBuilder.swift in Sources */,
@ -4510,8 +4595,15 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
EDF838C12C00B9D6007E4E67 /* iCloudDirectoryMonitorTests.swift in Sources */,
EDF838C02C00B9D0007E4E67 /* DefaultLocalDirectoryMonitorTests.swift in Sources */,
ED1ADA332BC6B1B40029209F /* CarPlayServiceTests.swift in Sources */,
EDF838C32C00B9D6007E4E67 /* UbiquitousDirectoryMonitorDelegateMock.swift in Sources */,
EDF838BE2C00B9D0007E4E67 /* LocalDirectoryMonitorDelegateMock.swift in Sources */,
EDF838BF2C00B9D0007E4E67 /* SynchronizationStateManagerTests.swift in Sources */,
EDF838C22C00B9D6007E4E67 /* MetadataItemStubs.swift in Sources */,
4B4153B52BF9695500EE4B02 /* MWMTextToSpeechTests.mm in Sources */,
EDF838C42C00B9D6007E4E67 /* FileManagerMock.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View file

@ -2,11 +2,28 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:omaps.app</string>
</array>
<key>com.apple.developer.carplay-maps</key>
<true/>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.app.organicmaps.debug</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudDocuments</string>
<string>CloudKit</string>
</array>
<key>com.apple.developer.ubiquity-container-identifiers</key>
<array>
<string>iCloud.app.organicmaps.debug</string>
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
</dict>
</plist>

View file

@ -2,11 +2,28 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:omaps.app</string>
</array>
<key>com.apple.developer.carplay-maps</key>
<true/>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.app.organicmaps</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudDocuments</string>
<string>CloudKit</string>
</array>
<key>com.apple.developer.ubiquity-container-identifiers</key>
<array>
<string>iCloud.app.organicmaps</string>
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
</dict>
</plist>

View file

@ -252,5 +252,26 @@
</dict>
</dict>
</array>
<key>NSUbiquitousContainers</key>
<dict>
<key>iCloud.app.organicmaps</key>
<dict>
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
<true/>
<key>NSUbiquitousContainerSupportedFolderLevels</key>
<string>ANY</string>
<key>NSUbiquitousContainerName</key>
<string>OrganicMaps</string>
</dict>
<key>iCloud.app.organicmaps.debug</key>
<dict>
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
<true/>
<key>NSUbiquitousContainerSupportedFolderLevels</key>
<string>ANY</string>
<key>NSUbiquitousContainerName</key>
<string>OrganicMapsDEBUG</string>
</dict>
</dict>
</dict>
</plist>

View file

@ -0,0 +1,134 @@
import XCTest
@testable import Organic_Maps__Debug_
final class DefaultLocalDirectoryMonitorTests: XCTestCase {
let fileManager = FileManager.default
let tempDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
var directoryMonitor: DefaultLocalDirectoryMonitor!
var mockDelegate: LocalDirectoryMonitorDelegateMock!
override func setUpWithError() throws {
try super.setUpWithError()
// Setup with a temporary directory and a mock delegate
directoryMonitor = try DefaultLocalDirectoryMonitor(fileManager: fileManager, directory: tempDirectory)
mockDelegate = LocalDirectoryMonitorDelegateMock()
directoryMonitor.delegate = mockDelegate
}
override func tearDownWithError() throws {
directoryMonitor.stop()
mockDelegate = nil
try? fileManager.removeItem(at: tempDirectory)
try super.tearDownWithError()
}
func testInitialization() {
XCTAssertEqual(directoryMonitor.directory, tempDirectory, "Monitor initialized with incorrect directory.")
XCTAssertTrue(directoryMonitor.state == .stopped, "Monitor should be stopped initially.")
}
func testStartMonitoring() {
let startExpectation = expectation(description: "Start monitoring")
directoryMonitor.start { result in
switch result {
case .success:
XCTAssertTrue(self.directoryMonitor.state == .started, "Monitor should be started.")
case .failure(let error):
XCTFail("Monitoring failed to start with error: \(error)")
}
startExpectation.fulfill()
}
wait(for: [startExpectation], timeout: 5.0)
}
func testStopMonitoring() {
directoryMonitor.start()
directoryMonitor.stop()
XCTAssertTrue(directoryMonitor.state == .stopped, "Monitor should be stopped.")
}
func testPauseAndResumeMonitoring() {
directoryMonitor.start()
directoryMonitor.pause()
XCTAssertTrue(directoryMonitor.state == .paused, "Monitor should be paused.")
directoryMonitor.resume()
XCTAssertTrue(directoryMonitor.state == .started, "Monitor should be started.")
}
func testDelegateDidFinishGathering() {
mockDelegate.didFinishGatheringExpectation = expectation(description: "didFinishGathering called")
directoryMonitor.start()
wait(for: [mockDelegate.didFinishGatheringExpectation!], timeout: 5.0)
}
func testDelegateDidReceiveError() {
mockDelegate.didReceiveErrorExpectation = expectation(description: "didReceiveLocalMonitorError called")
let error = NSError(domain: NSCocoaErrorDomain, code: NSFileNoSuchFileError, userInfo: nil)
directoryMonitor.delegate?.didReceiveLocalMonitorError(error)
wait(for: [mockDelegate.didReceiveErrorExpectation!], timeout: 1.0)
}
func testContentUpdateDetection() {
let startExpectation = expectation(description: "Start monitoring")
let didFinishGatheringExpectation = expectation(description: "didFinishGathering called")
let didUpdateExpectation = expectation(description: "didUpdate called")
mockDelegate.didFinishGatheringExpectation = didFinishGatheringExpectation
mockDelegate.didUpdateExpectation = didUpdateExpectation
directoryMonitor.start { result in
if case .success = result {
XCTAssertTrue(self.directoryMonitor.state == .started, "Monitor should be started.")
}
startExpectation.fulfill()
}
wait(for: [startExpectation], timeout: 5)
let fileURL = tempDirectory.appendingPathComponent("test.kml")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.fileManager.createFile(atPath: fileURL.path, contents: Data(), attributes: nil)
}
wait(for: [didFinishGatheringExpectation, didUpdateExpectation], timeout: 20)
}
func testFileWithIncorrectExtension() {
let startExpectation = expectation(description: "Start monitoring")
let didFinishGatheringExpectation = expectation(description: "didFinishGathering called")
mockDelegate.didFinishGatheringExpectation = didFinishGatheringExpectation
let file1URL = tempDirectory.appendingPathComponent("test.kml.tmp")
let file2URL = tempDirectory.appendingPathComponent("test2.tmp")
let file3URL = tempDirectory.appendingPathComponent("test3.jpg")
let correctFileURL = tempDirectory.appendingPathComponent("test.kml")
var fileData = Data(count: 12)
try! fileData.write(to: file1URL, options: .atomic)
try! fileData.write(to: file2URL, options: .atomic)
try! fileData.write(to: file3URL, options: .atomic)
try! fileData.write(to: correctFileURL, options: .atomic)
directoryMonitor.start { result in
switch result {
case .failure(let error):
XCTFail("Monitoring failed to start with error: \(error)")
case .success:
XCTAssertTrue(self.directoryMonitor.state == .started, "Monitor should be started.")
startExpectation.fulfill()
}
}
wait(for: [startExpectation, didFinishGatheringExpectation], timeout: 5)
let contents = self.mockDelegate.contents.map { $0.fileUrl }
XCTAssertFalse(contents.contains(file1URL), "File with incorrect extension should not be included")
XCTAssertFalse(contents.contains(file2URL), "File with incorrect extension should not be included")
XCTAssertFalse(contents.contains(file3URL), "File with incorrect extension should not be included")
XCTAssertTrue(contents.contains(correctFileURL), "File with correct extension should be included")
}
}

View file

@ -0,0 +1,24 @@
import XCTest
@testable import Organic_Maps__Debug_
class LocalDirectoryMonitorDelegateMock: LocalDirectoryMonitorDelegate {
var contents = LocalContents()
var didFinishGatheringExpectation: XCTestExpectation?
var didUpdateExpectation: XCTestExpectation?
var didReceiveErrorExpectation: XCTestExpectation?
func didFinishGathering(contents: LocalContents) {
self.contents = contents
didFinishGatheringExpectation?.fulfill()
}
func didUpdate(contents: LocalContents) {
self.contents = contents
didUpdateExpectation?.fulfill()
}
func didReceiveLocalMonitorError(_ error: Error) {
didReceiveErrorExpectation?.fulfill()
}
}

View file

@ -0,0 +1,36 @@
@testable import Organic_Maps__Debug_
extension LocalMetadataItem {
static func stub(fileName: String,
lastModificationDate: TimeInterval) -> LocalMetadataItem {
let item = LocalMetadataItem(fileName: fileName,
fileUrl: URL(string: "url")!,
fileSize: 0,
contentType: "",
creationDate: Date().timeIntervalSince1970,
lastModificationDate: lastModificationDate)
return item
}
}
extension CloudMetadataItem {
static func stub(fileName: String,
lastModificationDate: TimeInterval,
isInTrash: Bool,
isDownloaded: Bool = true,
hasUnresolvedConflicts: Bool = false) -> CloudMetadataItem {
let item = CloudMetadataItem(fileName: fileName,
fileUrl: URL(string: "url")!,
fileSize: 0,
contentType: "",
isDownloaded: isDownloaded,
creationDate: Date().timeIntervalSince1970,
lastModificationDate: lastModificationDate,
isRemoved: isInTrash,
downloadingError: nil,
uploadingError: nil,
hasUnresolvedConflicts: hasUnresolvedConflicts)
return item
}
}

View file

@ -0,0 +1,752 @@
import XCTest
@testable import Organic_Maps__Debug_
final class SynchronizationStateManagerTests: XCTestCase {
var syncStateManager: SynchronizationStateManager!
var outgoingEvents: [OutgoingEvent] = []
override func setUp() {
super.setUp()
syncStateManager = DefaultSynchronizationStateManager(isInitialSynchronization: false)
}
override func tearDown() {
syncStateManager = nil
outgoingEvents.removeAll()
super.tearDown()
}
// MARK: - Test didFinishGathering without errors and on initial synchronization
func testInitialSynchronization() {
syncStateManager = DefaultSynchronizationStateManager(isInitialSynchronization: true)
let localItem1 = LocalMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1))
let localItem2 = LocalMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(3)) // Local only item
let cloudItem1 = CloudMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(2), isInTrash: false) // Conflicting item
let cloudItem3 = CloudMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(4), isInTrash: false) // Cloud only item
let localItems: LocalContents = [localItem1, localItem2]
let cloudItems: CloudContents = [cloudItem1, cloudItem3]
outgoingEvents.append(contentsOf: syncStateManager.resolveEvent(.didFinishGatheringLocalContents(localItems)))
outgoingEvents.append(contentsOf: syncStateManager.resolveEvent(.didFinishGatheringCloudContents(cloudItems)))
XCTAssertTrue(outgoingEvents.contains { event in
if case .resolveInitialSynchronizationConflict(let item) = event, item == localItem1 {
return true
}
return false
}, "Expected to resolve initial synchronization conflict for localItem1")
XCTAssertTrue(outgoingEvents.contains { event in
if case .createLocalItem(let item) = event, item == cloudItem3 {
return true
}
return false
}, "Expected to create local item for cloudItem3")
XCTAssertTrue(outgoingEvents.contains { event in
if case .createCloudItem(let item) = event, item == localItem2 {
return true
}
return false
}, "Expected to create cloud item for localItem2")
XCTAssertTrue(outgoingEvents.contains { event in
if case .didFinishInitialSynchronization = event {
return true
}
return false
}, "Expected to finish initial synchronization")
}
func testInitialSynchronizationWithNewerCloudItem() {
syncStateManager = DefaultSynchronizationStateManager(isInitialSynchronization: true)
let localItem = LocalMetadataItem.stub(fileName: "file", lastModificationDate: TimeInterval(1))
let cloudItem = CloudMetadataItem.stub(fileName: "file", lastModificationDate: TimeInterval(2), isInTrash: false)
outgoingEvents.append(contentsOf: syncStateManager.resolveEvent(.didFinishGatheringLocalContents([localItem])))
outgoingEvents.append(contentsOf: syncStateManager.resolveEvent(.didFinishGatheringCloudContents([cloudItem])))
XCTAssertTrue(outgoingEvents.contains { if case .resolveInitialSynchronizationConflict(_) = $0 { return true } else { return false } }, "Expected conflict resolution for a newer cloud item")
}
func testInitialSynchronizationWithNewerLocalItem() {
syncStateManager = DefaultSynchronizationStateManager(isInitialSynchronization: true)
let localItem = LocalMetadataItem.stub(fileName: "file", lastModificationDate: TimeInterval(2))
let cloudItem = CloudMetadataItem.stub(fileName: "file", lastModificationDate: TimeInterval(1), isInTrash: false)
outgoingEvents.append(contentsOf: syncStateManager.resolveEvent(.didFinishGatheringLocalContents([localItem])))
outgoingEvents.append(contentsOf: syncStateManager.resolveEvent(.didFinishGatheringCloudContents([cloudItem])))
XCTAssertTrue(outgoingEvents.contains { if case .resolveInitialSynchronizationConflict(_) = $0 { return true } else { return false } }, "Expected conflict resolution for a newer local item")
}
func testInitialSynchronizationWithNonConflictingItems() {
syncStateManager = DefaultSynchronizationStateManager(isInitialSynchronization: true)
let localItem = LocalMetadataItem.stub(fileName: "localFile", lastModificationDate: TimeInterval(1))
let cloudItem = CloudMetadataItem.stub(fileName: "cloudFile", lastModificationDate: TimeInterval(2), isInTrash: false)
outgoingEvents.append(contentsOf: syncStateManager.resolveEvent(.didFinishGatheringLocalContents([localItem])))
outgoingEvents.append(contentsOf: syncStateManager.resolveEvent(.didFinishGatheringCloudContents([cloudItem])))
XCTAssertTrue(outgoingEvents.contains { if case .createLocalItem(_) = $0 { return true } else { return false } }, "Expected creation of local item for cloudFile")
XCTAssertTrue(outgoingEvents.contains { if case .createCloudItem(_) = $0 { return true } else { return false } }, "Expected creation of cloud item for localFile")
}
// MARK: - Test didFinishGathering without errors and after initial synchronization
func testDidFinishGatheringWhenCloudAndLocalIsEmpty() {
let localItems: LocalContents = []
let cloudItems: CloudContents = []
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringCloudContents(cloudItems))
XCTAssertEqual(outgoingEvents.count, 0)
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringLocalContents(localItems))
XCTAssertEqual(outgoingEvents.count, 0)
}
func testDidFinishGatheringWhenOnlyCloudIsEmpty() {
let localItem1 = LocalMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1))
let localItem2 = LocalMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(2))
let localItem3 = LocalMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(3))
let localItems: LocalContents = LocalContents([localItem1, localItem2, localItem3])
let cloudItems: CloudContents = []
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringCloudContents(cloudItems))
XCTAssertEqual(outgoingEvents.count, 0)
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringLocalContents(localItems))
outgoingEvents.forEach { event in
switch event {
case .createCloudItem(let item):
XCTAssertTrue(localItems.containsByName(item))
default:
XCTFail()
}
}
}
func testDidFinishGatheringWhenOnlyLocalIsEmpty() {
let cloudItem1 = CloudMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1), isInTrash: false)
let cloudItem2 = CloudMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(2), isInTrash: false)
let cloudItem3 = CloudMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(3), isInTrash: false)
let localItems = LocalContents()
let cloudItems = CloudContents([cloudItem1, cloudItem2, cloudItem3])
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringCloudContents(cloudItems))
XCTAssertEqual(outgoingEvents.count, 0)
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringLocalContents(localItems))
outgoingEvents.forEach { event in
switch event {
case .createLocalItem(let item):
XCTAssertTrue(cloudItems.containsByName(item))
default:
XCTFail()
}
}
}
func testDidFinishGatheringWhenLocalIsEmptyAndAllCloudFilesWasDeleted() {
let localItem1 = LocalMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1))
let localItem2 = LocalMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(2))
let localItem3 = LocalMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(3))
let cloudItem1 = CloudMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(2), isInTrash: true)
let cloudItem2 = CloudMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(3), isInTrash: true)
let cloudItem3 = CloudMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(4), isInTrash: true)
let localItems = [localItem1, localItem2, localItem3]
let cloudItems = [cloudItem1, cloudItem2, cloudItem3]
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringCloudContents(cloudItems))
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringLocalContents(localItems))
XCTAssertEqual(outgoingEvents.count, 3)
outgoingEvents.forEach { event in
switch event {
case .removeLocalItem(let item):
XCTAssertTrue(localItems.containsByName(item))
default:
XCTFail()
}
}
}
func testDidFinishGatheringWhenLocalIsNotEmptyAndAllCloudFilesWasDeleted() {
let cloudItem1 = CloudMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1), isInTrash: true)
let cloudItem2 = CloudMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(2), isInTrash: true)
let cloudItem3 = CloudMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(3), isInTrash: true)
let localItems = LocalContents()
let cloudItems = CloudContents([cloudItem1, cloudItem2, cloudItem3])
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringCloudContents(cloudItems))
XCTAssertEqual(outgoingEvents.count, 0)
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringLocalContents(localItems))
XCTAssertEqual(outgoingEvents.count, 0)
}
func testDidFinishGatheringWhenLocalIsEmptyAndSomeCloudFilesWasDeleted() {
let cloudItem1 = CloudMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1), isInTrash: true)
let cloudItem2 = CloudMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(2), isInTrash: true)
let cloudItem3 = CloudMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(3), isInTrash: false)
let localItems = LocalContents()
let cloudItems = CloudContents([cloudItem1, cloudItem2, cloudItem3])
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringCloudContents(cloudItems))
XCTAssertEqual(outgoingEvents.count, 0)
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringLocalContents(localItems))
XCTAssertEqual(outgoingEvents.count, 1)
outgoingEvents.forEach { event in
switch event {
case .createLocalItem(let item):
XCTAssertEqual(item, cloudItem3)
default:
XCTFail()
}
}
}
func testDidFinishGatheringWhenLocalAndCloudAreNotEmptyAndEqual() {
let localItem1 = LocalMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1))
let localItem2 = LocalMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(2))
let localItem3 = LocalMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(3))
let cloudItem1 = CloudMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1), isInTrash: false)
let cloudItem2 = CloudMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(2), isInTrash: false)
let cloudItem3 = CloudMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(3), isInTrash: false)
let localItems = LocalContents([localItem1, localItem2, localItem3])
let cloudItems = CloudContents([cloudItem1, cloudItem2, cloudItem3])
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringLocalContents(localItems))
XCTAssertEqual(outgoingEvents.count, 0)
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringCloudContents(cloudItems))
XCTAssertEqual(outgoingEvents.count, 0)
}
func testDidFinishGatheringWhenLocalAndCloudAreNotEmptyAndSomeLocalItemsAreNewer() {
let localItem1 = LocalMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1))
let localItem2 = LocalMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(3))
let localItem3 = LocalMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(4))
let cloudItem1 = CloudMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1), isInTrash: false)
let cloudItem2 = CloudMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(2), isInTrash: false)
let cloudItem3 = CloudMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(3), isInTrash: false)
let localItems = LocalContents([localItem1, localItem2, localItem3])
let cloudItems = CloudContents([cloudItem1, cloudItem2, cloudItem3])
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringLocalContents(localItems))
XCTAssertEqual(outgoingEvents.count, 0)
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringCloudContents(cloudItems))
XCTAssertEqual(outgoingEvents.count, 2)
outgoingEvents.forEach { event in
switch event {
case .updateCloudItem(let item):
XCTAssertTrue([localItem2, localItem3].containsByName(item))
default:
XCTFail()
}
}
}
func testDidFinishGatheringWhenLocalAndCloudAreNotEmptyAndSomeCloudItemsAreNewer() {
let localItem1 = LocalMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1))
let localItem2 = LocalMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(2))
let localItem3 = LocalMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(3))
let cloudItem1 = CloudMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(4), isInTrash: false)
let cloudItem2 = CloudMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(2), isInTrash: false)
let cloudItem3 = CloudMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(7), isInTrash: false)
let localItems = LocalContents([localItem1, localItem2, localItem3])
let cloudItems = CloudContents([cloudItem1, cloudItem2, cloudItem3])
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringLocalContents(localItems))
XCTAssertEqual(outgoingEvents.count, 0)
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringCloudContents(cloudItems))
XCTAssertEqual(outgoingEvents.count, 2)
outgoingEvents.forEach { event in
switch event {
case .updateLocalItem(let item):
XCTAssertTrue([cloudItem1, cloudItem3].containsByName(item))
default:
XCTFail()
}
}
}
func testDidFinishGatheringWhenLocalAndCloudAreNotEmptyMixed() {
let localItem1 = LocalMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1))
let localItem2 = LocalMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(3))
let localItem3 = LocalMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(3))
let localItem4 = LocalMetadataItem.stub(fileName: "file4", lastModificationDate: TimeInterval(1))
let cloudItem1 = CloudMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(4), isInTrash: false)
let cloudItem2 = CloudMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(2), isInTrash: false)
let cloudItem3 = CloudMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(7), isInTrash: true)
let localItems = LocalContents([localItem1, localItem2, localItem3, localItem4])
let cloudItems = CloudContents([cloudItem1, cloudItem2, cloudItem3])
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringLocalContents(localItems))
XCTAssertEqual(outgoingEvents.count, 0)
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringCloudContents(cloudItems))
XCTAssertEqual(outgoingEvents.count, 4)
outgoingEvents.forEach { event in
switch event {
case .updateLocalItem(let item):
XCTAssertEqual(item, cloudItem1)
case .removeLocalItem(let item):
XCTAssertEqual(item, cloudItem3)
case .createCloudItem(let item):
XCTAssertEqual(item, localItem4)
case .updateCloudItem(let item):
XCTAssertEqual(item, localItem2)
default:
XCTFail()
}
}
}
func testDidFinishGatheringWhenCloudHaveTrashedNewerThanLocal() {
let localItem3 = LocalMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(3))
let cloudItem3Trashed = CloudMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(8), isInTrash: true)
let localItems = LocalContents([localItem3])
let cloudItems = CloudContents([cloudItem3Trashed])
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringLocalContents(localItems))
XCTAssertEqual(outgoingEvents.count, 0)
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringCloudContents(cloudItems))
XCTAssertEqual(outgoingEvents.count, 1)
outgoingEvents.forEach { event in
switch event {
case .removeLocalItem(let item):
XCTAssertEqual(item, cloudItem3Trashed)
default:
XCTFail()
}
}
}
func testDidFinishGatheringWhenLocallIsEmptyAndCloudHaveSameFileBothInTrashedAndNotAndTrashedOlder() {
let cloudItem3 = CloudMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(7), isInTrash: false)
let cloudItem3Trashed = CloudMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(6), isInTrash: true)
let localItems = LocalContents([])
let cloudItems = CloudContents([cloudItem3, cloudItem3Trashed])
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringLocalContents(localItems))
XCTAssertEqual(outgoingEvents.count, 0)
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringCloudContents(cloudItems))
XCTAssertEqual(outgoingEvents.count, 1)
outgoingEvents.forEach { event in
switch event {
case .createLocalItem(let item):
XCTAssertEqual(item, cloudItem3)
default:
XCTFail()
}
}
}
func testDidFinishGatheringWhenCloudHaveSameFileBothInTrashedAndNotAndTrashedOlder() {
let localItem3 = LocalMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(3))
let cloudItem3 = CloudMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(7), isInTrash: false)
let cloudItem3Trashed = CloudMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(6), isInTrash: true)
let localItems = LocalContents([localItem3])
let cloudItems = CloudContents([cloudItem3, cloudItem3Trashed])
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringLocalContents(localItems))
XCTAssertEqual(outgoingEvents.count, 0)
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringCloudContents(cloudItems))
XCTAssertEqual(outgoingEvents.count, 1)
outgoingEvents.forEach { event in
switch event {
case .updateLocalItem(let item):
XCTAssertEqual(item, cloudItem3)
default:
XCTFail()
}
}
}
func testDidFinishGatheringWhenCloudHaveSameFileBothInTrashedAndNotAndTrashedBotLocalIsNewer() {
let localItem3 = LocalMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(9))
let cloudItem3 = CloudMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(2), isInTrash: false)
let cloudItem3Trashed = CloudMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(6), isInTrash: true)
let localItems = LocalContents([localItem3])
let cloudItems = CloudContents([cloudItem3, cloudItem3Trashed])
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringLocalContents(localItems))
XCTAssertEqual(outgoingEvents.count, 0)
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringCloudContents(cloudItems))
XCTAssertEqual(outgoingEvents.count, 1)
outgoingEvents.forEach { event in
switch event {
case .updateCloudItem(let item):
XCTAssertEqual(item, localItem3)
default:
XCTFail()
}
}
}
func testDidFinishGatheringWhenUpdatetLocallyItemSameAsDeletedFromCloudOnTheOtherDevice() {
let localItem1 = LocalMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1))
let cloudItem1 = CloudMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1), isInTrash: true)
let localItems = LocalContents([localItem1])
let cloudItems = CloudContents([cloudItem1])
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringLocalContents(localItems))
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringCloudContents(cloudItems))
var localItemsToRemove: LocalContents = []
XCTAssertEqual(outgoingEvents.count, 1)
outgoingEvents.forEach { event in
switch event {
case .removeLocalItem(let cloudMetadataItem):
XCTAssertEqual(cloudMetadataItem, cloudItem1)
if let localItemToRemove = localItems.firstByName(cloudMetadataItem) {
localItemsToRemove.append(localItemToRemove)
}
default:
XCTFail()
}
}
outgoingEvents = syncStateManager.resolveEvent(.didUpdateLocalContents(localItemsToRemove))
XCTAssertEqual(outgoingEvents.count, 0)
}
// MARK: - Test didFinishGathering MergeConflicts
func testDidFinishGatheringMergeConflictWhenUpdatetLocallyItemNewerThanDeletedFromCloudOnTheOtherDevice() {
let localItem1 = LocalMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(2))
let cloudItem1 = CloudMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1), isInTrash: true)
let localItems = LocalContents([localItem1])
let cloudItems = CloudContents([cloudItem1])
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringLocalContents(localItems))
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringCloudContents(cloudItems))
// Here should be a merge conflict. New Cloud file should be created.
XCTAssertEqual(outgoingEvents.count, 1)
outgoingEvents.forEach { event in
switch event {
case .createCloudItem(let cloudMetadataItem):
XCTAssertEqual(cloudMetadataItem, localItem1)
default:
XCTFail()
}
}
}
// MARK: - Test didUpdateLocalContents
func testDidUpdateLocalContentsWhenContentWasNotChanged() {
let localItem1 = LocalMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1))
let localItem2 = LocalMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(2))
let localItem3 = LocalMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(3))
let localItems = LocalContents([localItem1, localItem2, localItem3])
let cloudItems = CloudContents([])
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringLocalContents(localItems))
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringCloudContents(cloudItems))
XCTAssertEqual(outgoingEvents.count, 3)
let newLocalItems = LocalContents([localItem1, localItem2, localItem3])
outgoingEvents = syncStateManager.resolveEvent(.didUpdateLocalContents(newLocalItems))
XCTAssertEqual(outgoingEvents.count, 3) // Should be equal to the previous results because cloudContent wasn't changed
}
func testDidUpdateLocalContentsWhenNewLocalItemWasAdded() {
let localItem1 = LocalMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1))
let localItem2 = LocalMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(2))
let localItem3 = LocalMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(3))
let cloudItem1 = CloudMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1), isInTrash: false)
let cloudItem2 = CloudMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(2), isInTrash: false)
let cloudItem3 = CloudMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(3), isInTrash: false)
let localItems = LocalContents([localItem1, localItem2, localItem3])
let cloudItems = CloudContents([cloudItem1, cloudItem2, cloudItem3])
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringLocalContents(localItems))
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringCloudContents(cloudItems))
XCTAssertEqual(outgoingEvents.count, 0)
let localItem4 = LocalMetadataItem.stub(fileName: "file4", lastModificationDate: TimeInterval(4))
let newLocalItems = LocalContents([localItem1, localItem2, localItem3, localItem4])
outgoingEvents = syncStateManager.resolveEvent(.didUpdateLocalContents(newLocalItems))
XCTAssertEqual(outgoingEvents.count, 1)
outgoingEvents.forEach { event in
switch event {
case .createCloudItem(let item):
XCTAssertEqual(item, localItem4)
default:
XCTFail()
}
}
}
func testDidUpdateLocalContentsWhenLocalItemWasUpdated() {
let localItem1 = LocalMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1))
let localItem2 = LocalMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(2))
let localItem3 = LocalMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(3))
let cloudItem1 = CloudMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1), isInTrash: false)
let cloudItem2 = CloudMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(2), isInTrash: false)
let cloudItem3 = CloudMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(3), isInTrash: false)
let localItems = LocalContents([localItem1, localItem2, localItem3])
let cloudItems = CloudContents([cloudItem1, cloudItem2, cloudItem3])
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringLocalContents(localItems))
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringCloudContents(cloudItems))
XCTAssertEqual(outgoingEvents.count, 0)
let localItem2Updated = LocalMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(3))
let localItem3Updated = LocalMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(4))
let newLocalItems = LocalContents([localItem1, localItem2Updated, localItem3Updated])
outgoingEvents = syncStateManager.resolveEvent(.didUpdateLocalContents(newLocalItems))
XCTAssertEqual(outgoingEvents.count, 2)
outgoingEvents.forEach { event in
switch event {
case .updateCloudItem(let item):
XCTAssertTrue([localItem2Updated, localItem3Updated].containsByName(item))
default:
XCTFail()
}
}
}
func testDidUpdateLocalContentsWhenLocalItemWasRemoved() {
let localItem1 = LocalMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1))
let localItem2 = LocalMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(2))
let localItem3 = LocalMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(3))
let cloudItem1 = CloudMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1), isInTrash: false)
let cloudItem2 = CloudMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(2), isInTrash: false)
let cloudItem3 = CloudMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(3), isInTrash: false)
let localItems = LocalContents([localItem1, localItem2, localItem3])
let cloudItems = CloudContents([cloudItem1, cloudItem2, cloudItem3])
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringLocalContents(localItems))
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringCloudContents(cloudItems))
XCTAssertEqual(outgoingEvents.count, 0)
let newLocalItems = LocalContents([localItem1, localItem2])
outgoingEvents = syncStateManager.resolveEvent(.didUpdateLocalContents(newLocalItems))
XCTAssertEqual(outgoingEvents.count, 1)
outgoingEvents.forEach { event in
switch event {
case .removeCloudItem(let item):
XCTAssertEqual(item, localItem3)
default:
XCTFail()
}
}
}
func testDidUpdateLocalContentsComplexUpdate() {
let localItem1 = LocalMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1))
let localItem2 = LocalMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(2))
let localItem3 = LocalMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(3))
let cloudItem1 = CloudMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1), isInTrash: false)
let cloudItem2 = CloudMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(2), isInTrash: false)
let cloudItem3 = CloudMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(3), isInTrash: false)
let localItems = LocalContents([localItem1, localItem2, localItem3])
let cloudItems = CloudContents([cloudItem1, cloudItem2, cloudItem3])
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringLocalContents(localItems))
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringCloudContents(cloudItems))
XCTAssertEqual(outgoingEvents.count, 0)
let localItem1New = LocalMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(2))
let localItem3New = LocalMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(4))
let localItem4New = LocalMetadataItem.stub(fileName: "file4", lastModificationDate: TimeInterval(5))
let localItem5New = LocalMetadataItem.stub(fileName: "file5", lastModificationDate: TimeInterval(5))
let newLocalItems = LocalContents([localItem1New, localItem3New, localItem4New, localItem5New])
outgoingEvents = syncStateManager.resolveEvent(.didUpdateLocalContents(newLocalItems))
XCTAssertEqual(outgoingEvents.count, 5)
outgoingEvents.forEach { event in
switch event {
case .createCloudItem(let localMetadataItem):
XCTAssertTrue([localItem4New, localItem5New].containsByName(localMetadataItem))
case .updateCloudItem(let localMetadataItem):
XCTAssertTrue([localItem1New, localItem3New].containsByName(localMetadataItem))
case .removeCloudItem(let localMetadataItem):
XCTAssertEqual(localMetadataItem, localItem2)
default:
XCTFail()
}
}
}
// TODO: Test didUpdateCloudContents
func testDidUpdateCloudContentsWhenContentWasNotChanged() {
let localItem1 = LocalMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1))
let localItem2 = LocalMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(2))
let localItem3 = LocalMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(3))
let cloudItem1 = CloudMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1), isInTrash: false)
let cloudItem2 = CloudMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(2), isInTrash: false)
let cloudItem3 = CloudMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(3), isInTrash: false)
let localItems = LocalContents([localItem1, localItem2, localItem3])
let cloudItems = CloudContents([cloudItem1, cloudItem2, cloudItem3])
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringLocalContents(localItems))
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringCloudContents(cloudItems))
XCTAssertEqual(outgoingEvents.count, 0)
let newCloudItems = CloudContents([cloudItem1, cloudItem2, cloudItem3])
outgoingEvents = syncStateManager.resolveEvent(.didUpdateCloudContents(newCloudItems))
XCTAssertEqual(outgoingEvents.count, 0)
}
func testDidUpdateCloudContentsWhenContentItemWasAdded() {
let localItem1 = LocalMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1))
let localItem2 = LocalMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(2))
let localItem3 = LocalMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(3))
let cloudItem1 = CloudMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1), isInTrash: false)
let cloudItem2 = CloudMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(2), isInTrash: false)
let cloudItem3 = CloudMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(3), isInTrash: false)
let localItems = LocalContents([localItem1, localItem2, localItem3])
var cloudItems = CloudContents([cloudItem1, cloudItem2, cloudItem3])
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringLocalContents(localItems))
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringCloudContents(cloudItems))
XCTAssertEqual(outgoingEvents.count, 0)
var cloudItem4 = CloudMetadataItem.stub(fileName: "file4", lastModificationDate: TimeInterval(3), isInTrash: false, isDownloaded: false)
cloudItems.append(cloudItem4)
outgoingEvents = syncStateManager.resolveEvent(.didUpdateCloudContents(cloudItems))
XCTAssertEqual(outgoingEvents.count, 1)
outgoingEvents.forEach { event in
switch event {
case .startDownloading(let cloudMetadataItem):
XCTAssertEqual(cloudMetadataItem, cloudItem4)
default:
XCTFail()
}
}
cloudItem4.isDownloaded = true
// recreate collection
cloudItems = [cloudItem1, cloudItem2, cloudItem3, cloudItem4]
outgoingEvents = syncStateManager.resolveEvent(.didUpdateCloudContents(cloudItems))
XCTAssertEqual(outgoingEvents.count, 1)
outgoingEvents.forEach { event in
switch event {
case .createLocalItem(let cloudMetadataItem):
XCTAssertEqual(cloudMetadataItem, cloudItem4)
default:
XCTFail()
}
}
}
func testDidUpdateCloudContentsWhenAllContentWasTrashed() {
let localItem1 = LocalMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1))
let localItem2 = LocalMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(2))
let localItem3 = LocalMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(3))
var cloudItem1 = CloudMetadataItem.stub(fileName: "file1", lastModificationDate: TimeInterval(1), isInTrash: false)
var cloudItem2 = CloudMetadataItem.stub(fileName: "file2", lastModificationDate: TimeInterval(2), isInTrash: false)
var cloudItem3 = CloudMetadataItem.stub(fileName: "file3", lastModificationDate: TimeInterval(3), isInTrash: false)
let localItems = LocalContents([localItem1, localItem2, localItem3])
var cloudItems = CloudContents([cloudItem1, cloudItem2, cloudItem3])
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringLocalContents(localItems))
outgoingEvents = syncStateManager.resolveEvent(.didFinishGatheringCloudContents(cloudItems))
XCTAssertEqual(outgoingEvents.count, 0)
cloudItem1.isRemoved = true
cloudItem2.isRemoved = true
cloudItem3.isRemoved = true
cloudItems = CloudContents([cloudItem1, cloudItem2, cloudItem3])
outgoingEvents = syncStateManager.resolveEvent(.didUpdateCloudContents(cloudItems))
XCTAssertEqual(outgoingEvents.count, 3)
var localItemsToRemove: LocalContents = []
outgoingEvents.forEach { event in
switch event {
case .removeLocalItem(let cloudMetadataItem):
XCTAssertTrue(cloudItems.containsByName(cloudMetadataItem))
if let localItemToRemove = localItems.firstByName(cloudMetadataItem) {
localItemsToRemove.append(localItemToRemove)
}
default:
XCTFail()
}
}
outgoingEvents = syncStateManager.resolveEvent(.didUpdateLocalContents(localItemsToRemove))
// Because all cloud items in .trash and we have removed all local items, we should not have any outgoing events.
XCTAssertEqual(outgoingEvents.count, 0)
}
}

View file

@ -0,0 +1,13 @@
class FileManagerMock: FileManager {
var stubUbiquityIdentityToken: UbiquityIdentityToken?
var shouldReturnContainerURL: Bool = true
var stubCloudDirectory: URL?
override var ubiquityIdentityToken: (any UbiquityIdentityToken)? {
return stubUbiquityIdentityToken
}
override func url(forUbiquityContainerIdentifier identifier: String?) -> URL? {
return shouldReturnContainerURL ? stubCloudDirectory ?? URL(fileURLWithPath: NSTemporaryDirectory()) : nil
}
}

View file

@ -0,0 +1,31 @@
import XCTest
@testable import Organic_Maps__Debug_
class UbiquitousDirectoryMonitorDelegateMock: CloudDirectoryMonitorDelegate {
var didFinishGatheringCalled = false
var didUpdateCalled = false
var didReceiveErrorCalled = false
var didFinishGatheringExpectation: XCTestExpectation?
var didUpdateExpectation: XCTestExpectation?
var didReceiveErrorExpectation: XCTestExpectation?
var contents = CloudContents()
func didFinishGathering(contents: CloudContents) {
didFinishGatheringCalled = true
didFinishGatheringExpectation?.fulfill()
self.contents = contents
}
func didUpdate(contents: CloudContents) {
didUpdateCalled = true
didUpdateExpectation?.fulfill()
self.contents = contents
}
func didReceiveCloudMonitorError(_ error: Error) {
didReceiveErrorCalled = true
didReceiveErrorExpectation?.fulfill()
}
}

View file

@ -0,0 +1,151 @@
import XCTest
@testable import Organic_Maps__Debug_
typealias UbiquityIdentityToken = NSCoding & NSCopying & NSObjectProtocol
class iCloudDirectoryMonitorTests: XCTestCase {
var cloudMonitor: iCloudDocumentsDirectoryMonitor!
var mockFileManager: FileManagerMock!
var mockDelegate: UbiquitousDirectoryMonitorDelegateMock!
var cloudContainerIdentifier: String = "iCloud.app.organicmaps.debug"
override func setUp() {
super.setUp()
mockFileManager = FileManagerMock()
mockDelegate = UbiquitousDirectoryMonitorDelegateMock()
cloudMonitor = iCloudDocumentsDirectoryMonitor(fileManager: mockFileManager, cloudContainerIdentifier: cloudContainerIdentifier, fileType: .kml)
cloudMonitor.delegate = mockDelegate
}
override func tearDown() {
cloudMonitor = nil
mockFileManager = nil
mockDelegate = nil
super.tearDown()
}
func testInitialization() {
XCTAssertNotNil(cloudMonitor)
XCTAssertEqual(cloudMonitor.containerIdentifier, cloudContainerIdentifier)
}
func testCloudAvailability() {
mockFileManager.stubUbiquityIdentityToken = NSString(string: "mockToken")
XCTAssertTrue(cloudMonitor.isCloudAvailable())
mockFileManager.stubUbiquityIdentityToken = nil
XCTAssertFalse(cloudMonitor.isCloudAvailable())
}
func testStartWhenCloudAvailable() {
mockFileManager.stubUbiquityIdentityToken = NSString(string: "mockToken")
let startExpectation = expectation(description: "startExpectation")
cloudMonitor.start { result in
if case .success = result {
startExpectation.fulfill()
}
}
waitForExpectations(timeout: 5)
XCTAssertTrue(cloudMonitor.state == .started, "Monitor should be started when the cloud is available.")
}
func testStartWhenCloudNotAvailable() {
mockFileManager.stubUbiquityIdentityToken = nil
let startExpectation = expectation(description: "startExpectation")
cloudMonitor.start { result in
if case .failure(let error) = result, case SynchronizationError.iCloudIsNotAvailable = error {
startExpectation.fulfill()
}
}
waitForExpectations(timeout: 5)
XCTAssertTrue(cloudMonitor.state == .stopped, "Monitor should not start when the cloud is not available.")
}
func testStopAfterStart() {
testStartWhenCloudAvailable()
cloudMonitor.stop()
XCTAssertTrue(cloudMonitor.state == .stopped, "Monitor should not be started after stopping.")
}
func testPauseAndResume() {
testStartWhenCloudAvailable()
cloudMonitor.pause()
XCTAssertTrue(cloudMonitor.state == .paused, "Monitor should be paused.")
cloudMonitor.resume()
XCTAssertTrue(cloudMonitor.state == .started, "Monitor should not be paused after resuming.")
}
func testFetchUbiquityDirectoryUrl() {
let expectation = self.expectation(description: "Fetch Ubiquity Directory URL")
mockFileManager.shouldReturnContainerURL = true
cloudMonitor.fetchUbiquityDirectoryUrl { result in
if case .success = result {
expectation.fulfill()
}
}
wait(for: [expectation], timeout: 5.0)
}
func testDelegateMethods() {
testStartWhenCloudAvailable()
guard let metadataQuery = cloudMonitor.metadataQuery else {
XCTFail("Metadata query should not be nil")
return
}
let didFinishGatheringExpectation = expectation(description: "didFinishGathering")
mockDelegate.didFinishGatheringExpectation = didFinishGatheringExpectation
NotificationCenter.default.post(name: NSNotification.Name.NSMetadataQueryDidFinishGathering, object: metadataQuery)
wait(for: [didFinishGatheringExpectation], timeout: 5.0)
let didUpdateExpectation = expectation(description: "didUpdate")
mockDelegate.didUpdateExpectation = didUpdateExpectation
NotificationCenter.default.post(name: NSNotification.Name.NSMetadataQueryDidUpdate, object: metadataQuery)
wait(for: [didUpdateExpectation], timeout: 5.0)
}
// MARK: - Delegate
func testDelegateDidFinishGathering() {
testStartWhenCloudAvailable()
guard let metadataQuery = cloudMonitor.metadataQuery else {
XCTFail("Metadata query should not be nil")
return
}
let didFinishGatheringExpectation = expectation(description: "didFinishGathering")
mockDelegate.didFinishGatheringExpectation = didFinishGatheringExpectation
NotificationCenter.default.post(name: .NSMetadataQueryDidFinishGathering, object: metadataQuery)
wait(for: [didFinishGatheringExpectation], timeout: 5.0)
XCTAssertTrue(mockDelegate.didFinishGatheringCalled, "Delegate's didFinishGathering should be called.")
}
func testDelegateDidUpdate() {
testStartWhenCloudAvailable()
guard let metadataQuery = cloudMonitor.metadataQuery else {
XCTFail("Metadata query should not be nil")
return
}
let didUpdateExpectation = expectation(description: "didUpdate")
mockDelegate.didUpdateExpectation = didUpdateExpectation
NotificationCenter.default.post(name: NSNotification.Name.NSMetadataQueryDidUpdate, object: metadataQuery)
wait(for: [didUpdateExpectation], timeout: 5.0)
XCTAssertTrue(mockDelegate.didUpdateCalled, "Delegate's didUpdate should be called.")
}
func testDelegateDidReceiveError() {
testStartWhenCloudAvailable()
let didReceiveErrorExpectation = expectation(description: "didReceiveError")
mockDelegate.didReceiveErrorExpectation = didReceiveErrorExpectation
cloudMonitor.delegate?.didReceiveCloudMonitorError(SynchronizationError.containerNotFound)
wait(for: [didReceiveErrorExpectation], timeout: 5.0)
XCTAssertTrue(mockDelegate.didReceiveErrorCalled, "Delegate's didReceiveError should be called.")
}
}

View file

@ -22,7 +22,7 @@ final class CarPlayMapViewController: MWMViewController {
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if mapView?.drapeEngineCreated == false && !MapsAppDelegate.isDrapeDisabled() {
if mapView?.drapeEngineCreated == false && !MapsAppDelegate.isTestsEnvironment() {
mapView?.createDrapeEngine()
}
updateVisibleViewPortState(viewPortState)

View file

@ -28,6 +28,7 @@ final class EditBookmarkViewController: MWMTableViewController {
private var bookmarkGroupId = FrameworkHelper.invalidCategoryId()
private var newBookmarkGroupId = FrameworkHelper.invalidCategoryId()
private var bookmarkColor: BookmarkColor!
private let bookmarksManager = BookmarksManager.shared()
override func viewDidLoad() {
super.viewDidLoad()
@ -44,26 +45,36 @@ final class EditBookmarkViewController: MWMTableViewController {
tableView.registerNib(cell: BookmarkTitleCell.self)
tableView.registerNib(cell: MWMButtonCell.self)
tableView.registerNib(cell: MWMNoteCell.self)
addToBookmarksManagerObserverList()
}
func configure(with bookmarkId: MWMMarkID, editCompletion completion: @escaping (Bool) -> Void) {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
updateBookmarkIfNeeded()
}
deinit {
removeFromBookmarksManagerObserverList()
}
func configure(with bookmarkId: MWMMarkID, editCompletion completion: ((Bool) -> Void)?) {
self.bookmarkId = bookmarkId
let bm = BookmarksManager.shared()
let bookmark = bm.bookmark(withId: bookmarkId)
let bookmark = bookmarksManager.bookmark(withId: bookmarkId)
bookmarkTitle = bookmark.bookmarkName
bookmarkColor = bookmark.bookmarkColor
bookmarkDescription = bm.description(forBookmarkId: bookmarkId)
bookmarkDescription = bookmarksManager.description(forBookmarkId: bookmarkId)
let bookmarkGroup = bm.category(forBookmarkId: bookmarkId)
let bookmarkGroup = bookmarksManager.category(forBookmarkId: bookmarkId)
bookmarkGroupId = bookmarkGroup.categoryId
bookmarkGroupTitle = bookmarkGroup.title
editingCompleted = completion
}
@objc(configureWithPlacePageData:)
func configure(with placePageData: PlacePageData) {
guard let bookmarkData = placePageData.bookmarkData else { fatalError("placePageData and bookmarkData can't be nil") }
@ -152,6 +163,23 @@ final class EditBookmarkViewController: MWMTableViewController {
// MARK: - Private
private func updateBookmarkIfNeeded() {
// Skip for the regular place page.
guard bookmarkId != FrameworkHelper.invalidBookmarkId() else { return }
// TODO: Update the bookmark content on the Edit screen instead of closing it when the bookmark gets updated from cloud.
if !bookmarksManager.hasBookmark(bookmarkId) {
goBack()
}
}
private func addToBookmarksManagerObserverList() {
bookmarksManager.add(self)
}
private func removeFromBookmarksManagerObserverList() {
bookmarksManager.remove(self)
}
@objc private func onSave() {
view.endEditing(true)
@ -225,3 +253,16 @@ extension EditBookmarkViewController: SelectBookmarkGroupViewControllerDelegate
tableView.reloadRows(at: [IndexPath(row: InfoSectionRows.bookmarkGroup.rawValue, section: Sections.info.rawValue)], with: .none)
}
}
// MARK: - BookmarksObserver
extension EditBookmarkViewController: BookmarksObserver {
func onBookmarksLoadFinished() {
updateBookmarkIfNeeded()
}
func onBookmarksCategoryDeleted(_ groupId: MWMMarkGroupID) {
if bookmarkGroupId == groupId {
goBack()
}
}
}

View file

@ -42,22 +42,34 @@ final class EditTrackViewController: MWMTableViewController {
editingCompleted = completion
super.init(style: .grouped)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
updateTrackIfNeeded()
}
deinit {
removeFromBookmarksManagerObserverList()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
title = L("track_title")
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .save,
target: self,
action: #selector(onSave))
tableView.registerNib(cell: BookmarkTitleCell.self)
tableView.registerNib(cell: MWMButtonCell.self)
addToBookmarksManagerObserverList()
}
// MARK: - Table view data source
@ -123,7 +135,22 @@ final class EditTrackViewController: MWMTableViewController {
}
// MARK: - Private
private func updateTrackIfNeeded() {
// TODO: Update the track content on the Edit screen instead of closing it when the track gets updated from cloud.
if !bookmarksManager.hasTrack(trackId) {
goBack()
}
}
private func addToBookmarksManagerObserverList() {
bookmarksManager.add(self)
}
private func removeFromBookmarksManagerObserverList() {
bookmarksManager.remove(self)
}
@objc private func onSave() {
view.endEditing(true)
BookmarksManager.shared().updateTrack(trackId, setGroupId: trackGroupId, color: trackColor, title: trackTitle ?? "")
@ -185,3 +212,16 @@ extension EditTrackViewController: SelectBookmarkGroupViewControllerDelegate {
with: .none)
}
}
// MARK: - BookmarksObserver
extension EditTrackViewController: BookmarksObserver {
func onBookmarksLoadFinished() {
updateTrackIfNeeded()
}
func onBookmarksCategoryDeleted(_ groupId: MWMMarkGroupID) {
if trackGroupId == groupId {
goBack()
}
}
}

View file

@ -2,27 +2,42 @@ protocol PlacePageInteractorProtocol: AnyObject {
func updateTopBound(_ bound: CGFloat, duration: TimeInterval)
}
class PlacePageInteractor {
class PlacePageInteractor: NSObject {
weak var presenter: PlacePagePresenterProtocol?
weak var viewController: UIViewController?
weak var mapViewController: MapViewController?
private let bookmarksManager = BookmarksManager.shared()
private var placePageData: PlacePageData
init (viewController: UIViewController, data: PlacePageData, mapViewController: MapViewController) {
init(viewController: UIViewController, data: PlacePageData, mapViewController: MapViewController) {
self.placePageData = data
self.viewController = viewController
self.mapViewController = mapViewController
super.init()
addToBookmarksManagerObserverList()
}
deinit {
removeFromBookmarksManagerObserverList()
}
private func updateBookmarkIfNeeded() {
guard let bookmarkId = placePageData.bookmarkData?.bookmarkId else { return }
if !BookmarksManager.shared().hasBookmark(bookmarkId) {
guard bookmarksManager.hasBookmark(bookmarkId) else {
presenter?.closeAnimated()
return
}
FrameworkHelper.updatePlacePageData()
placePageData.updateBookmarkStatus()
}
private func addToBookmarksManagerObserverList() {
bookmarksManager.add(self)
}
private func removeFromBookmarksManagerObserverList() {
bookmarksManager.remove(self)
}
}
extension PlacePageInteractor: PlacePageInteractorProtocol {
@ -224,3 +239,18 @@ extension PlacePageInteractor: PlacePageHeaderViewControllerDelegate {
presenter?.showNextStop()
}
}
// MARK: - BookmarksObserver
extension PlacePageInteractor: BookmarksObserver {
func onBookmarksLoadFinished() {
updateBookmarkIfNeeded()
}
func onBookmarksCategoryDeleted(_ groupId: MWMMarkGroupID) {
guard let bookmarkGroupId = placePageData.bookmarkData?.bookmarkGroupId else { return }
if bookmarkGroupId == groupId {
presenter?.closeAnimated()
}
}
}

View file

@ -4,9 +4,9 @@ protocol SettingsTableViewSwitchCellDelegate {
}
@objc
final class SettingsTableViewSwitchCell: MWMTableViewCell {
class SettingsTableViewSwitchCell: MWMTableViewCell {
private let switchButton = UISwitch()
let switchButton = UISwitch()
@IBOutlet weak var delegate: SettingsTableViewSwitchCellDelegate?
@ -24,6 +24,11 @@ final class SettingsTableViewSwitchCell: MWMTableViewCell {
set { switchButton.isOn = newValue }
}
@objc
func setOn(_ isOn: Bool, animated: Bool) {
switchButton.setOn(isOn, animated: animated)
}
override func awakeFromNib() {
super.awakeFromNib()
setupCell()

View file

@ -0,0 +1,34 @@
final class SettingsTableViewiCloudSwitchCell: SettingsTableViewSwitchCell {
override func awakeFromNib() {
super.awakeFromNib()
styleDetail()
}
@objc
func updateWithError(_ error: NSError?) {
if let error = error as? SynchronizationError {
switch error {
case .fileUnavailable, .fileNotUploadedDueToQuota, .ubiquityServerNotAvailable:
accessoryView = switchButton
case .iCloudIsNotAvailable, .containerNotFound:
accessoryView = nil
accessoryType = .detailButton
default:
break
}
detailTextLabel?.text = error.localizedDescription
} else {
accessoryView = switchButton
detailTextLabel?.text?.removeAll()
}
setNeedsLayout()
}
private func styleDetail() {
let detailTextLabelStyle = "regular12:blackSecondaryText"
detailTextLabel?.setStyleAndApply(detailTextLabelStyle)
detailTextLabel?.numberOfLines = 0
detailTextLabel?.lineBreakMode = .byWordWrapping
}
}

View file

@ -2,6 +2,7 @@
#import "MWMAuthorizationCommon.h"
#import "MWMTextToSpeech+CPP.h"
#import "SwiftBridge.h"
#import "MWMActivityViewController.h"
#import <CoreApi/CoreApi.h>
@ -9,6 +10,8 @@
using namespace power_management;
static NSString * const kUDDidShowICloudSynchronizationEnablingAlert = @"kUDDidShowICloudSynchronizationEnablingAlert";
@interface MWMSettingsViewController ()<SettingsTableViewSwitchCellDelegate>
@property(weak, nonatomic) IBOutlet SettingsTableViewLinkCell *profileCell;
@ -29,6 +32,7 @@ using namespace power_management;
@property(weak, nonatomic) IBOutlet SettingsTableViewSwitchCell *autoZoomCell;
@property(weak, nonatomic) IBOutlet SettingsTableViewLinkCell *voiceInstructionsCell;
@property(weak, nonatomic) IBOutlet SettingsTableViewLinkCell *drivingOptionsCell;
@property(weak, nonatomic) IBOutlet SettingsTableViewiCloudSwitchCell *iCloudSynchronizationCell;
@end
@ -180,6 +184,14 @@ using namespace power_management;
break;
}
[self.nightModeCell configWithTitle:L(@"pref_appearance_title") info:nightMode];
BOOL isICLoudSynchronizationEnabled = [MWMSettings iCLoudSynchronizationEnabled];
[self.iCloudSynchronizationCell configWithDelegate:self
title:@"iCloud Synchronization (Beta)"
isOn:isICLoudSynchronizationEnabled];
[CloudStorageManager.shared addObserver:self onErrorCompletionHandler:^(NSError * _Nullable error) {
[self.iCloudSynchronizationCell updateWithError:error];
}];
}
- (void)show3dBuildingsAlert:(UITapGestureRecognizer *)recognizer {
@ -209,6 +221,68 @@ using namespace power_management;
[self.drivingOptionsCell configWithTitle:L(@"driving_options_title") info:@""];
}
- (void)showICloudSynchronizationEnablingAlert:(void (^)(BOOL))isEnabled {
UIAlertController * alertController = [UIAlertController alertControllerWithTitle:L(@"enable_icloud_synchronization_title")
message:L(@"enable_icloud_synchronization_message")
preferredStyle:UIAlertControllerStyleAlert];
UIAlertAction * enableButton = [UIAlertAction actionWithTitle:L(@"enable")
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
[self setICloudSynchronizationEnablingAlertIsShown];
isEnabled(YES);
}];
UIAlertAction * backupButton = [UIAlertAction actionWithTitle:L(@"backup")
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
[MWMBookmarksManager.sharedManager shareAllCategoriesWithCompletion:^(MWMBookmarksShareStatus status, NSURL * _Nonnull url) {
switch (status) {
case MWMBookmarksShareStatusSuccess: {
MWMActivityViewController * shareController = [MWMActivityViewController shareControllerForURL:url message:L(@"share_bookmarks_email_body") completionHandler:^(UIActivityType _Nullable activityType, BOOL completed, NSArray * _Nullable returnedItems, NSError * _Nullable activityError) {
[self setICloudSynchronizationEnablingAlertIsShown];
isEnabled(completed);
}];
[shareController presentInParentViewController:self anchorView:self.iCloudSynchronizationCell];
break;
}
case MWMBookmarksShareStatusEmptyCategory:
[[MWMToast toastWithText:L(@"bookmarks_error_title_share_empty")] show];
isEnabled(NO);
break;
case MWMBookmarksShareStatusArchiveError:
case MWMBookmarksShareStatusFileError:
[[MWMToast toastWithText:L(@"dialog_routing_system_error")] show];
isEnabled(NO);
break;
}
}];
}];
UIAlertAction * cancelButton = [UIAlertAction actionWithTitle:L(@"cancel")
style:UIAlertActionStyleCancel
handler:^(UIAlertAction * action) {
isEnabled(NO);
}];
[alertController addAction:cancelButton];
if (![MWMBookmarksManager.sharedManager areAllCategoriesEmpty])
[alertController addAction:backupButton];
[alertController addAction:enableButton];
[self presentViewController:alertController animated:YES completion:nil];
}
- (void)setICloudSynchronizationEnablingAlertIsShown {
[NSUserDefaults.standardUserDefaults setBool:YES forKey:kUDDidShowICloudSynchronizationEnablingAlert];
}
- (void)showICloudIsDisabledAlert {
UIAlertController * alertController = [UIAlertController alertControllerWithTitle:L(@"icloud_disabled_title")
message:L(@"icloud_disabled_message")
preferredStyle:UIAlertControllerStyleAlert];
UIAlertAction * okButton = [UIAlertAction actionWithTitle:L(@"ok")
style:UIAlertActionStyleCancel
handler:nil];
[alertController addAction:okButton];
[self presentViewController:alertController animated:YES completion:nil];
}
#pragma mark - SettingsTableViewSwitchCellDelegate
@ -241,12 +315,22 @@ using namespace power_management;
auto &f = GetFramework();
f.AllowAutoZoom(value);
f.SaveAutoZoom(value);
} else if (cell == self.iCloudSynchronizationCell) {
if (![NSUserDefaults.standardUserDefaults boolForKey:kUDDidShowICloudSynchronizationEnablingAlert]) {
[self showICloudSynchronizationEnablingAlert:^(BOOL isEnabled) {
[self.iCloudSynchronizationCell setOn:isEnabled animated:YES];
[MWMSettings setICLoudSynchronizationEnabled:isEnabled];
}];
} else {
[MWMSettings setICLoudSynchronizationEnabled:value];
}
}
}
#pragma mark - UITableViewDelegate
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
[tableView deselectRowAtIndexPath:indexPath animated:true];
auto cell = [tableView cellForRowAtIndexPath:indexPath];
if (cell == self.profileCell) {
[self performSegueWithIdentifier:@"SettingsToProfileSegue" sender:nil];
@ -267,6 +351,13 @@ using namespace power_management;
}
}
- (void)tableView:(UITableView *)tableView accessoryButtonTappedForRowWithIndexPath:(NSIndexPath *)indexPath {
auto cell = [tableView cellForRowAtIndexPath:indexPath];
if (cell == self.iCloudSynchronizationCell) {
[self showICloudIsDisabledAlert];
}
}
#pragma mark - UITableViewDataSource
- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section {

View file

@ -284,6 +284,31 @@
</subviews>
</tableViewCellContentView>
</tableViewCell>
<tableViewCell contentMode="scaleToFill" selectionStyle="default" hidesAccessoryWhenEditing="NO" indentationLevel="1" indentationWidth="0.0" reuseIdentifier="SettingsTableViewSwitchCell" textLabel="QEX-D4-ejR" detailTextLabel="Iy7-de-pvk" style="IBUITableViewCellStyleSubtitle" id="E6M-av-wQu" userLabel="iCloud synchronization" customClass="SettingsTableViewiCloudSwitchCell" customModule="Organic_Maps" customModuleProvider="target">
<rect key="frame" x="0.0" y="586" width="414" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="E6M-av-wQu" id="PbS-4v-dzK">
<rect key="frame" x="0.0" y="0.0" width="414" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="iCloud Synchronization (Beta)" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="QEX-D4-ejR">
<rect key="frame" x="20" y="0.0" width="374" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<fontDescription key="fontDescription" type="system" pointSize="0.0"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Error" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="Iy7-de-pvk">
<rect key="frame" x="20" y="22.5" width="28" height="14.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="0.0"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
</tableViewCell>
</cells>
</tableViewSection>
<tableViewSection headerTitle="НАВИГАЦИЯ" id="E4E-hs-9xW">
@ -392,6 +417,7 @@
<outlet property="compassCalibrationCell" destination="P5e-67-f4k" id="KcB-EC-S2y"/>
<outlet property="drivingOptionsCell" destination="KrE-Sc-fI1" id="XOl-eI-xJX"/>
<outlet property="fontScaleCell" destination="pri-6G-9Zb" id="rHJ-ZT-lwM"/>
<outlet property="iCloudSynchronizationCell" destination="E6M-av-wQu" id="05q-Wq-SQa"/>
<outlet property="is3dCell" destination="0Lf-xU-P2U" id="obI-bL-FLh"/>
<outlet property="mobileInternetCell" destination="6NC-QX-WiF" id="L1V-gS-sTe"/>
<outlet property="nightModeCell" destination="QNt-XC-xma" id="nSn-Jr-KuZ"/>

View file

@ -1994,7 +1994,7 @@ void BookmarkManager::LoadBookmark(std::string const & filePath, bool isTemporar
// Defer bookmark loading in case of another asynchronous process.
if (!m_loadBookmarksFinished || m_asyncLoadingInProgress)
{
m_bookmarkLoadingQueue.emplace_back(filePath, isTemporaryFile);
m_bookmarkLoadingQueue.emplace_back(filePath, isTemporaryFile, false);
return;
}
@ -2002,6 +2002,18 @@ void BookmarkManager::LoadBookmark(std::string const & filePath, bool isTemporar
LoadBookmarkRoutine(filePath, isTemporaryFile);
}
void BookmarkManager::ReloadBookmark(std::string const & filePath)
{
CHECK_THREAD_CHECKER(m_threadChecker, ());
if (!m_loadBookmarksFinished || m_asyncLoadingInProgress)
{
m_bookmarkLoadingQueue.emplace_back(filePath, false, true);
return;
}
NotifyAboutStartAsyncLoading();
ReloadBookmarkRoutine(filePath);
}
void BookmarkManager::LoadBookmarkRoutine(std::string const & filePath, bool isTemporaryFile)
{
GetPlatform().RunTask(Platform::Thread::File, [this, filePath, isTemporaryFile]()
@ -2053,6 +2065,36 @@ void BookmarkManager::LoadBookmarkRoutine(std::string const & filePath, bool isT
});
}
void BookmarkManager::ReloadBookmarkRoutine(std::string const & filePath)
{
GetPlatform().RunTask(Platform::Thread::File, [this, filePath]()
{
if (m_needTeardown)
return;
auto const ext = GetLowercaseFileExt(filePath);
std::unique_ptr<kml::FileData> kmlData;
if (ext == kKmlExtension)
kmlData = LoadKmlFile(filePath, KmlFileType::Text);
else if (ext == kGpxExtension)
kmlData = LoadKmlFile(filePath, KmlFileType::Gpx);
else
ASSERT(false, ("Unsupported bookmarks extension", ext));
if (m_needTeardown)
return;
auto collection = std::make_shared<KMLDataCollection>();
if (kmlData)
collection->emplace_back(std::move(filePath), std::move(kmlData));
if (m_needTeardown)
return;
NotifyAboutFinishAsyncLoading(std::move(collection));
});
}
void BookmarkManager::NotifyAboutStartAsyncLoading()
{
if (m_needTeardown)
@ -2088,7 +2130,10 @@ void BookmarkManager::NotifyAboutFinishAsyncLoading(KMLDataCollectionPtr && coll
if (!m_bookmarkLoadingQueue.empty())
{
ASSERT(m_asyncLoadingInProgress, ());
LoadBookmarkRoutine(m_bookmarkLoadingQueue.front().m_filename,
if (m_bookmarkLoadingQueue.front().m_isReloading)
ReloadBookmarkRoutine(m_bookmarkLoadingQueue.front().m_filename);
else
LoadBookmarkRoutine(m_bookmarkLoadingQueue.front().m_filename,
m_bookmarkLoadingQueue.front().m_isTemporaryFile);
m_bookmarkLoadingQueue.pop_front();
}
@ -2296,6 +2341,13 @@ bool BookmarkManager::HasBookmark(kml::MarkId markId) const
return (GetBookmark(markId) != nullptr);
}
bool BookmarkManager::HasTrack(kml::TrackId trackId) const
{
CHECK_THREAD_CHECKER(m_threadChecker, ());
ASSERT(IsBookmark(trackId), ());
return (GetTrack(trackId) != nullptr);
}
void BookmarkManager::UpdateBmGroupIdList()
{
CHECK_THREAD_CHECKER(m_threadChecker, ());
@ -2409,7 +2461,7 @@ void BookmarkManager::CheckAndResetLastIds()
idStorage.ResetTrackId();
}
bool BookmarkManager::DeleteBmCategory(kml::MarkGroupId groupId)
bool BookmarkManager::DeleteBmCategory(kml::MarkGroupId groupId, bool deleteFile)
{
CHECK_THREAD_CHECKER(m_threadChecker, ());
auto it = m_categories.find(groupId);
@ -2419,7 +2471,8 @@ bool BookmarkManager::DeleteBmCategory(kml::MarkGroupId groupId)
ClearGroup(groupId);
m_changesTracker.OnDeleteGroup(groupId);
FileWriter::DeleteFileX(it->second->GetFileName());
if (deleteFile)
FileWriter::DeleteFileX(it->second->GetFileName());
DeleteCompilations(it->second->GetCategoryData().m_compilationIds);
m_categories.erase(it);
@ -3498,7 +3551,7 @@ void BookmarkManager::EditSession::SetCategoryCustomProperty(kml::MarkGroupId ca
bool BookmarkManager::EditSession::DeleteBmCategory(kml::MarkGroupId groupId)
{
return m_bmManager.DeleteBmCategory(groupId);
return m_bmManager.DeleteBmCategory(groupId, true);
}
void BookmarkManager::EditSession::NotifyChanges()

View file

@ -273,6 +273,7 @@ public:
std::string GetCategoryName(kml::MarkGroupId categoryId) const;
std::string GetCategoryFileName(kml::MarkGroupId categoryId) const;
kml::MarkGroupId GetCategoryByFileName(std::string const & fileName) const;
m2::RectD GetCategoryRect(kml::MarkGroupId categoryId, bool addIconsSize) const;
kml::CategoryData const & GetCategoryData(kml::MarkGroupId categoryId) const;
@ -283,6 +284,7 @@ public:
size_t GetBmGroupsCount() const { return m_unsortedBmGroupsIdList.size(); };
bool HasBmCategory(kml::MarkGroupId groupId) const;
bool HasBookmark(kml::MarkId markId) const;
bool HasTrack(kml::TrackId trackId) const;
kml::MarkGroupId LastEditedBMCategory();
kml::PredefinedColor LastEditedBMColor() const;
@ -300,6 +302,7 @@ public:
/// Scans and loads all kml files with bookmarks.
void LoadBookmarks();
void LoadBookmark(std::string const & filePath, bool isTemporaryFile);
void ReloadBookmark(std::string const & filePath);
/// Uses the same file name from which was loaded, or
/// creates unique file name on first save and uses it every time.
@ -573,7 +576,7 @@ private:
void SetCategoryTags(kml::MarkGroupId categoryId, std::vector<std::string> const & tags);
void SetCategoryAccessRules(kml::MarkGroupId categoryId, kml::AccessRules accessRules);
void SetCategoryCustomProperty(kml::MarkGroupId categoryId, std::string const & key, std::string const & value);
bool DeleteBmCategory(kml::MarkGroupId groupId);
bool DeleteBmCategory(kml::MarkGroupId groupId, bool deleteFile);
void ClearCategories();
void MoveBookmark(kml::MarkId bmID, kml::MarkGroupId curGroupID, kml::MarkGroupId newGroupID);
@ -609,6 +612,7 @@ private:
void NotifyAboutFinishAsyncLoading(KMLDataCollectionPtr && collection);
void NotifyAboutFile(bool success, std::string const & filePath, bool isTemporaryFile);
void LoadBookmarkRoutine(std::string const & filePath, bool isTemporaryFile);
void ReloadBookmarkRoutine(std::string const & filePath);
using BookmarksChecker = std::function<bool(kml::FileData const &)>;
KMLDataCollectionPtr LoadBookmarks(std::string const & dir, std::string_view ext,
@ -628,8 +632,6 @@ private:
kml::MarkGroupId CheckAndCreateDefaultCategory();
void CheckAndResetLastIds();
kml::MarkGroupId GetCategoryByFileName(std::string const & fileName) const;
std::unique_ptr<kml::FileData> CollectBmGroupKMLData(BookmarkCategory const * group) const;
KMLDataCollectionPtr PrepareToSaveBookmarks(kml::GroupIdCollection const & groupIdCollection);
@ -761,12 +763,13 @@ private:
bool m_asyncLoadingInProgress = false;
struct BookmarkLoaderInfo
{
BookmarkLoaderInfo(std::string const & filename, bool isTemporaryFile)
: m_filename(filename), m_isTemporaryFile(isTemporaryFile)
BookmarkLoaderInfo(std::string const & filename, bool isTemporaryFile, bool isReloading)
: m_filename(filename), m_isTemporaryFile(isTemporaryFile), m_isReloading(isReloading)
{}
std::string m_filename;
bool m_isTemporaryFile = false;
bool m_isReloading = false;
};
std::list<BookmarkLoaderInfo> m_bookmarkLoadingQueue;