From 1347fe72b707e24727fb5a6c860c308491155dcf Mon Sep 17 00:00:00 2001 From: Noelia Alcala Date: Mon, 20 May 2024 16:54:00 +0100 Subject: [PATCH 01/17] Experiment localisation (#4549) Task/Issue URL: https://app.asana.com/0/1201807753394693/1207358494811543/f ### Description Add translations strings for onboarding experiment ### Steps to test this PR (Optional) _Changes shouldn't be visible because experiment is filtered by locale `en_XX`_ - [ ] Smoke test ### No UI changes Co-authored-by: root --- app/src/main/res/values-bg/strings.xml | 41 +++++++++++++++++ app/src/main/res/values-cs/strings.xml | 45 +++++++++++++++++++ app/src/main/res/values-da/strings.xml | 41 +++++++++++++++++ app/src/main/res/values-de/strings.xml | 41 +++++++++++++++++ app/src/main/res/values-el/strings.xml | 41 +++++++++++++++++ app/src/main/res/values-es/strings.xml | 41 +++++++++++++++++ app/src/main/res/values-et/strings.xml | 41 +++++++++++++++++ app/src/main/res/values-fi/strings.xml | 41 +++++++++++++++++ app/src/main/res/values-fr/strings.xml | 41 +++++++++++++++++ app/src/main/res/values-hr/strings.xml | 45 +++++++++++++++++++ app/src/main/res/values-hu/strings.xml | 41 +++++++++++++++++ app/src/main/res/values-it/strings.xml | 41 +++++++++++++++++ app/src/main/res/values-lt/strings.xml | 45 +++++++++++++++++++ app/src/main/res/values-lv/strings.xml | 43 ++++++++++++++++++ app/src/main/res/values-nb/strings.xml | 41 +++++++++++++++++ app/src/main/res/values-nl/strings.xml | 41 +++++++++++++++++ app/src/main/res/values-pl/strings.xml | 45 +++++++++++++++++++ app/src/main/res/values-pt/strings.xml | 41 +++++++++++++++++ app/src/main/res/values-ro/strings.xml | 43 ++++++++++++++++++ app/src/main/res/values-ru/strings.xml | 45 +++++++++++++++++++ app/src/main/res/values-sk/strings.xml | 45 +++++++++++++++++++ app/src/main/res/values-sl/strings.xml | 45 +++++++++++++++++++ app/src/main/res/values-sv/strings.xml | 41 +++++++++++++++++ app/src/main/res/values-tr/strings.xml | 41 +++++++++++++++++ app/src/main/res/values/donottranslate.xml | 35 --------------- app/src/main/res/values/strings.xml | 41 +++++++++++++++++ .../res/values-bg/strings-autofill-impl.xml | 4 ++ .../res/values-cs/strings-autofill-impl.xml | 4 ++ .../res/values-da/strings-autofill-impl.xml | 4 ++ .../res/values-de/strings-autofill-impl.xml | 4 ++ .../res/values-el/strings-autofill-impl.xml | 4 ++ .../res/values-es/strings-autofill-impl.xml | 4 ++ .../res/values-et/strings-autofill-impl.xml | 4 ++ .../res/values-fi/strings-autofill-impl.xml | 4 ++ .../res/values-fr/strings-autofill-impl.xml | 4 ++ .../res/values-hr/strings-autofill-impl.xml | 4 ++ .../res/values-hu/strings-autofill-impl.xml | 4 ++ .../res/values-it/strings-autofill-impl.xml | 4 ++ .../res/values-lt/strings-autofill-impl.xml | 4 ++ .../res/values-lv/strings-autofill-impl.xml | 4 ++ .../res/values-nb/strings-autofill-impl.xml | 4 ++ .../res/values-nl/strings-autofill-impl.xml | 4 ++ .../res/values-pl/strings-autofill-impl.xml | 4 ++ .../res/values-pt/strings-autofill-impl.xml | 4 ++ .../res/values-ro/strings-autofill-impl.xml | 4 ++ .../res/values-ru/strings-autofill-impl.xml | 4 ++ .../res/values-sk/strings-autofill-impl.xml | 4 ++ .../res/values-sl/strings-autofill-impl.xml | 4 ++ .../res/values-sv/strings-autofill-impl.xml | 4 ++ .../res/values-tr/strings-autofill-impl.xml | 4 ++ 50 files changed, 1153 insertions(+), 35 deletions(-) diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 203d221ba4a8..c0fb3aae7209 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -828,4 +828,45 @@ Сертификатът за сигурност за %1$s е изтекъл. Възможно е уебсайтът да е конфигуриран грешно, хакер да е компрометирал връзката Ви или системният Ви часовник да е неправилно настроен. Сертификатът за сигурност за %1$s не съответства на *.%2$s. Възможно е уебсайтът да е конфигуриран грешно, хакер да е компрометирал връзката Ви или системният Ви часовник да е неправилно настроен. Операционната система на Вашето устройство не се доверява на сертификата за сигурност за %1$s. Възможно е уебсайтът да е конфигуриран грешно, хакер да е компрометирал връзката Ви или системният Ви часовник да е неправилно настроен. + + +
Готови ли сте за по-добро и по-поверително изживяване в интернет?]]>
+ Да го направим! + Защитата на поверителността е активирана! + Изберете своя браузър + Поверително търсене по подразбиране + Блокиране на тракерите на трети страни + Блокиране на изскачащи прозорци за бисквитки + Блокиране на досадни реклами + Бързо изтриване на данните за сърфиране + Страхотно! Добре дошли от страната на Duck. + Стартиране на сърфирането + Опитайте търсене! + Търсенето в DuckDuckGo винаги е анонимно. + как се казва „патица“ на испански + прогнозата на мощните патици + местното време + Изненадайте ме! + Опитайте да посетите сайт! + Ще блокирам тракерите, за да не ви шпионират. + espn.com + yahoo.com + ebay.com + Изненадайте ме! + Това е DuckDuckGo Search. Поверителен. Бърз. По-малко реклами. + Разбрах! + Сега опитайте да посетите някой сайт! + + %1$d друг се опитваха да ви проследят тук. Блокирах ги!

☝️ Докоснете щита за повече информация.️]]>
+ %1$d други се опитваха да ви проследят тук. Блокирах ги!

☝️ Докоснете щита за повече информация.️]]>
+
+ +
☝️ Докоснете щита за повече информация.️]]>
+
☝️ Докоснете щита за повече информация.️]]>
+
+ Разбрах! + Fire Button.

Изпробвайте го! ☝️️]]>
+ "Справихте се!" + Запомнете: всеки път, когато сърфирате с мен, аз ще подрязвам крилцата на досадните реклами. 👌 + diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 0480061272ac..afdd6ab55adb 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -838,4 +838,49 @@ Platnost bezpečnostního certifikátu pro %1$s vypršela. Je možné, že web je nesprávně nakonfigurovaný, že útočník prolomil tvoje připojení nebo že tvoje systémové hodiny jsou nesprávné. Bezpečnostní certifikát pro %1$s neodpovídá *.%2$s. Je možné, že web je nesprávně nakonfigurovaný, že útočník prolomil tvoje připojení nebo že tvoje systémové hodiny jsou nesprávné. Operační systém tvého zařízení nedůvěřuje bezpečnostnímu certifikátu %1$s. Je možné, že web je nesprávně nakonfigurovaný, že útočník prolomil tvoje připojení nebo že tvoje systémové hodiny jsou nesprávné. + + +
Těšíš se na lepší internet s větší ochranou soukromí?]]>
+ Pojďme na to! + Ochrana osobních údajů je aktivní! + Vyber si prohlížeč + Soukromé vyhledávání ve výchozím nastavení + Blokuj trackery třetích stran + Blokování vyskakovacích oken ohledně cookies + Blokování reklam, které tě všude pronásledují + Rychle vymaž údaje o procházení + Bezva! Vítej v Kačerově. + Spustit procházení + Vyzkoušejte vyhledávání! + Tvoje vyhledávání v DuckDuckGo je vždycky anonymní. + jak se řekne „kachna“ španělsky + herci z filmu Šampióni + aktuální počasí + Překvap mě! + Zkus přejít na nějaký web! + Zablokuju trackery, aby tě nemohly šmírovat. + espn.com + yahoo.com + ebay.com + Překvap mě! + Tohle je vyhledávač DuckDuckGo Search. Soukromý. Rychlý. S méně reklamami. + Mám to! + A teď zkus přejít na nějaký web! + + %1$d další tracker se tě tady snaží sledovat. A tak jsem je zablokoval!

☝️ Další informace zobrazíš klepnutím na štít.️]]>
+ %1$d další trackery se tě tady snaží sledovat. A tak jsem je zablokoval!

☝️ Další informace zobrazíš klepnutím na štít.️]]>
+ %1$d dalšího trackeru se tě tady snaží sledovat. A tak jsem je zablokoval!

☝️ Další informace zobrazíš klepnutím na štít.️]]>
+ %1$d dalších trackerů se tě tady snaží sledovat. A tak jsem je zablokoval!

☝️ Další informace zobrazíš klepnutím na štít.️]]>
+
+ +
☝️ Klepnutím na štít si zobrazíš další informace.️]]>
+
☝️ Klepnutím na štít si zobrazíš další informace.️]]>
+
☝️ Klepnutím na štít si zobrazíš další informace.️]]>
+
☝️ Klepnutím na štít si zobrazíš další informace.️]]>
+
+ Mám to! + Fire Button.

Zkus ji! ☝️️]]>
+ "A je to!" + Pamatuj: Když se mnou na webu surfuješ, příšerné reklamy zaženeš. 👌 + diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 319d9137a17b..c261a4b036ed 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -828,4 +828,45 @@ Sikkerhedscertifikatet for %1$s er udløbet. Det er muligt, at webstedet er forkert konfigureret, at en angriber har kompromitteret din forbindelse, eller at dit systemur er forkert. Sikkerhedscertifikatet for %1$s matcher ikke *.%2$s. Det er muligt, at webstedet er forkert konfigureret, eller at en angriber har kompromitteret din forbindelse, eller at dit systemur er forkert. Enhedens operativsystem har ikke tillid til sikkerhedscertifikatet for %1$s. Det er muligt, at webstedet er forkert konfigureret, eller at en angriber har kompromitteret din forbindelse, eller at dit systemur er forkert. + + +
Er du klar til et bedre og mere privat internet?]]>
+ Lad os gøre det! + Beskyttelse af privatlivet er aktiveret! + Vælg din browser + Søg fortroligt som standard + Bloker tredjeparts-trackere + Bloker pop op-vinduer om cookies + Bloker uhyggelige annoncer + Ryd hurtigt browserdata + Fantastisk! Velkommen til den \"anden\" side. + Start søgning + Prøv at søge! + Dine DuckDuckGo-søgninger er altid anonyme. + hvordan siger man \"and\" på spansk + mighty ducks film + lokalt vejr + Overrask mig! + Prøv at besøge en hjemmeside! + Jeg blokerer trackere, så de ikke kan udspionere dig. + espn.com + yahoo.com + ebay.com + Overrask mig! + Det er DuckDuckGo Search. Privat. Hurtig. Færre annoncer. + Forstået + Prøv nu at besøge en hjemmeside! + + %1$d anden forsøgte at spore dig her. Jeg blokerede dem!

☝️ Tryk på skjoldet for at få flere oplysninger.️]]>
+ %1$d andre forsøgte at spore dig her. Jeg blokerede dem!

☝️ Tryk på skjoldet for at få flere oplysninger.️]]>
+
+ +
☝️ Tryk på skjoldet for at få flere oplysninger.️]]>
+
☝️ Tryk på skjoldet for at få flere oplysninger.️]]>
+
+ Forstået + Fire Button.

Prøv det! ☝️️]]>
+ "Du har forstået det!" + Husk: hver gang du browser med mig, mister en uhyggelig annonce sine vinger. 👌 + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index c820c7621bbe..f90186cc0150 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -828,4 +828,45 @@ Das Sicherheitszertifikat für %1$s ist abgelaufen. Es ist möglich, dass die Website falsch konfiguriert ist, ein Angreifer deine Verbindung kompromittiert hat oder deine Systemuhr falsch ist. Das Sicherheitszertifikat für %1$s stimmt nicht mit *.%2$s überein. Es ist möglich, dass die Website falsch konfiguriert ist, ein Angreifer deine Verbindung kompromittiert hat oder deine Systemuhr falsch ist. Das Sicherheitszertifikat für %1$s wird vom Betriebssystem deines Geräts als nicht vertrauenswürdig eingestuft. Es ist möglich, dass die Website falsch konfiguriert ist, ein Angreifer deine Verbindung kompromittiert hat oder deine Systemuhr falsch ist. + + +
Bereit für ein besseres, privateres Internet?]]>
+ Machen wir das! + Datenschutz aktiviert! + Wähle deinen Browser + Standardmäßig privat suchen + Blockiert Tracker von Drittanbietern + Cookie-Pop-ups blockieren + Aufdringliche Werbung blockieren + Browserdaten schnell löschen + Fantastisch! Willkommen auf der Entenseite. + Mit dem Browsen beginnen + Suche ausprobieren! + Deine DuckDuckGo-Suchanfragen sind immer anonym. + wie sagt man „Ente“ auf Spanisch + Besetzung von Mighty Ducks + Lokales Wetter + Überrasche mich! + Versuche, eine Website zu besuchen! + Ich blockiere Tracker, damit sie dich nicht ausspionieren können. + espn.com + yahoo.com + ebay.com + Überrasche mich! + Das ist DuckDuckGo Search. Privat. Schnell. Weniger Werbung. + Verstanden. + Versuche als nächstes, eine Website zu besuchen! + + %1$d weiteres Tracker-Netzwerk hat versucht, dich hier zu tracken. Ich habe es blockiert!

☝️ Tippe auf das Schild, um weitere Informationen zu erhalten.]]>
+ %1$d weitere Tracker-Netzwerke haben versucht, dich hier zu tracken. Ich habe sie blockiert!

☝️ Tippe auf das Schild, um weitere Informationen zu erhalten.]]>
+
+ +
☝️ Tippe auf das Schild, um weitere Informationen zu erhalten.]]>
+
☝️ Tippe auf das Schild, um weitere Informationen zu erhalten.]]>
+
+ Verstanden. + Fire Button.

Versuch es doch mal! ☝️️]]>
+ "Du schaffst das!" + Hinweis: Jedes Mal, wenn du mit mir browst, verliert eine gruselige Anzeige ihren Schrecken. 👌 + diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 80dfc16b08ab..aaff316401ef 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -828,4 +828,45 @@ Το πιστοποιητικό ασφαλείας για τον τομέα %1$s έχει λήξει. Είναι πιθανόν ο ιστότοπος να μην έχει διαμορφωθεί σωστά, κάποιος εισβολέας να έχει παραβιάσει τη σύνδεσή σας ή το ρολόι του συστήματός σας να είναι λανθασμένο. Το πιστοποιητικό ασφάλειας για τον τομέα %1$s δεν ταιριάζει με το *.%2$s. Είναι πιθανόν ο ιστότοπος να μην έχει διαμορφωθεί σωστά ή κάποιος εισβολέας να έχει παραβιάσει τη σύνδεσή σας, ή το ρολόι του συστήματός σας να είναι λανθασμένο. Το πιστοποιητικό ασφαλείας για τον τομέα %1$s δεν εκλαμβάνεται ως αξιόπιστο από το λειτουργικό σύστημα της συσκευής σας. Είναι πιθανόν ο ιστότοπος να μην έχει διαμορφωθεί σωστά ή κάποιος εισβολέας να έχει παραβιάσει τη σύνδεσή σας, ή το ρολόι του συστήματός σας να είναι λανθασμένο. + + +
Έτοιμοι για ένα καλύτερο και πιο ιδιωτικό διαδίκτυο;]]>
+ Ας το κάνουμε! + H προστασία προσωπικών δεδομένων energopoi;huhke! + Επιλέξτε το πρόγραμμα περιήγησής σας + Ιδιωτική αναζήτηση βάσει προεπιλογής + Αποκλείστε εφαρμογές παρακολούθησης τρίτων + Αποκλεισμός αναδυόμενων παραθύρων cookie + Αποκλεισμός στοχευμένων διαφημίσεων + Γρήγορη διαγραφή δεδομένων περιήγησης + Φανταστικά! Το Παπί σε καλωσορίζει! + Έναρξη περιήγησης + Δοκίμασε μια αναζήτηση! + Οι αναζητήσεις σας στο DuckDuckGo είναι πάντα ανώνυμες. + πώς μπορείτε να πείτε «duck» στα ισπανικά + Mighty Ducks Cast + τοπικός καιρός + Κάνε μου έκπληξη! + Δοκιμάστε να επισκεφτείτε έναν ιστότοπο! + Θα εμποδίσω τις εφαρμογές παρακολούθησης ώστε να μην μπορούν να σας κατασκοπεύουν. + espn.com + yahoo.com + ebay.com + Κάνε μου έκπληξη! + Αυτό είναι το DuckDuckGo Search. Ιδιωτικά. Γρήγορα. Λιγότερες διαφημίσεις. + Το κατάλαβα! + Στη συνέχεια, δοκιμάστε να επισκεφτείτε έναν ιστότοπο! + + %1$d ακόμα προσπαθούσαν να σας παρακολουθήσουν εδώ. Τους μπλόκαρα!

☝️ Πατήστε την ασπίδα για περισσότερες πληροφορίες.️]]>
+ %1$d ακόμα προσπαθούσαν να σας παρακολουθήσουν εδώ. Τους μπλόκαρα!

☝️ Πατήστε την ασπίδα για περισσότερες πληροφορίες.️]]>
+
+ +
☝️ Πατήστε την ασπίδα για περισσότερες πληροφορίες.️]]>
+
☝️ Πατήστε την ασπίδα για περισσότερες πληροφορίες.️]]>
+
+ Το κατάλαβα! + Fire Button.

Δοκιμάστε το! ☝️️]]>
+ "Το έχετε!" + Να θυμάστε: κάθε φορά που περιηγείστε μαζί μου, μια ανατριχιαστική διαφήμιση χάνει τη δύναμή της! 👌 + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index bd148d17b51d..7dda35846ae3 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -828,4 +828,45 @@ El certificado de seguridad de %1$s ha caducado. Es posible que el sitio web esté mal configurado, que un atacante haya comprometido tu conexión o que el reloj del sistema no sea correcto. El certificado de seguridad de %1$s no coincide con *.%2$s. Es posible que el sitio web esté mal configurado, que un atacante haya comprometido tu conexión o que el reloj del sistema no sea correcto. El sistema operativo del dispositivo no confía en el certificado de seguridad de %1$s. Es posible que el sitio web esté mal configurado, que un atacante haya comprometido tu conexión o que el reloj del sistema no sea correcto. + + +
¿Listo para un internet mejor y más privado?]]>
+ ¡De acuerdo! + ¡Protecciones de privacidad activadas! + Elige tu navegador + Búsqueda privada por defecto + Bloquea rastreadores de terceros + Bloqueo de ventanas emergentes de cookies + Bloqueo de anuncios escalofriantes + Borra rápidamente los datos de navegación + ¡Genial! Te damos la bienvenida al lado Duck. + Empezar a navegar + Prueba una búsqueda! + Tus búsquedas en DuckDuckGo son siempre anónimas. + cómo se dice «pato» en inglés + reparto de Mighty Ducks + el tiempo local + ¡Sorpréndeme! + ¡Intenta visitar un sitio! + Bloquearé los rastreadores para que no puedan espiarte. + espn.com + yahoo.com + ebay.com + ¡Sorpréndeme! + Eso es DuckDuckGo Search. Privado. Rápido. Menos anuncios. + Entendido + ¡A continuación, intenta visitar un sitio! + + %1$d persona más intentaba rastrearte hasta aquí. ¡Los he bloqueado!

☝️ Pulsa en el escudo para obtener más información.️]]>
+ %1$d personas más intentaban rastrearte hasta aquí. ¡Los he bloqueado!

☝️ Pulsa en el escudo para obtener más información.️]]>
+
+ +
☝️ Pulsa en el escudo para obtener más información.️]]>
+
☝️ Pulsa en el escudo para obtener más información.️]]>
+
+ Entendido + Fire Button.

¡Pruébalo! ☝️️]]>
+ "¡Lo estás haciendo muy bien!" + Recuerda: cada vez que navegas conmigo corto las alas a un anuncio horrible. 👌 + diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 7833a9af6b3a..e6705efc8ab0 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -828,4 +828,45 @@ %1$s turvasertifikaat on aegunud. Võimalik, et veebisait on valesti konfigureeritud, ründaja on sinu ühenduse ohtu seadnud või sinu süsteemi kell on vale. %1$s turvasertifikaat ei ühti *.%2$s. Võimalik, et veebisait on valesti konfigureeritud, ründaja on sinu ühenduse ohtu seadnud või sinu süsteemi kell on vale. Sinu seadme operatsioonisüsteem ei usalda %1$s turvasertifikaati. Võimalik, et veebisait on valesti konfigureeritud, ründaja on sinu ühenduse ohtu seadnud või sinu süsteemi kell on vale. + + +
Kas oled valmis kasutama paremat ja privaatsemat internetti?]]>
+ Teeme ära! + Privaatsuskaitsed on aktiveeritud! + Vali oma brauser + Otsi vaikimisi privaatselt + Blokeeri kolmanda poole jälgurid + Blokeeri küpsiste hüpikaknad + Blokeeri sihitud reklaamid + Sirvimisandmete kiire kustutamine + Suurepärane! Tere tulemast pardi poolele. + Alusta sirvimist + Proovige otsingut! + Sinu DuckDuckGo otsingud on alati anonüümsed. + kuidas öelda „part“ hispaania keeles + Mighty Ducks näitlejad + kohalik ilm + Üllata mind! + Proovi külastada saiti! + Ma blokeerin jälgijaid, et nad ei saaks sind luurata. + espn.com + yahoo.com + ebay.com + Üllata mind! + See on DuckDuckGo otsing. Privaatne. Kiire. Vähem reklaame. + Sain aru! + Järgmisena proovi mõnda saiti külastada! + + veel %1$d teine üritas sind siin jälgida. Ma blokeerisin need!

☝️ Lisateabe saamiseks vajuta kilbile.]]>
+ veel %1$d teist üritasid sind siin jälgida. Ma blokeerisin need!

☝️ Lisateabe saamiseks vajuta kilbile.]]>
+
+ +
☝️ Lisateabe saamiseks vajuta kilbile. ️]]>
+
☝️ Lisateabe saamiseks vajuta kilbile.]]>
+
+ Sain aru! + Fire Button abil.

Proovi! ☝️️]]>
+ "Sa saad hakkama!" + Pea meeles: iga kord kui minuga sirvid, kaotab jube reklaam oma tiivad. 👌 + diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 4b533442cfbc..1306fc34078e 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -828,4 +828,45 @@ Verkkotunnuksen %1$s turvasertifikaatti on vanhentunut. Verkkosivusto on ehkä väärin määritetty, hyökkäys on saattanut vaarantaa yhteytesi tai järjestelmäsi kellossa on virhe. Verkkotunnuksen %1$s suojausvarmenne ei vastaa *.%2$s. Verkkosivusto on ehkä väärin määritetty, hyökkäys on saattanut vaarantaa yhteytesi tai järjestelmäsi kellossa on virhe. Laitteesi käyttöjärjestelmä ei luota verkkotunnuksen %1$s suojausvarmenteeseen. Verkkosivusto on ehkä väärin määritetty, hyökkäys on saattanut vaarantaa yhteytesi tai järjestelmäsi kellossa on virhe. + + +
Oletko valmis parempaan ja yksityisempään internetiin?]]>
+ Aloitetaan! + Yksityisyyden suoja aktivoitu! + Valitse selaimesi + Yksityinen selaus oletuksena + Estä kolmannen osapuolen seuraimet + Estä evästeponnahdusikkunat + Estä rasittavat mainokset + Tyhjennä selaustiedot nopeasti + Mahtavaa! Tervetuloa käyttämään Duckia. + Aloita selailu + Kokeile hakua! + DuckDuckGo-hakusi ovat aina nimettömiä. + kuinka sanotaan \"duck\" espanjaksi + mighty ducks cast + paikallissää + Yllätä minut! + Siirry sivustolle! + Estän jäljittäjät, jotta ne eivät voi vakoilla sinua. + espn.com + yahoo.com + ebay.com + Yllätä minut! + Tällainen on DuckDuckGo Search. Yksityinen. Nopea. Vähemmän mainoksia. + Selvä! + Siirry seuraavaksi sivustolle! + + %1$d muu yritti seurata sinua täällä. Estin ne!

☝️ Napauta kilpeä saadaksesi lisätietoja.️]]>
+ %1$d muuta yrittivät seurata sinua täällä. Estin ne!

☝️ Napauta kilpiä saadaksesi lisätietoja.️]]>
+
+ +
☝️ Napauta kilpeä saadaksesi lisätietoja.️]]>
+
☝️ Napauta kilpeä saadaksesi lisätietoja.️]]>
+
+ Selvä! + Fire Button -painikkeella.

Kokeile nyt! ☝️️]]>
+ "Hyvin menee!" + Muista, että joka kerta kun käytät minua selaamiseen, rasittavat mainokset katoavat. 👌 + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 63c4c7d7548f..9b694871d221 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -828,4 +828,45 @@ Le certificat de sécurité de %1$s a expiré. Il est possible que le site soit mal configuré, qu\'un pirate ait compromis votre connexion ou que l\'horloge de votre système soit incorrecte. Le certificat de sécurité de %1$s ne correspond pas à *.%2$s. Il est possible que le site soit mal configuré, qu\'un pirate ait compromis votre connexion ou que l\'horloge de votre système soit incorrecte. Le certificat de sécurité de %1$s n\'est pas approuvé par le système d\'exploitation de votre appareil. Il est possible que le site soit mal configuré, qu\'un pirate ait compromis votre connexion ou que l\'horloge de votre système soit incorrecte. + + +
Envie de profiter d\'Internet d\'une meilleure façon plus confidentielle ?]]>
+ Allons-y ! + Protections de la confidentialité activées ! + Choisissez votre navigateur + Faire une recherche privée par défaut + Bloquer les traqueurs tiers + Bloquez les fenêtres contextuelles de cookies + Bloquez les publicités douteuses + Effacer rapidement les données de navigation + Génial ! Bienvenue chez DuckDuckGo. + Commencer la navigation + Essayez une recherche ! + Vos recherches sur DuckDuckGo sont toujours anonymes. + comment dire « duck » en espagnol + casting de mighty ducks + météo locale + Surprenez-moi ! + Essayez de visiter un site ! + Je bloquerai les traqueurs afin qu\'ils ne puissent pas vous espionner. + espn.com + yahoo.com + ebay.com + Surprenez-moi ! + C\'est DuckDuckGo Search. Privé. Rapide. Moins de publicités. + J\'ai compris ! + Ensuite, essayez de visiter un site ! + + %1$d autre essayaient de vous suivre ici. Je les ai bloqués !

☝️ Appuyez sur le bouclier pour en savoir plus.️]]>
+ %1$d autres essayaient de vous suivre ici. Je les ai bloqués !

☝️ Appuyez sur le bouclier pour en savoir plus.️]]>
+
+ +
☝️ Appuyez sur le bouclier pour en savoir plus.️]]>
+
☝️ Appuyez sur le bouclier pour en savoir plus.️]]>
+
+ J\'ai compris ! + Fire Button.

Essayez par vous-même ! ☝️️]]>
+ "Bien joué !" + Pensez-y : chaque fois que vous naviguez avec moi, une publicité douteuse disparaît. 👌 + diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 303088505392..8ed6aa052742 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -838,4 +838,49 @@ Sigurnosni certifikat za %1$s je istekao. Moguće je da je web-mjesto pogrešno konfigurirano, da je napadač ugrozio tvoju vezu ili da je sat tvog sustava netočan. Sigurnosni certifikat za %1$s ne odgovara *.%2$s. Moguće je da je web-mjesto pogrešno konfigurirano, da je napadač ugrozio tvoju vezu ili da je sat tvog sustava netočan. Operativni sustav tvog uređaja ne vjeruje sigurnosnom certifikatu za %1$s. Moguće je da je web-mjesto pogrešno konfigurirano, da je napadač ugrozio tvoju vezu ili da je sat tvog sustava netočan. + + +
Jesi li spreman za bolji, privatniji internet?]]>
+ Učinimo to! + Zaštita privatnosti aktivirana! + Odaberi preglednik + Pretražuj privatno po zadanim postavkama + Blokiraj alate za praćenje trećih strana + Blokiraj skočne prozore kolačića + Blokiraj neželjene oglase + Brzo izbriši podatke pregledavanja + Odlično! Dobrodošli na našu stranu! + Započni pretraživanje + Isprobajte pretragu! + Tvoja su DuckDuckGo pretraživanja uvijek anonimna. + kako se kaže \"patka\" na španjolskom + moćne patke + lokalno vrijeme + Iznenadi me! + Pokušaj posjetiti web-mjesto! + Blokirat ću tragače kako te ne bi mogli špijunirati. + espn.com + yahoo.com + ebay.com + Iznenadi me! + To je DuckDuckGo Search. Privatno. Brzo. Manje oglasa. + Shvaćam! + Nakon toga pokušaj posjetiti neko web-mjesto! + + još %1$d drugih pokušavalo te ovdje pratiti. Blokirani su!

☝️ Dodirni štit za više informacija.️]]>
+ još %1$d drugih pokušavalo te ovdje pratiti. Blokirani su!

☝️ Dodirni štit za više informacija.️]]>
+ još %1$d drugih pokušavalo te ovdje pratiti. Blokirani su!

☝️ Dodirni štit za više informacija.️]]>
+ još %1$d drugih pokušavalo te ovdje pratiti. Blokirani su!

☝️ Dodirni štit za više informacija.️]]>
+
+ +
☝️ Dodirni štit za više informacija.️]]>
+
☝️ Dodirni štit za više informacija.️]]>
+
☝️ Dodirni štit za više informacija.️]]>
+
☝️ Dodirni štit za više informacija.️]]>
+
+ Shvaćam! + Fire Buttona.

Probaj! ☝️️]]>
+ "Možeš ti to!" + Zapamti: svaki put kada me koristiš za pregledavanje, grozne reklame odlaze u zaborav. 👌 + diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 55cd9b79591a..03e8d4278df0 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -828,4 +828,45 @@ A megnyitni kívánt %1$s biztonsági tanúsítványa lejárt. Lehetséges, hogy a webhely nincs megfelelően konfigurálva, esetleg egy támadó feltörte a kapcsolatodat, vagy a rendszeróra beállítása helytelen. A megnyitni kívánt %1$s biztonsági tanúsítványa nem egyezik ezzel: *.%2$s. Lehetséges, hogy a webhely nincs megfelelően konfigurálva, esetleg egy támadó feltörte a kapcsolatodat, vagy a rendszeróra beállítása helytelen. A megnyitni kívánt %1$s biztonsági tanúsítványát az eszköz operációs rendszere nem ítéli meg biztonságosnak. Lehetséges, hogy a webhely nincs megfelelően konfigurálva, esetleg egy támadó feltörte a kapcsolatodat, vagy a rendszeróra beállítása helytelen. + + +
Készen állsz egy jobb, privátabb internetre?]]>
+ Csináljuk! + Adatvédelem aktiválva! + Böngésző kiválasztása + Privát keresés alapértelmezés szerint + Harmadik féltől származó nyomkövetők blokkolása + Felugró sütiablakok blokkolása + Gyanús hirdetések blokkolása + Böngészési adatok gyors törlése + Nagyszerű! Üdvözlünk a kacsa világában! + Böngészés indítása + Keress rá! + A DuckDuckGo-kereséseid mindig névtelenek. + hogyan mondják spanyolul, hogy „kacsa” + kerge kacsák szereposztása + helyi időjárás + Lepj meg! + Próbálj meg ellátogatni egy webhelyre! + Én blokkolom a nyomkövetőket, hogy ne tudjanak kémkedni utánad. + espn.com + yahoo.com + ebay.com + Lepj meg! + Ez a DuckDuckGo Search. Privát. Gyors. Kevesebb hirdetés. + Megvan! + A következő lépésben próbálj meg ellátogatni egy webhelyre! + + további %1$d próbált téged követni itt. Blokkoltam őket!

☝️ További információkért koppints a pajzsra.️]]>
+ további %1$d próbált téged követni itt. Blokkoltam őket!

☝️ További információkért koppints a pajzsra.️]]>
+
+ +
☝️ További információkért koppints a pajzsra.️]]>
+
☝️ További információkért koppints a pajzsra.️]]>
+
+ Rendben! + Fire Button használatával.

Próbáld ki! ☝️️]]>
+ "Megvan, ez az!" + Ne feledd: minden alkalommal, amikor velem böngészel, egy undok hirdetés elveszíti az erejét. 👌 + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 59e14e56ef88..5c54c5578b5e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -828,4 +828,45 @@ Il certificato di protezione per %1$s è scaduto. È possibile che il sito web non sia configurato correttamente, che un aggressore abbia compromesso la tua connessione o che l\'orologio del tuo sistema non sia corretto. Il certificato di protezione per %1$s non corrisponde a *.%2$s. È possibile che il sito web non sia configurato correttamente, che un aggressore abbia compromesso la tua connessione o che l\'orologio del tuo sistema non sia corretto. Il certificato di sicurezza di %1$s non è considerato attendibile dal sistema operativo del tuo dispositivo. È possibile che il sito web non sia configurato correttamente, che un aggressore abbia compromesso la tua connessione o che l\'orologio del tuo sistema non sia corretto. + + +
Vuoi navigare in Internet con più privacy?]]>
+ Facciamolo! + Protezioni della privacy attivate! + Scegli il tuo browser + Cerca privatamente per impostazione predefinita + Blocca i sistemi di tracciamento di terze parti + Blocca i popup dei cookie + Blocca gli annunci invasivi + Elimina rapidamente i dati di navigazione + Fantastico! Duck ti dà il benvenuto! + Inizia a navigare + Prova una ricerca! + Le tue ricerche su DuckDuckGo sono sempre anonime. + come si dice \"anatra\" in spagnolo + cast delle papere potenti + meteo locale + Sorprendimi! + Prova a visitare un sito! + Bloccherò i sistemi di tracciamento in modo che non possano spiarti. + espn.com + yahoo.com + ebay.com + Sorprendimi! + È DuckDuckGo Search. Veloce, privata e con meno annunci. + Ho capito! + In seguito, prova a visitare un sito! + + %1$d altro stavano tentando di tracciare la tua attività qui. Li ho bloccati!

☝️ Tocca lo scudo per maggiori informazioni.️]]>
+ altri %1$d stavano tentando di tracciare la tua attività qui. Li ho bloccati!

☝️ Tocca lo scudo per maggiori informazioni.️]]>
+
+ +
☝️ Tocca lo scudo per maggiori informazioni.️]]>
+
☝️ Tocca lo scudo per maggiori informazioni.]]>
+
+ Ho capito! + Fire Button.

Provalo! ☝️️]]>
+ "Ben fatto!" + Ricorda: quando navighi con me gli annunci inquietanti non possono seguirti. 👌 + diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index 9de52c6465f7..ed9cff25ace9 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -838,4 +838,49 @@ %1$s saugumo sertifikato galiojimas yra pasibaigęs. Gali būti, kad svetainė neteisingai sukonfigūruota arba atakos vykdytojas pakenkė jūsų ryšiui arba kad jūsų sistemos laikrodžio rodomas laikas yra neteisingas. %1$s saugos sertifikatas neatitinka *.%2$s. Gali būti, kad svetainė neteisingai sukonfigūruota arba atakos vykdytojas pakenkė jūsų ryšiui arba kad jūsų sistemos laikrodžio rodomas laikas yra neteisingas. %1$s saugos sertifikatu nepasitiki jūsų įrenginio operacinė sistema. Gali būti, kad svetainė neteisingai sukonfigūruota arba atakos vykdytojas pakenkė jūsų ryšiui arba kad jūsų sistemos laikrodžio rodomas laikas yra neteisingas. + + +
Ar esate pasiruošę geresniam ir privatesniam internetui?]]>
+ Padarykime tai! + Privatumo apsaugos priemonės įjungtos! + Pasirinkite naršyklę + Vykdykite paiešką privačiai pagal numatytąjį nustatymą + Blokuoja trečiųjų šalių stebėjimo priemones + Blokuoti slapukų iššokančiuosius langus + Blokuokite taikomus skelbimus + Greitai ištrinkite naršymo duomenis + Nuostabu! Sveiki prisijungę prie „Duck“! + Pradėti naršyti + Išmėgink paiešką! + Jūsų „DuckDuckGo“ paieškos visada yra anoniminės. + kaip pasakyti „antis“ ispanų kalba + mighty ducks aktoriai + vietinis oras + Nustebink mane! + Pabandyk apsilankyti svetainėje! + Užblokuosiu stebėjimo priemones, kad jos negalėtų tavęs šnipinėti. + espn.com + yahoo.com + ebay.com + Nustebink mane! + Tai „DuckDuckGo Search“. Privati. Sparti. Mažiau reklamų. + Supratau! + Pabandyk apsilankyti svetainėje! + + %1$d žmogus bandė jus stebėti čia. Aš juos užblokavau!

☝️ Palieskite skydą, kad gautumėte daugiau informacijos.️]]>
+ %1$d žmonės bandė jus stebėti čia. Aš juos užblokavau!

☝️ Palieskite skydą, kad gautumėte daugiau informacijos.️]]>
+ %1$d žmogaus bandė jus stebėti čia. Aš juos užblokavau!

☝️ Palieskite skydą, kad gautumėte daugiau informacijos.️]]>
+ %1$d žmonių bandė jus stebėti čia. Aš juos užblokavau!

☝️ Palieskite skydą, kad gautumėte daugiau informacijos.️]]>
+
+ +
☝️ Palieskite skydą, kad gautumėte daugiau informacijos.️]]>
+
☝️ Bakstelėkite skydą, kad gautumėte daugiau informacijos.️]]>
+
☝️ Bakstelėkite skydą, kad gautumėte daugiau informacijos.️]]>
+
☝️ Bakstelėkite skydą, kad gautumėte daugiau informacijos.️]]>
+
+ Supratau! + Fire Button.

Išbandykite! ☝️️]]>
+ "Atlikote!" + Įsidėmėkite: kiekvieną kartą, kai naršai su manimi, bauginantis skelbimas praranda galią. 👌 + diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index fcb004dec02e..311ee12a3724 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -833,4 +833,47 @@ %1$s drošības sertifikāta derīguma termiņš ir beidzies. Iespējams, ka vietne ir nepareizi konfigurēta, ka tavu savienojumu ir apdraudējis kāds uzbrucējs vai ka tavas sistēmas pulkstenis ir nepareizs. %1$s drošības sertifikāts neatbilst *.%2$s. Iespējams, ka vietne ir nepareizi konfigurēta, ka tavu savienojumu ir apdraudējis kāds uzbrucējs vai ka tavas sistēmas pulkstenis ir nepareizs. Tavas ierīces operētājsistēma neuzticas %1$s drošības sertifikātam. Iespējams, ka vietne ir nepareizi konfigurēta, ka tavu savienojumu ir apdraudējis kāds uzbrucējs vai ka tavas sistēmas pulkstenis ir nepareizs. + + +
Vai esi gatavs labākam, privātākam Internetam?]]>
+ Aiziet! + Privātuma aizsardzība ir aktivizēta! + Izvēlies pārlūku + Meklē privāti pēc noklusējuma + Bloķē trešo pušu izsekotājus + Bloķē sīkfailu uznirstošos logus + Bloķē kaitinošas reklāmas + Ātri izdzēs pārlūkošanas datus + Lieliski! Apsveicam ar pievienošanos pīļu komandai. + Sākt pārlūkošanu + Izmēģini meklēt! + Tavi DuckDuckGo meklējumi vienmēr ir anonīmi. + kā spāniski pateikt “pīle” + mighty ducks aktieri + vietējie laikapstākļi + Pārsteidz mani! + Pamēģini apmeklēt kādu vietni! + Es nobloķēšu izsekotājus, lai tie nevarētu tevi izspiegot. + espn.com + yahoo.com + ebay.com + Pārsteidz mani! + Tas ir DuckDuckGo Search. Privāti. Ātri. Mazāk reklāmu. + Sapratu! + Tagad pamēģini atvērt kādu vietni! + + vēl %1$d mēģināja tevi šeit izsekot. Es tos bloķēju!

☝️ Pieskaries vairogam, lai uzzinātu vairāk.️]]>
+ vēl %1$d mēģināja tevi šeit izsekot. Es tos bloķēju!

☝️ Pieskaries vairogam, lai uzzinātu vairāk.️]]>
+ vēl %1$d mēģināja tevi šeit izsekot. Es tos bloķēju!

☝️ Pieskaries vairogam, lai uzzinātu vairāk.️]]>
+
+ +
☝️ Pieskaries vairogam, lai uzzinātu vairāk.]]>
+
☝️ Pieskaries vairogam, lai uzzinātu vairāk.]]>
+
☝️ Pieskaries vairogam, lai uzzinātu vairāk. ️]]>
+
+ Sapratu! + Fire Button.

Izmēģini! ☝️️]]>
+ "Izdevās!" + Atceries: katru reizi, kad pārlūkosi kopā ar mani, uzmācīgās reklāmas zaudēs savu spēku! 👌 + diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index fd2e4a9890c3..3daf0498490a 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -828,4 +828,45 @@ Sikkerhetssertifikatet for %1$s er utløpt. Det er mulig at nettstedet er feilkonfigurert, at en angriper har kompromittert forbindelsen eller at systemklokken din er feil. Sikkerhetssertifikatet for %1$s samsvarer ikke med *.%2$s. Det er mulig at nettstedet er feilkonfigurert, at en angriper har kompromittert tilkoblingen eller at systemklokken din er feil. Sikkerhetssertifikatet for %1$s er ikke klarert av enhetens operativsystem. Det er mulig at nettstedet er feilkonfigurert, at en angriper har kompromittert tilkoblingen eller at systemklokken din er feil. + + +
Klar for et bedre, mer privat internett?]]>
+ Sett i gang! + Personvern er aktivert! + Velg nettleseren din + Søk privat som standard + Blokker tredjepartssporere + Blokker popup-vinduer om informasjonskapsler + Blokker påtrengende annonser + Slett nettleserdata raskt + Fantastisk! Velkommen til the duck side. + Begynn å surfe + Prøv et søk! + DuckDuckGo-søkene dine er alltid anonyme. + hvordan si «duck» på spansk + mighty ducks rolleinnehavere + lokalt vær + Overrask meg! + Prøv å besøke et nettsted! + Jeg blokkerer sporingsforsøk slik at de ikke kan spionere på deg. + espn.com + yahoo.com + ebay.com + Overrask meg! + Det er DuckDuckGo Search. Privat. Raskt. Færre annonser. + Skjønner! + Nå kan du prøve å besøke et nettsted! + + %1$d andre prøvde å spore deg her. Jeg blokkerte dem!

☝️ Trykk på skjoldet for mer informasjon.️]]>
+ %1$d andre prøvde å spore deg her. Jeg blokkerte dem!

☝️ Trykk på skjoldet for mer informasjon.️]]>
+
+ +
☝️ Trykk på skjoldet for mer info.️]]>
+
☝️ Trykk på skjoldet for mer info.️]]>
+
+ Skjønner! + Fire Button.

Prøv det! ☝️️]]>
+ "Dette går bra!" + Husk: Hver gang du surfer med meg, klippes vingene på en uhyggelig annonse. 👌 + diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 0510fb78b3d5..552c646430c5 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -828,4 +828,45 @@ Het beveiligingscertificaat voor %1$s is verlopen. Mogelijk is de website verkeerd geconfigureerd, heeft een aanvaller je verbinding gecompromitteerd of staat de klok van je systeem niet goed. Het beveiligingscertificaat voor %1$s komt niet overeen met *.%2$s. Mogelijk is de website verkeerd geconfigureerd, heeft een aanvaller je verbinding gecompromitteerd of staat de klok van je systeem niet goed. Het besturingssysteem van je apparaat vertrouwt het beveiligingscertificaat voor %1$s niet. Mogelijk is de website verkeerd geconfigureerd, heeft een aanvaller je verbinding gecompromitteerd of staat de klok van je systeem niet goed. + + +
Klaar voor een beter internet met meer privacy?]]>
+ Laten we het doen! + Privacybescherming geactiveerd! + Kies je browser + Standaard privé zoeken + Blokkeer trackers van derden + Blokkeer cookiepop-ups + Blokkeer enge advertenties + Wis browsegegevens in een handomdraai + Geweldig! Welkom aan de Duck-kant. + Beginnen met browsen + Probeer een zoekopdracht! + Je DuckDuckGo-zoekopdrachten zijn altijd anoniem. + hoe zeg je \'eend\' in het Spaans? + cast van Mighty Ducks + lokaal weer + Verras me! + Bezoek eens een site! + Ik blokkeer trackers zodat ze je niet kunnen bespioneren. + espn.com + yahoo.com + ebay.com + Verras me! + Dat is DuckDuckGo Search. Privé. Snel. Minder advertenties. + Ik snap het! + Probeer nu een site te bezoeken! + + %1$d ander probeerden je hier te volgen. Ik heb ze geblokkeerd!

☝️ Tik op het schild voor meer informatie.️]]>
+ %1$d anderen probeerden je hier te volgen. Ik heb ze geblokkeerd!

☝️ Tik op het schild voor meer informatie.️]]>
+
+ +
☝️ Tik op het schild voor meer info.️]]>
+
☝️ Tik op het schild voor meer info.️]]>
+
+ Ik snap het! + Fire Button.

Probeer het maar! ☝️️]]>
+ "Je kunt het!" + Denk eraan: elke keer als je met mij browset, verliest een enge advertentie zijn vleugels. 👌 + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 129fc5e35451..d12bb706a3af 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -838,4 +838,49 @@ Certyfikat zabezpieczeń dla %1$s wygasł. Możliwe, że witryna jest błędnie skonfigurowana, osoba atakująca naruszyła połączenie lub ustawienie zegara systemowego jest nieprawidłowe. Certyfikat zabezpieczeń dla %1$s nie jest zgodny z *.%2$s. Możliwe, że witryna jest błędnie skonfigurowana, osoba atakująca naruszyła połączenie lub ustawienie zegara systemowego jest nieprawidłowe. Certyfikat bezpieczeństwa %1$s nie jest zaufany przez system operacyjny urządzenia. Możliwe, że witryna jest błędnie skonfigurowana, osoba atakująca naruszyła połączenie lub ustawienie zegara systemowego jest nieprawidłowe. + + +
Chcesz korzystać z lepszego, bardziej prywatnego Internetu?]]>
+ Zróbmy to! + Aktywowano ochronę prywatności! + Wybierz przeglądarkę + Domyślnie szukaj prywatnie + Blokuj mechanizmy śledzące innych firm + Blokuj wyskakujące okienka z informacją o plikach cookie + Blokuj wścibskie reklamy + Szybko usuwaj dane przeglądania + Fantastycznie! Witaj po Kaczej Stronie Mocy! + Rozpocznij przeglądanie + Spróbuj coś wyszukać! + Wyszukiwania w DuckDuckGo zawsze są anonimowe. + jak się mówi „kaczka” po hiszpańsku + obsada potężnych kaczorów + pogoda lokalna + Zaskocz mnie! + Spróbuj odwiedzić witrynę! + Zablokuję mechanizmy śledzące, aby nie mogły Cię szpiegować. + espn.com + yahoo.com + ebay.com + Zaskocz mnie! + To DuckDuckGo Search. Prywatna. Szybka. Z mniejszą liczbą reklam. + Rozumiem! + Następnie spróbuj odwiedzić witrynę! + + jeszcze %1$d mechanizm próbowały Cię tutaj śledzić. Zostały przeze mnie zablokowane!

☝️ Stuknij tarczę, aby uzyskać więcej informacji.️]]>
+ jeszcze %1$d mechanizmy próbowały Cię tutaj śledzić. Zostały przeze mnie zablokowane!

☝️ Stuknij tarczę, aby uzyskać więcej informacji.️]]>
+ jeszcze %1$d mechanizmów próbowało Cię tutaj śledzić. Zostały przeze mnie zablokowane!

☝️ Stuknij tarczę, aby uzyskać więcej informacji.️]]>
+ jeszcze %1$d mechanizmu próbowało Cię tutaj śledzić. Zostały przeze mnie zablokowane!

☝️ Stuknij tarczę, aby uzyskać więcej informacji.️]]>
+
+ +
☝️ Dotknij tarczy, aby uzyskać więcej informacji.️]]>
+
☝️ Dotknij tarczy, aby uzyskać więcej informacji.️]]>
+
☝️ Dotknij tarczy, aby uzyskać więcej informacji.️]]>
+
☝️ Dotknij tarczy, aby uzyskać więcej informacji.️]]>
+
+ Rozumiem! + Fire Button.

Wypróbuj go! ☝️️]]>
+ "Udało się!" + Pamiętaj: za każdym razem, gdy przeglądasz ze mną Internet, jakaś wstrętna reklama przestaje działać. 👌 + diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index d61d8cc1fc0c..c6f9bf849f38 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -828,4 +828,45 @@ O certificado de segurança de %1$s expirou. É possível que o site esteja mal configurado, que um invasor tenha comprometido a tua ligação ou que o teu relógio do sistema esteja incorreto. O certificado de segurança de %1$s não corresponde a *.%2$s. É possível que o site esteja mal configurado, que um invasor tenha comprometido a tua ligação ou que o teu relógio do sistema esteja incorreto. O sistema operativo do teu dispositivo não confia no certificado de segurança de %1$s. É possível que o site esteja mal configurado, que um invasor tenha comprometido a tua ligação ou que o teu relógio do sistema esteja incorreto. + + +
A postos para uma Internet melhor e mais privada?]]>
+ Vamos lá! + Proteções de privacidade ativadas! + Escolhe o teu navegador + Pesquisa em privado por predefinição + Bloqueia rastreadores de terceiros + Bloquear pop-ups de cookies + Bloquear anúncios assustadores + Apaga rapidamente os dados de navegação + Fantástico! Damos-te as boas-vindas ao Duck Side. + Iniciar a navegação + Tente uma pesquisa! + As tuas pesquisas no DuckDuckGo são sempre anónimas. + como dizer \"pato\" em espanhol + elenco do filme A Hora dos Campeões + meteorologia local + Surpreende-me! + Experimenta visitar um site! + Bloquearei rastreadores para que não te espiem. + espn.com + yahoo.com + ebay.com + Surpreende-me! + É a DuckDuckGo Search. Privado. Rápido. Menos anúncios. + Entendi! + Em seguida, experimenta visitar um site! + + mais %1$d estavam tentar rastrear-te aqui. Eu bloqueei-os!

☝️ Toca no escudo para obteres mais informações.️]]>
+ mais %1$d estavam a tentar rastrear-te aqui. Eu bloqueei-os!

☝️ Toca no escudo para obteres mais informações.]]>
+
+ +
☝️ Toca no escudo para obteres mais informações.️]]>
+
☝️ Toca no escudo para obteres mais informações.️]]>
+
+ Entendi! + Fire Button.

Experimenta! ☝️️]]>
+ "Tu consegues!" + Lembra-te: sempre que navegas comigo, um anúncio assustador perde as suas asas. 👌 + diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 4b7900afca60..3decd70bf6f9 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -833,4 +833,47 @@ Certificatul de securitate pentru %1$s a expirat. Este posibil ca site-ul web să fie configurat greșit, ca un atacator să-ți fi compromis conexiunea sau ca ceasul sistemului tău să fie incorect. Certificatul de securitate pentru %1$s nu se potrivește cu *.%2$s. Este posibil ca site-ul web să fie configurat greșit, ca un atacator să-ți fi compromis conexiunea sau ca ceasul sistemului tău să fie incorect. Sistemul de operare al dispozitivului tău nu are încredere în certificatul de securitate pentru %1$s. Este posibil ca site-ul web să fie configurat greșit, ca un atacator să-ți fi compromis conexiunea sau ca ceasul sistemului tău să fie incorect. + + +
Ești gata pentru un internet mai bun și mai privat?]]>
+ Hai să o facem! + Măsurile de protecție a confidențialității au fost activate! + Alege browserul + Caută implicit în mod privat + Blochează tehnologiile de urmărire terțe + Blochează ferestrele pop-up privind modulele cookie + Blochează reclamele înfiorătoare + Șterge rapid datele de navigare + Super! Bine ai venit de cealaltă parte! + Începe navigarea + Încearcă o căutare! + Căutările tale DuckDuckGo sunt întotdeauna anonime. + cum se spune „rață” în spaniolă + distribuție mighty ducks + vremea locală + Surprinde-mă! + Încearcă să vizitezi un site! + Voi bloca instrumentele de urmărire ca să nu te mai spioneze. + espn.com + yahoo.com + ebay.com + Surprinde-mă! + Acesta este DuckDuckGo Search. Privat. Rapid. Mai puține reclame. + Am înțeles! + Apoi, încearcă să vizitezi un site! + + încă %1$d încerca să te urmărească aici. L-am blocat!

☝️ Atinge scutul pentru mai multe informații.️]]>
+ încă %1$d încercau să te urmărească aici. Le-am blocat!

☝️ Atinge scutul pentru mai multe informații.️]]>
+ încă %1$d încercau să te urmărească aici. Le-am blocat!

☝️ Atinge scutul pentru mai multe informații.️]]>
+
+ +
☝️ Atinge scutul pentru mai multe informații.️]]>
+
☝️ Atinge scutul pentru mai multe informații.️]]>
+
☝️ Atinge scutul pentru mai multe informații.️]]>
+
+ Am înțeles! + Fire Button.

Încearcă! ☝️️]]>
+ "Ai ghicit!" + Reține: de fiecare dată când navighezi cu mine, o reclamă terifiantă își pierde aripile. 👌 + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 4cbbeaa2355f..7af6a2a90f72 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -838,4 +838,49 @@ Срок действия сертификата безопасности сайта %1$s истек. Возможно, неверно задана конфигурация сайта, соединение перехвачено злоумышленником либо неправильно настроены системные часы. Сертификат безопасности сайта %1$s не подходит для домена *.%2$s. Возможно, неверно задана конфигурация сайта, соединение перехвачено злоумышленником либо неправильно настроены системные часы. Операционная система вашего устройства не доверяет сертификату безопасности сайта %1$s. Возможно, неверно задана конфигурация сайта, соединение перехвачено злоумышленником либо неправильно настроены системные часы. + + +
Качественный интернет с защитой данных заказывали?]]>
+ Поехали! + Защита конфиденциальности уже включена! + Выбрать браузер + Конфиденциальный поиск по умолчанию + Блокировка сторонних трекеров + Блокировка всплывающих окон куки + Защита от надоедливой рекламы + Быстрая очистка данных из браузера + Супер! Добро пожаловать в утиную команду! + Начать просмотр + Попробуйте поискать! + Ваши поисковые запросы в DuckDuckGo всегда анонимны. + Как сказать «утка» по-испански? + Кто играет главные роли в фильме «Могучие утята»? + Местная погода + Удиви меня! + Попробуйте посетить сайт! + Мы заблокируем трекеры и пресечем слежку. + espn.com + yahoo.com + ebay.com + Удиви меня! + Это — DuckDuckGo Search. Надежно. Быстро. Меньше рекламы. + Понятно + А теперь попробуйте посетить сайт! + + %1$d сервис пытались вести за вами слежку, но мы их заблокировали.

☝️ Нажмите на щит, чтобы узнать подробности.]]>
+ %1$d сервиса пытались вести за вами слежку, но мы их заблокировали.

☝️ Нажмите на щит, чтобы узнать подробности.]]>
+ %1$d сервисов пытались вести за вами слежку, но мы их заблокировали.

☝️ Нажмите на щит, чтобы узнать подробности.]]>
+ другие сервисы (еще %1$d) пытались вести за вами слежку, но мы их заблокировали.

☝️ Нажмите на щит, чтобы узнать подробности.]]>
+
+ +
☝️ Нажмите на щит, чтобы узнать подробности.]]>
+
☝️ Нажмите на щит, чтобы узнать подробности.]]>
+
☝️ Нажмите на щит, чтобы узнать подробности.]]>
+
☝️ Нажмите на щит, чтобы узнать подробности.]]>
+
+ Понятно! + Fire Button моментально стирает из браузера данные о посещении сайтов.

Давайте попробуем! ☝️️]]>
+ "Проще некуда!" + Бродить по сайтам с нами — значит подрезать крылья назойливой рекламе. 👌 + diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 0ba11b3eb0b4..4dd0964168a4 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -838,4 +838,49 @@ Platnosť bezpečnostného certifikátu pre %1$s uplynula. Je možné, že webová lokalita je nesprávne nakonfigurovaná, že útočník ohrozil vaše pripojenie alebo že vaše systémové hodiny nie sú správne. Bezpečnostný certifikát pre %1$s sa nezhoduje s *.%2$s. Je možné, že webová lokalita je nesprávne nakonfigurovaná alebo že útočník ohrozil vaše pripojenie, alebo že vaše systémové hodiny nie sú správne. Bezpečnostný certifikát pre %1$s nie je pre operačný systém vášho zariadenia dôveryhodný. Je možné, že webová lokalita je nesprávne nakonfigurovaná alebo že útočník ohrozil vaše pripojenie, alebo že vaše systémové hodiny nie sú správne. + + +
Ste pripravený/-á na lepší, súkromnejší internet?]]>
+ Poďme na to! + Ochrana súkromia bola aktivovaná! + Vyberte si prehliadač + Predvolené vyhľadávanie so súkromím + Blokovať sledovanie tretími stranami + Blokovanie vyskakovacích okien o súboroch cookie + Blokovať nepríjemné reklamy + Rýchle vymazanie údajov prehliadania + Úžasné! Vitajte vo svete Duck. + Začnite prehliadať + Skúste vyhľadávanie! + Vyhľadávania v službe DuckDuckGo sú vždy anonymné. + ako sa povie „kačica“ po španielsky + mocné kačice obsadenie + Miestne počasie + Prekvapte ma! + Skúste navštíviť stránku! + Zablokujem sledovacie zariadenia, ktoré by vás mohli špehovať. + espn.com + yahoo.com + ebay.com + Prekvapte ma! + To je DuckDuckGo vyhľadávanie. Súkromne. Rýchlo. Menej reklám. + Rozumiem! + Nabudúce skúste navštíviť webovú stránku! + + ďalší %1$d sa vás tu pokúšali sledovať. Zablokoval som ich!

☝️ Klepnutím na štít získate ďalšie informácie. ️]]>
+ ďalší %1$d sa vás tu pokúšali sledovať. Zablokoval som ich!

☝️ Klepnutím na štít získate ďalšie informácie. ️]]>
+ ďalší %1$d sa vás tu pokúšali sledovať. Zablokoval som ho!

☝️ Klepnutím na štít získate ďalšie informácie. ️]]>
+ ďalší %1$d sa vás tu pokúšali sledovať. Zablokoval som ich!

☝️ Klepnutím na štít získate ďalšie informácie. ️]]>
+
+ +
☝️ Ťuknite na štít pre viac informácií.️]]>
+
☝️ Ťuknite na štít pre viac informácií.️]]>
+
☝️ Ťuknite na štít pre viac informácií.️]]>
+
☝️ Ťuknite na štít pre viac informácií.️]]>
+
+ Mám to! + Fire Button.

Skúste to! ☝️️]]>
+ "Hotovo!" + Pamätajte: zakaždým, keď prehliadate v našej aplikácii, tak čudným reklamám pristrihávate krídla. 👌 + diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 36e0947d06d5..4c299938fffc 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -838,4 +838,49 @@ Varnostno potrdilo za %1$s je poteklo. Morda je spletno mesto napačno konfigurirano, napadalec je morda ogrozil vašo povezavo ali pa je vaša sistemska ura napačna. Varnostno potrdilo za %1$s se ne ujema z *.%2$s. Morda je spletno mesto napačno konfigurirano, napadalec je morda ogrozil vašo povezavo ali pa je vaša sistemska ura napačna. Operacijski sistem naprave ne zaupa varnostnemu potrdilu za %1$s. Morda je spletno mesto napačno konfigurirano, napadalec je morda ogrozil vašo povezavo ali pa je vaša sistemska ura napačna. + + +
Ste pripravljeni na boljši, bolj zasebni internet?]]>
+ Pa se lotimo! + Zaščite zasebnosti so aktivirane! + Izberite brskalnik + Privzeto išče zasebno + Blokirajte sledilnike tretjih oseb + Blokirajte pojavna okna za piškotke + Blokirajte srhljive oglase + Hitro izbrišite podatke brskanja + Odlično! Veseli nas, da ste se nam pridružili. + Začni brskanje + Preizkusi z iskanjem! + Vaša iskanja v DuckDuckGo so vedno anonimna. + kako se reče »raca« v španščini + zasedba mogočnih racmanov + lokalno vreme + Preseneti me! + Poskusite obiskati spletno stran! + Blokiral bom sledilnike, da ne bodo vohunili za vami. + espn.com + yahoo.com + ebay.com + Preseneti me! + To je iskanje DuckDuckGo Search. Zasebno. Hitro. Z manj oglasi. + Razumem! + Nato obiščite spletno mesto! + + še %1$d sta vam poskušala tukaj slediti. Blokiral sem ju!

☝️ Za več informacij se dotaknite ščita.]]>
+ še %1$d so vam poskušali tukaj slediti. Blokiral sem jih!

☝️ Za več informacij se dotaknite ščita.]]>
+ še %1$d so vam poskušali tukaj slediti. Blokiral sem jih!

☝️ Za več informacij se dotaknite ščita.]]>
+ še %1$d vam je poskušalo tukaj slediti. Blokiral sem jih!

☝️ Za več informacij se dotaknite ščita.]]>
+
+ +
☝️ Za več informacij tapnite ščit.️]]>
+
☝️ Za več informacij tapnite ščit.️]]>
+
☝️ Za več informacij tapnite ščit.️]]>
+
☝️ Za več informacij tapnite ščit.️]]>
+
+ Razumem! + Fire Button.

Poskusite! ☝️️]]>
+ "Uspelo vam bo!" + Ne pozabite: Vedno kadar brskate z mano, shrljivemu oglasu pristrižete peruti. 👌 + diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 4228661cb989..fcb91ba23709 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -828,4 +828,45 @@ Säkerhetscertifikatet för %1$s har löpt ut. Det är möjligt att webbplatsen är felkonfigurerad, att en angripare har gjort intrång i din anslutning eller att din systemklocka är felaktig. Säkerhetscertifikatet för %1$s matchar inte *.%2$s. Det är möjligt att webbplatsen är felkonfigurerad, att en angripare har gjort intrång i din anslutning eller att din systemklocka är felaktig. Din enhets operativsystem litar inte på säkerhetscertifikatet för %1$s. Det är möjligt att webbplatsen är felkonfigurerad, att en angripare har gjort intrång i din anslutning eller att din systemklocka är felaktig. + + +
Är du redo för ett bättre, mer privat internet?]]>
+ Vi gör det! + Integritetsskydd aktiverat! + Välj din webbläsare + Sök privat som standard + Blockera spårare från tredje part + Blockera popup-fönster för cookies + Blockera påträngande annonser + Rensa snabbt surfdata + Fantastiskt! Välkommen till Duck-sidan. + Börja bläddra + Pröva att söka! + Dina DuckDuckGo-sökningar är alltid anonyma. + vad heter ”anka” på spanska + medverkande i mighty ducks + lokalt väder + Överraska mig! + Prova att besöka en webbplats! + Jag blockerar spårare så att de inte kan spionera på dig. + espn.com + yahoo.com + ebay.com + Överraska mig! + Det är DuckDuckGo Search. Privat. Snabbt. Färre annonser. + Jag förstår! + Prova sedan att besöka en webbplats! + + %1$d till försökte spåra dig här. Jag blockerade dem!

☝️ Tryck på skölden för mer information.️]]>
+ %1$d till försökte spåra dig här. Jag blockerade dem!

☝️ Tryck på skölden för mer information.️]]>
+
+ +
☝️ Tryck på skölden för mer information.️]]>
+
☝️ Tryck på skölden för mer information.️]]>
+
+ Jag förstår! + Fire Button.

Prova! ☝️️]]>
+ "Du klarar det här!" + Kom ihåg: varje gång du surfar med mig förlorar en läskig annons sina vingar. 👌 + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 7aa7bd1f3c8c..96a6314f736d 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -828,4 +828,45 @@ %1$s için güvenlik sertifikasının süresi doldu. Web sitesi yanlış yapılandırılmış, bir saldırgan bağlantınızı tehlikeye atmış veya sistem saatiniz yanlış ayarlanmış olabilir. %1$s için güvenlik sertifikası eşleşmiyor *.%2$s. Web sitesi yanlış yapılandırılmış, bir saldırgan bağlantınızı tehlikeye atmış veya sistem saatiniz yanlış ayarlanmış olabilir. Cihazınızın işletim sistemi, %1$s güvenlik sertifikasına güvenmiyor. Web sitesi yanlış yapılandırılmış, bir saldırgan bağlantınızı tehlikeye atmış veya sistem saatiniz yanlış ayarlanmış olabilir. + + +
Daha iyi, daha özel bir internete hazır mısınız?]]>
+ Hadi başlayalım! + Gizlilik korumaları etkinleştirildi! + Tarayıcınızı Seçin + Varsayılan olarak gizli arama yapın + Üçüncü taraf izleyicileri engelleyin + Çerez açılır pencerelerini engelle + Tekinsiz reklamları engelleyin + Tarama verilerini hızlıca silin + Harika! Duck Side\'a hoş geldiniz. + Gezinmeye Başla + Bir şeyler arayın! + DuckDuckGo aramalarınız her zaman anonimdir. + ispanyolca \"ördek\" nasıl denir + mighty ducks oyuncu kadrosu + yerel hava durumu + Şaşırt beni! + Bir siteyi ziyaret etmeyi deneyin! + Sizi gözetlemelerini önlemek için izleyicileri engelleyeceğim. + espn.com + yahoo.com + ebay.com + Şaşırt beni! + DuckDuckGo Search bu işte. Özel. Hızlı. Daha az reklam. + Anladım! + Sonra, bir siteyi ziyaret etmeyi deneyin! + + %1$d site daha sizi burada takip etmeye çalışıyordu. Onları engelledim!

☝️ Daha fazla bilgi için kalkana dokunun.️]]>
+ %1$d site daha sizi burada takip etmeye çalışıyordu. Onları engelledim!

☝️ Daha fazla bilgi için kalkana dokunun.️]]>
+
+ +
☝️ Daha fazla bilgi için kalkana dokunun.️]]>
+
☝️ Daha fazla bilgi için kalkana dokunun.️]]>
+
+ Anladım! + Fire Button ile göz atma etkinliğinizi anında temizleyin.

Hemen deneyin! ☝️️]]>
+ "İşte bu kadar!" + Unutmayın: İnterneti benimle ne kadar çok gezerseniz rahatsız edici reklamları da o kadar az görürsünüz. 👌 + diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index 33697da17d50..7f464a363086 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -46,39 +46,4 @@ Allow DuckDuckGo to ask for sound recorder access on this device Sites can only use the sound recorder if you allow DuckDuckGo to ask for access. - -
Ready for a better, more private internet?]]>
- Let\'s do it! - Privacy protections activated! - Choose Your Browser - Awesome! Welcome to the duck side. - Start Browsing - Try a search! - Your DuckDuckGo searches are always anonymous. - how to say “duck” in spanish - mighty ducks cast - local weather - Surprise me! - Try visiting a site! - I\'ll block trackers so they can\'t spy on you. - espn.com - yahoo.com - ebay.com - Surprise me! - That’s DuckDuckGo Search. Private. Fast. Fewer ads. - Got it! - Next, try visiting a site! - - %1$d other were trying to track you here. I blocked them!

☝️ Tap the shield for more info.️]]>
- %1$d others were trying to track you here. I blocked them!

☝️ Tap the shield for more info.️]]>
-
- -
☝️ Tap the shield for more info.️]]>
-
☝️ Tap the shield for more info.️]]>
-
- Got It! - Fire Button.

Give it a try! ☝️️]]>
- You\'ve got this!" - Remember: every time you browse with me a creepy ad loses its wings. 👌 - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 021e33820e9d..dfbd370c7f85 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -827,4 +827,45 @@ The security certificate for %1$s is expired. It’s possible that the website is misconfigured, that an attacker has compromised your connection, or that your system clock is incorrect. The security certificate for %1$s does not match *.%2$s. It’s possible that the website is misconfigured or that an attacker has compromised your connection, or that your system clock is incorrect. The security certificate for %1$s is not trusted by your device\'s operating system. It’s possible that the website is misconfigured or that an attacker has compromised your connection, or that your system clock is incorrect. + + +
Ready for a better, more private internet?]]>
+ Let\'s do it! + Privacy protections activated! + Choose Your Browser + Search privately by default + Block 3rd-party trackers + Block cookie pop-ups + Block creepy ads + Swiftly erase browsing data + Awesome! Welcome to the duck side. + Start Browsing + Try a search! + Your DuckDuckGo searches are always anonymous. + how to say “duck” in spanish + mighty ducks cast + local weather + Surprise me! + Try visiting a site! + I\'ll block trackers so they can\'t spy on you. + espn.com + yahoo.com + ebay.com + Surprise me! + That’s DuckDuckGo Search. Private. Fast. Fewer ads. + Got it! + Next, try visiting a site! + + %1$d other were trying to track you here. I blocked them!

☝️ Tap the shield for more info.️]]>
+ %1$d others were trying to track you here. I blocked them!

☝️ Tap the shield for more info.️]]>
+
+ +
☝️ Tap the shield for more info.️]]>
+
☝️ Tap the shield for more info.️]]>
+
+ Got It! + Fire Button.

Give it a try! ☝️️]]>
+ You\'ve got this!" + Remember: every time you browse with me a creepy ad loses its wings. 👌 + diff --git a/autofill/autofill-impl/src/main/res/values-bg/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-bg/strings-autofill-impl.xml index dc3006b9aecf..aeb1f457e086 100644 --- a/autofill/autofill-impl/src/main/res/values-bg/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-bg/strings-autofill-impl.xml @@ -64,6 +64,10 @@ Отмени Изтрий + + Help us improve! + We want to make using passwords in DuckDuckGo better. + Take Survey Потребителското име е копирано Паролата е копирана diff --git a/autofill/autofill-impl/src/main/res/values-cs/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-cs/strings-autofill-impl.xml index d6a2ea9e865b..ab6afeaea61c 100644 --- a/autofill/autofill-impl/src/main/res/values-cs/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-cs/strings-autofill-impl.xml @@ -64,6 +64,10 @@ Zrušit Smazat + + Help us improve! + We want to make using passwords in DuckDuckGo better. + Take Survey Uživatelské jméno zkopírováno Heslo zkopírováno diff --git a/autofill/autofill-impl/src/main/res/values-da/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-da/strings-autofill-impl.xml index a21f8f6fae03..8fcddccf6210 100644 --- a/autofill/autofill-impl/src/main/res/values-da/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-da/strings-autofill-impl.xml @@ -64,6 +64,10 @@ Annuller Slet + + Help us improve! + We want to make using passwords in DuckDuckGo better. + Take Survey Brugernavn kopieret Adgangskode kopieret diff --git a/autofill/autofill-impl/src/main/res/values-de/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-de/strings-autofill-impl.xml index 0288e872d21c..c8f35c4da949 100644 --- a/autofill/autofill-impl/src/main/res/values-de/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-de/strings-autofill-impl.xml @@ -64,6 +64,10 @@ Abbrechen Löschen + + Help us improve! + We want to make using passwords in DuckDuckGo better. + Take Survey Benutzername kopiert Passwort kopiert diff --git a/autofill/autofill-impl/src/main/res/values-el/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-el/strings-autofill-impl.xml index 3b44f2968270..8534eb27f080 100644 --- a/autofill/autofill-impl/src/main/res/values-el/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-el/strings-autofill-impl.xml @@ -64,6 +64,10 @@ Ακύρωση Διαγραφή + + Help us improve! + We want to make using passwords in DuckDuckGo better. + Take Survey Το όνομα χρήστη αντιγράφηκε Ο κωδικός πρόσβασης αντιγράφηκε diff --git a/autofill/autofill-impl/src/main/res/values-es/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-es/strings-autofill-impl.xml index 9ecc34b44139..ffd8beacd164 100644 --- a/autofill/autofill-impl/src/main/res/values-es/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-es/strings-autofill-impl.xml @@ -64,6 +64,10 @@ Cancelar Eliminar + + Help us improve! + We want to make using passwords in DuckDuckGo better. + Take Survey Nombre de usuario copiado Contraseña copiada diff --git a/autofill/autofill-impl/src/main/res/values-et/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-et/strings-autofill-impl.xml index dae49464418c..2d161acd5015 100644 --- a/autofill/autofill-impl/src/main/res/values-et/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-et/strings-autofill-impl.xml @@ -64,6 +64,10 @@ Loobu Kustuta + + Help us improve! + We want to make using passwords in DuckDuckGo better. + Take Survey Kasutajanimi on kopeeritud Parool on kopeeritud diff --git a/autofill/autofill-impl/src/main/res/values-fi/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-fi/strings-autofill-impl.xml index a1a9742920d2..581e38c4374f 100644 --- a/autofill/autofill-impl/src/main/res/values-fi/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-fi/strings-autofill-impl.xml @@ -64,6 +64,10 @@ Peruuta Poista + + Help us improve! + We want to make using passwords in DuckDuckGo better. + Take Survey Käyttäjätunnus kopioitu Salasana kopioitu diff --git a/autofill/autofill-impl/src/main/res/values-fr/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-fr/strings-autofill-impl.xml index e7603b536b55..f1d00baaa39a 100644 --- a/autofill/autofill-impl/src/main/res/values-fr/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-fr/strings-autofill-impl.xml @@ -64,6 +64,10 @@ Annuler Supprimer + + Help us improve! + We want to make using passwords in DuckDuckGo better. + Take Survey Nom d\'utilisateur copié Mot de passe copié diff --git a/autofill/autofill-impl/src/main/res/values-hr/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-hr/strings-autofill-impl.xml index 53c8456bc1cb..8934c8ff4525 100644 --- a/autofill/autofill-impl/src/main/res/values-hr/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-hr/strings-autofill-impl.xml @@ -64,6 +64,10 @@ Odustani Izbriši + + Help us improve! + We want to make using passwords in DuckDuckGo better. + Take Survey Korisničko ime je kopirano Lozinka je kopirana diff --git a/autofill/autofill-impl/src/main/res/values-hu/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-hu/strings-autofill-impl.xml index 720c988641e7..5a6856792633 100644 --- a/autofill/autofill-impl/src/main/res/values-hu/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-hu/strings-autofill-impl.xml @@ -64,6 +64,10 @@ Mégsem Törlés + + Help us improve! + We want to make using passwords in DuckDuckGo better. + Take Survey Felhasználónév másolva Jelszó másolva diff --git a/autofill/autofill-impl/src/main/res/values-it/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-it/strings-autofill-impl.xml index a3feb2e71645..2bbf09d4439b 100644 --- a/autofill/autofill-impl/src/main/res/values-it/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-it/strings-autofill-impl.xml @@ -64,6 +64,10 @@ Annulla Cancella + + Help us improve! + We want to make using passwords in DuckDuckGo better. + Take Survey Nome utente copiato Password copiata diff --git a/autofill/autofill-impl/src/main/res/values-lt/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-lt/strings-autofill-impl.xml index 15ac78d2e994..9f1bfbddce29 100644 --- a/autofill/autofill-impl/src/main/res/values-lt/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-lt/strings-autofill-impl.xml @@ -64,6 +64,10 @@ Atšaukti Trinti + + Help us improve! + We want to make using passwords in DuckDuckGo better. + Take Survey Naudotojo vardas nukopijuotas Slaptažodis nukopijuotas diff --git a/autofill/autofill-impl/src/main/res/values-lv/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-lv/strings-autofill-impl.xml index 7af38e6f3a99..084e091065cf 100644 --- a/autofill/autofill-impl/src/main/res/values-lv/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-lv/strings-autofill-impl.xml @@ -64,6 +64,10 @@ Atcelt Dzēst + + Help us improve! + We want to make using passwords in DuckDuckGo better. + Take Survey Lietotājvārds nokopēts Parole nokopēta diff --git a/autofill/autofill-impl/src/main/res/values-nb/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-nb/strings-autofill-impl.xml index 9c1e8ab42702..cadfa61ff2f0 100644 --- a/autofill/autofill-impl/src/main/res/values-nb/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-nb/strings-autofill-impl.xml @@ -64,6 +64,10 @@ Avbryt Slett + + Help us improve! + We want to make using passwords in DuckDuckGo better. + Take Survey Brukernavnet er kopiert Passordet er kopiert diff --git a/autofill/autofill-impl/src/main/res/values-nl/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-nl/strings-autofill-impl.xml index 849ad898b922..5f8c1b32424c 100644 --- a/autofill/autofill-impl/src/main/res/values-nl/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-nl/strings-autofill-impl.xml @@ -64,6 +64,10 @@ Annuleren Verwijderen + + Help us improve! + We want to make using passwords in DuckDuckGo better. + Take Survey Gebruikersnaam gekopieerd Wachtwoord gekopieerd diff --git a/autofill/autofill-impl/src/main/res/values-pl/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-pl/strings-autofill-impl.xml index 78ad2517cc32..5ec32fb8a91b 100644 --- a/autofill/autofill-impl/src/main/res/values-pl/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-pl/strings-autofill-impl.xml @@ -64,6 +64,10 @@ Anuluj Usuń + + Help us improve! + We want to make using passwords in DuckDuckGo better. + Take Survey Skopiowano nazwę użytkownika Skopiowano hasło diff --git a/autofill/autofill-impl/src/main/res/values-pt/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-pt/strings-autofill-impl.xml index 9a124d59c937..ec415aeda579 100644 --- a/autofill/autofill-impl/src/main/res/values-pt/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-pt/strings-autofill-impl.xml @@ -64,6 +64,10 @@ Cancelar Eliminar + + Help us improve! + We want to make using passwords in DuckDuckGo better. + Take Survey Nome de utilizador copiado Palavra-passe copiada diff --git a/autofill/autofill-impl/src/main/res/values-ro/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-ro/strings-autofill-impl.xml index 77ced41f91f8..0f81744b866c 100644 --- a/autofill/autofill-impl/src/main/res/values-ro/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-ro/strings-autofill-impl.xml @@ -64,6 +64,10 @@ Anulare Ștergere + + Help us improve! + We want to make using passwords in DuckDuckGo better. + Take Survey Numele de utilizator a fost copiat Parolă copiată diff --git a/autofill/autofill-impl/src/main/res/values-ru/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-ru/strings-autofill-impl.xml index 5e317b7a4ca0..b05c8b9e7b15 100644 --- a/autofill/autofill-impl/src/main/res/values-ru/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-ru/strings-autofill-impl.xml @@ -64,6 +64,10 @@ Отменить Удалить + + Help us improve! + We want to make using passwords in DuckDuckGo better. + Take Survey Имя пользователя скопировано Пароль скопирован diff --git a/autofill/autofill-impl/src/main/res/values-sk/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-sk/strings-autofill-impl.xml index 51dd24ba2895..6330ce8321e3 100644 --- a/autofill/autofill-impl/src/main/res/values-sk/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-sk/strings-autofill-impl.xml @@ -64,6 +64,10 @@ Zrušiť Odstrániť + + Help us improve! + We want to make using passwords in DuckDuckGo better. + Take Survey Meno používateľa bolo skopírované Heslo bolo skopírované diff --git a/autofill/autofill-impl/src/main/res/values-sl/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-sl/strings-autofill-impl.xml index 6c6f545510d9..e4cb1c4a8540 100644 --- a/autofill/autofill-impl/src/main/res/values-sl/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-sl/strings-autofill-impl.xml @@ -64,6 +64,10 @@ Prekliči Izbriši + + Help us improve! + We want to make using passwords in DuckDuckGo better. + Take Survey Uporabniško ime je bilo kopirano Geslo je bilo kopirano diff --git a/autofill/autofill-impl/src/main/res/values-sv/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-sv/strings-autofill-impl.xml index d15452600823..7ff0320fc3d5 100644 --- a/autofill/autofill-impl/src/main/res/values-sv/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-sv/strings-autofill-impl.xml @@ -64,6 +64,10 @@ Avbryt Ta bort + + Help us improve! + We want to make using passwords in DuckDuckGo better. + Take Survey Användarnamn kopierat Lösenord kopierat diff --git a/autofill/autofill-impl/src/main/res/values-tr/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-tr/strings-autofill-impl.xml index fddc12df213d..847d708be282 100644 --- a/autofill/autofill-impl/src/main/res/values-tr/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-tr/strings-autofill-impl.xml @@ -64,6 +64,10 @@ Vazgeç Sil + + Help us improve! + We want to make using passwords in DuckDuckGo better. + Take Survey Kullanıcı adı kopyalandı Şifre kopyalandı From 308e895a2edb8f55ca34d135bc11fe07efb00a43 Mon Sep 17 00:00:00 2001 From: Craig Russell Date: Mon, 20 May 2024 17:45:26 +0100 Subject: [PATCH 02/17] Prevent onboarded autofill metric overfiring if called multiple times quickly (#4563) Task/Issue URL: https://app.asana.com/0/608920331025315/1207360368013215/f ### Description Prevent onboarding metric getting over-triggered if multiple credentials added quickly in a short amount of time ### Steps to test this PR QA-optional --- .../engagement/EngagementPasswordAddedListener.kt | 8 ++++++++ .../EngagementPasswordAddedListenerTest.kt | 13 +++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/engagement/EngagementPasswordAddedListener.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/engagement/EngagementPasswordAddedListener.kt index 307ec460fa90..364504989645 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/engagement/EngagementPasswordAddedListener.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/engagement/EngagementPasswordAddedListener.kt @@ -38,7 +38,15 @@ class EngagementPasswordAddedListener @Inject constructor( private val pixel: Pixel, ) : PasswordStoreEventListener { + // keep in-memory state to avoid sending the pixel multiple times if called repeatedly in a short time + private var credentialAdded = false + + @Synchronized override fun onCredentialAdded(id: Long) { + if (credentialAdded) return + + credentialAdded = true + appCoroutineScope.launch(dispatchers.io()) { val daysInstalled = userBrowserProperties.daysSinceInstalled() Timber.v("onCredentialAdded. daysInstalled: $daysInstalled") diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/engagement/EngagementPasswordAddedListenerTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/engagement/EngagementPasswordAddedListenerTest.kt index 360fd1646832..7c9ccf9e69cc 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/engagement/EngagementPasswordAddedListenerTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/engagement/EngagementPasswordAddedListenerTest.kt @@ -31,7 +31,7 @@ class EngagementPasswordAddedListenerTest { fun whenDaysInstalledLessThan7ThenPixelSent() { whenever(userBrowserProperties.daysSinceInstalled()).thenReturn(0) testee.onCredentialAdded(0) - verifyPixelSent() + verifyPixelSentOnce() } @Test @@ -48,7 +48,16 @@ class EngagementPasswordAddedListenerTest { verifyPixelNotSent() } - private fun verifyPixelSent() { + @Test + fun whenCalledMultipleTimesThenOnlySendsPixelOnce() { + whenever(userBrowserProperties.daysSinceInstalled()).thenReturn(0) + repeat(10) { + testee.onCredentialAdded(0) + } + verifyPixelSentOnce() + } + + private fun verifyPixelSentOnce() { verify(pixel).fire(AUTOFILL_ENGAGEMENT_ONBOARDED_USER, type = UNIQUE) } From 84efb1b0acb5d77f09fd296dede77a4858529710 Mon Sep 17 00:00:00 2001 From: Craig Russell Date: Tue, 21 May 2024 09:58:31 +0100 Subject: [PATCH 03/17] Update fastlane to upload APK straight to production to reduce wasted time on double reviews (#4566) Task/Issue URL: https://app.asana.com/0/488551667048375/1207360448878908/f ### Description We experience lengthy Play Store reviews and pay the price twice for each release: - one review is triggered when we upload our APK to the `staging` track - a second review is triggered when we promote from `staging` to begin the `production` rollout Each of the reviews can amount to many hours each. This PR changes it so that we skip `staging` and go straight to production at 0.01% rollout so that the review clock starts immediately and we only have to wait for a single app review instead of needlessly waiting on two for the same release. ### Steps to test this PR - [x] Sense-check the change. Can't really test until we next do a release. --- fastlane/Fastfile | 5 +++-- fastlane/README.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 25b03763ac4a..5a3c943c2540 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -52,7 +52,7 @@ platform :android do end - desc "Upload APK to Play Store, in pre-production staging track" + desc "Upload APK to Play Store, in production track with a very small rollout percentage" lane :deploy_playstore do update_fastlane_release_notes() @@ -63,7 +63,8 @@ platform :android do upload_to_play_store( apk: apkPath, - track: 'Staging', + track: 'production', + rollout: '0.001', # ie. 0.1% skip_upload_screenshots: true, skip_upload_images: true, validate_only: false diff --git a/fastlane/README.md b/fastlane/README.md index 694d83a3b8aa..375d4b18ae12 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -21,7 +21,7 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do [bundle exec] fastlane android deploy_playstore ``` -Upload APK to Play Store, in pre-production staging track +Upload APK to Play Store, in production track with a very small rollout percentage ### android deploy_dogfood From ae7d23738730d918b16f6cfe1c97ba4bbeaf6738 Mon Sep 17 00:00:00 2001 From: Dax Mobile <44842493+daxmobile@users.noreply.github.com> Date: Tue, 21 May 2024 11:16:32 +0200 Subject: [PATCH 04/17] Update content scope scripts to version 5.17.0 (#4565) --- .../content-scope-scripts/README.md | 17 + .../build/android/contentScope.js | 641 ++++++++++++++---- package-lock.json | 16 +- package.json | 2 +- 4 files changed, 518 insertions(+), 158 deletions(-) diff --git a/node_modules/@duckduckgo/content-scope-scripts/README.md b/node_modules/@duckduckgo/content-scope-scripts/README.md index eadf6da2ff16..f7cdad1008cf 100644 --- a/node_modules/@duckduckgo/content-scope-scripts/README.md +++ b/node_modules/@duckduckgo/content-scope-scripts/README.md @@ -91,6 +91,23 @@ To handle the difference in scope injection we expose multiple utilities which b return await nativeImpl.call(this, queryObject) }) ``` +- `ContentFeature.shimInterface(interfaceName, ImplClass, options)` + - API for shimming standard constructors. See the WebCompat feature and JSDoc for more details. + - Example usage: + ```javascript + this.shimInterface('MediaSession', MyMediaSessionClass, { + disallowConstructor: true, + allowConstructorCall: false, + wrapToString: true + }) + ``` +- `ContentFeature.shimProperty(instanceHost, instanceProp, implInstance, readOnly = false)` + - API for shimming standard global objects. Usually you want to call `shimInterface()` first, and pass an object instance as `implInstance`. See the WebCompat feature and JSDoc for more details. + - Example usage: + ```javascript + this.shimProperty(Navigator.prototype, 'mediaSession', myMediaSessionInstance, true) + ``` + - `DDGProxy` - Behaves a lot like `new window.Proxy` with a few differences: - has an `overload` method to actually apply the function to the native property. diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/contentScope.js b/node_modules/@duckduckgo/content-scope-scripts/build/android/contentScope.js index debd5e6f6767..68e8bd10236a 100644 --- a/node_modules/@duckduckgo/content-scope-scripts/build/android/contentScope.js +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/contentScope.js @@ -7,8 +7,13 @@ globalThis.customElements?.get.bind(globalThis.customElements); globalThis.customElements?.define.bind(globalThis.customElements); const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; + const getOwnPropertyDescriptors = Object.getOwnPropertyDescriptors; const objectKeys = Object.keys; + const objectEntries = Object.entries; + const objectDefineProperty = Object.defineProperty; const URL$1 = globalThis.URL; + const Proxy$1 = globalThis.Proxy; + const functionToString = Function.prototype.toString; /* global cloneInto, exportFunction, false */ @@ -462,8 +467,12 @@ } overloadDescriptor () { + // TODO: this is not always correct! Use wrap* or shim* methods instead this.feature.defineProperty(this.objectScope, this.property, { - value: this.internal + value: this.internal, + writable: true, + enumerable: true, + configurable: true }); } } @@ -649,7 +658,7 @@ // Copy feature settings from remote config to preferences object output.featureSettings = parseFeatureSettings(data, enabledFeatures); - output.trackerLookup = {"org":{"cdn77":{"rsc":{"1558334541":1}},"adsrvr":1,"ampproject":1,"browser-update":1,"flowplayer":1,"privacy-center":1,"webvisor":1,"framasoft":1,"do-not-tracker":1,"trackersimulator":1},"io":{"1dmp":1,"1rx":1,"4dex":1,"adnami":1,"aidata":1,"arcspire":1,"bidr":1,"branch":1,"center":1,"cloudimg":1,"concert":1,"connectad":1,"cordial":1,"dcmn":1,"extole":1,"getblue":1,"hbrd":1,"instana":1,"karte":1,"leadsmonitor":1,"litix":1,"lytics":1,"marchex":1,"mediago":1,"mrf":1,"narrative":1,"ntv":1,"optad360":1,"oracleinfinity":1,"oribi":1,"p-n":1,"personalizer":1,"pghub":1,"piano":1,"powr":1,"pzz":1,"searchspring":1,"segment":1,"siteimproveanalytics":1,"sspinc":1,"t13":1,"webgains":1,"wovn":1,"yellowblue":1,"zprk":1,"axept":1,"akstat":1,"clarium":1,"hotjar":1},"com":{"2020mustang":1,"33across":1,"360yield":1,"3lift":1,"4dsply":1,"4strokemedia":1,"8353e36c2a":1,"a-mx":1,"a2z":1,"aamsitecertifier":1,"absorbingband":1,"abstractedauthority":1,"abtasty":1,"acexedge":1,"acidpigs":1,"acsbapp":1,"acuityplatform":1,"ad-score":1,"ad-stir":1,"adalyser":1,"adapf":1,"adara":1,"adblade":1,"addthis":1,"addtoany":1,"adelixir":1,"adentifi":1,"adextrem":1,"adgrx":1,"adhese":1,"adition":1,"adkernel":1,"adlightning":1,"adlooxtracking":1,"admanmedia":1,"admedo":1,"adnium":1,"adnxs-simple":1,"adnxs":1,"adobedtm":1,"adotmob":1,"adpone":1,"adpushup":1,"adroll":1,"adrta":1,"ads-twitter":1,"ads3-adnow":1,"adsafeprotected":1,"adstanding":1,"adswizz":1,"adtdp":1,"adtechus":1,"adtelligent":1,"adthrive":1,"adtlgc":1,"adtng":1,"adultfriendfinder":1,"advangelists":1,"adventive":1,"adventori":1,"advertising":1,"aegpresents":1,"affinity":1,"affirm":1,"agilone":1,"agkn":1,"aimbase":1,"albacross":1,"alcmpn":1,"alexametrics":1,"alicdn":1,"alikeaddition":1,"aliveachiever":1,"aliyuncs":1,"alluringbucket":1,"aloofvest":1,"amazon-adsystem":1,"amazon":1,"ambiguousafternoon":1,"amplitude":1,"analytics-egain":1,"aniview":1,"annoyedairport":1,"annoyingclover":1,"anyclip":1,"anymind360":1,"app-us1":1,"appboycdn":1,"appdynamics":1,"appsflyer":1,"aralego":1,"aspiringattempt":1,"aswpsdkus":1,"atemda":1,"att":1,"attentivemobile":1,"attractionbanana":1,"audioeye":1,"audrte":1,"automaticside":1,"avanser":1,"avmws":1,"aweber":1,"aweprt":1,"azure":1,"b0e8":1,"badgevolcano":1,"bagbeam":1,"ballsbanana":1,"bandborder":1,"batch":1,"bawdybalance":1,"bc0a":1,"bdstatic":1,"bedsberry":1,"beginnerpancake":1,"benchmarkemail":1,"betweendigital":1,"bfmio":1,"bidtheatre":1,"billowybelief":1,"bimbolive":1,"bing":1,"bizographics":1,"bizrate":1,"bkrtx":1,"blismedia":1,"blogherads":1,"bluecava":1,"bluekai":1,"blushingbread":1,"boatwizard":1,"boilingcredit":1,"boldchat":1,"booking":1,"borderfree":1,"bounceexchange":1,"brainlyads":1,"brand-display":1,"brandmetrics":1,"brealtime":1,"brightfunnel":1,"brightspotcdn":1,"btloader":1,"btstatic":1,"bttrack":1,"btttag":1,"bumlam":1,"butterbulb":1,"buttonladybug":1,"buzzfeed":1,"buzzoola":1,"byside":1,"c3tag":1,"cabnnr":1,"calculatorstatement":1,"callrail":1,"calltracks":1,"capablecup":1,"captcha-delivery":1,"carpentercomparison":1,"cartstack":1,"carvecakes":1,"casalemedia":1,"cattlecommittee":1,"cdninstagram":1,"cdnwidget":1,"channeladvisor":1,"chargecracker":1,"chartbeat":1,"chatango":1,"chaturbate":1,"cheqzone":1,"cherriescare":1,"chickensstation":1,"childlikecrowd":1,"childlikeform":1,"chocolateplatform":1,"cintnetworks":1,"circlelevel":1,"ck-ie":1,"clcktrax":1,"cleanhaircut":1,"clearbit":1,"clearbitjs":1,"clickagy":1,"clickcease":1,"clickcertain":1,"clicktripz":1,"clientgear":1,"cloudflare":1,"cloudflareinsights":1,"cloudflarestream":1,"cobaltgroup":1,"cobrowser":1,"cognitivlabs":1,"colossusssp":1,"combativecar":1,"comm100":1,"googleapis":{"commondatastorage":1,"imasdk":1,"storage":1,"fonts":1,"maps":1,"www":1},"company-target":1,"condenastdigital":1,"confusedcart":1,"connatix":1,"contextweb":1,"conversionruler":1,"convertkit":1,"convertlanguage":1,"cootlogix":1,"coveo":1,"cpmstar":1,"cquotient":1,"crabbychin":1,"cratecamera":1,"crazyegg":1,"creative-serving":1,"creativecdn":1,"criteo":1,"crowdedmass":1,"crowdriff":1,"crownpeak":1,"crsspxl":1,"ctnsnet":1,"cudasvc":1,"cuddlethehyena":1,"cumbersomecarpenter":1,"curalate":1,"curvedhoney":1,"cushiondrum":1,"cutechin":1,"cxense":1,"d28dc30335":1,"dailymotion":1,"damdoor":1,"dampdock":1,"dapperfloor":1,"datadoghq-browser-agent":1,"decisivebase":1,"deepintent":1,"defybrick":1,"delivra":1,"demandbase":1,"detectdiscovery":1,"devilishdinner":1,"dimelochat":1,"disagreeabledrop":1,"discreetfield":1,"disqus":1,"dmpxs":1,"dockdigestion":1,"dotomi":1,"doubleverify":1,"drainpaste":1,"dramaticdirection":1,"driftt":1,"dtscdn":1,"dtscout":1,"dwin1":1,"dynamics":1,"dynamicyield":1,"dynatrace":1,"ebaystatic":1,"ecal":1,"eccmp":1,"elfsight":1,"elitrack":1,"eloqua":1,"en25":1,"encouragingthread":1,"enormousearth":1,"ensighten":1,"enviousshape":1,"eqads":1,"ero-advertising":1,"esputnik":1,"evergage":1,"evgnet":1,"exdynsrv":1,"exelator":1,"exoclick":1,"exosrv":1,"expansioneggnog":1,"expedia":1,"expertrec":1,"exponea":1,"exponential":1,"extole":1,"ezodn":1,"ezoic":1,"ezoiccdn":1,"facebook":1,"facil-iti":1,"fadewaves":1,"fallaciousfifth":1,"farmergoldfish":1,"fastly-insights":1,"fearlessfaucet":1,"fiftyt":1,"financefear":1,"fitanalytics":1,"five9":1,"fixedfold":1,"fksnk":1,"flashtalking":1,"flipp":1,"flowerstreatment":1,"floweryflavor":1,"flutteringfireman":1,"flux-cdn":1,"foresee":1,"fortunatemark":1,"fouanalytics":1,"fox":1,"fqtag":1,"frailfruit":1,"freezingbuilding":1,"fronttoad":1,"fullstory":1,"functionalfeather":1,"fuzzybasketball":1,"gammamaximum":1,"gbqofs":1,"geetest":1,"geistm":1,"geniusmonkey":1,"geoip-js":1,"getbread":1,"getcandid":1,"getclicky":1,"getdrip":1,"getelevar":1,"getrockerbox":1,"getshogun":1,"getsitecontrol":1,"giraffepiano":1,"glassdoor":1,"gloriousbeef":1,"godpvqnszo":1,"google-analytics":1,"google":1,"googleadservices":1,"googlehosted":1,"googleoptimize":1,"googlesyndication":1,"googletagmanager":1,"googletagservices":1,"gorgeousedge":1,"govx":1,"grainmass":1,"greasysquare":1,"greylabeldelivery":1,"groovehq":1,"growsumo":1,"gstatic":1,"guarantee-cdn":1,"guiltlessbasketball":1,"gumgum":1,"haltingbadge":1,"hammerhearing":1,"handsomelyhealth":1,"harborcaption":1,"hawksearch":1,"amazonaws":{"us-east-2":{"s3":{"hb-obv2":1}}},"heapanalytics":1,"hellobar":1,"hhbypdoecp":1,"hiconversion":1,"highwebmedia":1,"histats":1,"hlserve":1,"hocgeese":1,"hollowafterthought":1,"honorableland":1,"hotjar":1,"hp":1,"hs-banner":1,"htlbid":1,"htplayground":1,"hubspot":1,"ib-ibi":1,"id5-sync":1,"igodigital":1,"iheart":1,"iljmp":1,"illiweb":1,"impactcdn":1,"impactradius-event":1,"impressionmonster":1,"improvedcontactform":1,"improvedigital":1,"imrworldwide":1,"indexww":1,"infolinks":1,"infusionsoft":1,"inmobi":1,"inq":1,"inside-graph":1,"instagram":1,"intentiq":1,"intergient":1,"investingchannel":1,"invocacdn":1,"iperceptions":1,"iplsc":1,"ipredictive":1,"iteratehq":1,"ivitrack":1,"j93557g":1,"jaavnacsdw":1,"jimstatic":1,"journity":1,"js7k":1,"jscache":1,"juiceadv":1,"juicyads":1,"justanswer":1,"justpremium":1,"jwpcdn":1,"kakao":1,"kampyle":1,"kargo":1,"kissmetrics":1,"klarnaservices":1,"klaviyo":1,"knottyswing":1,"krushmedia":1,"ktkjmp":1,"kxcdn":1,"laboredlocket":1,"ladesk":1,"ladsp":1,"laughablelizards":1,"leadsrx":1,"lendingtree":1,"levexis":1,"liadm":1,"licdn":1,"lightboxcdn":1,"lijit":1,"linkedin":1,"linksynergy":1,"list-manage":1,"listrakbi":1,"livechatinc":1,"livejasmin":1,"localytics":1,"loggly":1,"loop11":1,"looseloaf":1,"lovelydrum":1,"lunchroomlock":1,"lwonclbench":1,"macromill":1,"maddeningpowder":1,"mailchimp":1,"mailchimpapp":1,"mailerlite":1,"maillist-manage":1,"marinsm":1,"marketiq":1,"marketo":1,"marphezis":1,"marriedbelief":1,"materialparcel":1,"matheranalytics":1,"mathtag":1,"maxmind":1,"mczbf":1,"measlymiddle":1,"medallia":1,"meddleplant":1,"media6degrees":1,"mediacategory":1,"mediavine":1,"mediawallahscript":1,"medtargetsystem":1,"megpxs":1,"memberful":1,"memorizematch":1,"mentorsticks":1,"metaffiliation":1,"metricode":1,"metricswpsh":1,"mfadsrvr":1,"mgid":1,"micpn":1,"microadinc":1,"minutemedia-prebid":1,"minutemediaservices":1,"mixpo":1,"mkt932":1,"mktoresp":1,"mktoweb":1,"ml314":1,"moatads":1,"mobtrakk":1,"monsido":1,"mookie1":1,"motionflowers":1,"mountain":1,"mouseflow":1,"mpeasylink":1,"mql5":1,"mrtnsvr":1,"murdoog":1,"mxpnl":1,"mybestpro":1,"myregistry":1,"nappyattack":1,"navistechnologies":1,"neodatagroup":1,"nervoussummer":1,"netmng":1,"newrelic":1,"newscgp":1,"nextdoor":1,"ninthdecimal":1,"nitropay":1,"noibu":1,"nondescriptnote":1,"nosto":1,"npttech":1,"ntvpwpush":1,"nuance":1,"nutritiousbean":1,"nxsttv":1,"omappapi":1,"omnisnippet1":1,"omnisrc":1,"omnitagjs":1,"ondemand":1,"oneall":1,"onesignal":1,"onetag-sys":1,"oo-syringe":1,"ooyala":1,"opecloud":1,"opentext":1,"opera":1,"opmnstr":1,"opti-digital":1,"optimicdn":1,"optimizely":1,"optinmonster":1,"optmnstr":1,"optmstr":1,"optnmnstr":1,"optnmstr":1,"osano":1,"otm-r":1,"outbrain":1,"overconfidentfood":1,"ownlocal":1,"pailpatch":1,"panickypancake":1,"panoramicplane":1,"parastorage":1,"pardot":1,"parsely":1,"partplanes":1,"patreon":1,"paypal":1,"pbstck":1,"pcmag":1,"peerius":1,"perfdrive":1,"perfectmarket":1,"permutive":1,"picreel":1,"pinterest":1,"pippio":1,"piwikpro":1,"pixlee":1,"placidperson":1,"pleasantpump":1,"plotrabbit":1,"pluckypocket":1,"pocketfaucet":1,"possibleboats":1,"postaffiliatepro":1,"postrelease":1,"potatoinvention":1,"powerfulcopper":1,"predictplate":1,"prepareplanes":1,"pricespider":1,"priceypies":1,"pricklydebt":1,"profusesupport":1,"proofpoint":1,"protoawe":1,"providesupport":1,"pswec":1,"psychedelicarithmetic":1,"psyma":1,"ptengine":1,"publir":1,"pubmatic":1,"pubmine":1,"pubnation":1,"qualaroo":1,"qualtrics":1,"quantcast":1,"quantserve":1,"quantummetric":1,"quietknowledge":1,"quizzicalpartner":1,"quizzicalzephyr":1,"quora":1,"r42tag":1,"radiateprose":1,"railwayreason":1,"rakuten":1,"rambunctiousflock":1,"rangeplayground":1,"rating-widget":1,"realsrv":1,"rebelswing":1,"reconditerake":1,"reconditerespect":1,"recruitics":1,"reddit":1,"redditstatic":1,"rehabilitatereason":1,"repeatsweater":1,"reson8":1,"resonantrock":1,"resonate":1,"responsiveads":1,"restrainstorm":1,"restructureinvention":1,"retargetly":1,"revcontent":1,"rezync":1,"rfihub":1,"rhetoricalloss":1,"richaudience":1,"righteouscrayon":1,"rightfulfall":1,"riotgames":1,"riskified":1,"rkdms":1,"rlcdn":1,"rmtag":1,"rogersmedia":1,"rokt":1,"route":1,"rtbsystem":1,"rubiconproject":1,"ruralrobin":1,"s-onetag":1,"saambaa":1,"sablesong":1,"sail-horizon":1,"salesforceliveagent":1,"samestretch":1,"sascdn":1,"satisfycork":1,"savoryorange":1,"scarabresearch":1,"scaredsnakes":1,"scaredsong":1,"scaredstomach":1,"scarfsmash":1,"scene7":1,"scholarlyiq":1,"scintillatingsilver":1,"scorecardresearch":1,"screechingstove":1,"screenpopper":1,"scribblestring":1,"sddan":1,"seatsmoke":1,"securedvisit":1,"seedtag":1,"sefsdvc":1,"segment":1,"sekindo":1,"selectivesummer":1,"selfishsnake":1,"servebom":1,"servedbyadbutler":1,"servenobid":1,"serverbid":1,"serving-sys":1,"shakegoldfish":1,"shamerain":1,"shapecomb":1,"shappify":1,"shareaholic":1,"sharethis":1,"sharethrough":1,"shopifyapps":1,"shopperapproved":1,"shrillspoon":1,"sibautomation":1,"sicksmash":1,"signifyd":1,"singroot":1,"site":1,"siteimprove":1,"siteimproveanalytics":1,"sitescout":1,"sixauthority":1,"skillfuldrop":1,"skimresources":1,"skisofa":1,"sli-spark":1,"slickstream":1,"slopesoap":1,"smadex":1,"smartadserver":1,"smashquartz":1,"smashsurprise":1,"smg":1,"smilewanted":1,"smoggysnakes":1,"snapchat":1,"snapkit":1,"snigelweb":1,"socdm":1,"sojern":1,"songsterritory":1,"sonobi":1,"soundstocking":1,"spectacularstamp":1,"speedcurve":1,"sphereup":1,"spiceworks":1,"spookyexchange":1,"spookyskate":1,"spookysleet":1,"sportradarserving":1,"sportslocalmedia":1,"spotxchange":1,"springserve":1,"srvmath":1,"ssl-images-amazon":1,"stackadapt":1,"stakingsmile":1,"statcounter":1,"steadfastseat":1,"steadfastsound":1,"steadfastsystem":1,"steelhousemedia":1,"steepsquirrel":1,"stereotypedsugar":1,"stickyadstv":1,"stiffgame":1,"stingycrush":1,"straightnest":1,"stripchat":1,"strivesquirrel":1,"strokesystem":1,"stupendoussleet":1,"stupendoussnow":1,"stupidscene":1,"sulkycook":1,"sumo":1,"sumologic":1,"sundaysky":1,"superficialeyes":1,"superficialsquare":1,"surveymonkey":1,"survicate":1,"svonm":1,"swankysquare":1,"symantec":1,"taboola":1,"tailtarget":1,"talkable":1,"tamgrt":1,"tangycover":1,"taobao":1,"tapad":1,"tapioni":1,"taptapnetworks":1,"taskanalytics":1,"tealiumiq":1,"techlab-cdn":1,"technoratimedia":1,"techtarget":1,"tediousticket":1,"teenytinyshirt":1,"tendertest":1,"the-ozone-project":1,"theadex":1,"themoneytizer":1,"theplatform":1,"thestar":1,"thinkitten":1,"threetruck":1,"thrtle":1,"tidaltv":1,"tidiochat":1,"tiktok":1,"tinypass":1,"tiqcdn":1,"tiresomethunder":1,"trackjs":1,"traffichaus":1,"trafficjunky":1,"trafmag":1,"travelaudience":1,"treasuredata":1,"tremorhub":1,"trendemon":1,"tribalfusion":1,"trovit":1,"trueleadid":1,"truoptik":1,"truste":1,"trustpilot":1,"trvdp":1,"tsyndicate":1,"tubemogul":1,"turn":1,"tvpixel":1,"tvsquared":1,"tweakwise":1,"twitter":1,"tynt":1,"typicalteeth":1,"u5e":1,"ubembed":1,"uidapi":1,"ultraoranges":1,"unbecominglamp":1,"unbxdapi":1,"undertone":1,"uninterestedquarter":1,"unpkg":1,"unrulymedia":1,"unwieldyhealth":1,"unwieldyplastic":1,"upsellit":1,"urbanairship":1,"usabilla":1,"usbrowserspeed":1,"usemessages":1,"userreport":1,"uservoice":1,"valuecommerce":1,"vengefulgrass":1,"vidazoo":1,"videoplayerhub":1,"vidoomy":1,"viglink":1,"visualwebsiteoptimizer":1,"vivaclix":1,"vk":1,"vlitag":1,"voicefive":1,"volatilevessel":1,"voraciousgrip":1,"voxmedia":1,"vrtcal":1,"w3counter":1,"walkme":1,"warmafterthought":1,"warmquiver":1,"webcontentassessor":1,"webengage":1,"webeyez":1,"webtraxs":1,"webtrends-optimize":1,"webtrends":1,"wgplayer":1,"woosmap":1,"worldoftulo":1,"wpadmngr":1,"wpshsdk":1,"wpushsdk":1,"wsod":1,"wt-safetag":1,"wysistat":1,"xg4ken":1,"xiti":1,"xlirdr":1,"xlivrdr":1,"xnxx-cdn":1,"y-track":1,"yahoo":1,"yandex":1,"yieldmo":1,"yieldoptimizer":1,"yimg":1,"yotpo":1,"yottaa":1,"youtube-nocookie":1,"youtube":1,"zemanta":1,"zendesk":1,"zeotap":1,"zestycrime":1,"zonos":1,"zoominfo":1,"zopim":1,"createsend1":1,"veoxa":1,"parchedsofa":1,"sooqr":1,"adtraction":1,"addthisedge":1,"adsymptotic":1,"bootstrapcdn":1,"bugsnag":1,"dmxleo":1,"dtssrv":1,"fontawesome":1,"hs-scripts":1,"jwpltx":1,"nereserv":1,"onaudience":1,"outbrainimg":1,"quantcount":1,"rtactivate":1,"shopifysvc":1,"stripe":1,"twimg":1,"vimeo":1,"vimeocdn":1,"wp":1,"4jnzhl0d0":1,"aboardamusement":1,"aboardlevel":1,"absentairport":1,"absorbingcorn":1,"abstractedamount":1,"acceptableauthority":1,"accurateanimal":1,"accuratecoal":1,"actoramusement":1,"actuallysnake":1,"actuallything":1,"adamantsnail":1,"adorableanger":1,"adorableattention":1,"adventurousamount":1,"agreeablearch":1,"agreeabletouch":1,"aheadday":1,"alertarithmetic":1,"aliasanvil":1,"ambientdusk":1,"ambientlagoon":1,"ambiguousdinosaurs":1,"ambrosialsummit":1,"amethystzenith":1,"amuckafternoon":1,"amusedbucket":1,"analyzecorona":1,"ancientact":1,"annoyingacoustics":1,"aquaticowl":1,"arrivegrowth":1,"aspiringapples":1,"astonishingfood":1,"audioarctic":1,"automaticturkey":1,"awarealley":1,"awesomeagreement":1,"awzbijw":1,"axiomaticanger":1,"badgeboat":1,"baitbaseball":1,"balloonbelieve":1,"barbarousbase":1,"basketballbelieve":1,"beamvolcano":1,"beancontrol":1,"begintrain":1,"bestboundary":1,"bikesboard":1,"birthdaybelief":1,"blackbrake":1,"bleachbubble":1,"blesspizzas":1,"blushingbeast":1,"boredcrown":1,"boundarybusiness":1,"boundlessveil":1,"brainybasin":1,"brainynut":1,"branchborder":1,"bravecalculator":1,"breadbalance":1,"breakfastboat":1,"brighttoe":1,"briskstorm":1,"broadborder":1,"brotherslocket":1,"buildingknife":1,"bulbbait":1,"burlywhistle":1,"burnbubble":1,"bushesbag":1,"bustlingbath":1,"bustlingbook":1,"calculatingcircle":1,"callousbrake":1,"calmcactus":1,"calypsocapsule":1,"capriciouscorn":1,"captivatingcanyon":1,"carefuldolls":1,"caringcast":1,"cartkitten":1,"catalogcake":1,"catschickens":1,"causecherry":1,"cautiouscamera":1,"cautiouscherries":1,"cautiouscredit":1,"cavecurtain":1,"ceciliavenus":1,"celestialquasar":1,"celestialspectra":1,"chaireggnog":1,"chairsdonkey":1,"chalkoil":1,"changeablecats":1,"charmingplate":1,"chesscolor":1,"childlikeexample":1,"chinsnakes":1,"chipperisle":1,"chivalrouscord":1,"chunkycactus":1,"cloisteredcord":1,"cloisteredcurve":1,"closedcows":1,"coatfood":1,"cobaltoverture":1,"coldbalance":1,"colossalclouds":1,"colossalcoat":1,"colossalcry":1,"combbit":1,"combcattle":1,"combcompetition":1,"comfortablecheese":1,"concernedchange":1,"concernedchickens":1,"condemnedcomb":1,"conditioncrush":1,"confesschairs":1,"consciouscheese":1,"consciousdirt":1,"coordinatedcoat":1,"copycarpenter":1,"cosmicsculptor":1,"courageousbaby":1,"coverapparatus":1,"cozyhillside":1,"cozytryst":1,"creatorcherry":1,"creatorpassenger":1,"creaturecabbage":1,"crimsonmeadow":1,"critictruck":1,"crookedcreature":1,"crystalboulevard":1,"cubchannel":1,"cuddlylunchroom":1,"currentcollar":1,"curvycry":1,"cushionpig":1,"damagedadvice":1,"damageddistance":1,"daughterstone":1,"dazzlingbook":1,"debonairdust":1,"debonairtree":1,"decisivedrawer":1,"decisiveducks":1,"deerbeginner":1,"defeatedbadge":1,"delicatecascade":1,"deliciousducks":1,"dependenttrip":1,"detailedkitten":1,"dewdroplagoon":1,"digestiondrawer":1,"dinnerquartz":1,"diplomahawaii":1,"discreetquarter":1,"dk4ywix":1,"dollardelta":1,"dq95d35":1,"dreamycanyon":1,"drollwharf":1,"dustydime":1,"dustyhammer":1,"eagerknight":1,"echoinghaven":1,"effervescentcoral":1,"effervescentvista":1,"effulgenttempest":1,"elasticchange":1,"elderlybean":1,"elusivebreeze":1,"eminentbubble":1,"enchantingdiscovery":1,"enchantingmystique":1,"endurablebulb":1,"energeticladybug":1,"engineertrick":1,"enigmaticcanyon":1,"enigmaticvoyage":1,"entertainskin":1,"equablekettle":1,"ethereallagoon":1,"evanescentedge":1,"evasivejar":1,"eventexistence":1,"exampleshake":1,"excitingtub":1,"executeknowledge":1,"exhibitsneeze":1,"exquisiteartisanship":1,"exuberantedge":1,"fadedsnow":1,"fairiesbranch":1,"fancyactivity":1,"farshake":1,"farsnails":1,"fastenfather":1,"fatcoil":1,"faucetfoot":1,"faultycanvas":1,"fearfulmint":1,"featherstage":1,"feignedfaucet":1,"fertilefeeling":1,"fewjuice":1,"fewkittens":1,"firstfrogs":1,"flameuncle":1,"flimsycircle":1,"flimsythought":1,"flourishingcollaboration":1,"flourishinginnovation":1,"flourishingpartnership":1,"flowerycreature":1,"floweryfact":1,"followborder":1,"forgetfulsnail":1,"franticroof":1,"frequentflesh":1,"friendlycrayon":1,"friendwool":1,"fumblingform":1,"furryfork":1,"futuristicfifth":1,"futuristicframe":1,"fuzzyerror":1,"gaudyairplane":1,"generateoffice":1,"giantsvessel":1,"giddycoat":1,"givevacation":1,"gladysway":1,"gleamingcow":1,"glisteningguide":1,"glitteringbrook":1,"goldfishgrowth":1,"gondolagnome":1,"gracefulmilk":1,"grandfatherguitar":1,"grayoranges":1,"grayreceipt":1,"grouchypush":1,"grumpydime":1,"guardeddirection":1,"guidecent":1,"gulliblegrip":1,"gustygrandmother":1,"halcyoncanyon":1,"halcyonsculpture":1,"hallowedinvention":1,"haltingdivision":1,"haltinggold":1,"handsomehose":1,"handsomelythumb":1,"handyfield":1,"handyfireman":1,"handyincrease":1,"haplesshydrant":1,"haplessland":1,"hatefulrequest":1,"headydegree":1,"heartbreakingmind":1,"hearthorn":1,"heavyplayground":1,"historicalbeam":1,"honeybulb":1,"horsenectar":1,"hospitablehall":1,"hospitablehat":1,"humdrumtouch":1,"hystericalcloth":1,"illinvention":1,"importantmeat":1,"impossibleexpansion":1,"impulsejewel":1,"impulselumber":1,"incompetentjoke":1,"inconclusiveaction":1,"inputicicle":1,"inquisitiveice":1,"internalcondition":1,"internalsink":1,"jubilantaura":1,"jubilantcanyon":1,"jubilantcascade":1,"jubilantglimmer":1,"jubilanttempest":1,"jubilantwhisper":1,"kaputquill":1,"keenquill":1,"knitstamp":1,"lameletters":1,"largebrass":1,"leftliquid":1,"liftedknowledge":1,"lightenafterthought":1,"lighttalon":1,"livelumber":1,"livelylaugh":1,"livelyreward":1,"livingsleet":1,"lizardslaugh":1,"loadsurprise":1,"lonelyflavor":1,"longingtrees":1,"lorenzourban":1,"losslace":1,"ludicrousarch":1,"luminousboulevard":1,"luminouscatalyst":1,"lumpylumber":1,"lustroushaven":1,"majesticwaterscape":1,"maliciousmusic":1,"marketspiders":1,"materialisticmoon":1,"materialplayground":1,"meadowlullaby":1,"meatydime":1,"melodiouschorus":1,"melodiouscomposition":1,"meltmilk":1,"memopilot":1,"memorizeneck":1,"merequartz":1,"mightyspiders":1,"minorcattle":1,"mixedreading":1,"modularmental":1,"monacobeatles":1,"moorshoes":1,"motionlessbag":1,"motionlessmeeting":1,"movemeal":1,"mundanenail":1,"mushywaste":1,"muteknife":1,"mysticalagoon":1,"naivestatement":1,"neatshade":1,"nebulacrescent":1,"nebulajubilee":1,"nebulousamusement":1,"nebulousgarden":1,"nebulousquasar":1,"nebulousripple":1,"needlessnorth":1,"niftyhospital":1,"nightwound":1,"nocturnalloom":1,"nondescriptcrowd":1,"nostalgicneed":1,"numberlessring":1,"nuttyorganization":1,"oafishchance":1,"obscenesidewalk":1,"oldfashionedoffer":1,"operationchicken":1,"optimallimit":1,"opulentsylvan":1,"orientedargument":1,"outstandingincome":1,"outstandingsnails":1,"painstakingpickle":1,"pamelarandom":1,"panickycurtain":1,"parallelbulb":1,"parentpicture":1,"passivepolo":1,"peacefullimit":1,"petiteumbrella":1,"piquantgrove":1,"piquantvortex":1,"placidactivity":1,"planebasin":1,"plantdigestion":1,"playfulriver":1,"pluckyzone":1,"poeticpackage":1,"pointdigestion":1,"pointlesspocket":1,"pointlessprofit":1,"polishedfolly":1,"politeplanes":1,"politicalporter":1,"popplantation":1,"possiblepencil":1,"powderjourney":1,"pricklypollution":1,"pristinegale":1,"processplantation":1,"protestcopy":1,"publicsofa":1,"puffypurpose":1,"pulsatingmeadow":1,"pumpedpancake":1,"punyplant":1,"purposepipe":1,"quillkick":1,"quirkysugar":1,"rabbitbreath":1,"rabbitrifle":1,"radiantlullaby":1,"railwaygiraffe":1,"raintwig":1,"rainyhand":1,"rainyrule":1,"rangecake":1,"raresummer":1,"readymoon":1,"rebelhen":1,"rebelsubway":1,"receptivereaction":1,"recessrain":1,"reconditeprison":1,"reflectivestatement":1,"regularplants":1,"regulatesleet":1,"relationrest":1,"rememberdiscussion":1,"replaceroute":1,"resonantbrush":1,"respectrain":1,"resplendentecho":1,"retrievemint":1,"rhetoricalveil":1,"rhymezebra":1,"richstring":1,"rigidrobin":1,"rollconnection":1,"roofrelation":1,"roseincome":1,"rusticprice":1,"sadloaf":1,"samesticks":1,"samplesamba":1,"scarceshock":1,"scaredcomfort":1,"scaredslip":1,"scaredsnake":1,"scarefowl":1,"scatteredstream":1,"scientificshirt":1,"scintillatingscissors":1,"scissorsstatement":1,"scrapesleep":1,"screechingfurniture":1,"screechingstocking":1,"scribbleson":1,"seashoresociety":1,"secondhandfall":1,"secretturtle":1,"seemlysuggestion":1,"separatesort":1,"seraphicjubilee":1,"serenecascade":1,"serenepebble":1,"serioussuit":1,"serpentshampoo":1,"settleshoes":1,"shadeship":1,"shakyseat":1,"shakysurprise":1,"shallowblade":1,"sheargovernor":1,"shesubscriptions":1,"shinypond":1,"shirtsidewalk":1,"shiveringspot":1,"shiverscissors":1,"shockingship":1,"sierrakermit":1,"sillyscrew":1,"simulateswing":1,"sincerebuffalo":1,"sincerepelican":1,"sinceresubstance":1,"sinkbooks":1,"sixscissors":1,"slinksuggestion":1,"smilingswim":1,"smoggysongs":1,"sneakwind":1,"soggysponge":1,"soggyzoo":1,"solarislabyrinth":1,"somberscarecrow":1,"sombersticks":1,"soothingglade":1,"sordidsmile":1,"soresidewalk":1,"soretrain":1,"sortsail":1,"sortsummer":1,"spellmist":1,"spellsalsa":1,"spotlessstamp":1,"spottednoise":1,"sprysummit":1,"spuriousair":1,"spysubstance":1,"squalidscrew":1,"stakingbasket":1,"stakingshock":1,"stalesummer":1,"statuesqueship":1,"steadycopper":1,"stealsteel":1,"steepscale":1,"stepplane":1,"stereoproxy":1,"stimulatingsneeze":1,"stingyshoe":1,"stingyspoon":1,"stockingsleet":1,"stomachscience":1,"stopstomach":1,"stormyfold":1,"strangeclocks":1,"strangersponge":1,"strangesink":1,"stretchsister":1,"stretchsneeze":1,"stretchsquirrel":1,"stripedbat":1,"strivesidewalk":1,"sublimequartz":1,"succeedscene":1,"sugarfriction":1,"suggestionbridge":1,"superficialspring":1,"supportwaves":1,"suspectmark":1,"swellstocking":1,"swelteringsleep":1,"swingslip":1,"synonymousrule":1,"synonymoussticks":1,"synthesizescarecrow":1,"tackytrains":1,"tangyamount":1,"tastelesstrees":1,"tastelesstrucks":1,"tearfulglass":1,"teenytinycellar":1,"teenytinytongue":1,"tempertrick":1,"temptteam":1,"terriblethumb":1,"terrifictooth":1,"thingstaste":1,"thirdrespect":1,"thomastorch":1,"thoughtlessknot":1,"thrivingmarketplace":1,"ticketaunt":1,"tidymitten":1,"tiredthroat":1,"tradetooth":1,"tranquilcan":1,"tranquilcanyon":1,"tranquilplume":1,"tranquilveil":1,"tranquilveranda":1,"tremendousearthquake":1,"tremendousplastic":1,"tritebadge":1,"tritethunder":1,"troubledtail":1,"troubleshade":1,"truculentrate":1,"tumbleicicle":1,"typicalairplane":1,"ubiquitoussea":1,"ubiquitousyard":1,"unablehope":1,"unaccountablepie":1,"unbecominghall":1,"uncoveredexpert":1,"unequalbrake":1,"unequaltrail":1,"unknowncrate":1,"untidyrice":1,"unusedstone":1,"unwieldyimpulse":1,"uppitytime":1,"uselesslumber":1,"vanishmemory":1,"velvetquasar":1,"venomousvessel":1,"venusgloria":1,"verdantanswer":1,"verdantlabyrinth":1,"verdantloom":1,"verseballs":1,"vibrantcelebration":1,"vibrantgale":1,"vibranthaven":1,"vibrantpact":1,"vibranttalisman":1,"virtualvincent":1,"vividcanopy":1,"vividfrost":1,"vividmeadow":1,"vividplume":1,"volatileprofit":1,"wantingwindow":1,"wearbasin":1,"wellgroomedhydrant":1,"whimsicalcanyon":1,"whimsicalgrove":1,"whisperingcascade":1,"whisperingquasar":1,"whisperingsummit":1,"whispermeeting":1,"wildcommittee":1,"wistfulwaste":1,"wittyshack":1,"workoperation":1,"wretchedfloor":1,"wrongwound":1,"zephyrlabyrinth":1,"zestyhorizon":1,"zestyrover":1,"zipperxray":1},"net":{"2mdn":1,"2o7":1,"3gl":1,"a-mo":1,"acint":1,"adform":1,"adhigh":1,"admixer":1,"adobedc":1,"adspeed":1,"adverticum":1,"apicit":1,"appier":1,"akamaized":{"assets-momentum":1},"aticdn":1,"edgekey":{"au":1,"ca":1,"ch":1,"cn":1,"com-v1":1,"es":1,"ihg":1,"in":1,"io":1,"it":1,"jp":1,"net":1,"org":1,"com":{"scene7":1},"uk-v1":1,"uk":1},"azure":1,"azurefd":1,"bannerflow":1,"bf-tools":1,"bidswitch":1,"bitsngo":1,"blueconic":1,"boldapps":1,"buysellads":1,"cachefly":1,"cedexis":1,"certona":1,"confiant-integrations":1,"contentsquare":1,"criteo":1,"crwdcntrl":1,"cloudfront":{"d1af033869koo7":1,"d1cr9zxt7u0sgu":1,"d1s87id6169zda":1,"d1vg5xiq7qffdj":1,"d1y068gyog18cq":1,"d214hhm15p4t1d":1,"d21gpk1vhmjuf5":1,"d2zah9y47r7bi2":1,"d38b8me95wjkbc":1,"d38xvr37kwwhcm":1,"d3fv2pqyjay52z":1,"d3i4yxtzktqr9n":1,"d3odp2r1osuwn0":1,"d5yoctgpv4cpx":1,"d6tizftlrpuof":1,"dbukjj6eu5tsf":1,"dn0qt3r0xannq":1,"dsh7ky7308k4b":1,"d2g3ekl4mwm40k":1},"demdex":1,"dotmetrics":1,"doubleclick":1,"durationmedia":1,"e-planning":1,"edgecastcdn":1,"emsecure":1,"episerver":1,"esm1":1,"eulerian":1,"everestjs":1,"everesttech":1,"eyeota":1,"ezoic":1,"fastly":{"global":{"shared":{"f2":1},"sni":{"j":1}},"map":{"prisa-us-eu":1,"scribd":1},"ssl":{"global":{"qognvtzku-x":1}}},"facebook":1,"fastclick":1,"fonts":1,"azureedge":{"fp-cdn":1,"sdtagging":1},"fuseplatform":1,"fwmrm":1,"go-mpulse":1,"hadronid":1,"hs-analytics":1,"hsleadflows":1,"im-apps":1,"impervadns":1,"iocnt":1,"iprom":1,"jsdelivr":1,"kanade-ad":1,"krxd":1,"line-scdn":1,"listhub":1,"livecom":1,"livedoor":1,"liveperson":1,"lkqd":1,"llnwd":1,"lpsnmedia":1,"magnetmail":1,"marketo":1,"maxymiser":1,"media":1,"microad":1,"mobon":1,"monetate":1,"mxptint":1,"myfonts":1,"myvisualiq":1,"naver":1,"nr-data":1,"ojrq":1,"omtrdc":1,"onecount":1,"openx":1,"openxcdn":1,"opta":1,"owneriq":1,"pages02":1,"pages03":1,"pages04":1,"pages05":1,"pages06":1,"pages08":1,"pingdom":1,"pmdstatic":1,"popads":1,"popcash":1,"primecaster":1,"pro-market":1,"akamaihd":{"pxlclnmdecom-a":1},"rfihub":1,"sancdn":1,"sc-static":1,"semasio":1,"sensic":1,"sexad":1,"smaato":1,"spreadshirts":1,"storygize":1,"tfaforms":1,"trackcmp":1,"trackedlink":1,"tradetracker":1,"truste-svc":1,"uuidksinc":1,"viafoura":1,"visilabs":1,"visx":1,"w55c":1,"wdsvc":1,"witglobal":1,"yandex":1,"yastatic":1,"yieldlab":1,"zencdn":1,"zucks":1,"opencmp":1,"azurewebsites":{"app-fnsp-matomo-analytics-prod":1},"ad-delivery":1,"chartbeat":1,"msecnd":1,"cloudfunctions":{"us-central1-adaptive-growth":1},"eviltracker":1},"co":{"6sc":1,"ayads":1,"getlasso":1,"idio":1,"increasingly":1,"jads":1,"nanorep":1,"nc0":1,"pcdn":1,"prmutv":1,"resetdigital":1,"t":1,"tctm":1,"zip":1},"gt":{"ad":1},"ru":{"adfox":1,"adriver":1,"digitaltarget":1,"mail":1,"mindbox":1,"rambler":1,"rutarget":1,"sape":1,"smi2":1,"tns-counter":1,"top100":1,"ulogin":1,"yandex":1,"yadro":1},"jp":{"adingo":1,"admatrix":1,"auone":1,"co":{"dmm":1,"i-mobile":1,"rakuten":1,"yahoo":1},"fout":1,"genieesspv":1,"gmossp-sp":1,"gsspat":1,"gssprt":1,"ne":{"hatena":1},"i2i":1,"impact-ad":1,"microad":1,"nakanohito":1,"r10s":1,"reemo-ad":1,"rtoaster":1,"shinobi":1,"team-rec":1,"uncn":1,"yimg":1,"yjtag":1},"pl":{"adocean":1,"gemius":1,"nsaudience":1,"onet":1,"salesmanago":1,"wp":1},"pro":{"adpartner":1,"piwik":1,"usocial":1},"de":{"adscale":1,"auswaertiges-amt":1,"fiduciagad":1,"ioam":1,"itzbund":1,"vgwort":1,"werk21system":1},"re":{"adsco":1},"info":{"adxbid":1,"bitrix":1,"navistechnologies":1,"usergram":1,"webantenna":1},"tv":{"affec":1,"attn":1,"iris":1,"ispot":1,"samba":1,"teads":1,"twitch":1,"videohub":1},"dev":{"amazon":1},"us":{"amung":1,"samplicio":1,"slgnt":1,"trkn":1},"media":{"andbeyond":1,"nextday":1,"townsquare":1,"underdog":1},"link":{"app":1},"cloud":{"avct":1,"egain":1,"matomo":1},"delivery":{"ay":1,"monu":1},"ly":{"bit":1},"br":{"com":{"btg360":1,"clearsale":1,"jsuol":1,"shopconvert":1,"shoptarget":1,"soclminer":1},"org":{"ivcbrasil":1}},"ch":{"ch":1,"da-services":1,"google":1},"me":{"channel":1,"contentexchange":1,"grow":1,"line":1,"loopme":1,"t":1},"ms":{"clarity":1},"my":{"cnt":1},"se":{"codigo":1},"to":{"cpx":1,"tawk":1},"chat":{"crisp":1,"gorgias":1},"fr":{"d-bi":1,"open-system":1,"weborama":1},"uk":{"co":{"dailymail":1,"hsbc":1}},"gov":{"dhs":1},"ai":{"e-volution":1,"hybrid":1,"m2":1,"nrich":1,"wknd":1},"be":{"geoedge":1},"au":{"com":{"google":1,"news":1,"nine":1,"zipmoney":1,"telstra":1}},"stream":{"ibclick":1},"cz":{"imedia":1,"seznam":1,"trackad":1},"app":{"infusionsoft":1,"permutive":1,"shop":1},"tech":{"ingage":1,"primis":1},"eu":{"kameleoon":1,"medallia":1,"media01":1,"ocdn":1,"rqtrk":1,"slgnt":1},"fi":{"kesko":1,"simpli":1},"live":{"lura":1},"services":{"marketingautomation":1},"sg":{"mediacorp":1},"bi":{"newsroom":1},"fm":{"pdst":1},"ad":{"pixel":1},"xyz":{"playground":1},"it":{"plug":1,"repstatic":1},"cc":{"popin":1},"network":{"pub":1},"nl":{"rijksoverheid":1},"fyi":{"sda":1},"es":{"socy":1},"im":{"spot":1},"market":{"spotim":1},"am":{"tru":1},"no":{"uio":1,"medietall":1},"at":{"waust":1},"pe":{"shop":1},"ca":{"bc":{"gov":1}},"gg":{"clean":1},"example":{"ad-company":1},"site":{"ad-company":1,"third-party":{"bad":1,"broken":1}},"pw":{"zlp6s":1}}; + output.trackerLookup = {"org":{"cdn77":{"rsc":{"1558334541":1}},"adsrvr":1,"ampproject":1,"browser-update":1,"flowplayer":1,"privacy-center":1,"webvisor":1,"framasoft":1,"do-not-tracker":1,"trackersimulator":1},"io":{"1dmp":1,"1rx":1,"4dex":1,"adnami":1,"aidata":1,"arcspire":1,"bidr":1,"branch":1,"center":1,"cloudimg":1,"concert":1,"connectad":1,"cordial":1,"dcmn":1,"extole":1,"getblue":1,"hbrd":1,"instana":1,"karte":1,"leadsmonitor":1,"litix":1,"lytics":1,"marchex":1,"mediago":1,"mrf":1,"narrative":1,"ntv":1,"optad360":1,"oracleinfinity":1,"oribi":1,"p-n":1,"personalizer":1,"pghub":1,"piano":1,"powr":1,"pzz":1,"searchspring":1,"segment":1,"siteimproveanalytics":1,"sspinc":1,"t13":1,"webgains":1,"wovn":1,"yellowblue":1,"zprk":1,"axept":1,"akstat":1,"clarium":1,"hotjar":1},"com":{"2020mustang":1,"33across":1,"360yield":1,"3lift":1,"4dsply":1,"4strokemedia":1,"8353e36c2a":1,"a-mx":1,"a2z":1,"aamsitecertifier":1,"absorbingband":1,"abstractedauthority":1,"abtasty":1,"acexedge":1,"acidpigs":1,"acsbapp":1,"acuityplatform":1,"ad-score":1,"ad-stir":1,"adalyser":1,"adapf":1,"adara":1,"adblade":1,"addthis":1,"addtoany":1,"adelixir":1,"adentifi":1,"adextrem":1,"adgrx":1,"adhese":1,"adition":1,"adkernel":1,"adlightning":1,"adlooxtracking":1,"admanmedia":1,"admedo":1,"adnium":1,"adnxs-simple":1,"adnxs":1,"adobedtm":1,"adotmob":1,"adpone":1,"adpushup":1,"adroll":1,"adrta":1,"ads-twitter":1,"ads3-adnow":1,"adsafeprotected":1,"adstanding":1,"adswizz":1,"adtdp":1,"adtechus":1,"adtelligent":1,"adthrive":1,"adtlgc":1,"adtng":1,"adultfriendfinder":1,"advangelists":1,"adventive":1,"adventori":1,"advertising":1,"aegpresents":1,"affinity":1,"affirm":1,"agilone":1,"agkn":1,"aimbase":1,"albacross":1,"alcmpn":1,"alexametrics":1,"alicdn":1,"alikeaddition":1,"aliveachiever":1,"aliyuncs":1,"alluringbucket":1,"aloofvest":1,"amazon-adsystem":1,"amazon":1,"ambiguousafternoon":1,"amplitude":1,"analytics-egain":1,"aniview":1,"annoyedairport":1,"annoyingclover":1,"anyclip":1,"anymind360":1,"app-us1":1,"appboycdn":1,"appdynamics":1,"appsflyer":1,"aralego":1,"aspiringattempt":1,"aswpsdkus":1,"atemda":1,"att":1,"attentivemobile":1,"attractionbanana":1,"audioeye":1,"audrte":1,"automaticside":1,"avanser":1,"avmws":1,"aweber":1,"aweprt":1,"azure":1,"b0e8":1,"badgevolcano":1,"bagbeam":1,"ballsbanana":1,"bandborder":1,"batch":1,"bawdybalance":1,"bc0a":1,"bdstatic":1,"bedsberry":1,"beginnerpancake":1,"benchmarkemail":1,"betweendigital":1,"bfmio":1,"bidtheatre":1,"billowybelief":1,"bimbolive":1,"bing":1,"bizographics":1,"bizrate":1,"bkrtx":1,"blismedia":1,"blogherads":1,"bluecava":1,"bluekai":1,"blushingbread":1,"boatwizard":1,"boilingcredit":1,"boldchat":1,"booking":1,"borderfree":1,"bounceexchange":1,"brainlyads":1,"brand-display":1,"brandmetrics":1,"brealtime":1,"brightfunnel":1,"brightspotcdn":1,"btloader":1,"btstatic":1,"bttrack":1,"btttag":1,"bumlam":1,"butterbulb":1,"buttonladybug":1,"buzzfeed":1,"buzzoola":1,"byside":1,"c3tag":1,"cabnnr":1,"calculatorstatement":1,"callrail":1,"calltracks":1,"capablecup":1,"captcha-delivery":1,"carpentercomparison":1,"cartstack":1,"carvecakes":1,"casalemedia":1,"cattlecommittee":1,"cdninstagram":1,"cdnwidget":1,"channeladvisor":1,"chargecracker":1,"chartbeat":1,"chatango":1,"chaturbate":1,"cheqzone":1,"cherriescare":1,"chickensstation":1,"childlikecrowd":1,"childlikeform":1,"chocolateplatform":1,"cintnetworks":1,"circlelevel":1,"ck-ie":1,"clcktrax":1,"cleanhaircut":1,"clearbit":1,"clearbitjs":1,"clickagy":1,"clickcease":1,"clickcertain":1,"clicktripz":1,"clientgear":1,"cloudflare":1,"cloudflareinsights":1,"cloudflarestream":1,"cobaltgroup":1,"cobrowser":1,"cognitivlabs":1,"colossusssp":1,"combativecar":1,"comm100":1,"googleapis":{"commondatastorage":1,"imasdk":1,"storage":1,"fonts":1,"maps":1,"www":1},"company-target":1,"condenastdigital":1,"confusedcart":1,"connatix":1,"contextweb":1,"conversionruler":1,"convertkit":1,"convertlanguage":1,"cootlogix":1,"coveo":1,"cpmstar":1,"cquotient":1,"crabbychin":1,"cratecamera":1,"crazyegg":1,"creative-serving":1,"creativecdn":1,"criteo":1,"crowdedmass":1,"crowdriff":1,"crownpeak":1,"crsspxl":1,"ctnsnet":1,"cudasvc":1,"cuddlethehyena":1,"cumbersomecarpenter":1,"curalate":1,"curvedhoney":1,"cushiondrum":1,"cutechin":1,"cxense":1,"d28dc30335":1,"dailymotion":1,"damdoor":1,"dampdock":1,"dapperfloor":1,"datadoghq-browser-agent":1,"decisivebase":1,"deepintent":1,"defybrick":1,"delivra":1,"demandbase":1,"detectdiscovery":1,"devilishdinner":1,"dimelochat":1,"disagreeabledrop":1,"discreetfield":1,"disqus":1,"dmpxs":1,"dockdigestion":1,"dotomi":1,"doubleverify":1,"drainpaste":1,"dramaticdirection":1,"driftt":1,"dtscdn":1,"dtscout":1,"dwin1":1,"dynamics":1,"dynamicyield":1,"dynatrace":1,"ebaystatic":1,"ecal":1,"eccmp":1,"elfsight":1,"elitrack":1,"eloqua":1,"en25":1,"encouragingthread":1,"enormousearth":1,"ensighten":1,"enviousshape":1,"eqads":1,"ero-advertising":1,"esputnik":1,"evergage":1,"evgnet":1,"exdynsrv":1,"exelator":1,"exoclick":1,"exosrv":1,"expansioneggnog":1,"expedia":1,"expertrec":1,"exponea":1,"exponential":1,"extole":1,"ezodn":1,"ezoic":1,"ezoiccdn":1,"facebook":1,"facil-iti":1,"fadewaves":1,"fallaciousfifth":1,"farmergoldfish":1,"fastly-insights":1,"fearlessfaucet":1,"fiftyt":1,"financefear":1,"fitanalytics":1,"five9":1,"fixedfold":1,"fksnk":1,"flashtalking":1,"flipp":1,"flowerstreatment":1,"floweryflavor":1,"flutteringfireman":1,"flux-cdn":1,"foresee":1,"fortunatemark":1,"fouanalytics":1,"fox":1,"fqtag":1,"frailfruit":1,"freezingbuilding":1,"fronttoad":1,"fullstory":1,"functionalfeather":1,"fuzzybasketball":1,"gammamaximum":1,"gbqofs":1,"geetest":1,"geistm":1,"geniusmonkey":1,"geoip-js":1,"getbread":1,"getcandid":1,"getclicky":1,"getdrip":1,"getelevar":1,"getrockerbox":1,"getshogun":1,"getsitecontrol":1,"giraffepiano":1,"glassdoor":1,"gloriousbeef":1,"godpvqnszo":1,"google-analytics":1,"google":1,"googleadservices":1,"googlehosted":1,"googleoptimize":1,"googlesyndication":1,"googletagmanager":1,"googletagservices":1,"gorgeousedge":1,"govx":1,"grainmass":1,"greasysquare":1,"greylabeldelivery":1,"groovehq":1,"growsumo":1,"gstatic":1,"guarantee-cdn":1,"guiltlessbasketball":1,"gumgum":1,"haltingbadge":1,"hammerhearing":1,"handsomelyhealth":1,"harborcaption":1,"hawksearch":1,"amazonaws":{"us-east-2":{"s3":{"hb-obv2":1}}},"heapanalytics":1,"hellobar":1,"hhbypdoecp":1,"hiconversion":1,"highwebmedia":1,"histats":1,"hlserve":1,"hocgeese":1,"hollowafterthought":1,"honorableland":1,"hotjar":1,"hp":1,"hs-banner":1,"htlbid":1,"htplayground":1,"hubspot":1,"ib-ibi":1,"id5-sync":1,"igodigital":1,"iheart":1,"iljmp":1,"illiweb":1,"impactcdn":1,"impactradius-event":1,"impressionmonster":1,"improvedcontactform":1,"improvedigital":1,"imrworldwide":1,"indexww":1,"infolinks":1,"infusionsoft":1,"inmobi":1,"inq":1,"inside-graph":1,"instagram":1,"intentiq":1,"intergient":1,"investingchannel":1,"invocacdn":1,"iperceptions":1,"iplsc":1,"ipredictive":1,"iteratehq":1,"ivitrack":1,"j93557g":1,"jaavnacsdw":1,"jimstatic":1,"journity":1,"js7k":1,"jscache":1,"juiceadv":1,"juicyads":1,"justanswer":1,"justpremium":1,"jwpcdn":1,"kakao":1,"kampyle":1,"kargo":1,"kissmetrics":1,"klarnaservices":1,"klaviyo":1,"knottyswing":1,"krushmedia":1,"ktkjmp":1,"kxcdn":1,"laboredlocket":1,"ladesk":1,"ladsp":1,"laughablelizards":1,"leadsrx":1,"lendingtree":1,"levexis":1,"liadm":1,"licdn":1,"lightboxcdn":1,"lijit":1,"linkedin":1,"linksynergy":1,"list-manage":1,"listrakbi":1,"livechatinc":1,"livejasmin":1,"localytics":1,"loggly":1,"loop11":1,"looseloaf":1,"lovelydrum":1,"lunchroomlock":1,"lwonclbench":1,"macromill":1,"maddeningpowder":1,"mailchimp":1,"mailchimpapp":1,"mailerlite":1,"maillist-manage":1,"marinsm":1,"marketiq":1,"marketo":1,"marphezis":1,"marriedbelief":1,"materialparcel":1,"matheranalytics":1,"mathtag":1,"maxmind":1,"mczbf":1,"measlymiddle":1,"medallia":1,"meddleplant":1,"media6degrees":1,"mediacategory":1,"mediavine":1,"mediawallahscript":1,"medtargetsystem":1,"megpxs":1,"memberful":1,"memorizematch":1,"mentorsticks":1,"metaffiliation":1,"metricode":1,"metricswpsh":1,"mfadsrvr":1,"mgid":1,"micpn":1,"microadinc":1,"minutemedia-prebid":1,"minutemediaservices":1,"mixpo":1,"mkt932":1,"mktoresp":1,"mktoweb":1,"ml314":1,"moatads":1,"mobtrakk":1,"monsido":1,"mookie1":1,"motionflowers":1,"mountain":1,"mouseflow":1,"mpeasylink":1,"mql5":1,"mrtnsvr":1,"murdoog":1,"mxpnl":1,"mybestpro":1,"myregistry":1,"nappyattack":1,"navistechnologies":1,"neodatagroup":1,"nervoussummer":1,"netmng":1,"newrelic":1,"newscgp":1,"nextdoor":1,"ninthdecimal":1,"nitropay":1,"noibu":1,"nondescriptnote":1,"nosto":1,"npttech":1,"ntvpwpush":1,"nuance":1,"nutritiousbean":1,"nxsttv":1,"omappapi":1,"omnisnippet1":1,"omnisrc":1,"omnitagjs":1,"ondemand":1,"oneall":1,"onesignal":1,"onetag-sys":1,"oo-syringe":1,"ooyala":1,"opecloud":1,"opentext":1,"opera":1,"opmnstr":1,"opti-digital":1,"optimicdn":1,"optimizely":1,"optinmonster":1,"optmnstr":1,"optmstr":1,"optnmnstr":1,"optnmstr":1,"osano":1,"otm-r":1,"outbrain":1,"overconfidentfood":1,"ownlocal":1,"pailpatch":1,"panickypancake":1,"panoramicplane":1,"parastorage":1,"pardot":1,"parsely":1,"partplanes":1,"patreon":1,"paypal":1,"pbstck":1,"pcmag":1,"peerius":1,"perfdrive":1,"perfectmarket":1,"permutive":1,"picreel":1,"pinterest":1,"pippio":1,"piwikpro":1,"pixlee":1,"placidperson":1,"pleasantpump":1,"plotrabbit":1,"pluckypocket":1,"pocketfaucet":1,"possibleboats":1,"postaffiliatepro":1,"postrelease":1,"potatoinvention":1,"powerfulcopper":1,"predictplate":1,"prepareplanes":1,"pricespider":1,"priceypies":1,"pricklydebt":1,"profusesupport":1,"proofpoint":1,"protoawe":1,"providesupport":1,"pswec":1,"psychedelicarithmetic":1,"psyma":1,"ptengine":1,"publir":1,"pubmatic":1,"pubmine":1,"pubnation":1,"qualaroo":1,"qualtrics":1,"quantcast":1,"quantserve":1,"quantummetric":1,"quietknowledge":1,"quizzicalpartner":1,"quizzicalzephyr":1,"quora":1,"r42tag":1,"radiateprose":1,"railwayreason":1,"rakuten":1,"rambunctiousflock":1,"rangeplayground":1,"rating-widget":1,"realsrv":1,"rebelswing":1,"reconditerake":1,"reconditerespect":1,"recruitics":1,"reddit":1,"redditstatic":1,"rehabilitatereason":1,"repeatsweater":1,"reson8":1,"resonantrock":1,"resonate":1,"responsiveads":1,"restrainstorm":1,"restructureinvention":1,"retargetly":1,"revcontent":1,"rezync":1,"rfihub":1,"rhetoricalloss":1,"richaudience":1,"righteouscrayon":1,"rightfulfall":1,"riotgames":1,"riskified":1,"rkdms":1,"rlcdn":1,"rmtag":1,"rogersmedia":1,"rokt":1,"route":1,"rtbsystem":1,"rubiconproject":1,"ruralrobin":1,"s-onetag":1,"saambaa":1,"sablesong":1,"sail-horizon":1,"salesforceliveagent":1,"samestretch":1,"sascdn":1,"satisfycork":1,"savoryorange":1,"scarabresearch":1,"scaredsnakes":1,"scaredsong":1,"scaredstomach":1,"scarfsmash":1,"scene7":1,"scholarlyiq":1,"scintillatingsilver":1,"scorecardresearch":1,"screechingstove":1,"screenpopper":1,"scribblestring":1,"sddan":1,"seatsmoke":1,"securedvisit":1,"seedtag":1,"sefsdvc":1,"segment":1,"sekindo":1,"selectivesummer":1,"selfishsnake":1,"servebom":1,"servedbyadbutler":1,"servenobid":1,"serverbid":1,"serving-sys":1,"shakegoldfish":1,"shamerain":1,"shapecomb":1,"shappify":1,"shareaholic":1,"sharethis":1,"sharethrough":1,"shopifyapps":1,"shopperapproved":1,"shrillspoon":1,"sibautomation":1,"sicksmash":1,"signifyd":1,"singroot":1,"site":1,"siteimprove":1,"siteimproveanalytics":1,"sitescout":1,"sixauthority":1,"skillfuldrop":1,"skimresources":1,"skisofa":1,"sli-spark":1,"slickstream":1,"slopesoap":1,"smadex":1,"smartadserver":1,"smashquartz":1,"smashsurprise":1,"smg":1,"smilewanted":1,"smoggysnakes":1,"snapchat":1,"snapkit":1,"snigelweb":1,"socdm":1,"sojern":1,"songsterritory":1,"sonobi":1,"soundstocking":1,"spectacularstamp":1,"speedcurve":1,"sphereup":1,"spiceworks":1,"spookyexchange":1,"spookyskate":1,"spookysleet":1,"sportradarserving":1,"sportslocalmedia":1,"spotxchange":1,"springserve":1,"srvmath":1,"ssl-images-amazon":1,"stackadapt":1,"stakingsmile":1,"statcounter":1,"steadfastseat":1,"steadfastsound":1,"steadfastsystem":1,"steelhousemedia":1,"steepsquirrel":1,"stereotypedsugar":1,"stickyadstv":1,"stiffgame":1,"stingycrush":1,"straightnest":1,"stripchat":1,"strivesquirrel":1,"strokesystem":1,"stupendoussleet":1,"stupendoussnow":1,"stupidscene":1,"sulkycook":1,"sumo":1,"sumologic":1,"sundaysky":1,"superficialeyes":1,"superficialsquare":1,"surveymonkey":1,"survicate":1,"svonm":1,"swankysquare":1,"symantec":1,"taboola":1,"tailtarget":1,"talkable":1,"tamgrt":1,"tangycover":1,"taobao":1,"tapad":1,"tapioni":1,"taptapnetworks":1,"taskanalytics":1,"tealiumiq":1,"techlab-cdn":1,"technoratimedia":1,"techtarget":1,"tediousticket":1,"teenytinyshirt":1,"tendertest":1,"the-ozone-project":1,"theadex":1,"themoneytizer":1,"theplatform":1,"thestar":1,"thinkitten":1,"threetruck":1,"thrtle":1,"tidaltv":1,"tidiochat":1,"tiktok":1,"tinypass":1,"tiqcdn":1,"tiresomethunder":1,"trackjs":1,"traffichaus":1,"trafficjunky":1,"trafmag":1,"travelaudience":1,"treasuredata":1,"tremorhub":1,"trendemon":1,"tribalfusion":1,"trovit":1,"trueleadid":1,"truoptik":1,"truste":1,"trustpilot":1,"trvdp":1,"tsyndicate":1,"tubemogul":1,"turn":1,"tvpixel":1,"tvsquared":1,"tweakwise":1,"twitter":1,"tynt":1,"typicalteeth":1,"u5e":1,"ubembed":1,"uidapi":1,"ultraoranges":1,"unbecominglamp":1,"unbxdapi":1,"undertone":1,"uninterestedquarter":1,"unpkg":1,"unrulymedia":1,"unwieldyhealth":1,"unwieldyplastic":1,"upsellit":1,"urbanairship":1,"usabilla":1,"usbrowserspeed":1,"usemessages":1,"userreport":1,"uservoice":1,"valuecommerce":1,"vengefulgrass":1,"vidazoo":1,"videoplayerhub":1,"vidoomy":1,"viglink":1,"visualwebsiteoptimizer":1,"vivaclix":1,"vk":1,"vlitag":1,"voicefive":1,"volatilevessel":1,"voraciousgrip":1,"voxmedia":1,"vrtcal":1,"w3counter":1,"walkme":1,"warmafterthought":1,"warmquiver":1,"webcontentassessor":1,"webengage":1,"webeyez":1,"webtraxs":1,"webtrends-optimize":1,"webtrends":1,"wgplayer":1,"woosmap":1,"worldoftulo":1,"wpadmngr":1,"wpshsdk":1,"wpushsdk":1,"wsod":1,"wt-safetag":1,"wysistat":1,"xg4ken":1,"xiti":1,"xlirdr":1,"xlivrdr":1,"xnxx-cdn":1,"y-track":1,"yahoo":1,"yandex":1,"yieldmo":1,"yieldoptimizer":1,"yimg":1,"yotpo":1,"yottaa":1,"youtube-nocookie":1,"youtube":1,"zemanta":1,"zendesk":1,"zeotap":1,"zestycrime":1,"zonos":1,"zoominfo":1,"zopim":1,"createsend1":1,"veoxa":1,"parchedsofa":1,"sooqr":1,"adtraction":1,"addthisedge":1,"adsymptotic":1,"bootstrapcdn":1,"bugsnag":1,"dmxleo":1,"dtssrv":1,"fontawesome":1,"hs-scripts":1,"jwpltx":1,"nereserv":1,"onaudience":1,"outbrainimg":1,"quantcount":1,"rtactivate":1,"shopifysvc":1,"stripe":1,"twimg":1,"vimeo":1,"vimeocdn":1,"wp":1,"4jnzhl0d0":1,"aboardamusement":1,"aboardlevel":1,"absentairport":1,"absorbingcorn":1,"abstractedamount":1,"acceptableauthority":1,"accurateanimal":1,"accuratecoal":1,"actoramusement":1,"actuallysnake":1,"actuallything":1,"adamantsnail":1,"adorableanger":1,"adorableattention":1,"adventurousamount":1,"agilebreeze":1,"agreeablearch":1,"agreeabletouch":1,"aheadday":1,"alertarithmetic":1,"aliasanvil":1,"ambientdusk":1,"ambientlagoon":1,"ambiguousdinosaurs":1,"ambrosialsummit":1,"amethystzenith":1,"amuckafternoon":1,"amusedbucket":1,"analyzecorona":1,"ancientact":1,"annoyingacoustics":1,"aquaticowl":1,"arrivegrowth":1,"aspiringapples":1,"astonishingfood":1,"audioarctic":1,"automaticturkey":1,"awarealley":1,"awesomeagreement":1,"awzbijw":1,"axiomaticanger":1,"badgeboat":1,"badgerabbit":1,"baitbaseball":1,"balloonbelieve":1,"barbarousbase":1,"basketballbelieve":1,"beamvolcano":1,"beancontrol":1,"begintrain":1,"bestboundary":1,"bikesboard":1,"birthdaybelief":1,"blackbrake":1,"bleachbubble":1,"blesspizzas":1,"blissfullagoon":1,"blushingbeast":1,"boredcrown":1,"boundarybusiness":1,"boundlessveil":1,"brainybasin":1,"brainynut":1,"branchborder":1,"bravecalculator":1,"breadbalance":1,"breakfastboat":1,"brighttoe":1,"briskstorm":1,"broadborder":1,"brotherslocket":1,"buildingknife":1,"bulbbait":1,"burlywhistle":1,"burnbubble":1,"bushesbag":1,"bustlingbath":1,"bustlingbook":1,"calculatingcircle":1,"callousbrake":1,"calmcactus":1,"calypsocapsule":1,"capriciouscorn":1,"captivatingcanyon":1,"carefuldolls":1,"caringcast":1,"cartkitten":1,"catalogcake":1,"catschickens":1,"causecherry":1,"cautiouscamera":1,"cautiouscherries":1,"cautiouscredit":1,"cavecurtain":1,"ceciliavenus":1,"celestialquasar":1,"celestialspectra":1,"chaireggnog":1,"chairsdonkey":1,"chalkoil":1,"changeablecats":1,"charmingplate":1,"cheerycraze":1,"chesscolor":1,"childlikeexample":1,"chinsnakes":1,"chipperisle":1,"chivalrouscord":1,"chunkycactus":1,"cloisteredcord":1,"cloisteredcurve":1,"closedcows":1,"coatfood":1,"cobaltoverture":1,"coldbalance":1,"colossalclouds":1,"colossalcoat":1,"colossalcry":1,"combbit":1,"combcattle":1,"combcompetition":1,"comfortablecheese":1,"concernedchange":1,"concernedchickens":1,"condemnedcomb":1,"conditioncrush":1,"confesschairs":1,"consciouscheese":1,"consciousdirt":1,"cooingcoal":1,"coordinatedcoat":1,"copycarpenter":1,"cosmicsculptor":1,"courageousbaby":1,"coverapparatus":1,"cozyhillside":1,"cozytryst":1,"creatorcherry":1,"creatorpassenger":1,"creaturecabbage":1,"crimsonmeadow":1,"critictruck":1,"crookedcreature":1,"crystalboulevard":1,"cubchannel":1,"cuddlylunchroom":1,"currentcollar":1,"curvycry":1,"cushionpig":1,"damagedadvice":1,"damageddistance":1,"dandydune":1,"dandyglow":1,"daughterstone":1,"dazzlingbook":1,"debonairdust":1,"debonairtree":1,"decisivedrawer":1,"decisiveducks":1,"deerbeginner":1,"defeatedbadge":1,"delicatecascade":1,"deliciousducks":1,"dependenttrip":1,"detailedkitten":1,"dewdroplagoon":1,"digestiondrawer":1,"dinnerquartz":1,"diplomahawaii":1,"discreetquarter":1,"dk4ywix":1,"dollardelta":1,"dq95d35":1,"dreamycanyon":1,"drollwharf":1,"dustydime":1,"dustyhammer":1,"eagereden":1,"eagerknight":1,"echoinghaven":1,"effervescentcoral":1,"effervescentvista":1,"effulgenttempest":1,"elasticchange":1,"elderlybean":1,"elusivebreeze":1,"eminentbubble":1,"enchantingdiscovery":1,"enchantingmystique":1,"endurablebulb":1,"energeticladybug":1,"engineertrick":1,"enigmaticcanyon":1,"enigmaticvoyage":1,"entertainskin":1,"equablekettle":1,"ethereallagoon":1,"evanescentedge":1,"evasivejar":1,"eventexistence":1,"exampleshake":1,"excitingtub":1,"executeknowledge":1,"exhibitsneeze":1,"exquisiteartisanship":1,"exuberantedge":1,"facilitatebreakfast":1,"fadedsnow":1,"fairiesbranch":1,"fairytaleflame":1,"fancyactivity":1,"fancydune":1,"farshake":1,"farsnails":1,"fastenfather":1,"fatcoil":1,"faucetfoot":1,"faultycanvas":1,"fearfulmint":1,"featherstage":1,"feignedfaucet":1,"fertilefeeling":1,"fewjuice":1,"fewkittens":1,"firstfrogs":1,"flameuncle":1,"flimsycircle":1,"flimsythought":1,"flourishingcollaboration":1,"flourishinginnovation":1,"flourishingpartnership":1,"flowerycreature":1,"floweryfact":1,"followborder":1,"forgetfulsnail":1,"franticroof":1,"frequentflesh":1,"friendlycrayon":1,"friendwool":1,"fumblingform":1,"furryfork":1,"futuristicfifth":1,"futuristicframe":1,"fuzzyerror":1,"gaudyairplane":1,"generateoffice":1,"giantsvessel":1,"giddycoat":1,"givevacation":1,"gladysway":1,"gleamingcow":1,"glisteningguide":1,"glitteringbrook":1,"goldfishgrowth":1,"gondolagnome":1,"gracefulmilk":1,"grandfatherguitar":1,"grayoranges":1,"grayreceipt":1,"grouchypush":1,"grumpydime":1,"guardeddirection":1,"guidecent":1,"gulliblegrip":1,"gustygrandmother":1,"halcyoncanyon":1,"halcyonsculpture":1,"hallowedinvention":1,"haltingdivision":1,"haltinggold":1,"handsomehose":1,"handsomelythumb":1,"handyfield":1,"handyfireman":1,"handyincrease":1,"haplesshydrant":1,"haplessland":1,"hatefulrequest":1,"headydegree":1,"heartbreakingmind":1,"hearthorn":1,"heavyplayground":1,"helpcollar":1,"highfalutinhoney":1,"historicalbeam":1,"honeybulb":1,"horsenectar":1,"hospitablehall":1,"hospitablehat":1,"humdrumtouch":1,"hystericalcloth":1,"idyllicjazz":1,"illinvention":1,"importantmeat":1,"impossibleexpansion":1,"impulsejewel":1,"impulselumber":1,"incompetentjoke":1,"inconclusiveaction":1,"inputicicle":1,"inquisitiveice":1,"intelligentscissors":1,"internalcondition":1,"internalsink":1,"irritatingfog":1,"jollylens":1,"joyfulkeen":1,"jubilantaura":1,"jubilantcanyon":1,"jubilantcascade":1,"jubilantglimmer":1,"jubilanttempest":1,"jubilantwhisper":1,"kaputquill":1,"keenquill":1,"knitstamp":1,"lameletters":1,"largebrass":1,"leftliquid":1,"liftedknowledge":1,"lightenafterthought":1,"lighttalon":1,"livelumber":1,"livelylaugh":1,"livelyreward":1,"livingsleet":1,"lizardslaugh":1,"loadsurprise":1,"lonelyflavor":1,"longingtrees":1,"lorenzourban":1,"losslace":1,"ludicrousarch":1,"luminousboulevard":1,"luminouscatalyst":1,"lumpylumber":1,"lustroushaven":1,"magicaljoin":1,"majesticwaterscape":1,"majesticwilderness":1,"maliciousmusic":1,"marketspiders":1,"marriedvalue":1,"materialisticmoon":1,"materialplayground":1,"meadowlullaby":1,"meatydime":1,"melodiouschorus":1,"melodiouscomposition":1,"meltmilk":1,"memopilot":1,"memorizeneck":1,"meremark":1,"merequartz":1,"merryopal":1,"merryvault":1,"mightyspiders":1,"minorcattle":1,"minuteburst":1,"mixedreading":1,"modularmental":1,"monacobeatles":1,"moorshoes":1,"motionlessbag":1,"motionlessmeeting":1,"movemeal":1,"mundanenail":1,"mushywaste":1,"muteknife":1,"mysticalagoon":1,"naivestatement":1,"neatshade":1,"nebulacrescent":1,"nebulajubilee":1,"nebulousamusement":1,"nebulousgarden":1,"nebulousquasar":1,"nebulousripple":1,"needlessnorth":1,"niftyhospital":1,"nightwound":1,"nocturnalloom":1,"nondescriptcrowd":1,"nostalgicneed":1,"numberlessring":1,"nuttyorganization":1,"oafishchance":1,"obscenesidewalk":1,"oldfashionedoffer":1,"opalquill":1,"operationchicken":1,"optimallimit":1,"opulentsylvan":1,"orientedargument":1,"outstandingincome":1,"outstandingsnails":1,"painstakingpickle":1,"pamelarandom":1,"panickycurtain":1,"parallelbulb":1,"parentpicture":1,"passivepolo":1,"peacefullimit":1,"petiteumbrella":1,"piquantgrove":1,"piquantmeadow":1,"piquantvortex":1,"placidactivity":1,"planebasin":1,"plantdigestion":1,"playfulriver":1,"pluckyzone":1,"poeticpackage":1,"pointdigestion":1,"pointlesspocket":1,"pointlessprofit":1,"polishedcrescent":1,"polishedfolly":1,"politeplanes":1,"politicalporter":1,"popplantation":1,"possiblepencil":1,"powderjourney":1,"preciousplanes":1,"pricklypollution":1,"pristinegale":1,"processplantation":1,"protestcopy":1,"publicsofa":1,"puffypurpose":1,"pulsatingmeadow":1,"pumpedpancake":1,"punyplant":1,"purposepipe":1,"quaintlake":1,"quillkick":1,"quirkybliss":1,"quirkysugar":1,"rabbitbreath":1,"rabbitrifle":1,"radiantlullaby":1,"railwaygiraffe":1,"raintwig":1,"rainyhand":1,"rainyrule":1,"rangecake":1,"raresummer":1,"readymoon":1,"rebelhen":1,"rebelsubway":1,"receptivereaction":1,"recessrain":1,"reconditeprison":1,"reflectivestatement":1,"regularplants":1,"regulatesleet":1,"relationrest":1,"rememberdiscussion":1,"replaceroute":1,"resonantbrush":1,"respectrain":1,"resplendentecho":1,"retrievemint":1,"rhetoricalveil":1,"rhymezebra":1,"richstring":1,"rigidrobin":1,"rigidveil":1,"ringplant":1,"rollconnection":1,"roofrelation":1,"roseincome":1,"rusticprice":1,"sadloaf":1,"samesticks":1,"samplesamba":1,"scarceshock":1,"scarcestructure":1,"scaredcomfort":1,"scaredslip":1,"scaredsnake":1,"scarefowl":1,"scatteredstream":1,"scientificshirt":1,"scintillatingscissors":1,"scissorsstatement":1,"scrapesleep":1,"screechingfurniture":1,"screechingstocking":1,"scribbleson":1,"seashoresociety":1,"secondhandfall":1,"secretturtle":1,"seemlysuggestion":1,"separatesort":1,"seraphicjubilee":1,"serenecascade":1,"serenepebble":1,"serenesurf":1,"serioussuit":1,"serpentshampoo":1,"settleshoes":1,"shadeship":1,"shakyseat":1,"shakysurprise":1,"shallowblade":1,"sheargovernor":1,"shesubscriptions":1,"shinypond":1,"shirtsidewalk":1,"shiveringspot":1,"shiverscissors":1,"shockingship":1,"sierrakermit":1,"sillyscrew":1,"simulateswing":1,"sincerebuffalo":1,"sincerepelican":1,"sinceresubstance":1,"sinkbooks":1,"sixscissors":1,"slinksuggestion":1,"smilingswim":1,"smoggysongs":1,"sneakwind":1,"soggysponge":1,"soggyzoo":1,"solarislabyrinth":1,"somberscarecrow":1,"sombersticks":1,"soothingglade":1,"sordidsmile":1,"soresidewalk":1,"soretrain":1,"sortsail":1,"sortsummer":1,"spellmist":1,"spellsalsa":1,"spotlessstamp":1,"spottednoise":1,"sprysummit":1,"spuriousair":1,"spysubstance":1,"squalidscrew":1,"stakingbasket":1,"stakingshock":1,"stalesummer":1,"statuesqueship":1,"steadycopper":1,"stealsteel":1,"steepscale":1,"stepplane":1,"stereoproxy":1,"stimulatingsneeze":1,"stingyshoe":1,"stingyspoon":1,"stockingsleet":1,"stomachscience":1,"stopstomach":1,"stormyfold":1,"strangeclocks":1,"strangersponge":1,"strangesink":1,"stretchsister":1,"stretchsneeze":1,"stretchsquirrel":1,"stripedbat":1,"strivesidewalk":1,"sublimequartz":1,"succeedscene":1,"sugarfriction":1,"suggestionbridge":1,"superficialspring":1,"supportwaves":1,"suspectmark":1,"swellstocking":1,"swelteringsleep":1,"swingslip":1,"synonymousrule":1,"synonymoussticks":1,"synthesizescarecrow":1,"tackytrains":1,"tangyamount":1,"tastelesstrees":1,"tastelesstrucks":1,"tearfulglass":1,"teenytinycellar":1,"teenytinytongue":1,"tempertrick":1,"temptteam":1,"terriblethumb":1,"terrifictooth":1,"thingstaste":1,"thirdrespect":1,"thomastorch":1,"thoughtlessknot":1,"thrivingmarketplace":1,"ticketaunt":1,"tidymitten":1,"tiredthroat":1,"tradetooth":1,"tranquilcan":1,"tranquilcanyon":1,"tranquilplume":1,"tranquilveil":1,"tranquilveranda":1,"tremendousearthquake":1,"tremendousplastic":1,"tritebadge":1,"tritethunder":1,"troubledtail":1,"troubleshade":1,"truculentrate":1,"tumbleicicle":1,"typicalairplane":1,"ubiquitoussea":1,"ubiquitousyard":1,"unablehope":1,"unaccountablepie":1,"unbecominghall":1,"uncoveredexpert":1,"unequalbrake":1,"unequaltrail":1,"unknowncrate":1,"untidyrice":1,"unusedstone":1,"unwieldyimpulse":1,"uppitytime":1,"uselesslumber":1,"vanishmemory":1,"velvetquasar":1,"venomousvessel":1,"venusgloria":1,"verdantanswer":1,"verdantlabyrinth":1,"verdantloom":1,"verseballs":1,"vibrantcelebration":1,"vibrantgale":1,"vibranthaven":1,"vibrantpact":1,"vibranttalisman":1,"vibrantvale":1,"virtualvincent":1,"vividcanopy":1,"vividfrost":1,"vividmeadow":1,"vividplume":1,"volatileprofit":1,"wantingwindow":1,"wearbasin":1,"wellgroomedhydrant":1,"whimsicalcanyon":1,"whimsicalgrove":1,"whisperingcascade":1,"whisperingquasar":1,"whisperingsummit":1,"whispermeeting":1,"wildcommittee":1,"wistfulwaste":1,"wittyshack":1,"workoperation":1,"wretchedfloor":1,"wrongwound":1,"zephyrlabyrinth":1,"zestyhorizon":1,"zestyrover":1,"zipperxray":1},"net":{"2mdn":1,"2o7":1,"3gl":1,"a-mo":1,"acint":1,"adform":1,"adhigh":1,"admixer":1,"adobedc":1,"adspeed":1,"adverticum":1,"apicit":1,"appier":1,"akamaized":{"assets-momentum":1},"aticdn":1,"edgekey":{"au":1,"ca":1,"ch":1,"cn":1,"com-v1":1,"es":1,"ihg":1,"in":1,"io":1,"it":1,"jp":1,"net":1,"org":1,"com":{"scene7":1},"uk-v1":1,"uk":1},"azure":1,"azurefd":1,"bannerflow":1,"bf-tools":1,"bidswitch":1,"bitsngo":1,"blueconic":1,"boldapps":1,"buysellads":1,"cachefly":1,"cedexis":1,"certona":1,"confiant-integrations":1,"contentsquare":1,"criteo":1,"crwdcntrl":1,"cloudfront":{"d1af033869koo7":1,"d1cr9zxt7u0sgu":1,"d1s87id6169zda":1,"d1vg5xiq7qffdj":1,"d1y068gyog18cq":1,"d214hhm15p4t1d":1,"d21gpk1vhmjuf5":1,"d2zah9y47r7bi2":1,"d38b8me95wjkbc":1,"d38xvr37kwwhcm":1,"d3fv2pqyjay52z":1,"d3i4yxtzktqr9n":1,"d3odp2r1osuwn0":1,"d5yoctgpv4cpx":1,"d6tizftlrpuof":1,"dbukjj6eu5tsf":1,"dn0qt3r0xannq":1,"dsh7ky7308k4b":1,"d2g3ekl4mwm40k":1},"demdex":1,"dotmetrics":1,"doubleclick":1,"durationmedia":1,"e-planning":1,"edgecastcdn":1,"emsecure":1,"episerver":1,"esm1":1,"eulerian":1,"everestjs":1,"everesttech":1,"eyeota":1,"ezoic":1,"fastly":{"global":{"shared":{"f2":1},"sni":{"j":1}},"map":{"prisa-us-eu":1,"scribd":1},"ssl":{"global":{"qognvtzku-x":1}}},"facebook":1,"fastclick":1,"fonts":1,"azureedge":{"fp-cdn":1,"sdtagging":1},"fuseplatform":1,"fwmrm":1,"go-mpulse":1,"hadronid":1,"hs-analytics":1,"hsleadflows":1,"im-apps":1,"impervadns":1,"iocnt":1,"iprom":1,"jsdelivr":1,"kanade-ad":1,"krxd":1,"line-scdn":1,"listhub":1,"livecom":1,"livedoor":1,"liveperson":1,"lkqd":1,"llnwd":1,"lpsnmedia":1,"magnetmail":1,"marketo":1,"maxymiser":1,"media":1,"microad":1,"mobon":1,"monetate":1,"mxptint":1,"myfonts":1,"myvisualiq":1,"naver":1,"nr-data":1,"ojrq":1,"omtrdc":1,"onecount":1,"openx":1,"openxcdn":1,"opta":1,"owneriq":1,"pages02":1,"pages03":1,"pages04":1,"pages05":1,"pages06":1,"pages08":1,"pingdom":1,"pmdstatic":1,"popads":1,"popcash":1,"primecaster":1,"pro-market":1,"akamaihd":{"pxlclnmdecom-a":1},"rfihub":1,"sancdn":1,"sc-static":1,"semasio":1,"sensic":1,"sexad":1,"smaato":1,"spreadshirts":1,"storygize":1,"tfaforms":1,"trackcmp":1,"trackedlink":1,"tradetracker":1,"truste-svc":1,"uuidksinc":1,"viafoura":1,"visilabs":1,"visx":1,"w55c":1,"wdsvc":1,"witglobal":1,"yandex":1,"yastatic":1,"yieldlab":1,"zencdn":1,"zucks":1,"opencmp":1,"azurewebsites":{"app-fnsp-matomo-analytics-prod":1},"ad-delivery":1,"chartbeat":1,"msecnd":1,"cloudfunctions":{"us-central1-adaptive-growth":1},"eviltracker":1},"co":{"6sc":1,"ayads":1,"getlasso":1,"idio":1,"increasingly":1,"jads":1,"nanorep":1,"nc0":1,"pcdn":1,"prmutv":1,"resetdigital":1,"t":1,"tctm":1,"zip":1},"gt":{"ad":1},"ru":{"adfox":1,"adriver":1,"digitaltarget":1,"mail":1,"mindbox":1,"rambler":1,"rutarget":1,"sape":1,"smi2":1,"tns-counter":1,"top100":1,"ulogin":1,"yandex":1,"yadro":1},"jp":{"adingo":1,"admatrix":1,"auone":1,"co":{"dmm":1,"i-mobile":1,"rakuten":1,"yahoo":1},"fout":1,"genieesspv":1,"gmossp-sp":1,"gsspat":1,"gssprt":1,"ne":{"hatena":1},"i2i":1,"impact-ad":1,"microad":1,"nakanohito":1,"r10s":1,"reemo-ad":1,"rtoaster":1,"shinobi":1,"team-rec":1,"uncn":1,"yimg":1,"yjtag":1},"pl":{"adocean":1,"gemius":1,"nsaudience":1,"onet":1,"salesmanago":1,"wp":1},"pro":{"adpartner":1,"piwik":1,"usocial":1},"de":{"adscale":1,"auswaertiges-amt":1,"fiduciagad":1,"ioam":1,"itzbund":1,"vgwort":1,"werk21system":1},"re":{"adsco":1},"info":{"adxbid":1,"bitrix":1,"navistechnologies":1,"usergram":1,"webantenna":1},"tv":{"affec":1,"attn":1,"iris":1,"ispot":1,"samba":1,"teads":1,"twitch":1,"videohub":1},"dev":{"amazon":1},"us":{"amung":1,"samplicio":1,"slgnt":1,"trkn":1},"media":{"andbeyond":1,"nextday":1,"townsquare":1,"underdog":1},"link":{"app":1},"cloud":{"avct":1,"egain":1,"matomo":1},"delivery":{"ay":1,"monu":1},"ly":{"bit":1},"br":{"com":{"btg360":1,"clearsale":1,"jsuol":1,"shopconvert":1,"shoptarget":1,"soclminer":1},"org":{"ivcbrasil":1}},"ch":{"ch":1,"da-services":1,"google":1},"me":{"channel":1,"contentexchange":1,"grow":1,"line":1,"loopme":1,"t":1},"ms":{"clarity":1},"my":{"cnt":1},"se":{"codigo":1},"to":{"cpx":1,"tawk":1},"chat":{"crisp":1,"gorgias":1},"fr":{"d-bi":1,"open-system":1,"weborama":1},"uk":{"co":{"dailymail":1,"hsbc":1}},"gov":{"dhs":1},"ai":{"e-volution":1,"hybrid":1,"m2":1,"nrich":1,"wknd":1},"be":{"geoedge":1},"au":{"com":{"google":1,"news":1,"nine":1,"zipmoney":1,"telstra":1}},"stream":{"ibclick":1},"cz":{"imedia":1,"seznam":1,"trackad":1},"app":{"infusionsoft":1,"permutive":1,"shop":1},"tech":{"ingage":1,"primis":1},"eu":{"kameleoon":1,"medallia":1,"media01":1,"ocdn":1,"rqtrk":1,"slgnt":1},"fi":{"kesko":1,"simpli":1},"live":{"lura":1},"services":{"marketingautomation":1},"sg":{"mediacorp":1},"bi":{"newsroom":1},"fm":{"pdst":1},"ad":{"pixel":1},"xyz":{"playground":1},"it":{"plug":1,"repstatic":1},"cc":{"popin":1},"network":{"pub":1},"nl":{"rijksoverheid":1},"fyi":{"sda":1},"es":{"socy":1},"im":{"spot":1},"market":{"spotim":1},"am":{"tru":1},"no":{"uio":1,"medietall":1},"at":{"waust":1},"pe":{"shop":1},"ca":{"bc":{"gov":1}},"gg":{"clean":1},"example":{"ad-company":1},"site":{"ad-company":1,"third-party":{"bad":1,"broken":1}},"pw":{"zlp6s":1}}; output.bundledConfig = data; return output @@ -1306,26 +1315,83 @@ return parseJSONPointer(fromPointer); } - /* global false */ - // Tests don't define this variable so fallback to behave like chrome - const functionToString = Function.prototype.toString; + /* global false, cloneInto, exportFunction */ + /** - * add a fake toString() method to a wrapper function to resemble the original function + * Like Object.defineProperty, but with support for Firefox's mozProxies. + * @param {any} object - object whose property we are wrapping (most commonly a prototype, e.g. globalThis.BatteryManager.prototype) + * @param {string} propertyName + * @param {import('./wrapper-utils').StrictPropertyDescriptor} descriptor - requires all descriptor options to be defined because we can't validate correctness based on TS types + */ + function defineProperty (object, propertyName, descriptor) { + { + objectDefineProperty(object, propertyName, descriptor); + } + } + + /** + * return a proxy to `newFn` that fakes .toString() and .toString.toString() to resemble the `origFn`. + * WARNING: do NOT proxy toString multiple times, as it will not work as expected. + * * @param {*} newFn * @param {*} origFn + * @param {string} [mockValue] - when provided, .toString() will return this value */ - function wrapToString (newFn, origFn) { + function wrapToString (newFn, origFn, mockValue) { if (typeof newFn !== 'function' || typeof origFn !== 'function') { - return + return newFn } - newFn.toString = function () { - if (this === newFn) { - return functionToString.call(origFn) - } else { - return functionToString.call(this) + + return new Proxy(newFn, { get: toStringGetTrap(origFn, mockValue) }) + } + + /** + * generate a proxy handler trap that fakes .toString() and .toString.toString() to resemble the `targetFn`. + * Note that it should be used as the get() trap. + * @param {*} targetFn + * @param {string} [mockValue] - when provided, .toString() will return this value + * @returns { (target: any, prop: string, receiver: any) => any } + */ + function toStringGetTrap (targetFn, mockValue) { + // We wrap two levels deep to handle toString.toString() calls + return function get (target, prop, receiver) { + if (prop === 'toString') { + const origToString = Reflect.get(targetFn, 'toString', targetFn); + const toStringProxy = new Proxy(origToString, { + apply (target, thisArg, argumentsList) { + // only mock toString() when called on the proxy itself. If the method is applied to some other object, it should behave as a normal toString() + if (thisArg === receiver) { + if (mockValue) { + return mockValue + } + return Reflect.apply(target, targetFn, argumentsList) + } else { + return Reflect.apply(target, thisArg, argumentsList) + } + }, + get (target, prop, receiver) { + // handle toString.toString() result + if (prop === 'toString') { + const origToStringToString = Reflect.get(origToString, 'toString', origToString); + const toStringToStringProxy = new Proxy(origToStringToString, { + apply (target, thisArg, argumentsList) { + if (thisArg === toStringProxy) { + return Reflect.apply(target, origToString, argumentsList) + } else { + return Reflect.apply(target, thisArg, argumentsList) + } + } + }); + return toStringToStringProxy + } + return Reflect.get(target, prop, receiver) + } + }); + return toStringProxy } - }; + return Reflect.get(target, prop, receiver) + } } /** @@ -1355,6 +1421,271 @@ }) } + /** + * Wrap a `get`/`set` or `value` property descriptor. Only for data properties. For methods, use wrapMethod(). For constructors, use wrapConstructor(). + * @param {any} object - object whose property we are wrapping (most commonly a prototype, e.g. globalThis.Screen.prototype) + * @param {string} propertyName + * @param {Partial} descriptor + * @param {typeof Object.defineProperty} definePropertyFn - function to use for defining the property + * @returns {PropertyDescriptor|undefined} original property descriptor, or undefined if it's not found + */ + function wrapProperty (object, propertyName, descriptor, definePropertyFn) { + if (!object) { + return + } + + /** @type {StrictPropertyDescriptor} */ + // @ts-expect-error - we check for undefined below + const origDescriptor = getOwnPropertyDescriptor(object, propertyName); + if (!origDescriptor) { + // this happens if the property is not implemented in the browser + return + } + + if (('value' in origDescriptor && 'value' in descriptor) || + ('get' in origDescriptor && 'get' in descriptor) || + ('set' in origDescriptor && 'set' in descriptor) + ) { + definePropertyFn(object, propertyName, { + ...origDescriptor, + ...descriptor + }); + return origDescriptor + } else { + // if the property is defined with get/set it must be wrapped with a get/set. If it's defined with a `value`, it must be wrapped with a `value` + throw new Error(`Property descriptor for ${propertyName} may only include the following keys: ${objectKeys(origDescriptor)}`) + } + } + + /** + * Wrap a method descriptor. Only for function properties. For data properties, use wrapProperty(). For constructors, use wrapConstructor(). + * @param {any} object - object whose property we are wrapping (most commonly a prototype, e.g. globalThis.Bluetooth.prototype) + * @param {string} propertyName + * @param {(originalFn, ...args) => any } wrapperFn - wrapper function receives the original function as the first argument + * @param {DefinePropertyFn} definePropertyFn - function to use for defining the property + * @returns {PropertyDescriptor|undefined} original property descriptor, or undefined if it's not found + */ + function wrapMethod (object, propertyName, wrapperFn, definePropertyFn) { + if (!object) { + return + } + + /** @type {StrictPropertyDescriptor} */ + // @ts-expect-error - we check for undefined below + const origDescriptor = getOwnPropertyDescriptor(object, propertyName); + if (!origDescriptor) { + // this happens if the property is not implemented in the browser + return + } + + // @ts-expect-error - we check for undefined below + const origFn = origDescriptor.value; + if (!origFn || typeof origFn !== 'function') { + // method properties are expected to be defined with a `value` + throw new Error(`Property ${propertyName} does not look like a method`) + } + + const newFn = wrapToString(function () { + return wrapperFn.call(this, origFn, ...arguments) + }, origFn); + + definePropertyFn(object, propertyName, { + ...origDescriptor, + value: newFn + }); + return origDescriptor + } + + /** + * @template {keyof typeof globalThis} StandardInterfaceName + * @param {StandardInterfaceName} interfaceName - the name of the interface to shim (must be some known standard API, e.g. 'MediaSession') + * @param {typeof globalThis[StandardInterfaceName]} ImplClass - the class to use as the shim implementation + * @param {DefineInterfaceOptions} options - options for defining the interface + * @param {DefinePropertyFn} definePropertyFn - function to use for defining the property + */ + function shimInterface ( + interfaceName, + ImplClass, + options, + definePropertyFn + ) { + + /** @type {DefineInterfaceOptions} */ + const defaultOptions = { + allowConstructorCall: false, + disallowConstructor: false, + constructorErrorMessage: 'Illegal constructor', + wrapToString: true + }; + + const fullOptions = { + interfaceDescriptorOptions: { writable: true, enumerable: false, configurable: true, value: ImplClass }, + ...defaultOptions, + ...options + }; + + // In some cases we can get away without a full proxy, but in many cases below we need it. + // For example, we can't redefine `prototype` property on ES6 classes. + // Se we just always wrap the class to make the code more maintaibnable + + /** @type {ProxyHandler} */ + const proxyHandler = {}; + + // handle the case where the constructor is called without new + if (fullOptions.allowConstructorCall) { + // make the constructor function callable without new + proxyHandler.apply = function (target, thisArg, argumentsList) { + return Reflect.construct(target, argumentsList, target) + }; + } + + // make the constructor function throw when called without new + if (fullOptions.disallowConstructor) { + proxyHandler.construct = function () { + throw new TypeError(fullOptions.constructorErrorMessage) + }; + } + + if (fullOptions.wrapToString) { + // mask toString() on class methods. `ImplClass.prototype` is non-configurable: we can't override or proxy it, so we have to wrap each method individually + for (const [prop, descriptor] of objectEntries(getOwnPropertyDescriptors(ImplClass.prototype))) { + if (prop !== 'constructor' && descriptor.writable && typeof descriptor.value === 'function') { + ImplClass.prototype[prop] = new Proxy(descriptor.value, { + get: toStringGetTrap(descriptor.value, `function ${prop}() { [native code] }`) + }); + } + } + + // wrap toString on the constructor function itself + Object.assign(proxyHandler, { + get: toStringGetTrap(ImplClass, `function ${interfaceName}() { [native code] }`) + }); + } + + // Note that instanceof should still work, since the `.prototype` object is proxied too: + // Interface() instanceof Interface === true + // ImplClass() instanceof Interface === true + const Interface = new Proxy(ImplClass, proxyHandler); + + // Make sure that Interface().constructor === Interface (not ImplClass) + if (ImplClass.prototype?.constructor === ImplClass) { + /** @type {StrictDataDescriptor} */ + // @ts-expect-error - As long as ImplClass is a normal class, it should have the prototype property + const descriptor = getOwnPropertyDescriptor(ImplClass.prototype, 'constructor'); + if (descriptor.writable) { + ImplClass.prototype.constructor = Interface; + } + } + + // mock the name property + definePropertyFn(ImplClass, 'name', { + value: interfaceName, + configurable: true, + enumerable: false, + writable: false + }); + + // interfaces are exposed directly on the global object, not on its prototype + definePropertyFn( + globalThis, + interfaceName, + { ...fullOptions.interfaceDescriptorOptions, value: Interface } + ); + } + + /** + * Define a missing standard property on a global (prototype) object. Only for data properties. + * For constructors, use shimInterface(). + * Most of the time, you'd want to call shimInterface() first to shim the class itself (MediaSession), and then shimProperty() for the global singleton instance (Navigator.prototype.mediaSession). + * @template Base + * @template {keyof Base & string} K + * @param {Base} baseObject - object whose property we are shimming (most commonly a prototype object, e.g. Navigator.prototype) + * @param {K} propertyName - name of the property to shim (e.g. 'mediaSession') + * @param {Base[K]} implInstance - instance to use as the shim (e.g. new MyMediaSession()) + * @param {boolean} readOnly - whether the property should be read-only + * @param {DefinePropertyFn} definePropertyFn - function to use for defining the property + */ + function shimProperty (baseObject, propertyName, implInstance, readOnly, definePropertyFn) { + // @ts-expect-error - implInstance is a class instance + const ImplClass = implInstance.constructor; + + // mask toString() and toString.toString() on the instance + const proxiedInstance = new Proxy(implInstance, { + get: toStringGetTrap(implInstance, `[object ${ImplClass.name}]`) + }); + + /** @type {StrictPropertyDescriptor} */ + let descriptor; + + // Note that we only cover most common cases: a getter for "readonly" properties, and a value descriptor for writable properties. + // But there could be other cases, e.g. a property with both a getter and a setter. These could be defined with a raw defineProperty() call. + // Important: make sure to cover each new shim with a test that verifies that all descriptors match the standard API. + if (readOnly) { + const getter = function get () { return proxiedInstance }; + const proxiedGetter = new Proxy(getter, { + get: toStringGetTrap(getter, `function get ${propertyName}() { [native code] }`) + }); + descriptor = { + configurable: true, + enumerable: true, + get: proxiedGetter + }; + } else { + descriptor = { + configurable: true, + enumerable: true, + writable: true, + value: proxiedInstance + }; + } + + definePropertyFn(baseObject, propertyName, descriptor); + } + + /** + * @callback DefinePropertyFn + * @param {object} baseObj + * @param {PropertyKey} propertyName + * @param {StrictPropertyDescriptor} descriptor + * @returns {object} + */ + + /** + * @typedef {Object} BaseStrictPropertyDescriptor + * @property {boolean} configurable + * @property {boolean} enumerable + */ + + /** + * @typedef {BaseStrictPropertyDescriptor & { value: any; writable: boolean }} StrictDataDescriptor + * @typedef {BaseStrictPropertyDescriptor & { get: () => any; set: (v: any) => void }} StrictAccessorDescriptor + * @typedef {BaseStrictPropertyDescriptor & { get: () => any }} StrictGetDescriptor + * @typedef {BaseStrictPropertyDescriptor & { set: (v: any) => void }} StrictSetDescriptor + * @typedef {StrictDataDescriptor | StrictAccessorDescriptor | StrictGetDescriptor | StrictSetDescriptor} StrictPropertyDescriptor + */ + + /** + * @typedef {Object} BaseDefineInterfaceOptions + * @property {string} [constructorErrorMessage] + * @property {boolean} wrapToString + */ + + /** + * @typedef {{ allowConstructorCall: true; disallowConstructor: false }} DefineInterfaceOptionsWithAllowConstructorCallMixin + */ + + /** + * @typedef {{ allowConstructorCall: false; disallowConstructor: true }} DefineInterfaceOptionsWithDisallowConstructorMixin + */ + + /** + * @typedef {{ allowConstructorCall: false; disallowConstructor: false }} DefineInterfaceOptionsDefaultMixin + */ + + /** + * @typedef {BaseDefineInterfaceOptions & (DefineInterfaceOptionsWithAllowConstructorCallMixin | DefineInterfaceOptionsWithDisallowConstructorMixin | DefineInterfaceOptionsDefaultMixin)} DefineInterfaceOptions + */ + /** * @description * @@ -2953,8 +3284,19 @@ } } - /* global cloneInto, exportFunction */ + /** + * @typedef {object} AssetConfig + * @property {string} regularFontUrl + * @property {string} boldFontUrl + */ + /** + * @typedef {object} Site + * @property {string | null} domain + * @property {boolean} [isBroken] + * @property {boolean} [allowlisted] + * @property {string[]} [enabledFeatures] + */ class ContentFeature { /** @type {import('./utils.js').RemoteConfig | undefined} */ @@ -3245,10 +3587,11 @@ } /** - * Define a property descriptor. Mainly used for defining new properties. For overriding existing properties, consider using wrapProperty(), wrapMethod() and wrapConstructor(). - * @param {any} object - object whose property we are wrapping (most commonly a prototype) + * Define a property descriptor with debug flags. + * Mainly used for defining new properties. For overriding existing properties, consider using wrapProperty(), wrapMethod() and wrapConstructor(). + * @param {any} object - object whose property we are wrapping (most commonly a prototype, e.g. globalThis.BatteryManager.prototype) * @param {string} propertyName - * @param {PropertyDescriptor} descriptor + * @param {import('./wrapper-utils').StrictPropertyDescriptor} descriptor - requires all descriptor options to be defined because we can't validate correctness based on TS types */ defineProperty (object, propertyName, descriptor) { // make sure to send a debug flag when the property is used @@ -3257,88 +3600,68 @@ const descriptorProp = descriptor[k]; if (typeof descriptorProp === 'function') { const addDebugFlag = this.addDebugFlag.bind(this); - descriptor[k] = function () { - addDebugFlag(); - return Reflect.apply(descriptorProp, this, arguments) - }; + const wrapper = new Proxy$1(descriptorProp, { + apply (target, thisArg, argumentsList) { + addDebugFlag(); + return Reflect$1.apply(descriptorProp, thisArg, argumentsList) + } + }); + descriptor[k] = wrapToString(wrapper, descriptorProp); } }); - { - Object.defineProperty(object, propertyName, descriptor); - } + return defineProperty(object, propertyName, descriptor) } /** * Wrap a `get`/`set` or `value` property descriptor. Only for data properties. For methods, use wrapMethod(). For constructors, use wrapConstructor(). - * @param {any} object - object whose property we are wrapping (most commonly a prototype) + * @param {any} object - object whose property we are wrapping (most commonly a prototype, e.g. globalThis.Screen.prototype) * @param {string} propertyName * @param {Partial} descriptor * @returns {PropertyDescriptor|undefined} original property descriptor, or undefined if it's not found */ wrapProperty (object, propertyName, descriptor) { - if (!object) { - return - } - - const origDescriptor = getOwnPropertyDescriptor(object, propertyName); - if (!origDescriptor) { - // this happens if the property is not implemented in the browser - return - } - - if (('value' in origDescriptor && 'value' in descriptor) || - ('get' in origDescriptor && 'get' in descriptor) || - ('set' in origDescriptor && 'set' in descriptor) - ) { - wrapToString(descriptor.value, origDescriptor.value); - wrapToString(descriptor.get, origDescriptor.get); - wrapToString(descriptor.set, origDescriptor.set); - - this.defineProperty(object, propertyName, { - ...origDescriptor, - ...descriptor - }); - return origDescriptor - } else { - // if the property is defined with get/set it must be wrapped with a get/set. If it's defined with a `value`, it must be wrapped with a `value` - throw new Error(`Property descriptor for ${propertyName} may only include the following keys: ${objectKeys(origDescriptor)}`) - } + return wrapProperty(object, propertyName, descriptor, this.defineProperty.bind(this)) } /** * Wrap a method descriptor. Only for function properties. For data properties, use wrapProperty(). For constructors, use wrapConstructor(). - * @param {any} object - object whose property we are wrapping (most commonly a prototype) + * @param {any} object - object whose property we are wrapping (most commonly a prototype, e.g. globalThis.Bluetooth.prototype) * @param {string} propertyName * @param {(originalFn, ...args) => any } wrapperFn - wrapper function receives the original function as the first argument * @returns {PropertyDescriptor|undefined} original property descriptor, or undefined if it's not found */ wrapMethod (object, propertyName, wrapperFn) { - if (!object) { - return - } - const origDescriptor = getOwnPropertyDescriptor(object, propertyName); - if (!origDescriptor) { - // this happens if the property is not implemented in the browser - return - } - - const origFn = origDescriptor.value; - if (!origFn || typeof origFn !== 'function') { - // method properties are expected to be defined with a `value` - throw new Error(`Property ${propertyName} does not look like a method`) - } + return wrapMethod(object, propertyName, wrapperFn, this.defineProperty.bind(this)) + } - const newFn = function () { - return wrapperFn.call(this, origFn, ...arguments) - }; - wrapToString(newFn, origFn); + /** + * @template {keyof typeof globalThis} StandardInterfaceName + * @param {StandardInterfaceName} interfaceName - the name of the interface to shim (must be some known standard API, e.g. 'MediaSession') + * @param {typeof globalThis[StandardInterfaceName]} ImplClass - the class to use as the shim implementation + * @param {import('./wrapper-utils').DefineInterfaceOptions} options + */ + shimInterface ( + interfaceName, + ImplClass, + options + ) { + return shimInterface(interfaceName, ImplClass, options, this.defineProperty.bind(this)) + } - this.defineProperty(object, propertyName, { - ...origDescriptor, - value: newFn - }); - return origDescriptor + /** + * Define a missing standard property on a global (prototype) object. Only for data properties. + * For constructors, use shimInterface(). + * Most of the time, you'd want to call shimInterface() first to shim the class itself (MediaSession), and then shimProperty() for the global singleton instance (Navigator.prototype.mediaSession). + * @template Base + * @template {keyof Base & string} K + * @param {Base} instanceHost - object whose property we are shimming (most commonly a prototype object, e.g. Navigator.prototype) + * @param {K} instanceProp - name of the property to shim (e.g. 'mediaSession') + * @param {Base[K]} implInstance - instance to use as the shim (e.g. new MyMediaSession()) + * @param {boolean} [readOnly] - whether the property should be read-only (default: false) + */ + shimProperty (instanceHost, instanceProp, implInstance, readOnly = false) { + return shimProperty(instanceHost, instanceProp, implInstance, readOnly, this.defineProperty.bind(this)) } } @@ -4477,7 +4800,9 @@ return storage } return originalStorage - } + }, + enumerable: true, + configurable: true }); } @@ -4529,6 +4854,7 @@ if (!defaultGetter) { return } + // TODO: inner* and outer* should have a setter too this.defineProperty(scope, overrideKey, { get () { const defaultVal = Reflect$1.apply(defaultGetter, receiver, []); @@ -4536,7 +4862,9 @@ return returnVal } return defaultVal - } + }, + enumerable: true, + configurable: true }); } } @@ -5344,6 +5672,8 @@ for (const [prop, val] of Object.entries(spoofedValues)) { try { this.defineProperty(BatteryManager.prototype, prop, { + enumerable: true, + configurable: true, get: () => { return val } @@ -5353,6 +5683,9 @@ for (const eventProp of eventProperties) { try { this.defineProperty(BatteryManager.prototype, eventProp, { + enumerable: true, + configurable: true, + set: x => x, // noop get: () => { return null } @@ -6884,7 +7217,8 @@ get: () => value, // eslint-disable-next-line @typescript-eslint/no-empty-function set: () => {}, - configurable: true + configurable: true, + enumerable: true }); } catch (e) {} } @@ -6975,7 +7309,11 @@ // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f org.call(navigator.webkitTemporaryStorage, modifiedCallback, err); }; - this.defineProperty(Navigator.prototype, 'webkitTemporaryStorage', { get: () => tStorage }); + this.defineProperty(Navigator.prototype, 'webkitTemporaryStorage', { + get: () => tStorage, + enumerable: true, + configurable: true + }); } catch (e) {} } } @@ -7631,21 +7969,19 @@ }, writable: true, configurable: true, - enumerable: false + enumerable: true }); this.defineProperty(window.Notification, 'permission', { - value: 'denied', - writable: true, + get: () => 'denied', configurable: true, enumerable: false }); this.defineProperty(window.Notification, 'maxActions', { - value: 2, - writable: true, + get: () => 2, configurable: true, - enumerable: false + enumerable: true }); } @@ -7800,16 +8136,17 @@ if ('credentials' in navigator && 'get' in navigator.credentials) { return } - // TODO: change the property descriptor shape to match the original const value = { get () { return Promise.reject(new Error()) } }; + // TODO: original property is an accessor descriptor this.defineProperty(Navigator.prototype, 'credentials', { value, configurable: true, - enumerable: true + enumerable: true, + writable: true }); } catch { // Ignore exceptions that could be caused by conflicting with other extensions @@ -7876,60 +8213,40 @@ mediaSessionFix () { try { - if (window.navigator.mediaSession) { + if (window.navigator.mediaSession && "android" !== 'integration') { return } - this.defineProperty(window.navigator, 'mediaSession', { - value: { - }, - writable: true, - configurable: true, - enumerable: true - }); - this.defineProperty(window.navigator.mediaSession, 'metadata', { - value: null, - writable: true, - configurable: false, - enumerable: false - }); - this.defineProperty(window.navigator.mediaSession, 'playbackState', { - value: 'none', - writable: true, - configurable: false, - enumerable: false - }); - this.defineProperty(window.navigator.mediaSession, 'setActionHandler', { - value: () => {}, - configurable: true, - enumerable: true - }); - this.defineProperty(window.navigator.mediaSession, 'setCameraActive', { - value: () => {}, - configurable: true, - enumerable: true - }); - this.defineProperty(window.navigator.mediaSession, 'setMicrophoneActive', { - value: () => {}, - configurable: true, - enumerable: true - }); - this.defineProperty(window.navigator.mediaSession, 'setPositionState', { - value: () => {}, - configurable: true, - enumerable: true + class MyMediaSession { + metadata = null + /** @type {MediaSession['playbackState']} */ + playbackState = 'none' + + setActionHandler () {} + setCameraActive () {} + setMicrophoneActive () {} + setPositionState () {} + } + + this.shimInterface('MediaSession', MyMediaSession, { + disallowConstructor: true, + allowConstructorCall: false, + wrapToString: true }); + this.shimProperty(Navigator.prototype, 'mediaSession', new MyMediaSession(), true); - class MediaMetadata { + this.shimInterface('MediaMetadata', class { constructor (metadata = {}) { this.title = metadata.title; this.artist = metadata.artist; this.album = metadata.album; this.artwork = metadata.artwork; } - } - - window.MediaMetadata = new Proxy(MediaMetadata, {}); + }, { + disallowConstructor: false, + allowConstructorCall: false, + wrapToString: true + }); } catch { // Ignore exceptions that could be caused by conflicting with other extensions } @@ -7938,29 +8255,55 @@ presentationFix () { try { // @ts-expect-error due to: Property 'presentation' does not exist on type 'Navigator' - if (window.navigator.presentation) { + if (window.navigator.presentation && "android" !== 'integration') { return } - this.defineProperty(window.navigator, 'presentation', { - value: { - }, - writable: true, - configurable: true, - enumerable: true + const MyPresentation = class { + get defaultRequest () { + return null + } + + get receiver () { + return null + } + }; + + // @ts-expect-error Presentation API is still experimental, TS types are missing + this.shimInterface('Presentation', MyPresentation, { + disallowConstructor: true, + allowConstructorCall: false, + wrapToString: true }); - // @ts-expect-error due to: Property 'presentation' does not exist on type 'Navigator' - this.defineProperty(window.navigator.presentation, 'defaultRequest', { - value: null, - configurable: true, - enumerable: true + + // @ts-expect-error Presentation API is still experimental, TS types are missing + this.shimInterface('PresentationAvailability', class { + // class definition is empty because there's no way to get an instance of it anyways + }, { + disallowConstructor: true, + allowConstructorCall: false, + wrapToString: true }); - // @ts-expect-error due to: Property 'presentation' does not exist on type 'Navigator' - this.defineProperty(window.navigator.presentation, 'receiver', { - value: null, - configurable: true, - enumerable: true + + // @ts-expect-error Presentation API is still experimental, TS types are missing + this.shimInterface('PresentationRequest', class { + // class definition is empty because there's no way to get an instance of it anyways + }, { + disallowConstructor: true, + allowConstructorCall: false, + wrapToString: true }); + + /** TODO: add shims for other classes in the Presentation API: + * PresentationConnection, + * PresentationReceiver, + * PresentationConnectionList, + * PresentationConnectionAvailableEvent, + * PresentationConnectionCloseEvent + */ + + // @ts-expect-error Presentation API is still experimental, TS types are missing + this.shimProperty(Navigator.prototype, 'presentation', new MyPresentation(), true); } catch { // Ignore exceptions that could be caused by conflicting with other extensions } diff --git a/package-lock.json b/package-lock.json index 4d63174c57df..65a0a4fa28f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@duckduckgo/autoconsent": "^10.8.0", "@duckduckgo/autofill": "github:duckduckgo/duckduckgo-autofill#10.2.0", - "@duckduckgo/content-scope-scripts": "github:duckduckgo/content-scope-scripts#5.15.0", + "@duckduckgo/content-scope-scripts": "github:duckduckgo/content-scope-scripts#5.17.0", "@duckduckgo/privacy-dashboard": "github:duckduckgo/privacy-dashboard#3.5.0", "@duckduckgo/privacy-reference-tests": "github:duckduckgo/privacy-reference-tests#1708702034" }, @@ -69,7 +69,7 @@ "hasInstallScript": true }, "node_modules/@duckduckgo/content-scope-scripts": { - "resolved": "git+ssh://git@github.com/duckduckgo/content-scope-scripts.git#bb8e7e62104ed6506c7bfd3ef7aa4aca3686ed4f", + "resolved": "git+ssh://git@github.com/duckduckgo/content-scope-scripts.git#fa861c4eccb21d235e34070b208b78bdc32ece08", "hasInstallScript": true, "workspaces": [ "packages/special-pages", @@ -266,9 +266,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.12.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.11.tgz", - "integrity": "sha512-vDg9PZ/zi+Nqp6boSOT7plNuthRugEKixDv5sFTIpkE89MmNtEArAShI4mxuX2+UrLEe9pxC1vm2cjm9YlWbJw==", + "version": "20.12.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz", + "integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -837,9 +837,9 @@ } }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", "dev": true }, "node_modules/picomatch": { diff --git a/package.json b/package.json index cc68c1aae529..3fa8785a8388 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "dependencies": { "@duckduckgo/autoconsent": "^10.8.0", "@duckduckgo/autofill": "github:duckduckgo/duckduckgo-autofill#10.2.0", - "@duckduckgo/content-scope-scripts": "github:duckduckgo/content-scope-scripts#5.15.0", + "@duckduckgo/content-scope-scripts": "github:duckduckgo/content-scope-scripts#5.17.0", "@duckduckgo/privacy-dashboard": "github:duckduckgo/privacy-dashboard#3.5.0", "@duckduckgo/privacy-reference-tests": "github:duckduckgo/privacy-reference-tests#1708702034" } From a8dc39446f36625cbea840ad536fd210e9cf09df Mon Sep 17 00:00:00 2001 From: Aitor Viana Date: Tue, 21 May 2024 20:48:55 +0100 Subject: [PATCH 05/17] Register in VPN process as well (#4519) Task/Issue URL: https://app.asana.com/0/488551667048375/1207294405155930 ### Description Add native crash handling for VPN process now that we also have it for the main process ### Steps to test this PR _Test native crash handing_ - [x] apply the patch from [this Asana task](https://app.asana.com/0/0/1207294405155932/f) to this PR - [x] build and fresh install and launch app - [x] Enable AppTP - [x] verify `Native crash handler init pixel sent on vpn` appears in logcat - [x] verify `Native crash handler successfully initialized on vpn.` apperas in logcat - [x] verify VPN crashes and `Native crash pixel sent on vpn` appears in logcat - [ ] In kibana, verify pixels appear when applying this filter `pixel:m.app*.native.crash.*` _(pixels should have `pn=vpn` and the right version, may be difficult to find them as now we send for `pn=main` in production. Logcats above should be enough)_ - [ ] pixels have the right params, `vpn` process name, right `appVersion` and right `customTab` true/false value _Test disabling crash handling_ - [x] use jsonblob or something else to set custom privacy config - [x] add the following feature and set `nativeCrashHandlingSecondaryProcess` to `disabled` ```json "androidNativeCrash": { "exceptions": [], "state": "enabled", "hash": "38", "features": { "nativeCrashHandlingSecondaryProcess": { "state": "enabled" } } } ``` - [x] kill the processes and re-launch the app and verify none of the `vpn` related logcats in previous test appear --- anrs/anrs-impl/build.gradle | 2 + anrs/anrs-impl/src/main/cpp/jni.cpp | 72 +------------------ anrs/anrs-impl/src/main/cpp/pixel.cpp | 4 +- .../app/anr/AnrOfflinePixelSender.kt | 6 +- .../app/anr/ndk/NativeCrashFeature.kt | 54 ++++++++++++++ .../duckduckgo/app/anr/ndk/NativeCrashInit.kt | 20 +++++- ...NativeCrashFeatureMultiProcessStoreTest.kt | 43 +++++++++++ 7 files changed, 123 insertions(+), 78 deletions(-) create mode 100644 anrs/anrs-impl/src/test/java/com/duckduckgo/app/anr/ndk/NativeCrashFeatureMultiProcessStoreTest.kt diff --git a/anrs/anrs-impl/build.gradle b/anrs/anrs-impl/build.gradle index 3b28f498607b..baf8e3ae232b 100644 --- a/anrs/anrs-impl/build.gradle +++ b/anrs/anrs-impl/build.gradle @@ -36,6 +36,7 @@ dependencies { implementation project(':verified-installation-api') implementation project(':library-loader-api') implementation project(':feature-toggles-api') + implementation project(':data-store-api') implementation AndroidX.core.ktx implementation KotlinX.coroutines.core @@ -48,6 +49,7 @@ dependencies { implementation AndroidX.room.rxJava2 testImplementation project(':common-test') + implementation project(':data-store-test') testImplementation Testing.junit4 testImplementation AndroidX.archCore.testing testImplementation AndroidX.test.ext.junit diff --git a/anrs/anrs-impl/src/main/cpp/jni.cpp b/anrs/anrs-impl/src/main/cpp/jni.cpp index a5707c0a5baa..8709461b57a5 100644 --- a/anrs/anrs-impl/src/main/cpp/jni.cpp +++ b/anrs/anrs-impl/src/main/cpp/jni.cpp @@ -9,15 +9,6 @@ /////////////////////////////////////////////////////////////////////////// -static JavaVM *JVM = NULL; -jclass clsCrash; -jobject CLASS_JVM_CRASH = NULL; - - -static jobject jniGlobalRef(JNIEnv *env, jobject cls); -static jclass jniFindClass(JNIEnv *env, const char *name); -static jmethodID jniGetMethodID(JNIEnv *env, jclass cls, const char *name, const char *signature); - int loglevel = 0; char appVersion[256]; char pname[256]; @@ -35,63 +26,6 @@ void __platform_log_print(int prio, const char *tag, const char *fmt, ...) { va_end(argptr); } -/////////////////////////////////////////////////////////////////////////// -// JNI utils -/////////////////////////////////////////////////////////////////////////// - -static jobject jniGlobalRef(JNIEnv *env, jobject cls) { - jobject gcls = env->NewGlobalRef(cls); - if (gcls == NULL) - log_print(ANDROID_LOG_ERROR, "Global ref failed (out of memory?)"); - return gcls; -} - -static jclass jniFindClass(JNIEnv *env, const char *name) { - jclass cls = env->FindClass(name); - if (cls == NULL) - log_print(ANDROID_LOG_ERROR, "Class %s not found", name); - return cls; -} - -static jmethodID jniGetMethodID(JNIEnv *env, jclass cls, const char *name, const char *signature) { - jmethodID method = env->GetMethodID(cls, name, signature); - if (method == NULL) { - log_print(ANDROID_LOG_ERROR, "Method %s %s not found", name, signature); - } - return method; -} - -/////////////////////////////////////////////////////////////////////////// -// JNI lifecycle -/////////////////////////////////////////////////////////////////////////// - -jint JNI_OnLoad(JavaVM *vm, void *reserved) { - JNIEnv *env; - if ((vm)->GetEnv((void **) &env, JNI_VERSION_1_6) != JNI_OK) { - log_print(ANDROID_LOG_INFO, "JNI load GetEnv failed"); - return -1; - } - - jint rs = env->GetJavaVM(&JVM); - if (rs != JNI_OK) { - log_print(ANDROID_LOG_ERROR, "Could not get JVM"); - return -1; - } - - return JNI_VERSION_1_6; -} - -void JNI_OnUnload(JavaVM *vm, void *reserved) { - log_print(ANDROID_LOG_INFO, "JNI unload"); - - JNIEnv *env; - if (vm->GetEnv((void **) &env, JNI_VERSION_1_6) != JNI_OK) - log_print(ANDROID_LOG_INFO, "JNI load GetEnv failed"); - else { - env->DeleteGlobalRef(clsCrash); - } -} - /////////////////////////////////////////////////////////////////////////// // native<>JVM interface /////////////////////////////////////////////////////////////////////////// @@ -129,13 +63,9 @@ Java_com_duckduckgo_app_anr_ndk_NativeCrashInit_jni_1register_1sighandler( // get and set isCustomTabs isCustomTab = customtab_; - clsCrash = env->GetObjectClass(instance); - const char *emptyParamVoidSig = "()V"; - CLASS_JVM_CRASH = env->NewGlobalRef(instance); - send_crash_handle_init_pixel(); - log_print(ANDROID_LOG_ERROR, "Native crash handler successfully initialized."); + log_print(ANDROID_LOG_ERROR, "Native crash handler successfully initialized on %s.", pname); } extern "C" JNIEXPORT void JNICALL diff --git a/anrs/anrs-impl/src/main/cpp/pixel.cpp b/anrs/anrs-impl/src/main/cpp/pixel.cpp index 4fa1c5104bdb..19162b81db1b 100644 --- a/anrs/anrs-impl/src/main/cpp/pixel.cpp +++ b/anrs/anrs-impl/src/main/cpp/pixel.cpp @@ -58,7 +58,7 @@ void send_crash_pixel() { char path[2048]; sprintf(path, "/t/m_app_native_crash_android?appVersion=%s&pn=%s&customTab=%s", appVersion, pname, isCustomTab ? "true" : "false"); send_request(host, path); - log_print(ANDROID_LOG_ERROR, "Native crash pixel sent"); + log_print(ANDROID_LOG_ERROR, "Native crash pixel sent on %s", pname); } void send_crash_handle_init_pixel() { @@ -66,5 +66,5 @@ void send_crash_handle_init_pixel() { char path[2048]; sprintf(path, "/t/m_app_register_native_crash_handler_android?appVersion=%s&pn=%s&customTab=%s", appVersion, pname, isCustomTab ? "true" : "false"); send_request(host, path); - log_print(ANDROID_LOG_ERROR, "Native crash handler init pixel sent"); + log_print(ANDROID_LOG_ERROR, "Native crash handler init pixel sent on %s", pname); } diff --git a/anrs/anrs-impl/src/main/java/com/duckduckgo/app/anr/AnrOfflinePixelSender.kt b/anrs/anrs-impl/src/main/java/com/duckduckgo/app/anr/AnrOfflinePixelSender.kt index 22fa6742b826..b88dc1b89798 100644 --- a/anrs/anrs-impl/src/main/java/com/duckduckgo/app/anr/AnrOfflinePixelSender.kt +++ b/anrs/anrs-impl/src/main/java/com/duckduckgo/app/anr/AnrOfflinePixelSender.kt @@ -57,9 +57,9 @@ class AnrOfflinePixelSender @Inject constructor( } companion object { - const val ANR_STACKTRACE = "stackTrace" - const val ANR_WEBVIEW_VERSION = "webView" - const val ANR_CUSTOM_TAB = "customTab" + private const val ANR_STACKTRACE = "stackTrace" + private const val ANR_WEBVIEW_VERSION = "webView" + private const val ANR_CUSTOM_TAB = "customTab" } } diff --git a/anrs/anrs-impl/src/main/java/com/duckduckgo/app/anr/ndk/NativeCrashFeature.kt b/anrs/anrs-impl/src/main/java/com/duckduckgo/app/anr/ndk/NativeCrashFeature.kt index 3da5e676ba97..6c1fdca98d91 100644 --- a/anrs/anrs-impl/src/main/java/com/duckduckgo/app/anr/ndk/NativeCrashFeature.kt +++ b/anrs/anrs-impl/src/main/java/com/duckduckgo/app/anr/ndk/NativeCrashFeature.kt @@ -16,13 +16,29 @@ package com.duckduckgo.app.anr.ndk +import android.content.SharedPreferences +import androidx.core.content.edit import com.duckduckgo.anvil.annotations.ContributesRemoteFeature +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.data.store.api.SharedPreferencesProvider import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.RemoteFeatureStoreNamed import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.State +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch @ContributesRemoteFeature( scope = AppScope::class, featureName = "androidNativeCrash", + toggleStore = NativeCrashFeatureMultiProcessStore::class, ) interface NativeCrashFeature { @Toggle.DefaultValue(true) @@ -31,4 +47,42 @@ interface NativeCrashFeature { @Toggle.DefaultValue(true) fun nativeCrashHandling(): Toggle + + @Toggle.DefaultValue(true) + fun nativeCrashHandlingSecondaryProcess(): Toggle +} + +@ContributesBinding(AppScope::class) +@SingleInstanceIn(AppScope::class) +@RemoteFeatureStoreNamed(NativeCrashFeature::class) +class NativeCrashFeatureMultiProcessStore @Inject constructor( + @AppCoroutineScope private val coroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, + private val sharedPreferencesProvider: SharedPreferencesProvider, + moshi: Moshi, +) : Toggle.Store { + + private val preferences: SharedPreferences by lazy { + sharedPreferencesProvider.getSharedPreferences(PREFS_FILENAME, multiprocess = true, migrate = false) + } + + private val stateAdapter: JsonAdapter by lazy { + moshi.newBuilder().add(KotlinJsonAdapterFactory()).build().adapter(State::class.java) + } + + override fun set(key: String, state: State) { + coroutineScope.launch(dispatcherProvider.io()) { + preferences.edit(commit = true) { putString(key, stateAdapter.toJson(state)) } + } + } + + override fun get(key: String): State? { + return preferences.getString(key, null)?.let { + stateAdapter.fromJson(it) + } + } + + companion object { + private const val PREFS_FILENAME = "com.duckduckgo.app.androidNativeCrash.feature.v1" + } } diff --git a/anrs/anrs-impl/src/main/java/com/duckduckgo/app/anr/ndk/NativeCrashInit.kt b/anrs/anrs-impl/src/main/java/com/duckduckgo/app/anr/ndk/NativeCrashInit.kt index 4eee2c711b81..bb5c2d1ab3da 100644 --- a/anrs/anrs-impl/src/main/java/com/duckduckgo/app/anr/ndk/NativeCrashInit.kt +++ b/anrs/anrs-impl/src/main/java/com/duckduckgo/app/anr/ndk/NativeCrashInit.kt @@ -23,6 +23,7 @@ import com.duckduckgo.app.browser.customtabs.CustomTabDetector import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.di.IsMainProcess import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver +import com.duckduckgo.app.lifecycle.VpnProcessLifecycleObserver import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.appbuildconfig.api.isInternalBuild import com.duckduckgo.common.utils.DispatcherProvider @@ -42,6 +43,10 @@ import logcat.logcat scope = AppScope::class, boundType = MainProcessLifecycleObserver::class, ) +@ContributesMultibinding( + scope = AppScope::class, + boundType = VpnProcessLifecycleObserver::class, +) @SingleInstanceIn(AppScope::class) class NativeCrashInit @Inject constructor( context: Context, @@ -51,7 +56,7 @@ class NativeCrashInit @Inject constructor( private val nativeCrashFeature: NativeCrashFeature, private val dispatcherProvider: DispatcherProvider, @AppCoroutineScope private val coroutineScope: CoroutineScope, -) : MainProcessLifecycleObserver { +) : MainProcessLifecycleObserver, VpnProcessLifecycleObserver { private val isCustomTab: Boolean by lazy { customTabDetector.isCustomTab() } private val processName: String by lazy { if (isMainProcess) "main" else "vpn" } @@ -76,9 +81,20 @@ class NativeCrashInit @Inject constructor( } } + override fun onVpnProcessCreated() { + if (!isMainProcess) { + coroutineScope.launch { + jniRegisterNativeSignalHandler() + } + } else { + logcat(ERROR) { "ndk-crash: onCreate wrongly called in the main process" } + } + } + private suspend fun jniRegisterNativeSignalHandler() = withContext(dispatcherProvider.io()) { runCatching { - if (!nativeCrashFeature.nativeCrashHandling().isEnabled()) return@withContext + if (isMainProcess && !nativeCrashFeature.nativeCrashHandling().isEnabled()) return@withContext + if (!isMainProcess && !nativeCrashFeature.nativeCrashHandlingSecondaryProcess().isEnabled()) return@withContext val logLevel = if (appBuildConfig.isDebug || appBuildConfig.isInternalBuild()) { Log.VERBOSE diff --git a/anrs/anrs-impl/src/test/java/com/duckduckgo/app/anr/ndk/NativeCrashFeatureMultiProcessStoreTest.kt b/anrs/anrs-impl/src/test/java/com/duckduckgo/app/anr/ndk/NativeCrashFeatureMultiProcessStoreTest.kt new file mode 100644 index 000000000000..a2be93e67811 --- /dev/null +++ b/anrs/anrs-impl/src/test/java/com/duckduckgo/app/anr/ndk/NativeCrashFeatureMultiProcessStoreTest.kt @@ -0,0 +1,43 @@ +package com.duckduckgo.app.anr.ndk + +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.data.store.api.FakeSharedPreferencesProvider +import com.duckduckgo.feature.toggles.api.Toggle +import com.squareup.moshi.Moshi +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Rule +import org.junit.Test + +class NativeCrashFeatureMultiProcessStoreTest { + + @get:Rule var coroutineRule = CoroutineTestRule() + + private val store = NativeCrashFeatureMultiProcessStore( + coroutineRule.testScope, + coroutineRule.testDispatcherProvider, + FakeSharedPreferencesProvider(), + Moshi.Builder().build(), + ) + + @Test + fun `test set value`() = runTest { + val expected = Toggle.State(enable = true) + store.set("key", expected) + + Assert.assertEquals(expected, store.get("key")) + } + + @Test + fun `test get missing value`() = runTest { + Assert.assertNull(store.get("key")) + } + + @Test + fun `test get when value is not present`() { + val expected = Toggle.State(enable = true) + store.set("key", expected) + + Assert.assertNull(store.get("wrong key")) + } +} From 77fcbb606854a0dc9b0622f3a365756ebe96ef5d Mon Sep 17 00:00:00 2001 From: Craig Russell Date: Wed, 22 May 2024 16:16:33 +0100 Subject: [PATCH 06/17] Close autofill survey (#4576) Task/Issue URL: https://app.asana.com/0/608920331025315/1207370057643957/f ### Description Closes the autofill survey. The PR has two commits (might help to view them separately): 1. a small refactor to make it easier to remove the active survey without having to delete useful tests 2. the actual removal of the survey ### Steps to test this PR - [ ] Clean app install - [ ] Visit Password management screen; verify the survey prompt is **not shown** --- .../management/AutofillSettingsViewModel.kt | 2 +- .../management/survey/AutofillSurvey.kt | 20 +++++------------ .../management/survey/AutofillSurveyStore.kt | 5 +++++ .../management/survey/SurveyDetails.kt | 22 +++++++++++++++++++ .../viewing/AutofillManagementListMode.kt | 2 +- .../AutofillSettingsViewModelTest.kt | 2 +- .../survey/AutofillSurveyImplTest.kt | 12 +++++++++- .../survey/AutofillSurveyStoreImplTest.kt | 5 +++++ 8 files changed, 51 insertions(+), 19 deletions(-) create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/SurveyDetails.kt diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModel.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModel.kt index 0e0e8992984c..5348832119a6 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModel.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModel.kt @@ -73,7 +73,7 @@ import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsVie import com.duckduckgo.autofill.impl.ui.credential.management.neversaved.NeverSavedSitesViewState import com.duckduckgo.autofill.impl.ui.credential.management.searching.CredentialListFilter import com.duckduckgo.autofill.impl.ui.credential.management.survey.AutofillSurvey -import com.duckduckgo.autofill.impl.ui.credential.management.survey.AutofillSurvey.SurveyDetails +import com.duckduckgo.autofill.impl.ui.credential.management.survey.SurveyDetails import com.duckduckgo.autofill.impl.ui.credential.management.viewing.duckaddress.DuckAddressIdentifier import com.duckduckgo.autofill.impl.ui.credential.repository.DuckAddressStatusRepository import com.duckduckgo.autofill.impl.ui.credential.repository.DuckAddressStatusRepository.ActivationStatusResult diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurvey.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurvey.kt index b9e5f41a2735..b7c123536bb0 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurvey.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurvey.kt @@ -21,7 +21,6 @@ import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.app.usage.app.AppDaysUsedRepository import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.autofill.impl.store.InternalAutofillStore -import com.duckduckgo.autofill.impl.ui.credential.management.survey.AutofillSurvey.SurveyDetails import com.duckduckgo.autofill.impl.ui.credential.management.survey.AutofillSurveyImpl.Companion.SurveyParams.IN_APP import com.duckduckgo.autofill.impl.ui.credential.management.survey.AutofillSurveyImpl.Companion.SurveyParams.NUMBER_PASSWORD_BUCKET_LOTS import com.duckduckgo.autofill.impl.ui.credential.management.survey.AutofillSurveyImpl.Companion.SurveyParams.NUMBER_PASSWORD_BUCKET_MANY @@ -39,11 +38,6 @@ import kotlinx.coroutines.withContext interface AutofillSurvey { suspend fun firstUnusedSurvey(): SurveyDetails? suspend fun recordSurveyAsUsed(id: String) - - data class SurveyDetails( - val id: String, - val url: String, - ) } @ContributesBinding(AppScope::class) @@ -58,9 +52,11 @@ class AutofillSurveyImpl @Inject constructor( ) : AutofillSurvey { override suspend fun firstUnusedSurvey(): SurveyDetails? { - if (!canShowSurvey()) return null - val survey = availableSurveys.firstOrNull { !surveyTakenPreviously(it.id) } ?: return null - return survey.copy(url = survey.url.addSurveyParameters()) + return withContext(dispatchers.io()) { + if (!canShowSurvey()) return@withContext null + val survey = autofillSurveyStore.availableSurveys().firstOrNull { !surveyTakenPreviously(it.id) } ?: return@withContext null + return@withContext survey.copy(url = survey.url.addSurveyParameters()) + } } private fun canShowSurvey(): Boolean { @@ -109,12 +105,6 @@ class AutofillSurveyImpl @Inject constructor( } companion object { - private val availableSurveys = listOf( - SurveyDetails( - id = "autofill-2024-04-26", - url = "https://selfserve.decipherinc.com/survey/selfserve/32ab/240308", - ), - ) private object SurveyParams { const val ATB = "atb" diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurveyStore.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurveyStore.kt index 07b7635b0782..ece5055fb447 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurveyStore.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurveyStore.kt @@ -30,6 +30,7 @@ interface AutofillSurveyStore { suspend fun hasSurveyBeenTaken(id: String): Boolean suspend fun recordSurveyWasShown(id: String) suspend fun resetPreviousSurveys() + suspend fun availableSurveys(): List } @ContributesBinding(AppScope::class) @@ -69,6 +70,10 @@ class AutofillSurveyStoreImpl @Inject constructor( } } + override suspend fun availableSurveys(): List { + return emptyList() + } + companion object { private const val PREFS_FILE_NAME = "autofill_survey_store" private const val SURVEY_IDS = "survey_ids" diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/SurveyDetails.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/SurveyDetails.kt new file mode 100644 index 000000000000..295214e3a591 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/SurveyDetails.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.ui.credential.management.survey + +data class SurveyDetails( + val id: String, + val url: String, +) diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/AutofillManagementListMode.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/AutofillManagementListMode.kt index e2628a05c9d6..4d7c3a726b6e 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/AutofillManagementListMode.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/AutofillManagementListMode.kt @@ -55,7 +55,7 @@ import com.duckduckgo.autofill.impl.ui.credential.management.sorting.CredentialG import com.duckduckgo.autofill.impl.ui.credential.management.sorting.InitialExtractor import com.duckduckgo.autofill.impl.ui.credential.management.suggestion.SuggestionListBuilder import com.duckduckgo.autofill.impl.ui.credential.management.suggestion.SuggestionMatcher -import com.duckduckgo.autofill.impl.ui.credential.management.survey.AutofillSurvey.SurveyDetails +import com.duckduckgo.autofill.impl.ui.credential.management.survey.SurveyDetails import com.duckduckgo.common.ui.DuckDuckGoFragment import com.duckduckgo.common.ui.view.MessageCta.Message import com.duckduckgo.common.ui.view.SearchBar diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModelTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModelTest.kt index 2a0dc071b92b..9e95214c57ad 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModelTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModelTest.kt @@ -52,7 +52,7 @@ import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsVie import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.PromptUserToAuthenticateMassDeletion import com.duckduckgo.autofill.impl.ui.credential.management.searching.CredentialListFilter import com.duckduckgo.autofill.impl.ui.credential.management.survey.AutofillSurvey -import com.duckduckgo.autofill.impl.ui.credential.management.survey.AutofillSurvey.SurveyDetails +import com.duckduckgo.autofill.impl.ui.credential.management.survey.SurveyDetails import com.duckduckgo.autofill.impl.ui.credential.management.viewing.duckaddress.DuckAddressIdentifier import com.duckduckgo.autofill.impl.ui.credential.management.viewing.duckaddress.RealDuckAddressIdentifier import com.duckduckgo.autofill.impl.ui.credential.repository.DuckAddressStatusRepository diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurveyImplTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurveyImplTest.kt index 06e4fe0147a0..6b960eaa7950 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurveyImplTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurveyImplTest.kt @@ -4,7 +4,6 @@ import androidx.core.net.toUri import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.autofill.impl.store.InternalAutofillStore -import com.duckduckgo.autofill.impl.ui.credential.management.survey.AutofillSurvey.SurveyDetails import com.duckduckgo.common.test.CoroutineTestRule import java.util.* import kotlinx.coroutines.flow.flowOf @@ -42,10 +41,21 @@ class AutofillSurveyImplTest { whenever(appBuildConfig.deviceLocale).thenReturn(Locale("en")) coroutineTestRule.testScope.runTest { + whenever(autofillSurveyStore.availableSurveys()).thenReturn( + listOf( + SurveyDetails("autofill-2024-04-26", "https://example.com/survey"), + ), + ) configureCredentialCount(0) } } + @Test + fun whenNoSurveyAvailableThenFirstUnusedSurveyReturnsNull() = runTest { + whenever(autofillSurveyStore.availableSurveys()).thenReturn(emptyList()) + assertNull(testee.firstUnusedSurvey()) + } + @Test fun whenSurveyHasNotBeenShownBeforeThenFirstUnusedSurveyReturnsIt() = runTest { whenever(autofillSurveyStore.hasSurveyBeenTaken("autofill-2024-04-26")).thenReturn(false) diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurveyStoreImplTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurveyStoreImplTest.kt index bfb93773d756..3e3493f74750 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurveyStoreImplTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurveyStoreImplTest.kt @@ -45,4 +45,9 @@ class AutofillSurveyStoreImplTest { testee.recordSurveyWasShown("surveyId-3") assertTrue(testee.hasSurveyBeenTaken("surveyId-2")) } + + @Test + fun whenAvailableSurveysCalledThenOneSurveyReturned() = runTest { + assertEquals(0, testee.availableSurveys().size) + } } From ad432353afd4631918d8c40e73314ebd1d04f1cb Mon Sep 17 00:00:00 2001 From: Aitor Viana Date: Thu, 23 May 2024 13:56:23 +0100 Subject: [PATCH 07/17] Active plugins (#4553) Task/Issue URL: https://app.asana.com/0/488551667048375/1207335546470303/f ### Description This PR adds active plugin points, ie. plugin points that are automatically guarded by remote feature flags. See [this](https://app.asana.com/0/1202552961248957/1207322408444916/f) for more context ### Steps to test this PR _Test Main Process_ - [x] build and install from https://github.com/duckduckgo/Android/pull/4554 - [x] filter logcat by `UserOfThePluginPoint | Aitor` - [x] launch the app - [x] verify `Main`, `Baz`, `Bar`, `Second Main` plugins print in that order and `In Process main` - [x] Enable AppTP - [x] verify `Main`, `Baz`, `Bar`, `Second Main` plugins print in that order and `In Process vpn` - [x] Disable Baz plugin ``` "pluginPointMyPlugin": { "exceptions": [], "state": "enabled", "hash": "1", "features": { "pluginBazActivePlugin": { "state": "disabled" } } } ``` - [x] use fire button to update remote config - [x] verify `Main`, `Bar`, `Second Main` plugins print in that order and `In Process main` - [x] Disable and re-enable AppTP - [x] verify `Main`, `Bar`, `Second Main` plugins print in that order and `In Process vpn` - [x] Disable Bar plugin ``` "pluginPointMyPlugin": { "exceptions": [], "state": "enabled", "hash": "2", "features": { "pluginBarActivePlugin": { "state": "disabled" } } } ``` - [x] verify `Main`, `Second Main` plugins print in that order and `In Process main` - [x] Disable and re-enable AppTP - [x] verify `Main`, `Second Main` plugins print in that order and `In Process vpn` - [x] Disable plugin point ``` "pluginPointMyPlugin": { "exceptions": [], "state": "disabled", "hash": "3", "features": { } } ``` - [x] verify no plugin prints any message - [x] Disable and re-enable AppTP - [x] verify no plugin prints any message - [x] re-enable plugin point and all plugins ``` "pluginPointMyPlugin": { "hash": "5", "exceptions": [], "state": "enabled", "features": { "pluginBazActivePlugin": { "state": "enabled" }, "pluginBarActivePlugin": { "state": "enabled" } } } ``` - [x] verify `Main`, `Baz`, `Bar`, `Second Main` plugins print in that order and `In Process main` - [x] Disable and re-enable AppTP - [x] verify `Main`, `Baz`, `Bar`, `Second Main` plugins print in that order and `In Process vpn` - [x] disable all plugins individually ``` "pluginPointMyPlugin": { "exceptions": [], "state": "enabled", "hash": "6", "features": { "pluginBazActivePlugin": { "state": "disabled" }, "pluginBarActivePlugin": { "state": "disabled" }, "pluginFooActivePlugin": { "state": "disabled" }, "pluginMainActivePlugin": { "state": "disabled" }, "pluginSecondMainActivePlugin": { "state": "disabled" } } } ``` - [x] verify no plugin prints any message - [x] Disable and re-enable AppTP - [x] verify no plugin prints any message - [x] re-enable all individually - [x] verify `Main`, `Baz`, `Bar`, `Second Main` plugins print in that order and `In Process main` - [x] Disable and re-enable AppTP - [x] verify `Main`, `Baz`, `Bar`, `Second Main` plugins print in that order and `In Process vpn` --- .../annotations/ContributesActivePlugin.kt | 63 ++ .../ContributesActivePluginPoint.kt | 56 ++ ...ntributesActivePluginPointCodeGenerator.kt | 583 ++++++++++++++++++ .../common/utils/plugins/ActivePluginPoint.kt | 27 + .../feature-toggles-impl/build.gradle | 3 + ...butesActivePluginPointCodeGeneratorTest.kt | 280 +++++++++ .../toggles/codegen/TestActivePlugins.kt | 80 +++ .../config/impl/PrivacyConfigPersister.kt | 2 +- .../impl/RealPrivacyConfigPersisterTest.kt | 23 + 9 files changed, 1116 insertions(+), 1 deletion(-) create mode 100644 anvil/anvil-annotations/src/main/java/com/duckduckgo/anvil/annotations/ContributesActivePlugin.kt create mode 100644 anvil/anvil-annotations/src/main/java/com/duckduckgo/anvil/annotations/ContributesActivePluginPoint.kt create mode 100644 anvil/anvil-compiler/src/main/java/com/duckduckgo/anvil/compiler/ContributesActivePluginPointCodeGenerator.kt create mode 100644 common/common-utils/src/main/java/com/duckduckgo/common/utils/plugins/ActivePluginPoint.kt create mode 100644 feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/ContributesActivePluginPointCodeGeneratorTest.kt create mode 100644 feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/TestActivePlugins.kt diff --git a/anvil/anvil-annotations/src/main/java/com/duckduckgo/anvil/annotations/ContributesActivePlugin.kt b/anvil/anvil-annotations/src/main/java/com/duckduckgo/anvil/annotations/ContributesActivePlugin.kt new file mode 100644 index 000000000000..0ffde057864a --- /dev/null +++ b/anvil/anvil-annotations/src/main/java/com/duckduckgo/anvil/annotations/ContributesActivePlugin.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.anvil.annotations + +import kotlin.reflect.KClass + +/** + * Anvil annotation to contribute plugins into an Active Plugin Point. + * Active plugins are also guarded by remote feature flags + * + * This annotation is the counterpart of [ContributesActivePluginPoint] + * + * Usage: + * ```kotlin + * @ContributesActivePlugin(SomeDaggerScope::class) + * class MyPluginImpl : MyPlugin { + * + * } + * + * interface MyPlugin : ActivePluginPoint.ActivePlugin {...} + * ``` + */ +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class ContributesActivePlugin( + /** The scope in which to include this contributed PluginPoint */ + val scope: KClass<*>, + + /** + * This is the type of the plugin the annotated class is extending from + * This is a required member to help the code generation. + */ + val boundType: KClass<*>, + + /** + * The default value of remote feature flag. + * Default is true (ie. enabled) + */ + val defaultActiveValue: Boolean = true, + + /** + * The priority for the plugin. + * Lower priority values mean the associated plugin comes first in the list of plugins. + * + * This is equivalent to the [PriorityKey] annotation we use with [ContributesMultibinding] for normal plugins. + * The [ContributesActivePlugin] coalesce both + */ + val priority: Int = 0, +) diff --git a/anvil/anvil-annotations/src/main/java/com/duckduckgo/anvil/annotations/ContributesActivePluginPoint.kt b/anvil/anvil-annotations/src/main/java/com/duckduckgo/anvil/annotations/ContributesActivePluginPoint.kt new file mode 100644 index 000000000000..9b9d42a9b4d2 --- /dev/null +++ b/anvil/anvil-annotations/src/main/java/com/duckduckgo/anvil/annotations/ContributesActivePluginPoint.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.anvil.annotations + +import kotlin.reflect.KClass + +/** + * Anvil annotation to generate plugin points that are guarded by a remote feature flag. + * + * Active plugins need to extend from [ActivePluginPoint.ActivePlugin] + * + * Usage: + * ```kotlin + * @ContributesActivePluginPoint(SomeDaggerScope::class) + * interface MyPlugin : ActivePluginPoint.ActivePlugin { + * + * } + * ``` + */ +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class ContributesActivePluginPoint( + /** The scope in which to include this contributed PluginPoint */ + val scope: KClass<*>, + + /** + * The type that the plugin point will be bound to. This is useful when the plugin interfaces are defined in + * modules where we don't want or can't generate code, eg. API gradle modules. + * + * usage: + * ```kotlin + * @ContributesActivePluginPoint( + * scope = AppScope::class, + * boundType: MyPlugin::class + * ) + * interface MyPluginPoint : MyPlugin + * + * interface MyPlugin : ActivePluginPoint.ActivePlugin {...} + * ``` + */ + val boundType: KClass<*> = Unit::class, +) diff --git a/anvil/anvil-compiler/src/main/java/com/duckduckgo/anvil/compiler/ContributesActivePluginPointCodeGenerator.kt b/anvil/anvil-compiler/src/main/java/com/duckduckgo/anvil/compiler/ContributesActivePluginPointCodeGenerator.kt new file mode 100644 index 000000000000..a242b6290d38 --- /dev/null +++ b/anvil/anvil-compiler/src/main/java/com/duckduckgo/anvil/compiler/ContributesActivePluginPointCodeGenerator.kt @@ -0,0 +1,583 @@ +/* + * Copyright (c) 2022 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.anvil.compiler + +import com.duckduckgo.anvil.annotations.ContributesActivePlugin +import com.duckduckgo.anvil.annotations.ContributesActivePluginPoint +import com.duckduckgo.anvil.annotations.ContributesPluginPoint +import com.duckduckgo.anvil.annotations.ContributesRemoteFeature +import com.duckduckgo.anvil.annotations.PriorityKey +import com.duckduckgo.feature.toggles.api.RemoteFeatureStoreNamed +import com.duckduckgo.feature.toggles.api.Toggle +import com.google.auto.service.AutoService +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.anvil.annotations.ContributesMultibinding +import com.squareup.anvil.annotations.ContributesTo +import com.squareup.anvil.annotations.ExperimentalAnvilApi +import com.squareup.anvil.compiler.api.* +import com.squareup.anvil.compiler.internal.asClassName +import com.squareup.anvil.compiler.internal.buildFile +import com.squareup.anvil.compiler.internal.fqName +import com.squareup.anvil.compiler.internal.reference.* +import com.squareup.kotlinpoet.AnnotationSpec +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.CodeBlock +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier.ABSTRACT +import com.squareup.kotlinpoet.KModifier.OVERRIDE +import com.squareup.kotlinpoet.KModifier.PRIVATE +import com.squareup.kotlinpoet.KModifier.SUSPEND +import com.squareup.kotlinpoet.ParameterSpec +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.asClassName +import dagger.Binds +import java.io.File +import javax.inject.Inject +import org.jetbrains.kotlin.descriptors.ModuleDescriptor +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.psi.KtFile + +/** + * This Anvil code generator generates Active Plugins, ie. those that can be controlled via remote feature flag. + * Active plugins and Active plugin points are generated using the [ContributesActivePluginPoint] and [ContributesActivePlugin] annotations. + * + * For classes annotated with [ContributesActivePluginPoint], this generator will + * - generate a regular plugin point + * - generate a wrapper around the normal plugin point to handle the associated remote feature flag + * - generate a remote feature flag that will control the plugin point + * - generate the bindings so that users can depend on ActivePluginPoint + * + * For classes annotated with [ContributesActivePlugin] this generator will: + * - generate a binding to contribute the plugin into the associated plugin point, using [ContributesMultibinding] and [PriorityKey] + * - generate a remote feature flag that will control the plugin + * + * The business logic generated will ensure that: + * - disabling a given remote feature flag associated to plugins will de-activate such plugin, ie. won't be return in getPlugins() + * - disabling the remote feature associated to the plugin point will make getPlugins() method to return empty list, regardless of whether the + * plugin is active or not + * + */ +@OptIn(ExperimentalAnvilApi::class) +@AutoService(CodeGenerator::class) +class ContributesActivePluginPointCodeGenerator : CodeGenerator { + + private val activePluginPointAnnotations = listOf( + ContributesActivePlugin::class, + ContributesActivePluginPoint::class, + ) + + override fun isApplicable(context: AnvilContext): Boolean = true + + override fun generateCode(codeGenDir: File, module: ModuleDescriptor, projectFiles: Collection): Collection { + return projectFiles.classAndInnerClassReferences(module) + .toList() + .filter { reference -> reference.isAnnotatedWith(activePluginPointAnnotations.map { it.fqName }) } + .flatMap { + listOf( + generateActivePluginsPointAndPlugins(it, codeGenDir, module), + ) + } + .toMutableList().apply { + // this.addAll(generatePluginPointRemoteFeature(codeGenDir, module)) + }.toList() + } + + private fun generateActivePluginsPointAndPlugins( + vmClass: ClassReference.Psi, + codeGenDir: File, + module: ModuleDescriptor, + ): GeneratedFileWithSources { + return if (vmClass.isContributesActivePluginPoint()) { + generatedActivePluginPoint(vmClass, codeGenDir, module) + } else { + generatedActivePlugin(vmClass, codeGenDir, module) + } + } + + private fun generatedActivePluginPoint(vmClass: ClassReference.Psi, codeGenDir: File, module: ModuleDescriptor): GeneratedFileWithSources { + val generatedPackage = vmClass.packageFqName.toString() + val pluginPointClassFileName = "${vmClass.shortName}_ActivePluginPoint" + val pluginPointClassName = "Trigger_${vmClass.shortName}_ActivePluginPoint" + val pluginPointWrapperClassName = "${vmClass.shortName}_PluginPoint_ActiveWrapper" + val pluginPointWrapperBindingModuleClassName = "${vmClass.shortName}_PluginPoint_ActiveWrapper_Binding_Module" + val pluginPointRemoteFeatureClassName = "${vmClass.shortName}_ActivePluginPoint_RemoteFeature" + val pluginPointRemoteFeatureStoreClassName = "${vmClass.shortName}_ActivePluginPoint_RemoteFeature_MultiProcessStore" + val scope = vmClass.annotations.firstOrNull { it.fqName == ContributesActivePluginPoint::class.fqName }?.scopeOrNull(0)!! + val pluginClassType = vmClass.pluginClassName(ContributesActivePluginPoint::class.fqName) ?: vmClass.asClassName() + val featureName = "pluginPoint${pluginClassType.simpleName}" + + val content = FileSpec.buildFile(generatedPackage, pluginPointClassFileName) { + // This is the normal plugin point + addType( + TypeSpec.interfaceBuilder(pluginPointClassName) + .addModifiers(PRIVATE) + .addAnnotation( + AnnotationSpec.builder(ContributesPluginPoint::class) + .addMember("scope = %T::class", scope.asClassName()) + .addMember("boundType = %T::class", pluginClassType) + .build(), + ) + .addAnnotation( + AnnotationSpec.builder(Suppress::class) + .addMember("%S", "unused") + .build(), + ) + .build(), + ).build() + + // Generate the feature flag that guards the plugin points + addType( + TypeSpec.interfaceBuilder(pluginPointRemoteFeatureClassName) + .addAnnotation( + AnnotationSpec.builder(ContributesRemoteFeature::class) + .addMember("scope = %T::class", scope.asClassName()) + .addMember("featureName = %S", featureName) + .addMember("toggleStore = %T::class", ClassName(packageName, pluginPointRemoteFeatureStoreClassName)) + .build(), + ) + .addFunction( + FunSpec.builder("self") + .addModifiers(ABSTRACT) + .addAnnotation( + AnnotationSpec.builder(Toggle.DefaultValue::class) + .addMember("defaultValue = %L", true) + .build(), + ) + .returns(Toggle::class) + .build(), + ) + .build(), + ).build() + + // This is the plugin point active wrapper. Depends on the normal plugin point above and wraps to allow "active" behavior, that is + // just return the plugins that have the remote feature enabled. + addType( + TypeSpec.classBuilder(pluginPointWrapperClassName).apply { + primaryConstructor( + FunSpec.constructorBuilder() + .addAnnotation(AnnotationSpec.builder(Inject::class).build()) + .addParameter("toggle", ClassName(generatedPackage, pluginPointRemoteFeatureClassName)) + .addParameter( + ParameterSpec.builder( + "pluginPoint", + pluginPointFqName.asClassName(module).parameterizedBy( + pluginClassType.copy( + annotations = listOf(AnnotationSpec.builder(JvmSuppressWildcards::class).build()), + ), + ), + ).build(), + ) + .addParameter(ParameterSpec.builder("dispatcherProvider", dispatcherProviderFqName.asClassName(module)).build()) + .build(), + ) + addProperty( + PropertySpec.builder("toggle", ClassName(generatedPackage, pluginPointRemoteFeatureClassName), PRIVATE) + .initializer("toggle") + .build(), + ) + addProperty( + PropertySpec.builder( + "pluginPoint", + pluginPointFqName.asClassName(module).parameterizedBy( + pluginClassType.copy( + annotations = listOf(AnnotationSpec.builder(JvmSuppressWildcards::class).build()), + ), + ), + PRIVATE, + ).initializer("pluginPoint").build(), + ) + addProperty( + PropertySpec.builder( + "dispatcherProvider", + dispatcherProviderFqName.asClassName(module), + PRIVATE, + ) + .initializer("dispatcherProvider") + .build(), + ) + + addSuperinterface( + activePluginPointFqName + .asClassName(module) + .parameterizedBy( + pluginClassType.copy(annotations = listOf(AnnotationSpec.builder(JvmSuppressWildcards::class).build())), + ), + ) + + addFunction( + FunSpec.builder("getPlugins") + .addModifiers(OVERRIDE, SUSPEND) + .returns(Collection::class.fqName.asClassName(module).parameterizedBy(pluginClassType)) + .addCode( + CodeBlock.of( + """ + return kotlinx.coroutines.withContext(dispatcherProvider.io()) { + if (toggle.self().isEnabled()) { + pluginPoint.getPlugins().filter { it.isActive() } + } else { + emptyList() + } + } + """.trimIndent(), + ), + ) + .build(), + ) + }.build(), + ) + + // create the multiprocess remote feature store + createRemoteFeatureFlagMultiprocessStore( + scope = scope, + module = module, + pluginRemoteFeatureClassName = pluginPointRemoteFeatureClassName, + pluginRemoteFeatureStoreClassName = pluginPointRemoteFeatureStoreClassName, + parentFeatureName = featureName, + ) + + // Finally we're gonna create the dagger binding module, which will bind the plugin point wrapper type to the ActivePluginPoint type + addType( + TypeSpec.classBuilder(pluginPointWrapperBindingModuleClassName) + .addAnnotation(AnnotationSpec.builder(dagger.Module::class).build()) + .addAnnotation( + AnnotationSpec + .builder(ContributesTo::class).addMember("scope = %T::class", scope.asClassName()) + .build(), + ) + .addModifiers(ABSTRACT) + .addFunction( + FunSpec.builder("binds$pluginPointWrapperClassName") + .addModifiers(ABSTRACT) + .addAnnotation(Binds::class.asClassName()) + .addParameter( + ParameterSpec.builder( + "pluginPoint", + ClassName(generatedPackage, pluginPointWrapperClassName), + // FqName(pluginPointWrapperClassName).asClassName(module) + ).build(), + ) + .returns( + activePluginPointFqName + .asClassName(module) + .parameterizedBy( + pluginClassType.copy(annotations = listOf(AnnotationSpec.builder(JvmSuppressWildcards::class).build())), + ), + ) + .build(), + ) + .build(), + ) + } + + return createGeneratedFile(codeGenDir, generatedPackage, pluginPointClassFileName, content, setOf(vmClass.containingFileAsJavaFile)) + } + + private fun generatedActivePlugin(vmClass: ClassReference.Psi, codeGenDir: File, module: ModuleDescriptor): GeneratedFileWithSources { + val scope = vmClass.annotations.firstOrNull { it.fqName == ContributesActivePlugin::class.fqName }?.scopeOrNull(0)!! + val boundType = vmClass.annotations.firstOrNull { it.fqName == ContributesActivePlugin::class.fqName }?.boundTypeOrNull()!! + val featureDefaultValue = vmClass.annotations.firstOrNull { + it.fqName == ContributesActivePlugin::class.fqName + }?.defaultActiveValueOrNull() ?: true + // the parent feature name is taken from the plugin interface name implemented by this class + val parentFeatureName = "pluginPoint${boundType.shortName}" + val featureName = "plugin${vmClass.shortName}" + val generatedPackage = vmClass.packageFqName.toString() + val pluginClassName = "${vmClass.shortName}_ActivePlugin" + val pluginRemoteFeatureClassName = "${vmClass.shortName}_ActivePlugin_RemoteFeature" + val pluginRemoteFeatureStoreClassName = "${vmClass.shortName}_ActivePlugin_RemoteFeature_MultiProcessStore" + val pluginPriority = vmClass.annotations.firstOrNull { it.fqName == ContributesActivePlugin::class.fqName }?.priorityOrNull() + + val content = FileSpec.buildFile(generatedPackage, pluginClassName) { + // First create the class that will contribute the active plugin. + // We do expect that the plugins are define using the "ContributesActivePlugin" annotation but are also injected + // using @Inject in the constructor, as the concrete plugin type is use as delegate. + addType( + TypeSpec.classBuilder(pluginClassName).apply { + addAnnotation( + AnnotationSpec.builder(ContributesMultibinding::class) + .addMember("scope = %T::class", scope.asClassName()) + .addMember("boundType = %T::class", boundType.asClassName()) + .build(), + ) + // If the active plugin defined a priority then add the right annotation + pluginPriority?.let { + addAnnotation( + AnnotationSpec.builder(PriorityKey::class) + .addMember("%L", it) + .build(), + ) + } + + // primary constructor and parameters. We need the active plugin and the remote feature toggle + primaryConstructor( + FunSpec.constructorBuilder() + .addAnnotation(AnnotationSpec.builder(Inject::class).build()) + .addParameter("activePlugin", vmClass.asClassName()) + .addParameter("toggle", ClassName(generatedPackage, pluginRemoteFeatureClassName)) + .build(), + ) + addProperty( + PropertySpec.builder("activePlugin", vmClass.asClassName(), PRIVATE) + .initializer("activePlugin") + .build(), + ) + addProperty( + PropertySpec.builder("toggle", ClassName(generatedPackage, pluginRemoteFeatureClassName), PRIVATE) + .initializer("toggle") + .build(), + ) + + addSuperinterface( + boundType.asClassName(), + delegate = CodeBlock.of("activePlugin"), + ) + + addFunction( + FunSpec.builder("isActive") + .addModifiers(OVERRIDE, SUSPEND) + .returns(Boolean::class) + .addCode(CodeBlock.of("return toggle.$featureName().isEnabled()")) + .build(), + ) + }.build(), + ).build() + + // Now generate the feature flag that guards the plugin + addType( + TypeSpec.interfaceBuilder(pluginRemoteFeatureClassName).apply { + addAnnotation( + AnnotationSpec.builder(ContributesRemoteFeature::class) + .addMember("scope = %T::class", scope.asClassName()) + .addMember("featureName = %S", parentFeatureName) + .addMember("toggleStore = %T::class", ClassName(packageName, pluginRemoteFeatureStoreClassName)) + .build(), + ) + addFunction( + FunSpec.builder("self") + .addModifiers(ABSTRACT) + .addAnnotation( + AnnotationSpec.builder(Toggle.DefaultValue::class) + // The parent feature toggle is the one guarding the plugin point, for convention is default enabled. + .addMember("defaultValue = %L", true) + .build(), + ) + .returns(Toggle::class) + .build(), + ) + addFunction( + FunSpec.builder(featureName) + .addModifiers(ABSTRACT) + .addAnnotation( + AnnotationSpec.builder(Toggle.DefaultValue::class) + .addMember("defaultValue = %L", featureDefaultValue) + .build(), + ) + .returns(Toggle::class) + .build(), + ) + }.build(), + ).build() + + // generate the feature flag multi-process store + createRemoteFeatureFlagMultiprocessStore( + scope, + module, + pluginRemoteFeatureStoreClassName, + pluginRemoteFeatureClassName, + parentFeatureName, + ) + } + + return createGeneratedFile(codeGenDir, generatedPackage, pluginClassName, content, setOf(vmClass.containingFileAsJavaFile)) + } + + private fun FileSpec.Builder.createRemoteFeatureFlagMultiprocessStore( + scope: ClassReference, + module: ModuleDescriptor, + pluginRemoteFeatureStoreClassName: String, + pluginRemoteFeatureClassName: String, + parentFeatureName: String, + ): FileSpec { + val preferencesName = "com.duckduckgo.feature.toggle.$parentFeatureName.mp.store" + + // needed for the launch() and prefs.edit() {} inside the createToggleStoreImplementation() + addImport("kotlinx.coroutines", "launch") + addImport("androidx.core.content", "edit") + addImport("com.squareup.moshi.kotlin.reflect", "KotlinJsonAdapterFactory") + + return addType( + TypeSpec.classBuilder(pluginRemoteFeatureStoreClassName).apply { + addAnnotation( + AnnotationSpec.builder(ContributesBinding::class) + .addMember("scope = %T::class", scope.asClassName()) + .build(), + ) + + addAnnotation( + AnnotationSpec.builder(RemoteFeatureStoreNamed::class) + .addMember("value = %T::class", ClassName(packageName, pluginRemoteFeatureClassName)) + .build(), + ) + + addSuperinterface(Toggle.Store::class) + + primaryConstructor( + FunSpec.constructorBuilder() + .addAnnotation(AnnotationSpec.builder(Inject::class).build()) + .addParameter( + ParameterSpec.builder("coroutineScope", coroutineScopeFqName.asClassName(module)) + .addAnnotation(appCoroutineScopeFqName.asClassName(module)) + .build(), + ) + .addParameter("dispatcherProvider", dispatcherProviderFqName.asClassName(module)) + .addParameter("sharedPreferencesProvider", sharedPreferencesProviderFqName.asClassName(module)) + .addParameter("moshi", moshiFqName.asClassName(module)) + .build(), + ) + addProperty( + PropertySpec.builder("coroutineScope", coroutineScopeFqName.asClassName(module), PRIVATE) + .initializer("coroutineScope") + .build(), + ) + addProperty( + PropertySpec.builder("dispatcherProvider", dispatcherProviderFqName.asClassName(module), PRIVATE) + .initializer("dispatcherProvider") + .build(), + ) + addProperty( + PropertySpec.builder("sharedPreferencesProvider", sharedPreferencesProviderFqName.asClassName(module), PRIVATE) + .initializer("sharedPreferencesProvider") + .build(), + ) + addProperty( + PropertySpec.builder("moshi", moshiFqName.asClassName(module), PRIVATE) + .initializer("moshi") + .build(), + ) + addProperty( + PropertySpec.builder("preferences", sharedPreferencesFqName.asClassName(module), PRIVATE) + .delegate( + CodeBlock.builder() + .beginControlFlow("lazy") + .add( + """ + sharedPreferencesProvider.getSharedPreferences("$preferencesName", multiprocess = true, migrate = false) + """.trimIndent(), + ) + .endControlFlow() + .build(), + ) + .build(), + ) + addProperty( + PropertySpec.builder( + "stateAdapter", + jsonAdapterFqName.asClassName(module).parameterizedBy(Toggle.State::class.asClassName()), + PRIVATE, + ) + .delegate( + CodeBlock.builder() + .beginControlFlow("lazy") + .add( + """ + moshi.newBuilder().add(KotlinJsonAdapterFactory()).build().adapter(%T::class.java) + """.trimIndent(), + Toggle.State::class.asClassName(), + ) + .endControlFlow() + .build(), + ) + .build(), + ) + + addFunctions(createToggleStoreImplementation(module)) + }.build(), + ).build() + } + private fun createToggleStoreImplementation(module: ModuleDescriptor): List { + return listOf( + FunSpec.builder("set") + .addModifiers(OVERRIDE) + .addParameter("key", String::class.asClassName()) + .addParameter("state", Toggle.State::class.asClassName()) + .addCode( + CodeBlock.of( + """ + coroutineScope.launch(dispatcherProvider.io()) { + preferences.edit(commit = true) { putString(key, stateAdapter.toJson(state)) } + } + """.trimIndent(), + ), + ) + .build(), + FunSpec.builder("get") + .addModifiers(OVERRIDE) + .addParameter("key", String::class.asClassName()) + .addCode( + CodeBlock.of( + """ + return preferences.getString(key, null)?.let { + stateAdapter.fromJson(it) + } + """.trimIndent(), + ), + ) + .returns(Toggle.State::class.asClassName().copy(nullable = true)) + .build(), + ) + } + + private fun ClassReference.Psi.isContributesActivePlugin(): Boolean { + return this.annotations.firstOrNull { it.fqName == ContributesActivePlugin::class.fqName } != null + } + + private fun ClassReference.Psi.isContributesActivePluginPoint(): Boolean { + return this.annotations.firstOrNull { it.fqName == ContributesActivePluginPoint::class.fqName } != null + } + + @OptIn(ExperimentalAnvilApi::class) + private fun AnnotationReference.defaultActiveValueOrNull(): Boolean? = argumentAt("defaultActiveValue", 2)?.value() + + @OptIn(ExperimentalAnvilApi::class) + private fun AnnotationReference.priorityOrNull(): Int? = argumentAt("priority", 3)?.value() + + private fun ClassReference.Psi.pluginClassName( + fqName: FqName, + ): ClassName? { + return annotations + .first { it.fqName == fqName } + .argumentAt(name = "boundType", index = 1) + ?.annotation + ?.boundTypeOrNull() + ?.asClassName() + } + + companion object { + private val pluginPointFqName = FqName("com.duckduckgo.common.utils.plugins.PluginPoint") + private val dispatcherProviderFqName = FqName("com.duckduckgo.common.utils.DispatcherProvider") + private val activePluginPointFqName = FqName("com.duckduckgo.common.utils.plugins.ActivePluginPoint") + private val coroutineScopeFqName = FqName("kotlinx.coroutines.CoroutineScope") + private val sharedPreferencesProviderFqName = FqName("com.duckduckgo.data.store.api.SharedPreferencesProvider") + private val moshiFqName = FqName("com.squareup.moshi.Moshi") + private val appCoroutineScopeFqName = FqName("com.duckduckgo.app.di.AppCoroutineScope") + private val sharedPreferencesFqName = FqName("android.content.SharedPreferences") + private val jsonAdapterFqName = FqName("com.squareup.moshi.JsonAdapter") + } +} diff --git a/common/common-utils/src/main/java/com/duckduckgo/common/utils/plugins/ActivePluginPoint.kt b/common/common-utils/src/main/java/com/duckduckgo/common/utils/plugins/ActivePluginPoint.kt new file mode 100644 index 000000000000..e4b0bffb1b60 --- /dev/null +++ b/common/common-utils/src/main/java/com/duckduckgo/common/utils/plugins/ActivePluginPoint.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.common.utils.plugins + +/** A PluginPoint provides a list of plugins of a particular type T */ +interface ActivePluginPoint { + /** @return the list of plugins of type */ + suspend fun getPlugins(): Collection + + interface ActivePlugin { + suspend fun isActive(): Boolean = true + } +} diff --git a/feature-toggles/feature-toggles-impl/build.gradle b/feature-toggles/feature-toggles-impl/build.gradle index 68ef084c9adc..c442fa25c66d 100644 --- a/feature-toggles/feature-toggles-impl/build.gradle +++ b/feature-toggles/feature-toggles-impl/build.gradle @@ -43,7 +43,10 @@ dependencies { testImplementation project(':common-test') testImplementation project(':app-build-config-api') testImplementation project(':privacy-config-api') + testImplementation project(':data-store-api') + testImplementation project(':data-store-test') testImplementation "org.mockito.kotlin:mockito-kotlin:_" + testImplementation "com.squareup.moshi:moshi-kotlin:_" testImplementation Testing.robolectric testImplementation AndroidX.test.ext.junit testImplementation Square.retrofit2.converter.moshi diff --git a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/ContributesActivePluginPointCodeGeneratorTest.kt b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/ContributesActivePluginPointCodeGeneratorTest.kt new file mode 100644 index 000000000000..7cc3b92fdf66 --- /dev/null +++ b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/ContributesActivePluginPointCodeGeneratorTest.kt @@ -0,0 +1,280 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.feature.toggles.codegen + +import com.duckduckgo.anvil.annotations.ContributesPluginPoint +import com.duckduckgo.anvil.annotations.ContributesRemoteFeature +import com.duckduckgo.anvil.annotations.PriorityKey +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.data.store.api.FakeSharedPreferencesProvider +import com.duckduckgo.data.store.api.SharedPreferencesProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.RemoteFeatureStoreNamed +import com.duckduckgo.feature.toggles.api.Toggle +import com.squareup.moshi.Moshi +import kotlin.reflect.KClass +import kotlin.reflect.full.functions +import kotlinx.coroutines.CoroutineScope +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class ContributesActivePluginPointCodeGeneratorTest { + + @get:Rule var coroutineRule = CoroutineTestRule() + + private val sharedPreferencesProvider = FakeSharedPreferencesProvider() + private val moshi = Moshi.Builder().build() + + @Test + fun `generated plugins have right annotations`() { + val clazz = Class + .forName("com.duckduckgo.feature.toggles.codegen.BarActivePlugin_ActivePlugin") + .kotlin + + assertNotNull(clazz.functions.find { it.name == "isActive" }) + assertTrue(clazz extends MyPlugin::class) + + val priorityAnnotation = clazz.java.getAnnotation(PriorityKey::class.java)!! + assertNotNull(priorityAnnotation) + assertEquals(100, priorityAnnotation.priority) + } + + @Test + fun `test generated bar remote features`() { + val clazz = Class + .forName("com.duckduckgo.feature.toggles.codegen.BarActivePlugin_ActivePlugin_RemoteFeature") + + assertNotNull(clazz.methods.find { it.name == "self" && it.returnType.kotlin == Toggle::class }) + assertNotNull(clazz.methods.find { it.name == "pluginBarActivePlugin" && it.returnType.kotlin == Toggle::class }) + + assertNotNull( + clazz.kotlin.functions.firstOrNull { it.name == "self" }!!.annotations.firstOrNull { it.annotationClass == Toggle.DefaultValue::class }, + ) + assertNotNull( + clazz.kotlin.functions.firstOrNull { it.name == "pluginBarActivePlugin" }!!.annotations + .firstOrNull { it.annotationClass == Toggle.DefaultValue::class }, + ) + assertTrue(clazz.kotlin.java.methods.find { it.name == "self" }!!.getAnnotation(Toggle.DefaultValue::class.java)!!.defaultValue) + assertTrue( + clazz.kotlin.java.methods.find { it.name == "pluginBarActivePlugin" }!!.getAnnotation(Toggle.DefaultValue::class.java)!!.defaultValue, + ) + + val featureAnnotation = clazz.kotlin.java.getAnnotation(ContributesRemoteFeature::class.java)!! + assertEquals(AppScope::class, featureAnnotation.scope) + assertEquals("pluginPointMyPlugin", featureAnnotation.featureName) + val expectedClass = Class + .forName("com.duckduckgo.feature.toggles.codegen.BarActivePlugin_ActivePlugin_RemoteFeature_MultiProcessStore") + assertEquals(expectedClass.kotlin, featureAnnotation.toggleStore) + } + + @Test + fun `test generated foo remote features`() { + val clazz = Class + .forName("com.duckduckgo.feature.toggles.codegen.FooActivePlugin_ActivePlugin_RemoteFeature") + + assertNotNull(clazz.methods.find { it.name == "self" && it.returnType.kotlin == Toggle::class }) + assertNotNull(clazz.methods.find { it.name == "pluginFooActivePlugin" && it.returnType.kotlin == Toggle::class }) + + assertNotNull( + clazz.kotlin.functions.firstOrNull { it.name == "self" }!!.annotations.firstOrNull { it.annotationClass == Toggle.DefaultValue::class }, + ) + assertNotNull( + clazz.kotlin.functions.firstOrNull { it.name == "pluginFooActivePlugin" }!!.annotations + .firstOrNull { it.annotationClass == Toggle.DefaultValue::class }, + ) + assertTrue(clazz.kotlin.java.methods.find { it.name == "self" }!!.getAnnotation(Toggle.DefaultValue::class.java)!!.defaultValue) + assertFalse( + clazz.kotlin.java.methods.find { it.name == "pluginFooActivePlugin" }!!.getAnnotation(Toggle.DefaultValue::class.java)!!.defaultValue, + ) + + val featureAnnotation = clazz.kotlin.java.getAnnotation(ContributesRemoteFeature::class.java)!! + assertEquals(AppScope::class, featureAnnotation.scope) + assertEquals("pluginPointMyPlugin", featureAnnotation.featureName) + val expectedClass = Class + .forName("com.duckduckgo.feature.toggles.codegen.FooActivePlugin_ActivePlugin_RemoteFeature_MultiProcessStore") + assertEquals(expectedClass.kotlin, featureAnnotation.toggleStore) + } + + @Test + fun `test generated plugin point`() { + val clazz = Class + .forName("com.duckduckgo.feature.toggles.codegen.Trigger_MyPlugin_ActivePluginPoint") + + val featureAnnotation = clazz.kotlin.java.getAnnotation(ContributesPluginPoint::class.java)!! + assertEquals(AppScope::class, featureAnnotation.scope) + assertEquals(MyPlugin::class, featureAnnotation.boundType) + } + + @Test + fun `test generated plugin point remote feature`() { + val clazz = Class + .forName("com.duckduckgo.feature.toggles.codegen.MyPlugin_ActivePluginPoint_RemoteFeature") + + assertNotNull(clazz.methods.find { it.name == "self" && it.returnType.kotlin == Toggle::class }) + + assertNotNull( + clazz.kotlin.functions.firstOrNull { it.name == "self" }!!.annotations.firstOrNull { it.annotationClass == Toggle.DefaultValue::class }, + ) + assertTrue(clazz.kotlin.java.methods.find { it.name == "self" }!!.getAnnotation(Toggle.DefaultValue::class.java)!!.defaultValue) + + val featureAnnotation = clazz.kotlin.java.getAnnotation(ContributesRemoteFeature::class.java)!! + assertEquals(AppScope::class, featureAnnotation.scope) + assertEquals("pluginPointMyPlugin", featureAnnotation.featureName) + val expectedClass = Class + .forName("com.duckduckgo.feature.toggles.codegen.MyPlugin_ActivePluginPoint_RemoteFeature_MultiProcessStore") + assertEquals(expectedClass.kotlin, featureAnnotation.toggleStore) + } + + @Test + fun `test generated triggered plugin point`() { + val clazz = Class + .forName("com.duckduckgo.feature.toggles.codegen.Trigger_TriggeredMyPluginTrigger_ActivePluginPoint") + + val featureAnnotation = clazz.kotlin.java.getAnnotation(ContributesPluginPoint::class.java)!! + assertEquals(AppScope::class, featureAnnotation.scope) + assertEquals(TriggeredMyPlugin::class, featureAnnotation.boundType) + } + + @Test + fun `test generated triggered plugin point remote feature`() { + val clazz = Class + .forName("com.duckduckgo.feature.toggles.codegen.TriggeredMyPluginTrigger_ActivePluginPoint_RemoteFeature") + + assertNotNull(clazz.methods.find { it.name == "self" && it.returnType.kotlin == Toggle::class }) + + assertNotNull( + clazz.kotlin.functions.firstOrNull { it.name == "self" }!!.annotations.firstOrNull { it.annotationClass == Toggle.DefaultValue::class }, + ) + assertTrue(clazz.kotlin.java.methods.find { it.name == "self" }!!.getAnnotation(Toggle.DefaultValue::class.java)!!.defaultValue) + + val featureAnnotation = clazz.kotlin.java.getAnnotation(ContributesRemoteFeature::class.java)!! + assertEquals(AppScope::class, featureAnnotation.scope) + assertEquals("pluginPointTriggeredMyPlugin", featureAnnotation.featureName) + } + + @Test + fun `test generated triggered foo remote features`() { + val clazz = Class + .forName("com.duckduckgo.feature.toggles.codegen.FooActiveTriggeredMyPlugin_ActivePlugin_RemoteFeature") + + assertNotNull(clazz.methods.find { it.name == "self" && it.returnType.kotlin == Toggle::class }) + assertNotNull(clazz.methods.find { it.name == "pluginFooActiveTriggeredMyPlugin" && it.returnType.kotlin == Toggle::class }) + + assertNotNull( + clazz.kotlin.functions.firstOrNull { it.name == "self" }!!.annotations.firstOrNull { it.annotationClass == Toggle.DefaultValue::class }, + ) + assertNotNull( + clazz.kotlin.functions.firstOrNull { it.name == "pluginFooActiveTriggeredMyPlugin" }!!.annotations + .firstOrNull { it.annotationClass == Toggle.DefaultValue::class }, + ) + assertTrue(clazz.kotlin.java.methods.find { it.name == "self" }!!.getAnnotation(Toggle.DefaultValue::class.java)!!.defaultValue) + assertFalse( + clazz.kotlin.java.methods.find { it.name == "pluginFooActiveTriggeredMyPlugin" }!! + .getAnnotation(Toggle.DefaultValue::class.java)!!.defaultValue, + ) + + val featureAnnotation = clazz.kotlin.java.getAnnotation(ContributesRemoteFeature::class.java)!! + assertEquals(AppScope::class, featureAnnotation.scope) + assertEquals("pluginPointTriggeredMyPlugin", featureAnnotation.featureName) + val expectedClass = Class + .forName("com.duckduckgo.feature.toggles.codegen.FooActiveTriggeredMyPlugin_ActivePlugin_RemoteFeature_MultiProcessStore") + assertEquals(expectedClass.kotlin, featureAnnotation.toggleStore) + } + + @Test + fun `test generated plugin multiprocess toggle store`() { + val clazz = Class + .forName("com.duckduckgo.feature.toggles.codegen.FooActiveTriggeredMyPlugin_ActivePlugin_RemoteFeature_MultiProcessStore") + + val getMethod = clazz.methods.find { it.name == "get" }!! + assertEquals(Toggle.State::class, getMethod.returnType.kotlin) + assertEquals(listOf(String::class.java), getMethod.parameters.map { param -> param.type }.toList()) + + val setMethod = clazz.methods.find { it.name == "set" }!! + assertEquals(Void::class, setMethod.returnType.kotlin) + assertEquals(listOf(String::class.java, Toggle.State::class.java), setMethod.parameters.map { param -> param.type }.toList()) + + assertTrue(clazz.kotlin extends Toggle.Store::class) + + val remoteStoreAnnotation = clazz.kotlin.java.getAnnotation(RemoteFeatureStoreNamed::class.java)!! + val expectedClass = Class + .forName("com.duckduckgo.feature.toggles.codegen.FooActiveTriggeredMyPlugin_ActivePlugin_RemoteFeature") + assertEquals(expectedClass.kotlin, remoteStoreAnnotation.value) + } + + @Test + fun `test behavior plugin multiprocess toggle store`() { + val instance = "com.duckduckgo.feature.toggles.codegen.FooActiveTriggeredMyPlugin_ActivePlugin_RemoteFeature_MultiProcessStore" + .createClassForName() as Toggle.Store + + instance.set("foo", Toggle.State(enable = false)) + assertEquals(Toggle.State(enable = false), instance.get("foo")) + } + + @Test + fun `test generated plugin point multiprocess toggle store`() { + val clazz = Class + .forName("com.duckduckgo.feature.toggles.codegen.MyPlugin_ActivePluginPoint_RemoteFeature_MultiProcessStore") + + val getMethod = clazz.methods.find { it.name == "get" }!! + assertEquals(Toggle.State::class, getMethod.returnType.kotlin) + assertEquals(listOf(String::class.java), getMethod.parameters.map { param -> param.type }.toList()) + + val setMethod = clazz.methods.find { it.name == "set" }!! + assertEquals(Void::class, setMethod.returnType.kotlin) + assertEquals(listOf(String::class.java, Toggle.State::class.java), setMethod.parameters.map { param -> param.type }.toList()) + + assertTrue(clazz.kotlin extends Toggle.Store::class) + + val remoteStoreAnnotation = clazz.kotlin.java.getAnnotation(RemoteFeatureStoreNamed::class.java)!! + val expectedClass = Class + .forName("com.duckduckgo.feature.toggles.codegen.MyPlugin_ActivePluginPoint_RemoteFeature") + assertEquals(expectedClass.kotlin, remoteStoreAnnotation.value) + } + + @Test + fun `test behavior plugin point multiprocess toggle store`() { + val instance = "com.duckduckgo.feature.toggles.codegen.MyPlugin_ActivePluginPoint_RemoteFeature_MultiProcessStore" + .createClassForName() as Toggle.Store + + instance.set("foo", Toggle.State(enable = false)) + assertEquals(Toggle.State(enable = false), instance.get("foo")) + } + + private infix fun KClass<*>.extends(other: KClass<*>): Boolean = + other.java.isAssignableFrom(this.java) + + private fun String.createClassForName(): Any { + return Class.forName(this) + .getConstructor( + CoroutineScope::class.java, + DispatcherProvider::class.java, + SharedPreferencesProvider::class.java, + Moshi::class.java, + ).newInstance( + coroutineRule.testScope, + coroutineRule.testDispatcherProvider, + sharedPreferencesProvider, + moshi, + ) + } +} diff --git a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/TestActivePlugins.kt b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/TestActivePlugins.kt new file mode 100644 index 000000000000..7769287e0322 --- /dev/null +++ b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/TestActivePlugins.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.feature.toggles.codegen + +import com.duckduckgo.anvil.annotations.ContributesActivePlugin +import com.duckduckgo.anvil.annotations.ContributesActivePluginPoint +import com.duckduckgo.common.utils.plugins.ActivePluginPoint +import com.duckduckgo.di.scopes.AppScope +import javax.inject.Inject + +@ContributesActivePluginPoint( + scope = AppScope::class, +) +interface MyPlugin : ActivePluginPoint.ActivePlugin { + fun doSomething() +} + +interface TriggeredMyPlugin : ActivePluginPoint.ActivePlugin { + fun doSomething() +} + +@ContributesActivePluginPoint( + scope = AppScope::class, + boundType = TriggeredMyPlugin::class, +) +private interface TriggeredMyPluginTrigger + +@ContributesActivePlugin( + scope = AppScope::class, + boundType = TriggeredMyPlugin::class, + defaultActiveValue = false, +) +class FooActiveTriggeredMyPlugin @Inject constructor() : TriggeredMyPlugin { + override fun doSomething() { + } +} + +@ContributesActivePlugin( + scope = AppScope::class, + boundType = MyPlugin::class, + defaultActiveValue = false, +) +class FooActivePlugin @Inject constructor() : MyPlugin { + override fun doSomething() { + } +} + +@ContributesActivePlugin( + scope = AppScope::class, + boundType = MyPlugin::class, + priority = 100, +) +class BarActivePlugin @Inject constructor() : MyPlugin { + override fun doSomething() { + } +} + +@ContributesActivePlugin( + scope = AppScope::class, + boundType = MyPlugin::class, + priority = 50, +) +class BazActivePlugin @Inject constructor() : MyPlugin { + override fun doSomething() { + } +} diff --git a/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/PrivacyConfigPersister.kt b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/PrivacyConfigPersister.kt index f06ccea93e8e..7ac8e6798adc 100644 --- a/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/PrivacyConfigPersister.kt +++ b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/PrivacyConfigPersister.kt @@ -115,7 +115,7 @@ class RealPrivacyConfigPersister @Inject constructor( // Then feature flags jsonPrivacyConfig.features.forEach { feature -> feature.value?.let { jsonObject -> - privacyFeaturePluginPoint.getPlugins().firstOrNull { feature.key == it.featureName }?.let { featurePlugin -> + for (featurePlugin in privacyFeaturePluginPoint.getPlugins().filter { feature.key == it.featureName }) { featurePlugin.store(feature.key, jsonObject.toString()) } } diff --git a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigPersisterTest.kt b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigPersisterTest.kt index 8416bbaf64a0..c87fcd7204e8 100644 --- a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigPersisterTest.kt +++ b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigPersisterTest.kt @@ -155,6 +155,29 @@ class RealPrivacyConfigPersisterTest { assertEquals(1, plugin.count) } + @Test + fun whenPersistPrivacyConfigAndMultiplePluginMatchesFeatureNameThenCallThemAll() = + runTest { + val differentPluginPoint = FakePrivacyFeaturePluginPoint(listOf(FakePrivacyFeaturePlugin(), FakePrivacyFeaturePlugin())) + // override + val testee = + RealPrivacyConfigPersister( + differentPluginPoint, + variantManagerPlugin, + mockTogglesRepository, + unprotectedTemporaryRepository, + privacyRepository, + db, + mockPrivacyConfigUpdateListener, + sharedPreferences, + ) + testee.persistPrivacyConfig(getJsonPrivacyConfig()) + + for (plugin in differentPluginPoint.getPlugins()) { + assertEquals(1, (plugin as FakePrivacyFeaturePlugin).count) + } + } + @Test fun whenPersistPrivacyConfigAndVersionIsLowerThanPreviousOneStoredThenDoNothing() = runTest { From d31450ccc70faa9f2c76785270f88755b0d26b19 Mon Sep 17 00:00:00 2001 From: Noelia Alcala Date: Thu, 23 May 2024 15:09:26 +0100 Subject: [PATCH 08/17] Migrate comparison chart to code (#4577) Task/Issue URL: https://app.asana.com/0/1201807753394693/1207315451953296/f ### Description Migrate comparison chart to code so it can fetch translations ### Steps to test this PR _Pre steps_ Go to `ExtendedOnboardingExperiment` and set `fun isComparisonChartEnabled()` to always return `true` - [x] Fresh install - [x] Check comparison chart looks correct _Translations_ - [x] Change your device language - [x] Install from branch - [x] Check comparison chart is translated ### No UI changes --- .../page/experiment/ExperimentWelcomePage.kt | 8 +- .../res/drawable-night/comparison_chart.png | Bin 134024 -> 0 bytes .../res/drawable-nodpi/comparison_chart.png | Bin 127961 -> 0 bytes app/src/main/res/drawable/cross_24.xml | 13 + .../drawable/ic_chrome_comparison_chart.png | Bin 0 -> 13013 bytes .../res/drawable/ic_ddg_comparison_chart.png | Bin 0 -> 10924 bytes app/src/main/res/drawable/status_check.xml | 14 + .../content_onboarding_welcome_experiment.xml | 3 +- ...periment_pre_onboarding_dax_dialog_cta.xml | 5 + .../pre_onboarding_comparison_chart.xml | 240 ++++++++++++++++++ 10 files changed, 278 insertions(+), 5 deletions(-) delete mode 100644 app/src/main/res/drawable-night/comparison_chart.png delete mode 100644 app/src/main/res/drawable-nodpi/comparison_chart.png create mode 100644 app/src/main/res/drawable/cross_24.xml create mode 100644 app/src/main/res/drawable/ic_chrome_comparison_chart.png create mode 100644 app/src/main/res/drawable/ic_ddg_comparison_chart.png create mode 100644 app/src/main/res/drawable/status_check.xml create mode 100644 app/src/main/res/layout/pre_onboarding_comparison_chart.xml diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/experiment/ExperimentWelcomePage.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/experiment/ExperimentWelcomePage.kt index 6c6015729e45..0a2d125c399f 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/experiment/ExperimentWelcomePage.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/experiment/ExperimentWelcomePage.kt @@ -175,21 +175,21 @@ class ExperimentWelcomePage : OnboardingPageFragment(R.layout.content_onboarding ctaText = it.getString(R.string.preOnboardingDaxDialog2Title) binding.daxDialogCta.hiddenTextCta.text = ctaText.html(it) binding.daxDialogCta.dialogTextCta.textInDialog = ctaText.html(it) - binding.daxDialogCta.experimentDialogContentImage.alpha = MIN_ALPHA - binding.daxDialogCta.experimentDialogContentImage.show() binding.daxDialogCta.primaryCta.alpha = MIN_ALPHA - binding.daxDialogCta.experimentDialogContentImage.setImageResource(R.drawable.comparison_chart) + binding.daxDialogCta.comparisonChart.root.show() + binding.daxDialogCta.comparisonChart.root.alpha = MIN_ALPHA scheduleTypingAnimation { binding.daxDialogCta.primaryCta.text = it.getString(R.string.preOnboardingDaxDialog2Button) binding.daxDialogCta.primaryCta.setOnClickListener { viewModel.onPrimaryCtaClicked(COMPARISON_CHART) } ViewCompat.animate(binding.daxDialogCta.primaryCta).alpha(MAX_ALPHA).duration = ANIMATION_DURATION - ViewCompat.animate(binding.daxDialogCta.experimentDialogContentImage).alpha(MAX_ALPHA).duration = ANIMATION_DURATION + ViewCompat.animate(binding.daxDialogCta.comparisonChart.root).alpha(MAX_ALPHA).duration = ANIMATION_DURATION } } CELEBRATION -> { binding.daxDialogCta.dialogTextCta.text = "" + binding.daxDialogCta.comparisonChart.root.gone() binding.daxDialogCta.primaryCta.alpha = MIN_ALPHA ctaText = it.getString(R.string.preOnboardingDaxDialog3Title) binding.daxDialogCta.hiddenTextCta.text = ctaText.html(it) diff --git a/app/src/main/res/drawable-night/comparison_chart.png b/app/src/main/res/drawable-night/comparison_chart.png deleted file mode 100644 index 2c88bc6d1b12b1397aaade48fe0e057ed8045e65..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 134024 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 y_Li;1 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 D4I0cg diff --git a/app/src/main/res/drawable-nodpi/comparison_chart.png b/app/src/main/res/drawable-nodpi/comparison_chart.png deleted file mode 100644 index cecc018bf1aa8527de8ecff70e2f5f1e6824f082..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 127961 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 z z&WOe#xb%Q~)T}{Lepcc1KgjET-t}~Mr+4^0l*maap9vLH-M7?4!%8LSJgd!&F@p+~pQ$)AHj_&k+LxIDH1k4?5FK?4R@iUrQ|CnJ^@f z)X(ebMDR+orU>u}ibDZyfzMA~+Pb>psx&8@z}UBj&B_yO2JGD||z8B0VpEUjZFXW!r<#*HgX3(ArlRgnv?;?W?gl08OtEAve=S7gWrrP(dD9 zK~exFqTWq$$)CxcE4zF5@i3;_Oi6MwelMChXT5a{8|iNrN1+xb6`St)SY89uRR<68W$ZqR90xTcxRDifH= z@o9FB>Fn(6aBKm^Sl6&DQrnaAgE-snu^C0Lu*=^71z>PEG2lS{VW2XRQ1zRL!E>D= zU3}c{#$h-R1U9s`-Zof@^q*!e*rg0)%SpYFCdIqfMT|OO`?<2>$kw*_qwX_(KCsTx zMtM?rbmbW1&hr&~MC`;PK$>Pe>AX;V+;gz8!7(;AHc?YcARO)Xo`&A{du-)vmt-hg z#L^M;eHpk;(3sbA<-XV&ai`sVE5&QfS!jV>-IlyW6=+i#ZLtDbUi2Bj;y7Gu za$f5WICyK#rjeGOPDdbe7S8cm(vXPQ6yWEu^fb)!rJzE4TI16Be~W>{=Z_Z>{1(>j zdu4}SqSmW~30{c6B6A^HUT*iO5bLh4b{Z{KrRA;v-SMnYCI0x$ByGx0#{hBAlJkLh z|LLik3$`>?I3gyMEOxp9V2LlAx9G4-c5^^y`02RRI21=Zb0IGo!fCzSXm6l$#|8@S z#O>d#XOJdfdRfoYoN1TrOF`RPn~X<(DKzIy@S&}yW&(D#xkr^_wCx``Kx`Gm@pULY zP{;x>if%?>PXz|*3db?V1F0MGEGQDhIEKIv!_!%*#aMp!AL|YbL|#m~`T3+s4ba^EzspS41_pnb9$V zzL-9%FqZ{1A60Y@1wwi?EYy1rJ(Url91IaYIyj-&o2y-Z`0}~#<8+MPjfn#|IEnjC z?V)yJ0T*9Gh#y)FsGeAO{huuEr;2yvvZ;}&T_h{}c)LBvIy|n1dcGg1U!0}xH?^+Z z@Q~4g>ZQ(ny%&#Gy9{e2dQcii_#t;=rzC9Zh=B;5xQ>N-x8Axq-2R#IK{A0v@mV68 z|5fsV4I|)6LOjsU zp2hv3^8$m)a%L_gH&9`9c3JgsjFxMN&5A9@}Gz3>dnS9`jLlt&i%myh)CZ)$4_89a394loL3nap~cng4{ zt%kxbC=N};8^zlwnk7WpxfJrk-omWt=xBA-CmvEdY9WW+$h4VnCC^;7)3^nN1*+k^ ztm5@-no_vilzSMK(oskl6v-jn(4laFpZDMW8{Hga;>=FHNKH@}JpatU45w;#Sfn-6 zj#CVMe0(%m2$wJi;*p1*fq7mIedb%5AVNG*%SjRt0t|W$5%pL0h(R?Q&~ODo)D2~6 zHi+D|DHa8N<;V)@?){v%PH_XJwaUQ)5s-~0`*)-A57QZ<8K^>O6f{>vJ2F?Ijz zJo_lkE+@=%V-Ee%1WyUJhW8Y`8F9_jTe@K6H1}VwgEY(^XXT=MgdLveor`uR*5vC& ztrKmY_Jd@ST(%&V!`)@OIKjq;$}ZaZ?bJD(Jb*CQ*nq$=Zt;cg&<_Oz*N-rwD|@~r z4HYlitePmo(2@)YmejjxCKb{mLS=G*GGskRN|5jSvK4JFV#OIYc+d#{lQ6`6PgAs5 zjzAoQS$bHq>IY|mHC@+PszuQ280)!st!U`v2=!~at(|v2n1wKHzuZwr$OJ>s%2Gp% z-qYGu9v7crF17}?vQTFk7}A#Ggd4*}F*wy|~!1+^HhU z1j7k`Fq6D3gCsj{XDEYr$GjeJuU!9B^PJr0_jVP!v18vM`tIRZ3d&|Y|znbkrp zhw%JL(?VU?Y5UhB!OgnU%*ASGqH$CPvX0_iBVi#!jrGaO`oxK4n(TeR_R!GtpX)bA z{XqhJVSW$2h5oP8-s1ntd)huFT|RrdiM@9p?(OZJ8B%cQKL}z5)Ow$puLJ<7p_do= z&murvE)4?TS`e!vISU^jDuI0M0gzcrng4K*(r+|0KLc+73O6({jOu0!%lFjuJUn>) zx1mu?Q_PglbPTml{S>wZeTk&DGxQfKm~O5!tTRQSgg@baZo5XFDvMoAr|tXq?+xBa zi+c>Sl+fe6MMm&~SM4jl_A_0`{rliY)#iYFn+v`r0s%%?*82(_G(*Sq_}Z_iYi~dN z$Co%K1tDI`HS!*W%Lh^YIyI9TM$TO8Kefx^{um$G!+`valV5JOf1@vQW2C{hk@U!` z=9&Ab?K(eHLv8w*saCNyo5hZ%wS{LhD|Sh@kdjSH7kIpz$KE!w9-_NVQmAFGT*Iz>zw;&nk3Iyc}_X~0Ofhz~rt(S@?cb+qD9#2{X%5us~P&8C$0wvF18 zDw`GU;=uy9lP_63MJ%`RXd{2}siRxA0B7;n%uT{>mH&~+oH#>Pc6QPJAYNQtTw%8< zKC^YG4dC+)c%Pe=(zwr`4No`%c7M=IFgQB)g{~Snl=LqUiP0SzThCX{7k*UN!|6-- zlyT5kN($#FbF&FvWydZtQ>>t-r^|PC_fYY|Vg&SOsz90VJpxv1%$DVOyz=hpsCV&L zguK>tgWEWCgI{#h+q&_0)gnA1=UzDO5>SEDsGfslS^h!igzEum7<`QR?n|-J3a_a^ zxs3M({_r%OFNjm~rQ9B>IW*RZ7HDGjvtut=C>2wKH-nW1vp0mtc1`FCQ^Qm@xJnMK^yj$9hIu7(-ei@cdHf%AMyvmLPGK2tMhE z{ZR{6MNQ4$*orz?{YMT4c9?fv$!R}ka6V>;(yJ#;2h`wWy(uSV{l)gzVDQ1B?T1TT zjcsD!kEe9^4f&6iaI>eI`d#}UTJ2-}7{a$;xDmb)*>=6&liN)W@oG77q4F$&apO6^@!G+^XS3uGSr?5;=cUa?xAPbqC9u(o102etj zLB6~43fuF+>SB$E96m<_eYorukH>w453ZUMU;snaqqyE!Nm_RVQVxp`xEE$zNupz< z*$m2KaVK#-$+E>tKKvs_c42UI)|yt$u-M4Ug;F>B!#p)~bxRU79E&AXIzOg$9drMd zdBs1;+(Wug^b@+ALnDMqX}HIn=UN}I;MPvvk5_yJ;yGA3GdC$T>a`R%Zns=pm9;Z? zbj=MBH25QubGsvGZ#v_Mon)eTt5|Alet)^Q36KA%9IkZe>VDDF5qto6&SXU2>G!zqG>+i&ZxVTf4R5eDEF3%A3#t9{bug#l^BBa5h$; z_$HLN_*I=i-EgAV6=1Bc?k<&9Nao9QKG3@H`TJ|>-xy=NxkFkv@qg*Ys7J)%euH*BJntG9zWOVr{ibHs*yyV2N?mxul0 zYe|SZ3Q7^dcIzw=mkNa{OKwTH=QoFop`}vJ&rgq=buHr$nm$Q4uB5Lp89|cJrHiIO zB{CALvi9q2o^CS)0wN;p_8Njgp?u$1-xi4cN;Zo4vrVDC zgzodRP!#Ozf?TysE*U}s0*2X_k0g?BvN_E{KF@coGC0FI=~43Sp!gHObt{g4e@g{Q zOdq}=7|uOY z3t?($IZk(*R!bD8r+(!~fd?eHZllU<5lzfX(}dwlXjk%5{H%hg>AD6<93nD4lzJO8 zjrGr`_2!DQGt*V+%--Qu_awE}GhqaC1kbRpiBJD>t=Y@i>y5sx3c;`6zV-M$ z-R;WB9IHfA@6g%)Krm8m)EnN%R7;Ue)VqJ=jzb*rHr*|yiHHxcFpLL-02C~;FKdDb zTX)8D*&GXrX(MV8j!0}%obGYK{W;eYN#+HZ$x>yns!;*+ai6Z{7g_x!y5`}GmOYUv z&zlxnk65V6irW*48f?SG5KG|DzCHR(qPyM|DVx}X1OU47uAJA=sk)Kh^r&gfEvIQH zD4<~xfLz|%ACqfMS(J+D!^&#Z&qkB!S`yMgb$yyoS67h5>(>((c$~|F787}*HixBB zEJ{jB_&B$96#S*bBO{K1#V}T2lz!4^9HBH%(OYK3_*sYi|4QSXNShJ7>oSd)qXUkL zctPH^aF7m$5Ai=MaY1;Y0N&U@T-RW#d19kYa=2wwG$qkVjSQTIv9&TjR!<+Nl8zB% zL`V-wNgCNk($Kc9Kcz3CDd8lLHJ3Ma&sn(vftIn*wKM zpN7n4=e25G>aLfz_5}zav#Pp~j8N2Q3%(*Qt}3p|AdT`;#sWSV8%m!2x;9N6+l+cW zW+dmet>dV^Y}PzZKY$3;qM($uC_YRgnmqOzt9^LgB0;oRD=|?ga`uBai01bX1#80LoH=M4u8)y^*(8}>T>Rd%!3;!HKo#DEka zlJA>^#uGC8`LmUQWaXVOKUESna^PESAkXNaHU(Szy80@-Z>>OI`jOfH|v^q39*;BTx~N_0PMi|9h%^t4v!*WTovTX zeyJXON;h~uSXRJdoe6c&e*Z(5-2>uImQ2rE>z-uXd&ApcKEIyyfdydUG-*H@qG25x z{~1`hlMaA&-^=X#?oaT1p{_?Pki<9sHtz`Mi};UtUmxEYw`*gx{S%dFOiKD0gVO7w zNiH~io}}AgExcZ$kXUo{G=NMT5LBK#t(`k7MXbrRytIT@RaJF6 z8e4*kIEP1LB4hTdj_%v%vv6(LUf{fmSyxxr?CNU#aJT_ZBR0F2hlg#CfEJGat2Kbg z0Qz27o$Cc*Ipnt%k7g;dzWeFAWS!TEiCU9aO?x}(l&Oioe^>QsY_@u7akP!&9E5@T zbc>U0&koXH92|T2h+7as!}Zfx9D49?s_p^F#9Z|~>(`Ro;K$XQZr#$3pF)}za9BOQ zJ{8eS61fTu0E+bAd-6`_sXe{C;(g_B=8)Rn!}}WlC&ALhw!I7$mD~7eNOvKuQu>rU z`5S3^??YTlaryRlH!KE#y6kPu-mdb6uP;F=&sGWKE8H*QLHvqEhk{O&?(#F~ia;3N zuF=ZmILrC=T@oP@q444B1Z`{nqVyGR;M z1(veT!-u0t|Dag;qV2i5MsvAOyl8__^3y~}WXo9iN8Xx&#uveX5`P@0veqCdn$lQEF_`n2~#@fD1*8jvt+^FhSNXKLxNLrYUDe2{8I- zH!>3h8a*g_HH_Lo=vLOmtx;j=qqbkSjvr`ze)kK6XlJM;SSDs&bc^Pd<9Lo>`+Z@%8&*C(zrQA?SE1f+ zO@Jg!ckPWbORVyVQe5J&0H*x#XaZVfXlCcYDENCkj~)9}Yodz5p{vtZLq^+T61g~< z=+lZdFxqZsTm&w^Cgz`V`v&W=FS0)v8pa+m+zZ3;l-w`Fx#eTy8i@;H`{e=R(Y$Xc zti7U0OE6H)aV}NRi&o{(%%#u@?jM1`1vvc{ePa@I8?>wOm{)?Nb>e&?{(u>*PmS(%U@o%{Q+94WXRll_(QTGhV$wwIv|dGr(bJc3KevS+&ME zqNE=SmNqx1MUdUXDe{dMGWh%Gdl?L2*$(`d8k(+N-7_n=RH&HvQ!orKj{0KDA(f4e zbK1+V8E&#wZ#0-lzxyn;8vht5Tr5fB^WeeSU~bTxZOd|cBB#(fnR3~bjZVVVf>WB} znH^@?b*U>p7{CS0dai&&mfUK)LVLyiW*(6N2@o_3A0OLjOL>kYUWPutM0x$kF*P++ zTSFsQl$(EANO73Z6o&;AoaT2zE+&a7wUE84a9+Sxwv8cPkX4~GJ<2@+ww)=S+8#K8nW@cB|`ik9)Bbl}6~JA~@`fX6pQy4#@NL)7RJE z?7SE+fJYI#DAoq~@TT(+#ayb1a@ZmkC*q^e{T>i9rD8Q1M^zrox<(ru=!U}-^K6ucP z!pJ^aNI-Rv)O%V!NeRi#2Kv1X%s?sGEs$o$8fS_%Lt-P7WMf|>sz^+xT3%6sqK)t2 zI8&lFZ<|<*Xm!|cP923%SoM7$)nb&>oHek0gv$u+O^kJ}3DpJ@a-9Uft@a;#8=Ii~ zj9Pfs3J1;u3leI|%cl?2ed?>K;vz6mWs}na**_T`^odhY7ThTC(ob}Sp;vb#Gy0#J z+U^LROiK~RUMFay#`H#86f`MX4KATv+2=*8BW^^SsT4p}m>6b#;n`k#22gklE^XsR z94LgQv^4tKahl1)_jersDrg@(t&@p~_W0CJr-1eG-=wr4<9F;o1Z+Crjn;fmsIe5( z(%JRFC+{p=NY3$-t=G(AD)%1dILa>OIZIwgN-rwLh8jl`3;+w~Gst`hMPw~;RDK`7 zLni!8qXLx+LAw!zU-fyvzYKrf5X0Nh+zdaEAwOc;Do`>mEg{t)?h3tl1CM`?- z-C(~_>%pTXOL@L3cfkF~MyV`IiE^gqw=e+Y-V#3R5jxL(HitXM#PY-aF+>M3T3}Ef zE$cKUvqkKm^N_k7mSP!&mV3)qbcXPOJy~+ME#|Y+54G67etsVLb2BqrBpb60u=m>9 zZyKALqSDv+18WRM%Y;&@m$vQaJB3SZlh4Oa=udv#OcmN*30b+*e_xB-kMBE9=R@5h zCA15y(=O~@Jytnj8T>K-cwc0fyu_SJohIOY`c-?a4DHH)8DcT`8espE{m36x4FHT; zL{DixX@{qpiyogmqty$6aCgsiG=W;_|J8VamJ9Da)%SXW$jym$#Nb}I{34E;_#;n1 z1|7v%oc;7*8P!=H9{s1h(8i6yXUVy>Cl1pDJX-e8L*MhkRrOFDD1bY=juy{#vj>R- z6&UVur7hq{>SIMVsWU9IxnJ5||hdXhNGZN?GGOZHi&n2>msOE9=#(bYsdASSs&(*Yk z-<_!GuXUZV*ctc}{3iCNh-^`DTBdSJE3En=KI|EFNvM*4?GN*M2s*SB;V@5{)WEO{R@+t2TH^I=$YT z;g&P>C6;LQS06c6|Lb)Yf!-igKC`nn?a$}rtj3?5qEY#X4T!Z8pigojgXc=wRL7oK zNa#}@`Hx;e#3wz0Ynm6h>_bn61Dl$Z^f=vTxerr0*FMH-mvxq!m8WK4_uZA z4@DyhGn`9XO|$|b1Lf>P?3bMLRM(9P-jR=6W7>jl*R_{ZnL##fOKQ);|M;CeSrVkb z!@`xcRtw;)Yu_B9PeY-hsp&nl8763+_@e^#KEc&)Q3Ytj z15Np##=P=Pr709~V0UEY{s(Vi7A6D2tJsxi9y#tWgT6v^^l0Z{Dv4J*Tb7|T;wQfWR2m)ce2;RIjq$o*5eenHd zu8VAbhTrVuF3ZrRDLTH!sCmT^xh_kFlO8~h^8*tLIW&o)FcMp#`gHcI^(MhHaOULs zt;*dwzs;|Q2IghZkTcUsVPHu}c!IsFa%}do(FaZ}9!~rKU71-$62M7uq0o$kb{0q= zU|po%zbjyyRLf&}0)vkcCN^Lxjx!#CK)=+TTt28_P8Wm=`9DmZgp1s#v*Lhv* z$qFO-nRbB-UFQf(eXy0JAC=ZMmrTEa()lnyAhZw`oLY{yDog4Iew2R)*PYae-NJ_H z^@M|h;_t1@R*u0_-F4x9y325{7aX5WqoOm2wk?1%*rar{^ks=ME< zhUvnk36kYB-7)9mWssyc__)ocmoK3o)SO@bBl%rL+)K^P&+kP(0%88em;NH)H%ZLP z%(VcxYYj$!p`VAI45GIBez|{g!5|a4_HZf@Sv)U6tCtN`qo`o-$FV7Q%*x_ghnPdR z;Di#_SgF2?IbOoJ-N#_Y9#*Ga_O-9qtDC(QQ7&JV5bGv9&&=WeB<@sOum!KDh5TFA#O1sJs8eRx)PJX8E%xt4)l^Wjm<|c@1rtXG}cFIjZgScaN zEZVp0qUofXyvF;#uB+rvT@O;bNYw9Dr@m9EIbk`krGByIPiKL9UxhG&b*1$=P< z+hc_qf0})6KJpiU;Zs3n3^|>)j9nW$J;idYxi`fLTaNgY(#3N=EA+wWn%tB5xHJ^D z*LNK(fW>S@x2?TrtV;>+{kxBCN;%S9F-o@zJ}DzjcYPaid#Z%4&GPlAc7(%`D3N`q z3Z{t6C!gE_Hqj0vTHh!f1~v)B5>S6hBt&B0>06Zi)g2HrGB+~)#5+H6L}ec%=e6G! z?>y`M{Fx-3eRyKxvj!S)WVrYy^#$!L8=Q>lvDTxZ8AW1qM1z7UY;*&AatW7K71o*! zj;wzGZ~$8m7^ox*Iw8~oIoO0F0XVL=$@}7iF&Wfk+go7y+`Q;l*wgc~Px3^GDEue{ zi)VDD5>Y`i|M8|U8OE>EI$n^mnr&lLfv?Q~;=UTn2hd}qEd@{?p7a)*`HN0oD56*zP zrzaO~iiR_hAh@53i}-G4m8taJKj1D`$$&Jx`~Xv44I$)iF-~?#-qw)^r9(jFBC#S!rpYrqWi@;D7T(TJ8riKxE;pIlM`~}EWUwVYAdN7{0L)1 z+MC%ZD@O+(Iszt1_7^|qa_F_fUS}QO;(<#C!@o=+DG+t<5jjh;4gJWUuhZOz>khmE zwSCXSf6+Ey)ijvy2^Gp;ley~rFakuUZ*X%8k&7X3s#~yQ?#b)>gNMq@g^MqrVbLg1 zH69lwHYx9OB+&VEJ?T-q_Q7T= zSc)<%Am!Q#GOAvL@Que46-b6B0lIQ{z4JytP-pi7)vBV6igID7^@yxuD0{{Fery}xL=utn;ss^YCT`P`hxZ&x(} z?WzYcF>wZgGEPR*61)j+-u=r!(v^K*UN4rVLQvx(N9xrLDsl6@oiOC33Q>?`jcbpu zm#6n@WfqQXz1Pg6X`}xNyNsNPEP_*Cl(rWX{b#fjVg=vzREo?jqyDagRx7%H;5X>D1JG*gwQ{X4Z0KbZ{ zWcVH+Y~u)C;ny?)24qjbd;F8nW+Ll*uuIb^%b1_u(dEQjha|3oKKWby`ncW2cAA*{nH%|P}Gz->>@&)c)~4{X$#tpUMU_&D%m>vdBa zI>{5NfPz0+`dYRed7(BSyI$=c0H7O-1=q9;zjrr-9v4}xI9 z6ow%aR5?m=bBnU!ViJsH;x|bfK;P_2tXZvqJfC6aw@Z00g`3r1)VP(G zcIrQjX~L7EZmaa_=x!-$N3g!oFb_Ldxfq<99DPhci%)$Zfar4Z3n`Go#L+E7tq3M~ zft>_ew+#8i##3&qP%Y$rvx*0uAQuSO4EFSGgL8q}o1G^K^@&hGoiq@;*168UCYrM{Zw?l&?x9Q(j5%G~>R@iET%0d!(nO=f zZy}&J+h&N|Tr>?k^GC7ZY6DD@!=8?278)V*SnQGK!?EG}QU$;n9^dT|d(oaCttr8zz(H;iC*ykb&M z;IQn^x3t-<)Q5RW&(L1JAlYb{622#TQU>dRQ^g#2r)P4)TLOa@EXFUryY^lg2xf@i z#bBk##GdVHwoipAb&|&R{zES z!ZkpO0b6$J&NQztBy0Ib*Rlj0aW!n5ea1=~K|9C*?nfPAeyq;2%xW>oYCND}*ZwBr z8vzCA1rtrw3sDcXd^Pct(!>iWi4f!oLzt<(K31I$MXRSCZ%rmoAV|{0_1Kp3)p295G(t3z2Iu ziAaJxSJXAE0L(X=R@~s`c4oYIlPODpLbrz}cY5QMfiGU01p94)+tD+;%Og!y|NFKa zNREX(M|NhM8CLo#CAU}4Q7ukQ$-BC`UX${pC&SI(i(X>yZ7W3>D4e`jU~O3(U!h)y z+pOM=?Y3xUGa0dyD4fcc^l5_Lm`(K?){agaTIK!76ozX_;Vz{+sBPR&X4VX=FgE7q znl%t)`4;4zWk2Ql$g+l)O!qdF(t`9o^XM-1%)}X8-}x?{ZxUBAdk&rYz*5*Fgb(5= z%4q3b%cCH9Q^`GVZKu|$LsN~0ug!he&7M4T$kFHsEI?(x)3&iG-tgesvZ5R}+*$=R zurmlF3|cA)h7t)K5uluWp-Eto0llf7_~KxwSY@G(q`2_hyU z!UTJ*{+J=|8e%H_u`evaGO)M9Q_uBJz0*jqm%`0;c=7A|VTae%@t=lcvrRIf);{WJ zDlAl>{(_TpmuYuZGi={(l1$@H%p@UUlThCsaE8L8YxMH#c0IL%qv>+>7CDxQs_jm0 z%gwg0#2)t-$>(qH_edIU%ECqttU@ZWN%NqMt6yN!l8I{Fv7i)9wl-Pi!xsb~}wZ zw;Ex*n>V7`$27S|uOpyVXXZwE{J2X>vn4mlI-iF%yFb$_Yn%E{;BoR;Upn|r%2;1Q z!WdDP9JOED#eS;(>r~vmAR0Ow7xH~VSJ8WRh)bSqu(ED>aAkBwpJGwV2Vu`+GtuDs zW#lBF#Cg_EMQ`sAz(_hScs>#GA&rBcfZMSC{>7yAo^cJgIvV zu^jfgNOi&Q;SSb#6x~P5R`6rY7nxkR&kW$OwE>{>v4xE{@3qZB2YAfKtIk$8FP!@^ zY%9SYbz?-W{(>Ypo%FFq4tIV**d`D6R?G&+Uq83y)Ky{*TtA#`*qv&f%~?a+limCM z&?n95OuO?`)z@}NK>DMYmX%kNa66hTfUY4Oj?{1cD&(A}i6So=98NU)3nktp#$UQv z`x|yq9$4rk*#y1kNbs26X!jBI-Kf~b9HEB;4d$auwq+lg83t6f)fNWhN<41hY(Lo8 zps`&8cwP7xLtpZE69h2YoSB`inu(Q~Q{b~!rP!tS;hF7iNbv&rr_@TjiKq|yXNB%- z1M(7S@K^63Iart`Vf^_K$@QQ^z_#Lo2Khv~8bD--R9X4q4%E{PKKoM@Z|ZEO$o?83 zuDgkwbB513n=efoHW87hzRQC!{LWKh*aP~*iA+$d?9G2};Du#vm!LL53*({PkBdGR z;f#u(hZj>9uHQR(UVuXKy zY>S&Lz02Mxx(zEXxI}8O#7x*L2s|JRJvNU7yw}MEvsN^h2XUq8mmQek^7NdXMqNFO^%ra4bi3vrkJH$Br(8c)jAr&JPEzq;*ROrExr_8z*97 zo>j}t?&d~ctG6fH*RhaDsgy;JDKJJUAhC0LgYnzv+)D1cXyAJBN*p+h1d3^h6zfz$ zxqqe!ss{R9q-g{<%Po<^T29A)?`ubck%Aq$yK7+%{y^$?cbN6k%Rq2p6j1&WUww?&b&PYiE;5+USw%lWAP@l~aZP zSo3u|llACb)u(v&J`5EeyzEeoqVr{;kM6ik*`!{&%#QgnHO)UAHg#t5^Bk^NZlWt` zR&j4Eo8@^}4ViiSehr7;4VhIJ7Wz~g6CtRWv?j&-Cv7q3a);psZLO0Ibu>wa&g91v z&QUEH!nI%b>PA9>uCR!5oKD)_-f@&!)ZAaHcC(VxsPQHRFRjWRt&of`_hHtPfu(V) zSlfuycj9DmHQf7O#>DhJmOULg(6KtSZ|b_`g}^R>Dk`czgmCv}=bAbLu+GX~OrVhh zDrHVi5Aa9DrV*tP#()Y>GeG9H29jvdnI<-$5O_qn2@88~#`hRz{n+=3$e4bv#2Bq8 z@B1QfpTaT7_S%tio%EC?y za;R}NfV?j!@Be)Kqj~M(!3fR$(Z7nhF|BT*M7}~%1>y5pSpI6>W2Cz45gG@xww!3NGs}9%Jnc0j z{__6wgdDE;`y5*7m$-D@BVJKSO3Y4k%)c_zU!_K>*M)4q=rl{0J#fIQvN$i0oX%8f z6=8EBeglq}Caz5_`YLz*)%#JS+gKL&vZkpJfKGPNwfI_{R~TBGG* z?9Sfa*_X^u#|lXAH=sm_XcAx;Io&QUV*>2vhrLCZ`l735Y$b(?dW%WLV+v|oubEIQ zEcy2kKku*dPm^Y;H4sx?|G%Zg!!##p^v#J(W`jdof3DX6#v2=2>fy*oi*=)U%8FUP z+FJ-SvCi&_Z&WY6i_-L-K+7hqsOJES`?<^a>0B8;q)O;WXW~TKn}YJ6|43?IB&T_x zCd`lL z<6`DogKhKGiXI~~OdPTBGE!b!&M13vSv?((cSGeyBW5`g;_71RJ(^yYuQ2XDbL#L8 zxM-uG{`Rh3-Qf)~avmedXEkZfolyHUIDhKx;`Ico`mgdR1P+MgkoPe0_3$@gHnnhk z?q!6WY`WUWk2|z}@&a*BR2uc2g++7W@?d-#p|M{K8eb-82}PuYi*rvndzg=`DRFKu_l9{!=;T9 zv$@D_M_P~t<_vfg=qjU(Y8_B{>>`(7y}~ux!g;aQ%?}zz{}ti-MA{1#NNye91GeV{ zSTD+4Na&aFE^Je-=^huLiG$ls987QKLsF9wn%pmu@#KV`k>%MIehrRvdhv?Ejb*m7 znbdf2IzLQ`htJL?Ii2tAw(`rKYVSD(w`HCdX4Z7wL=tN!6#PgJ4ORL<7B2564vY=W z)1^Tl0gtRbVZb8-%;+Fc&~_q>z!_WFECJfN_NeX=EJa_=k&^`qZP`=iSL+H(vuaE*W$t<#fmO6`Ag;@^hBLB!L*Pa5qGidoLX()J!r#j zrXQlK_+7bOkM9hj@CB*{{z-FK=GkO)?Ww|0T^Z|$PRZJ3BFo#XYlaji381Pf4Ccms z-|n@;$H&L0CPPdW&e?MNu|4l6g63IsJ}pd&*LiR&!h~zCE|Yx@m4=zix!mU8(n>_S zna&HHRiQxX?5}@e(Jr*D`D~-@7D!M*dW#~u92=v5_n|9DROs}^Xq?gL$?k2u>V>=X zS>{LQV5pQG3WTk8K2bEb;XbSDk_lhdH1tCzy0IDCRwoi2tmI4sO^b28UIYc{Qv@*# z_dKZrgJEk9GTlzGm-CC4pC93~Ekzyt4wniltFP9Ck{Uq&*Oz;Tv*;rmHMU_uWO`F3Ohl6LNO19?RTj}tecBJZu10fvs z@xMhB#gV?|SGGLr8Mx2Uq~#~pr%A|sox{{u;s?A9w<~CXbDAOUcQVI0Z+--XT9a;0 zXWt`AxB(rZk7JWJT=1r*=|avf zGFC8SAd793we1pj?&D7mx|dP$SMTP4`m}ipz=7vb%c*PHUPb*g%`0oeGYk0+sCIYQ zaQ*~blP4q0+_7-_v>3SJd%;V{M~k@3Z60Y{=c~TJ@G-tux6TU}JZ|?d9_jZ6pDYqm zGM>)_+(WnMNqR0OT)S{QLWmjI-aC*W-uH~v31<cs==G?^%)52Z8TpA^2XiaTr3=r#ES?T;XBgg&ywQ;-^V;o$m9xRxLRvkC(D|w}TAc6V_ zkN9@ZxxK0ofsnq50Qe8{KVq>bggXoA9rD~LGcHUtY&NRkh_!qj3Gia`4wjdNvxbT~ z%b-=Vtb^ev$Xe~-yBgkd(d&WpcxiHubXj68K>{Vpu|3c@bNouAuM$@6{R<36+i-yN zSX_9t;Hi5+vHMoF0+v4nwfK&;KEqs|{~yT1dBCKYgLy*}(O+GK$?#7&vjK-UU5D#PIfdi(yaL-T_Qu-7Prnza{>AH~gGuOkipr=a0 zRbN-qG9XHK6>R}43enXf4U62%HTf5T$umnLHH=Z5&CesSRepZR8b ze96pA8UW0p3NiwiJ@2S@WS ze=hg>8z4k_QgEv(2@9CyVto%kMy=v>W6CfgxBY2>B%47HNF;CvC>&`IrWIgUo{&i* z+Kb3}Wvxr~@I6xooPy=Iz)@ERL4>R%TlDC*X9`4`YEPQ^XP8fD!#pJOt{DJMJx|Kt zvG_d|M@15|JFeDTHxQB{9W zY-QlQ%JbV{T~z^p1lv#sAuD7awTGHW1TN0Z!i{&CP=(N$REC)=kVcY3)}C{ehpiAX(x#r)v8O8)fB zJQ}R?0c;F)J&)jRU5rsegCJGeXaxe~ZSz6F;A@6!Ad2ksRx>v1G7C*$ua0JNaJD$u zFMYLk)}9b{UyJcpYCtsH;8(_fY+wTwSl~x*o$y$kBst4;D*_KuZdM{WC&rnNB!MU2 zMFHtFCC{B!$?vq%nL|Rz#Wp`)?=Fd}$Q+!zyTYW0e$l~o^uVDaOOG0%M1U4TiBHWM zI@VV~!HVv8j%3I>i!toLD~`M1h3}xho+1=fNdoKqdtx5!=o18 z&4#cd9O3?J+1CtgNkFuLnG7yR1dO8JdK>vi?Mue#shKt(!B%9QpS2#13GP;uyxlEu zxQL4G@2jv6i#gzO_JlyPU~aRJRcM8Ofd}>rF@j-}!Y9qiaeO&G9HCOJA^Bw3vqoZh zK$-sLkHV*m8A|BNpD%_Jd(?qb0APs<3}HFYjd7u-K=^CUefsyUuKj2(LO!O~;qaFX z|8<|3(ma`3>^Hm~`4U|C-u%}&J~z`?tbI7qQ>4A^Y^}0fL?QK-^n4cym%zcn!C+tl zO<_}h5DOucE>4I1+rkoWA4h`Q7t*0vQq2+bowq3YlEH9Z({Iz80e-|MAh1&qk(bBx zn40yjq{!~=!;1^+JzVwQQG+UyQO2R9`P2mVnW#WH`TD8OFqJ&*%-O(U z%NPRc%<;_uWAqG5Z8V}Y zxH41R}R0L+IPpr#;ke0-}# z8(?OXzPSowbi3Zlr;kvpiwE3w+di5P+LGj#Z06E%NuHNmiLyjLd3`==4oy-ieGL^{ zSS+Px?26su?KN?2$u0lnxXk{7Lse~}_sknhj7-`NP|Qi%Kv%*5U|(%HAM?%MT!VSO z8JEU~U;^W>TLf1pqgsYdyYwCFSLZ$@x~_kN51v_;Z{a-NkG0}cgRf%^yz!h*`KvcZY5V|-(D{I`U~)=gwvT!L8tF;@*6v=|X7#+7ieQQ- z$B0MAt|-^JOhxwX)8_BFi>Ny6*p$Z@I`w4|&k}>g!zkj+S$%k}ZWBs|n}7U{V0bia zqJ1c{CIi+~|E;l|EUy%o48Vj>!;{Fye&CzRf8n)G^a@jpC@KAnqsl$1+V{5u2I5$D zPLxx5v1CY>=HXTj4ug=;WC7z~y3%JAOBBw=DyZqK5S zn5fe88EL_!@5MzzLVfE*ijgM{YxOM2trx3TeNu9QGF3(&40eU|@uGdAksRj^AO%Bi zj21DZMFpc_on%d5Yy5Aa_8st=bMMP-RjW~}#NU;G@tEf9F!TA|6n69B=3}=w`8CQt zOU93|zVvb$i;1mp{N1~=Qh(|s{(7UfCodTDsCeD?-x)4w?VMr7r=qKJ59Fvb{yDtc zpPdBP%ktVyYE<%O+72z!OGs;dgVqeFX*SIgB+322kNm^0#5@q_u-M z{jcgHuQejKRC{70MFkQcTNguQ!3j@CZQf~UtPaj@Cl*Z90(x{Hb;f+?x$R6X2}n9m znA~%hOUQU>lmhpP$T;VYYr<@f7<-91v`3M%3EA|A<}=;4?3rCSK*svKhdKWF^M~hc zh6#PBLMAFm^dmaBOc{bZ+swuq7%fdO56QWCbBulaR`x6rScUlB9j>^Zzq7D z46%<>u`Vu2xSIR4jAJ-N%o1IuyYzGPlf6qn!)<3er3tyK+dt@&rvh*-9Y7K5HF5qw zdTWEdot>*%X+~uumS4R_Phoi)E5+2xJ5I|I@%9_TFV}Ku)J#taBnC6ekPPibh^l`HZ?%t3sPXZG&!gNL0I&VxfDAj|yxYb8Db8XB4}Q9`10U&IvV zlI%Ayk@(OF{}Xidn`DYaALGxt(PSv{X(2RuV7lyJZ=}!9szGs)0C+Xp@ ziycke{A;9&1duyHVFe-6p*&!^3!)e%{hp?*m(?gJ=nv<4>DQNqp%qMK@x`naCUMz8 ztqAHm|NL}p9I_iD@Tu&%#JOuigp1#sH6xww{x>LS;z1%{G+nHVPePpvnWyv&Ln`sd zk-gb}dy`XPo9b98hM5N9uJ&P*PF(i9!@xM&L?Gw5yL{oBxOD|Fi*cjFWHhzJdpcK= z-I*GkCxsVliK<=&n_=?Y$;riGlKuLCb3$8u2|A9|G-AxNIB1U49d%C}rW@(a;OooTHdVk`MPO$zwVl}W!abd&YboHn=ZVGC3;9!1`vxxm zUc4QYh@(aNlRtuE1-k$0!W|qv`>9d0dmxc})4LnHgF~S&ao_ zqIw>RE@sI;iZMCs!@i4~S!j7zWKn16?Z;9Pd_AH?A1%hVDj|Fj=p_rXr8FQtA&DFk zf(5=;YoF&Sr!xm>Cp2y1tTKkWS-+C|;J|vbW-+7y3*<#Wz!{~9o@uE-L~+6efMI_5H| znu8K-(@jdF5(#OV)Xx%#n7pwQZ9cq>WOHR;Q-gWI<#WVqJ61c4yn;#>PsEyybMf=0lUj z1pnD$vqn4d2Dgw1APhAi_sKC0Lc3)>l7yxj$UvxNHL%qBC%!*a)zk6hctn48PQ!iz9|n~ zh&pGPehnn~;+KN~ZGeW{h$PvYV0OB<@xs%97eIz+>cDFFi2_Sk zX_kw}2y+)Zzcoi}ClYN3(ZE=Z1&qa7mD(r^QMu|Zex*vz%AmeP<(aLcZP*1e&KX73 z!V}UngiiG$L;f+7f0D^prH~qUcpO87YbsqjIlZ0eLE|$5GZw;snwK<=L`sF8(3>ye zM^Gt#JqzI?=VyLB>-)(<9lo_l$Pr=ueTd7>c9Fej(ebAb1A}zKWX;a3@bxfXt5fF1fzFj z3;_^loe@Lie1}K7K>w?@p+R5gQsYT5ALU>_rQdfUz7=&PvhGs_vVD!u8Z6$S?*UlS zfIp$w-LU98TKs@GO-4Q5E;Y%pEJk1iooH7jF>7A1-q&uRs=Ayn#9))i$&`j5NlHud z$vzQqhtOEicPWi4NSN5Jc1P=rc0Vndwf~_^CWR}nqqhF9&?x&*&i@|L4i1n1r6w&* z;Ohv?%H(gj1AvUNo&LlLji^crXwGjTV{Dl`$c$df$17I4=rj~k%;%9pdy9FWhjN&c z)Dcb5*Y0vR>*)dx&!b(}l_T-HgzLaRH1GtSp-i0zNzLJ~z>)~`nMk?tTKy&IB2ENb zNgaH(V^uCBq?1d#OBO_a^tdhsPS*P)!=&|$xT@enhdTfIC4eOv6PW}AU(go8_3=6Z z^%Woo2&Yeo6r(BFQk;06QYz1$A*-qqWh0uk5h#9Hw{O5V9cOz#Vpm1->2<&BjXNI~vi}d<+wPJ39{F*3scW<}#KHu5vm$;Q<3|9k|k6 zk^_B*+1N}gw|lyl)QhgbhDn*&bv6jvS21?QEqVG!h|U$!F0;wcn?biE_}sqO%r(>V zUHF=rFAH;R)eH>w42eroP~<%p$yK=*jU3}_Z5f=b{7`fgeD9v&smhP1yOK*(q- z4NQ+#avA6e;{CUMRDpluHQ9n`auU7k@#(0l>T^Lye_K}x&D)>aXMHRIW>cbFt)(x# z0%7u}t9r&TgzJrL|64?nDwX|$vL0BnK;}%L8XEGFjco)PZPind5&5A=9USG5==(rpR^OxZkjIE5)==M~O$E-~34kIIBzA7^w+B zO<5Yv!E^ncjgbme9r?CuI{b_U`r(-FT!uT?{bu`cY1S|B%K1|H2#dWgs`*8(fM*Uq z@rc&Q({l|)tCxW`EoRG?$=?#z0v0vaEsyaipYYsCF^;lO7VyKy1Q)YjtbFsbqD9-A zpx8wLM@oUAcrO(22~NOFtTIY&+)2!L7wyVv;1)CGb30@*`Q@x?eGI&!&#Y<7^(FHwyu_i_Wa=h(BNGKkzB+0E)_y#?C<-lD?9_Y&e}M{=&cd+D1ZAu zN~!x)kv*9vMTbv-6djw^UnEml9XF6{Uz$^|5=n@Yj7i_H~M3Ng9Zn*rBgCfW^sQO>7hw6;*E3nf>{7FptksBqu}m=gCpO zq=?*_sWyut7t*TYSR+XG`bzrfX!uUk(AjN-8M-SK&$Uus-hJaH5^MkB`f}UtxpDIA(zT&$875#G$uNJakZ`uYi_6l%O zKn~Y9vw@+Qi7XYfiBhULc@aSo{Vhuh&sK}I_9iL>RXMppyJ^v;tSN6&fRTM*W{lUa z8gFR>{&WQq8WHwW2UqrnqhSerPS@26zp!j%r|a6j7k>z@SV>{dE!E^Gb-#&(KLzqo zpauliO${^*Kr&~OFpxlG!21`wKt}is3T-^!wNXC*0}N4-iI9?dfL#i#@!R{#MIZb$ zx8+|YQxWuHJ%SYW+38NUorVI}y)cO@&%szVQ`{hu`Y+B9kx!Pa&EIty0o=7cZ!V{5G5?cZNZ!6WAp2 z8tW%WD4J!-FFlD19Y3=q&JhSwqZw36-cBSSzF%4l?v?6Szcxb@i>Z@QYFFJU`;8NN zM1*MU#3WnmuxYWJ5Dm5WBe}HsV~1pIF@R^APM&YiMS?1C;VRCDi77rR$2t$)}9Mpa1P?T4Rlo zilzEB{Q|Rc)wqBpImIhcaujZ{>t zNL&{EIo7dTOIGExwVU{yM*cds{foRC7|$gp?VTjB)h@R}x`Bmz+nS;N!nbOvQCy!6 zq~c{Z(8_vffqH>|ehEjBwt-Z!k$k_-Q=x7b1Pw?3U5gL{>gd>yddxuAMe{vbp8LY% z86_q8X&9H;Hi-x4b5-sw+r)1A-}72o>eP*Y;=In=aX3Agd`sHbdPxaBU@-NSz@B}p-HQ5- zoK4k!p>bL@7gsA~tY@~mFB?oi@=pOv{yTY8wa{PCZQgZKifoi|5nsz0Sm?sA|KmU$ zhCP#mA<-Mnl}GOcvNKgrsB|{iZV^gU$;wa7z$y6xRotqkz%=;lE-n^aQNY=UvSU<@ zmve3_L!A4jv2xg<5lCNw^MP31j$izp$P`GNw4ki$l2hfU+Ng8-85R@3&j}d45>Aq3 z9}oYe6rk}XhBFL?h3TZvtp_^uEfD)P)1w_29t~(drao}3-Q3(9(mWe9r7l2BJ za9c!M^lB~fnnOlreq=-)^VX^ii&-5v(Gg?E`>39nWzW6k?)M<-|G13hGppSrmzhI7 zl{gy@r3Y_&pWU{i10=d_< z2FIt*2#I0kWXoA>KJY7em;W*ExU=?s!udAcg`TEJIVE;854 zS4d;|RsMcZKvLASEZx1J$idfBbArUr>Ljg3c&}u6*0VJLk8j?_xDn47X1lU{zvwYp z@l%e2Y-qp#-tdxf=!ELPkvg_~J+Rm#gP^M#FA-mUSw!34&A%EL0j~!FWgt{rd!3cG z-TORH<6NCYQd4(c*&SK3c=1*1|C5Z*d;K&5FOU=l3S{wedb#eT!#FyO`8jcu`va{^ zPB1t}eH@VQMgFdE=ELZAaV29^-)rBq6(aD<+M1f|YqKZuCPQK4-pRn45`|p=Bt6JS z^#zA3Fg{XbkAQC1A-rwgn09jeO@9;Lx{j1>`)|az(tCt2NYU~=&U0X!*hE^G?KW~A zL;7)1eaKyE7B%#F!TM!k zood!Fb}5H8s0wN_UrO`&SxOrTeiO7|Ht@!(YU6k}Hwi!z`wy?8GvOq9anj*Z$QSZ- z^DJ=t4XU4Q`1HY8M2RtVZ^xG25B^wRk*B54uaFXKb&}8N!uFtHTc(N+c8lV~Ps#yf z8eGLzqJk5^mLC|#FGH_*Z5u10s+HU97E+zM9kJotrO08~pM=w-ovq*N3-5>!_m$+V z=r5@3@T;qf5QT3e z{~e@-f0GQ~_M5^5|I{Au3&_VO2)!;cpi@_>+X515c34G4 zGrjkP4uIWG($~mR^>7!*(J$7E+?V0gn7RY2&H?gL*uL&zV%=*-P?LX$Z<+YrX74x) zsD~%aB)lxqJ}E!)HMAU69q@a0w%A^nAMZ&J!P9?H7f&wVL%xMUu&Ms$*NQ7o;lqzJ z)y3S)9Zi(e3;1ZGbxu#{rKT%~p|_!Vsu+XV!(^;5{=4NN5z8wpMwIyLxd1KmvYh9% z=~l85UcI<9D1KaY*6mPZVMbsMiH|bOoqek138dBkSXS|!VW5Iw%9B^U;d%u8Nr53MB-tE;)Tk;StDJ<&vg5RJtCfdS#IWvH=!wRHZcbA@jL(V7u1duix<&oFr@!* zD;0yv4Ur8eo)`<6fP65#VDD!t$iT_*8NEjvIA>*D*{KbO$(AjdetF`3Ze8taqLGCzFiI~I*1BU$)IH@=$*I`Ao!og#U@x$B=|#jhSx{4Axir1G|MT{|tcTCWYLZOpVRVyULUAvhWq*q2k}%SDhN_z%!koIem6%`sDwO=Z$@IZMb?ol| z)1CTH6tK<(Sqh)#o5m=5x?Kx{F94sa34Z{lEx2_>gcMH`DuWLITkEZOM1@JdaO9n6 za-oY156rZH-v@Y^(e@vkvE4hr68eunuuiwi=OXMQu&5zSWbm?WZf@=+mEWzCezjFR zm7w_D>1gw`c(Sl-(Ya`VlWT3%Ik1^%;du1|`qsfx9`)oQ8$Qm8!yTyGx$Q{YZ`2B` zk~4HcDj+Xs{#>1G?EI9*&gGs9+iGwrs@;;u zzR@$vxr=}76XQ95M@rp(2juqyq?z{f*MZIUnzh)uV$Xmi+z`jCVD3T9eqvztobaa1 znbrbQA+fx)^^%`h+PUJ7>EF`!80e&Ud6LOmQP(v%sN_Hxj113cVMPw$eTYyd1tJMWaaDu zH#n3*dG=24G6D#r4n;x6P`z``&p6nJVC!Q1;x7Y-n`$IzV449))<(I-Rg-tZNjMb+ zK8TSdNqG7?Efe}|Xb;5N#5EH_5l+8c3Mn3gp9w$|mVkXJMxImKd2BtIV=Y zAtQdz_%h|Ujrz)tlsf%XGR^*z*M ztm;T%RQ-B{Os=r2GnbTYK0&AW&5mF8Bxy(ldM9|^9A&;1eJy%On=y6#o#Zt7AP^d) zkc|st2g%Xw(*G zg}xqOnk~grg&7MK)moDpk~Sen*15&qsja!f3%6v;Hse_mS6PxxXvo} zOd=v~qhYT>-7#fsS!FF{2^o#e!aFOvU4p8aCxhE_TvugenZ8OWQoBJdm$(MJ%Z!Qq zk%J{M{%~?Px+Dv^&PZzx+<@ zb)$S*zj==CJEwttQ_DDX3z^@mr%zoU)dP4!3;NT5_-r|72>xNl`QEI|wCucxG;gLQ;`^ywo2(H# zx6(!4X^YLJ{0$141W}pHkYWi3DdY3#aTXI>`|QhlHbcUchx`Rd=661HtXyJ^a$1g}?U# zr3bUa`WZRpV`-+P#~O|T`cdd3tbE#-trE5EEDP?ULx!xhhSYU_55Af?V};gJEkHVo z&h^gbX4bn$4&PY*rhh)gNlO?uvD(<|NKwM)Jz6P%i7V{IN`UJTO-%+~8DGj}XomPJ zd;tLdkmwDtwCDa)Hg)`*hoPPD_ixhS)h3v2Frqh|Q@~;u@KP-112m}hpMl&L8uOPg z(U(e(0d&U-uq^I>@S};i!)KkCnqmRQC>RyMR&JrlqN}AwcMZ=YR55B??8tE3obn-J z_1XbpR&%8y2DDFIW}QN`Q3m%zmRp=ZGP0EfRj_owWbkE7d}@T?m_6LZho271kM=~VGP9MSS;v*mc5)xR<`5wUlhDMX;e47+x%bV+x2iZFnHfOL0*AT@L-Qj$Z*&@J8F-Q6Hammn<-QbRb@ ze%$-p@B97r@P|Bj*IL&)&que23doBQYh`nlA-dlk$ubw~Qj~%~oikpjV(FbmRepXx zy>&eJg@#P{^}o` z#t4ZJ(&LVW(j;-(f4J!_c=%SBz+mt{ojJvWq=Wxq4gRoeIr(ft{ijC9GS)W*x7jfM zc6;;LFJk(S+^L$j*1@@GTE1jL>+gdaX8+)pgr{Cde*&`6MSccf$c(x+rIOfiN6-Ba zk~wsrFIHiXJ!=*FTgB z&q<*l<+G$0#=Z>k8@g$lY~W6mq@8R-wRF!fONwU;V~xI+hr!w6n=cs`YfO5KQQK(A ziDzoQ0hJOc@FtxB8pz4q-{u55uD5b#3wQfU8PMdQ#KNQm3g0*ff$rB7jd9Qx_bSbO zzP?YA{y1c#;3EPZ<|+Q;w{T*qRb`+{8>wwr1jGQ)n4VbvG51z04Zd=yqpVy2LBBum z0#iBY@0suKo?_)SR0ICgA3c1y^AT98gY!Mpic7GMZ^?8@^^m;)cYxN_PT~_l@dFt9 zqAtx4WgrSfAGF?6RrLb^l1okwKXU(yjUcY@*}r$;qV3bGWFlh|`WO!eJblG=%I&Om zA9Y{JVANCvN7#T!Pb$uIKtP(ihs&VjE9a+Qt?`>2{~)CtP=%IWBvArER3uQxBy@C( z+9-9KK1f#ae}!5Wl_m-`;4?g>qhU0QK&YifRQV3{2FL|vecKBRI0nj#Y^MBq<+Z9Xk-dAgH=4Y70=4A!mj{c5~*oj!8g zD0Y87F^838P2DZsC@?{peq2lK-AdcCOUcGt<@8PNke==fDPkQaLJ3{Y8KS2{XM90W z=?f^U*@UdxfuA76dASap+Tg&T|EoMPz_aC&5qUrkxif_uQ37j0UT3uyoB_0BW@yYw zPdV@vxxc4v5o6sNGW=tZW6H(t2J7)N*GNH7;@C6oG3@Ja$^2}~9g0gZN%K4D`AI<3ij#OKetlwF+%0Y#up+ytc|z zyB9J6oMQ|L;AL?e{b>&q6zs?-tjI4%v3}owf1?rc7K5AzOihzKIeH9&pk{zs7sqDI zC}rk0rz`f?Z2DX>>DTM|pve$T)BEZCUg=*FK{RKD9eX^ASc36jU97n)o08JZ2xI~f z^RFa^5+RD8J4pL#*usnYzq(bD?T;3uh41(IfFXTcm7~#mW8++W%AH;_Ld(==R>PM9kV|Sd8%L*But*VT;Ih z&wWE``!&+%Ep%)`3g7+(=CMuz`A6sSee^_(J-MhY zh-<+R(9EDC%~eY@Z27zZfEY~YSm?5ksFKh92^E`qQYwxTzqQ)@qYr!@0t17Xy+nt0 zPoQ*r)E({r-RDy~2T0U48Ne_eUu6^58JR|_ALM!;oT9zD0Ad~nQ^cX$(MU&$Z)b}J z_RpVn8S76Ce%wlB`7(_$bf_6B-U;%r-jG5G;}Y}fmygCvW{*po0JME#^7)-N$G+%a0YdlE90lu9)S4jIj17fCOaQ+rSYUr zVLXhNFXmW%kggQKHddyDuru-zPqlRygr-_rK^)&MZ~HPlx7 z(X`x{Sj4-J?H$C3mUv+Uo;V8Ye{_fF$R+M#{B_*WJTe3**FeF5c{e@p>AlfVps$Nn z9)3^$F=fnIvg>MWJJ7BDf7R2p`ff)@hOV2bvph-$HNS(9vU1zog2#%R)qkUTYu0%P zOp8C#-&WAffbAeX_%836?(DY4vtPs~l&XhIzS9qtx4IWN9zmtU66oU|dd0opkktIY znp(mqRABEV(ko{01j!IFrvc+;Phqi;W)zAqg}2}|6x+*FP#W~*IOk7cM|MxnD5vVa zbSScu$Ur9FcmE4qvJXnb)KqxP#0`Y=-&s#gt*qGF zFukSx@7nz0+`BKeQN;bDf4zOhYT}QSK5~HssG<>$XCuIph1#)EBn#llpl5qA@%1P* ztr)9eHjIBKV{yCwW_eMwo#pl8e<-;s8?iald*&>F;8ES|3=Ij1VsvcHcQI3oJS!xLNCt5Tf>jI%7%K0ds-cfuz#4gFxM*F-KoRL!;+(9G5c` zrl1YbHe0gomUKx6B2&2W%q+TY=I2kxgDLN}-I4bt?%O26|Fd>P20+WV`!xXm!^F0J z0q7);*Li0!u?tCFhxRH+XFowK}hYgF(YV z&7B8O+5MVk5aGQbqIfOzdcuVl!;72knVhUI?AD$X3G_Tn7t&O_%IW@60F#vD+tMZb5qA&P*3Gq%uoY6&;grDalO&C9J#9&m&f8NiXNQVT& z6>@UnX=rJ^To&f_H4F|PqsF>(wv)z>65kLFS;Bq_Fr&tl^~a$0cMxBF*;Az z%8@J-r5Nmob2$HDlAUy;!$(7uZ&XRuas)<2XzTld?#FhsXQHO`)+nRZvb^oDn4jIl z=_lsrVvg$~&s~^0d4t|n(WA+|am(EhVNDjig>pzbZR@`YnTMCs;b{ZT89CPCzcrE$ zMT2=r&-M}O4r`u#)YQ~gAU!>Y!m!&Ou~^aeKbnuN=Pi*3InOX0SV)hN8cwGdPS>v| z{8z)n*;TNSqLH~+Vj}wiACXO>oBhOubcXza?uYe+C}hx!tH+No)C8o@i~P@;XneCm zQD-8pr43yxY9$9XC9An18K2J%rf8#n+Q?T1LHC;dFp)q$R#88@|BTK|{ghYo7J2p6 zZSa4pZ=Ru4HuOfb7%%VYe6~VJjxzf9Vj7+}vCOd8lBlSt`n5azMd8J~K0~!QK3<^= z5KWT~_!&b_d(2b)@y{eq{>$$lFwl*7cA*DEJdbt))H1Tt@onw#xk=wsg6>;ar}=@# z&EMw(>p^>G6(bI#_f-oYvP1!8g4kD@4X)Q#hMVtz!in9w)Ot9FZK`;=#!wQQRw0?l zk^DdWQJhMS>&;v?7-;o%3m1R5c4DCBA+Yqg6xR7tk$cD#)|8TJgq``z zgoSm8)vr|cUgJJu{!@Noc4wPAZZhQuqIL%H@~`@4>`i@%VVj;|Fa;=>^yA7)#;>8Y zwSWf7Hb0v6bp#nNVC@|aX0?*8T_&NL;jHG~lkQmN15Fs#fS}n1*BS_M+2vnzO8~|L z^1;%4rQU}R@96VMOFIr@lK2!~nA8C}qGUHe(2`zdaWM*>Gu!>g=MnpZ8r1AuCqSSP zB>wf#DP=Ac;#c%+|5r+O1a<>Em@?3C0=){c_nT3*-G#idOPlz@t<} zI_fm9q4THP=}61;-ZW5u1%iUqT3X5_c0C*ZMM><$WtNTYZ;LNa96y=gM|q|316+5y zc3oQ!Ii`pOlXsbcsj`4nNfM?*CZXKos0R?^OM-Kjj}|Jfoex~%bTiGIgP^!kr}g+< zBEr2UZO%neiqDd+ScBY(Uc7(HtLzYMPyv~OM4MQ5%VFZV6AzXWypZWlbbYOrfd$cE z!CrFo)9V#)LaMM;+fo&PJGKTZ>RFeMJ@KI3F&C-vZx`xdoe|@Ut<6eoV4N!Lsk_lK z2`3!Hr}{MC2;bqWL@VPcNL4I_zXCAUrjp?;LIzmlP<$yrE6;4+`aZ-CQ> z>OvnA49x49UzVW+`*nyMP)z=(Fy+4K&OHk{spp*eOsvI6CcUVSLqHh`qfiD-S5{RO z#cysWKF7QfyYBK5_31+lT%^@N_MO*}@64a)lh5DmcAhR7<<^1yod@Xpk6ByvOq!NtFPc*)7ohj%??W)_;>cc%O6U8=u{v>Nt(~@JLLU%s* zIe!r%69U)zxw^_*xPwxmo`%Q0*z+)_Ewb}_{?m%wiT%3@1oFs2(FK%85)E`P{9dO? zt4faUnj{|F3}?KNnW5Vooc8LPY?Pfs@6mL`w_H_qN`L*wl%8#a9ipTZ(g9gaQ{NGIhJG+X2T< z%^NGNP7L`LJ`InoNwlGMZC)Q8Wk4dR!Kl)^%BkEEuCA`~X}JS@6RwpIDB#t#{oM>$ zJ!}Z>DreRXd3K8b(@G(#sGa{Ij&Age z0Ot!nd~L#%Ih;4G02P%ePMAB<`3nQY1PQcvzEYY)`AWGM!+|GNmb6sFb~0tY!@`DU z|MktvHjf@cU}|owe^VrjyE??lWH8`O*%V$pDC6_(beqiYIY!9fAMJ9xchN|sS9ErG zlBg?8`D7Q=xobdDd(na>Rh!|tW@x`pS{-GPcOXnYG~(}LBt-1QdJGRlXJe-(C>vzuD6uP%-s*8t28kU?FJ4h;E%6+c1r;6%{t^Na(} zceWn=A7ua)4--xd{f`?GPJ=?o5G^x+5l?290rUgbo$03aXLOpC?d@Lr(KiDRo=d#2 zEmW~%t}rbuM6wUfm-J(`mbz$S;m&b{qc#^D`fkpqJDVj5Pkk5p)HARIQTWW5iXveH z;<)QH92C?LXl-LFNRypUai`J$Nuu6JOsEEZ)f*Z175ozT8sk74x21+zW>vQw*bm1 zPHrGaVtT};QE56LtoF2lpq8n~;IC((3Ru4wXLm3tfks~pSjbM*hW77F#`SCIc-y(a zZaiNqaNx;xJByCB0r~yo@#pP1!ye{yG%|%r7}I~0_5mXmh;BY9cg8kHjp&x zdW(Vo#5OV1m(4vBc2zQ^DM=C$cz~KeSX)(ETG~@s$oSGx#0Og&kl-efO8e%UzNjM# z%PLjy%h&;Zd-&$mo%xX?gjG;8>i>@oR+glC0)oT#jFS=uy0^e+NrpT{fJCuER%5p^ zJPa-`6Y*i4`xLAFj|xq!G6?1v6B$6{JgqIsVLB!uGEn$Tx%Eh28PC)EF)a%BBvYJo zFviGe2|ohApirJkbLvn{=FUg1>@jsk%6Hh^zJ?&c z1faTY2hmj=KIl{L0_ITzh!U?uX|InX3e>8PJN<2cOT68&BUr*=c zo&TK;n4%~B4w4&WB^mU$8x?XRk)gq5+mm&qYj+xnp~|)!fFlDhKqB11%?n#&DKlyV zEZ!&Yy+(!Uk-Sp0hauX~qoB-<7ePie|6y@;({FO1IFcQ62F@#Zr~Je~<7Q!@;Mp~u zWkH{L_b(|PzsFG0l~Qedo)+|C_gqw0eoOuGi-j|vVej=5YDi0{d zlB2Ja=ch-SW#){z^3AOqP@DJ@B#KFNS4mnt=yTduIH>-ThMC}PX>3(lSpc`ykBK6$ zXMz>2C=1kRT3pK;pX<4JA~p$KfI$QX`qM+TTTk<|wsP|~m>3w>jb~r>njLhpDD05G z(}AG0RvaoZg|sO19P2uJ())Qvl2}sLV*n2OJ5c07FM#9+fC~*2XSgi)6QW?KYBcuiHMS|;O9Y0BAnoFbeIHRx=)mcc2e1@{|(Im7uPmKNu z1MyupVYF*fCzPtnDi661USX4zUxOb{lxiRbqIiVD{-M%HjlI|~OqJWI6Ae%N#Wo?o zyGcrO!`SGD`+?W|u(!PoLZKHBW?3bX%Sca$m4G+8A@aB1N=4BndUbQOXD6UVeue-V zuaseU7Tvrl=gB?mv*(}#yh3bsVS#Abb++&wyosiKDP^B&BY!=6LX(0n`zJ(rWworK zD+v4c2K&fbcL5YE--u46eN~r%iZ@tI?j(U#+S87@_cv7zM$e5Gy^<~t;8A7oS)Chz zc|zr_t1^bRPeTi>k^~rA#Dq@t157@IY(X6~|BVVoaxYtIA2qmVf?9*)OzDSaHMO*O z?Ii6Rxr?deBh(6yEP{CtZPW-`Ola>owv+k}YEAu98cJM|JC>C&Dr+mgsr<=p(OKzp zND@dja%*f?Wfl2hlpu?nAzF?EdJnCvsF({9EmQ;h$4Xd80poey0HG-)+P`xvltRtG;YZNHpC`{deBgxkX925FSeX(E;$eH5!m6E zENEK6#oj@f;-o>PC)%0wwkc@`&;R}2(L@NFAeV8@mIEl}L#r=WUG7~3y2r#H=X17xbqIQvVWMuTh=XLnLAC>gUEcRZxW06_nVqKfj2^m2M4o0f z(SXAWP^QZuMuihF(6CvNDU$*=wi#4}pK+d+iDw)rGvy^EBOBVf+NupKlVgIKSor`P zJ^K%JdKJ=JN}K@98Iw<_hSvo~;U953vE8UfmMI-_6RlsGG}K(VS-E;4t_?v)=*&fc!DrIe#a9z%%cmc*bO^7oY$12uF}pk4v+IXBaGG$6Bv z_wEg1yk&)(ynGSz5hm_~xvLX`qeE--VkfLXfzPjnxM$zlQzI9{H7HL`J0KSYtGXW9 zOEukU_jqhy0q17fI?0{ejarsaGP82(bPupyWyzMU^RA^-ne>Q$39}xGAkti0o=q-@ zu01TF=-BSLKINU0xEK1*gT_Y&(@d81YDt38W883k`)9}W*Y~d;_-``ZgnsN4CaPY} zuN^PV`!GfG{M*YUX4u~7IGcv3;t>X6T5HNeP1*KiaN9>It;236ubK4T-B*k`4MAYz zp|a(y^3s^b0jyl0bJW380+!4oC>Ze`O16}scPl|2Vxuvk?A9I+(v!>1%oKKiGkaLc zTKw#M%=Q(Cs;~^l($t8dTCrcfR(DW_f{V8;BBpmq&+hwE6A_4eF~Gpw)%2>Nq2YU` zh*{uba{3;~vI80K(FpL-7Tea#Y;ZO{WvBOs`aO=@4yI#~hAeYGsdRSnl=YujrVW^C zzB`1`(|2Km#_!?csri-nv*Oaj)^kDqr8E0$cIEf{bcgDCB%N?qi{%AHEbo2vvpi)R z4TSYRIt-5>hEk8cF>RNH@5=|=+y=;?z$v5Kt&FM1wegL^g9HDBWdDsUFB7;9P0@Yj zd+(dkpAFWx3?cQ6joLN9(|tBXtJ{k<2FRj*2xiw{R;nf!1Cg^jfNcCm{*(Xi-Y$v! z-~`}0hMuOeJtO{JzLKqx&lz<6p2UW9Wp};Wo-9kJ$Mt{D};Gfg}gwzN4cf3zMw6C=W-sl)U+M}dkz=0>?s#=8Z>{`h7%(zp*;7K>(lDoV=}%yK`rAKjAhzw zom`t>{EibdgBScc0pd9!HXsgYTl~gdH~&}kr%$QM|6ARQZO%-6P9T+pm5C?>g`sV`^sqr>wtrlV)1!h=KynccFLl?2ZHq0?|8j@H#)UDeRiA~3ER z@n)qC;1en&{~1wE=%w*%NXHx@T&t2>%7$X!!==)Lq*ZZ1NM*4ew^ zD}2D+i6w2KV`#{cRmz8EgFEz_gDPClCMn^-uH?PRV~fNbqed1!^Mhq1mvRS9(}|KO zR#sM4zz<+oqT}@GJE7ht#k!;m?K`%YG$S*D>@~rDv^>SC#!_P74#te4at6+Z?Aw4| z8?`gS%{r}qwA9SjY}xFZIzE4Hl*=Fv9}Z8_O+6|2k={?&?wBs-*DWn$v@q#*|2F64 zdm1KuVafU@-!K`3v=<;A-k^`v|2Y`e6D|$)9&Lz8j9lZZ;mtssw2NxyEjODUqVg#V zN`na&&xI5Oz-@#7C9mEEygWZ08X8KOVJ)sI3F^LB>Nr51T0g6#n&6FsZL%Nm5I4m> zt3BL6`Nrm5kZuP~XjHv*(9Ns=x(POE6n?&%V;ESKp7xtqBVN&)hd?_IbJSlkc+XI^ z<<``kNi5u~$CkrcHR91v4oDeRf7Okr-9 z(I0?tq>=@QJ4zvcmkPrU2UvIr9^-jalCH;-S;Cm*<>ew;A)0Cn1BOCXjSphR0uYMu znW~pj_WI&)_0}@^v+!xz?Ka$XHE#E=<5zcu#Ux>S?#xqV(p$E3DCHl%QhqK`qW0;? zZtoQaevU^LDuu6wu8syZk!X}`J9}?(6%-!^j^nTkmHu?>5I_19WDdmgHTUc`_}P1X z2yX-IWeA_e;OtOSsDpG*@n?fWE`Q+27AIArCC49UHc)Oy01bjz+%zN2?yypAz8p%2 zSLzc|bEric5EiAeI`h7(}zAh|7>@M=T=#O8cUKGHCo?=NN{p-cQ<0Rj8pV zqd>NH_f2j3lj9?nN=>Osgi)TEggA?{qUws1bYYw&5=fXM|AsDtWU?PssQ>3s;;L(* z{w8#pu&V_HtdcC_vRGM~lD=HG#P)#cC@1wEI#> za{>blkXZ39yb$r?CZJm=H8uh3Dp`$zqVy80A+t{aaD0FO5%MU35E??b`OHDDQYy1SS z37$j-YrWriNM{_*su9Iw-Q2t54W!d1C6i$4<16sfDi7*dFAfiF z(di7pu&P8%4CZ?GKK#jdD z&iI^AJZo0?It?|dNghNiNp$%!@i7UaViD!osD@}33vUJB$QY?fQiNJy)=5s&){WU& zFo!y^Y5Q)%-&+zAsK5+qp2OHb)7l71G&v%a(*DDh(UHeP-|q+~0(sv(v1>wFBb;Pb zBVeGdkzSuLO*WEgjx$C5ZvrMS{w#jhr^d4z!&!JuG`||3|N z)I{dV|9_%RfRw~>J5HNd<5Z*?1z7qn{bv${de@`11*!7^X&;Nw*zal7n$)@B|?na@`> z?^Fq0hCtrRW=!E9DipM{l+3Zp2CKA^x$6g1vIgy6XaK|6A>iv@Z1il{G@_I`0h}hm zb#>Yh9?Q3BzN2`9+rtMyYGjm!oSYnqeh63yoYQ(LIighgsp|QulPDZqTwH*`vnN}Q z+!7Ep-%eaELGn6lH!;YiXz3jQ3v==pR|(mBDas($1^BsI+5XwOnrpvEmuRMBn!Rzy z-df%8Efj+T7&%Jj>h|wMPYZXx^Tw`jbJ6w=lbKmxbzjP$S-Wve)jJi-TQTzkMz#H1 zTzEfdVB!75ZB_Slez?1s^&#od(eu$TIaR`i*Ud(l>B{S#)v4sUVxC|bKob6wBhGPR zFh`gn!tO2t+z5j}`Vgpls=L>`uha`ta|C{0Qyh4w9^~eY4*VezG&IG#*=(@l-EGp{VO~B^Ww@6HxM??pE!%GXWcsEVn?q zT&T|WYu~(GT>?-Ukd!|Iw9tPc1GOw^3R(E?%iZx^VCcxLkaSUs`(Jqd4j^5p`qPxU zNGYw%kEy#_-VOKW{s%-02)B+B`4JS+5df0lg9o+;r&%1*oSjJRGhl#gvhlw>XNG9j zY&6-E(0`^b1daY{lkMcIO`E4Yr+)k4DR+deS{YCAm9S~8I!={8yP!yB*>5c1G^Xuo z!HpA74UBI6_Z@u-w^o|fiMe}?PzuK0b4=&qGaoZDXnJn$N)C-pKJs4)!q()kl(0_F zNYZlG)03~gbQNlhO^+__obc~>dj4M z1rs%uUF-CeX?!1>`<)0DB(B!>;~hrnjGn4j6r~0!ujxf%)D?tG796 zZ^>6gHJP{i+#fCmytxqLKN0Qsm|%MHcHg3&5``Y6rD1^ikqKf!?9jZd+XpYX)g2pe zI}PPbK@HV(zOl2`8C~~3zZ3x7SMXP?0Ge&bR<$58dL&SxM!z{uzL+Q%?)x=X&_u?W zr&;i3X@)7=m;aW?fvTIBZ)Yu08E{6)czU+=W?2?$2(5!ZV=#R?jW!pnpD0$Z1~O`6 z;VjvAxY16=_Nyuz593BL8~P~SW*W8$@u(sE!s`m(Jd3(u;sdJtgM^uCB*<13O2t3r zw7Vtb!onk#=15VuPdZcuw}Rc67tpf7z8FQGab-dZstaSaQDiZM4cWqfd-X2~uP7?8 zh+EO58-?P*8j5v)5^1*?egJ{ba{`xgH9HfKM+BK;T_8FEGc80@nS+h3`?-p@TcBG! zQ_h_7E*e<%=TPNSIbL%Sp4Z}PlGQohw{xs7%PSNB17?3JaCI&gI}Veh&mXN613Nj@ zX`c$zeLXcW^TBTt*nTDOi}uvLStSqa{;cgVQ5^DYog_N;A8I8~fMp7$8N_WmM}lYsb;*3*1N)IIH#nqR8#1 zg2$B&Pd0mFsVDcjMgn|%xsUx}s0%DxV$7eC;=}Rh0u*i7LBpj5te6R~oQ%0et zy$WTG@!birDJ;)DUdU_kWZ?su7}a%A_N zacFPs=#qK>WptIeZ4M5EvU?w71W;hV+;_VE7y3nI)ti<83lkoL5^9OnHp$`mI_$^^ z_$WCjYTk(erth#@bhF$tUyJeT>S{-=>^Q=a>Y^r^r*Pu2qj7t4O(xXfYC;V~l-ovG zxB|n&{QgJ&wya_jn&QSn>42h{FG+VyF&*ihfBk6Bf{#Zub;cwljw}E?#naAAwhFv( zD4KEh3fFV1C#d<;+_S7K?p7PYx{G{sQD zpc2wO9Yguxsj$&HlM_4RRW3{3U3A>IbjO|#6stH)P0oDN9Xm2qM9n)iQ)4V3eSmN9x1M9w9kCD%r&*!1< zpV@dvfLAt4t63GVb1%MaA@lLl%E!{yfcXp@Ji?ivVYP4rL&GAFVA!G0c8`g}#>P*svmz11Y;!2J_h~w2*+n~!O~L&PYur{k zZLkE=fLqK^L_*rMub|Uygf)eGQgSjnp-`_o`wD%tO122ujM<|+>d0+HQA>;7M2?t= zP+;ntC{qR#w!Js#pI>ZttgTsf-~QD4pU#sRVgI!j{S}4&S{^a9yzfOKt9};#eI5oA zR^`|)`C&yjcMA9XE?`EUrlMnoL_PbeWVmO>vE`BG(RP2sX0E#`{(e;5*m7BHVneN} zSh*76gwA``#I zK?U(^{Y?q=(z3FPO4h}tCA}{|{p*emZ1i$*jl1TiRmn=(NBwQu#5iq?hI*6p#{r-8 z+An3*FAh~mjW0xIeyXlzEY0Pq9G4xJH5qh{yK4u%heZw}D(Q)IxrZI4!0S{Y8#fZ9 z13Lf)F;$YVAR!?E3CTw~9H`Zh<);#Vp)$i7mpt9k%_&shRk$aM&pY$-gR~$2uO2of zgZ(o$Pq{i^hMxuk?R#F=o1giRcmmtF-YLF{sRB^=!tQ?vbqNP9)ZSmuA5RMIM7gHjH%Ifo?U6pUTQlvGyMYK+7sf7TH#2mFcfbA4Rmu1+FF-oYOS@Ad(hQ0% z92yrZ3O_icdDuj4S+xs9bIR3#5OH`zrH58&z^n+C6UV|Vn04xg9l?It? z)}IA=`H2_?@N^2lBK>n5;SG#ac+(;<>>ay7$+bT9W&n9z&CgBHjrn<`0SIRVU|PcI zC&P$lmH5HI!BNoUCvaS>z!Z{GXdbmq#zK+j@G`m7^R=}Ss}!OBSq#ZRZQ55yFL)|$ zUw`8*lP|nD%;bR(3wELY3H!8-CYdFEF*0m5=XALaQHwCLmaWGCjbToYZs-zVbAP>W z>b-i0@IjxFV%{PL`k!U5=9HftTqe0I2i}xxY&$+VlS0&Cb<+{R2nBimsVCR=0y=!p z&Fvi!GO7VuFDx)wvGD5GW7Kb_=GKM;AMES6a%yA0seIOANc~>e8*-cfTd-(ZnMFfA z52@DE4psob@c>GUCE%d5h5A@Q@{HCwo_016Sb`Tm*Rw& zIMZ1N+?$bBvPePPn;NW|SkFLrf)#~E+Av_2^baPQc{#)b2;%}{>>|TJCHaL!YPu(=#UWp7XJj z*r%_omZ**y3q{Vat;th-?Z>ILy$`~6{qct3EUKAkFDqy?OaL}}=3D!3#Qoa$GGfg8 z6KM?14nGsb7ZgnA`Z%HNL8De23LWuaes(@niegIzQ1vsZh~gYS z6H2@lBx02U>sI9+JELkpgBAsdqV3_P@RO}m6Ax&@L57sSpRLavdzm%ecL*>f)J6>03vQnt_DTFqSn*TX%%hYrnGs=Vc^?AuOD9Jo}nzNfF|XibjJ zb@^Uz`t9E-zwRni<$^=mXTAMN6Qe7XM}`+vr-(0Jn5tl89qW-Khzce9et7c+e>KlB z!Ff{-(&?s_;c?XDscdYnd*pdVsFUZi|;OO!; zN(l0gf2l;1`OXe<)GSe#R)J=FY=`e=ICi&HrcZ;;f>cRm@OYW92OqA&(?gSq%+t3^ z4sy8hi~6}C!!t>!=+C()sy@)uae;o~}PW%p)Of*^U zKOuuji)QNv%=4LAP9wlBXb>nl^) zB&T(Y)j-jbKQ*~^gI`sVbZ#Q=dMaw+{jL$d9A#Lj(I3Aj(jriN|NN<=E@uwspPp%d zy0V|Y{q7x~Pg(r7S$x5;yy+ADsJ0H@sMh;p>Oxua$5kR6fkN`F>>6jrKjx zI!1L{I_5mnn-B^DU9;(ifMvv+3w?(=!MZK6Vox4qp}^_pxdz;B^6r%cx{v1lV6i>> zqtE&d-(6mj{?hWhuVQ(=o_e^gSufXM0@Pt+U!8oKq~z{}G?-?DrKx&~>nLS;uhIQA09boBQ+qm3om!3VaA+nTnAqr=0he zVfRPpdQyWVJ*_FckzbUP!JIk~hx_!ErLu*cKoQ48^KJ^p6-w81*0!Im*L`Bib#I7C z0-=uhv|O9lsjr=!>K1COV`A?6vWz*=SYazmHEgN=z|;Q6uVAq83u1u`ZGsw(>ehY^ z0|PVT5sR@lj&i~ZIMLie0)@b52eIpUEhX{*O(O8nyYDf=e+1v=eDW034JN3XKJ1>f zRKgg>7Czy=_w$4OeICKYS#`CnBMYOL{&?TV(&Msw{CV}t8%`Gt9$!yM4Fh{s3X0|| zd?JLU?7!3LF6XM7g8Tvg(qH*@C)&LQu!d=74Bv;PJW)u*OJHB98-he0rdU6om`NxP zbXUM7A0voJl8T>Hq?5r%okDznSu+}l{C1lGI`VqO`;{8+wd3ydXh)wNXGD~xC-_p& z(;^K#j|RR<-^(gI*CM3?{ceae=sM$mR92F{pdHK=qST;u2Q=(V67R zX(%;aWmsE*03Lr8b8|Y%5c^TLC>3stA5u2-uDr9Rcs=(0jeZB(;P-3q4X}*=qAsoO zFQ6rKa3*?DWsUQP$Mi+)d&v3i`oqt#YMht)I;TW2Ab_5>Q7@3CtzO}>0=$El-%OfY z5-4GAgyTY!Vb6|u(By^Htz}1&yy~*Q7QbA1U>u8@$Q}<_m!gCP-Gtusgupi0n@$`U za7jNMW+MhaDB!d$F2sh6NB$5#>(mna`c`X>YYCvwHT~b!%e7M8KRvM>I2WNlpel4& z7)wHe;My4*aFF_LCyH0J+Pof3cU&=_o{}S$lS=Q%o#pk)rRt5;K77H4s-wL8eCNZX zV%KfgnM%zy+Hf(ez_i4HKxNj)))cYesrt^dpujr>3=D0yVsr7A!Fy$z6Nvvt;5kVo z?8wUPOmRc1hAwpYtFUSc_LIkn_iJ{)_p30^WEywPjvL)k^h2<@rJvVD z?>o)R{mw&+B9AKFR!+aqvn8beZ^g*khro&L4{IhbfkSBIforxMvO}?J?q$>78RLn_hOf!5m|>jUlAjO zUWnN3f>cL4DI#*HSt$LpCR4x|wBjaVO6H~2rwub-7VL8!nF2#9#3Ai5pURx+``E6?8f8T#-qcX((Yt2mz!bBc2=2XlSM$;_>dxVFS4KsmF+yjpaOV2H_laHmW{G*t&MxFn+I> zFGd3`66MKqAfZ*A{cLsGaTD|1rcm|zQ)~hf+K?+0Jnz10D#?E8jeRWXxxXEv3Hk_Ic1G{I_-IC>SW9jHj7iszPOzO(dYA1h` z#yq#G)ivXk^U8}wX#d~shVCF`cX~wLFd&uPcAiF|QJzSV)kA#)Y&Wd&fZT!cn%ZMC z;07qwepgkJwC<~~%!w3N?n^D_Sz*ci(m(PHuX%=#MD}?2E%-xa(>jlG81MrttTfmC#(fK=&yx-**|BGk;M zwsJ3K^udp0(mwz054+K9m8^lamT$XsUN`|Eo)ic@aX@MqDHTIHv6(EH-QB}`;FLOf z{(21YJ5SkIzIB;;!{s;R-{1H3KEB7}ZY;;|N7e1){j|soBB;mlh^rFKX#7fx6e??0 zRbDzytBmRUcx>W$ysV9Se}t))Y*tP@=c%$U_=*wFtuY4$e9`Ibt4+nPeW_OTe-9?f zUnOcVz2Dz(PP3yfb#;|l3ANBPkT5xoz8KsU)cgJfbhs7l+}|Xx6m-65Pc^8!-Guqw zg{@8kl429~H?+RPwddKs@Wbb)5cq{XRtgtRE(Gj^U%0f->ga1t*ke%Fn)7468v-{HJEbjsJoTh~4rKEoP!S?nfi5Igx~vS1(Y5E}A7C?9A4&~e zbrTAW*!gt$EV%0__ndLM;j*xIU6sTAy7G+jWA`Jx=XsCIxZys-qy;zB)<2Y9{k=Y= z0EVvH!|RHFcbx7Z@_C6zM&jq5H^-^}_Ie4bu%QdLX%nrHQkku8ggTn%3mUEr7s=V2?Ku1MX_i>E(4pI+SlVe1VnB z#f4Cl^ANpp7d`Tp1AVYOUb#SgBDRGccTU7ekR55Jt_)LFC!7rz^YOTGCthd3NtJF8 zYkGf^

ADQ*mP7vnbMJ(7Q>W55e-Cn093Hcs6jYS+Mc*dJP-b*4;R`*uLTt{-qZZ zBbzVsr*-FhfP{3OjMM96ssfT0RJRI$DYLN83vytm*PMP2-EuwG1JAqbe%D1KJy(8I z4lO;l`mCs{<0j80_uW^|o!g7vT$;4R2=2&W%eWfx*V-Ob^G+q^iYl&d{uMkLBW<6h zxL3Q+y6?ABVl%?rk@U)`uSAEoNBW_OIwL;i_*awHZrzwVDWde28{*1nL%`Rp<@Ra4 zIgfNDi#km4dCafEzJ(BqMW#D=a2H!8;Xkz-b!Q4;VP~PGkYw!=NR8Z&P`W6$y+O8; z@wf?phUGlXiE%AnqYp@WUXHQ&J#RdA_}$LZ|AKQO;|*B|@jnRGsiPO~$T4IXShv~% z7o|$}LB5b;*;5!;gP~4FCH=eSkkcQPZEnjvjLPItvxFO4U?FWM`#%w?l#@p0>@R?a zs$}!$+Z1)oEotk_20|^fBDI^ef(dn+VkDz*Gt0$FowL7T=7s$q_TK%U>Hq&9*I5+G zspL@4N;!m_$~hznIc{^F^D*Qw=S_(eMPZJyoY^qvQ%unkb7q!9gc#ulY=dX%)OaxCkHz2t@LUqKtGa|AvBjG#dbgRB z&;k2{$cv_fn7_8VJ0eZHW3rcCy68GRnJ~#zWmTn*rcW&%%wLc~bsZdPy)8gF0`0l? zNu)M`&?;HYXAezfP+IIadG@p%sGe=i!_YB=74@+7sK;R;XjF^i4~PXm*rW^659LJ; zizmb^us@{0)ZTFfV*JWw;W+t9U95A3K6rX!1U99_w;r zxF~Qvl2x|n&@f>7U{cCqE1{#|+H&m>I#ouPANhbW3I|*eg5P?hx&r6(_3uS31q!GV zl5>n{fmz1+)Zb@btB#{pIZQU=-2#&{bnsRx9E+@sEWPJ$CQdc_4c=WiqePZL@MfDe zCdlb@yIAuS2BrBS!k4ljNTB*^Sz&r-NJE0DOKzTEcIPd<+)dIJ#A(XraL~{sY)*@w z*RKVgb|THTjd_cBSt)Qet`GZbG1kNm3F!61TneLK7mTMqJYuh7!d7t7AOT#V0P(x_?!77@Gw1-g_ z_i!BtIF1?{y}Ry0Z{<0zk9>u3~;!}KJWRur2W;Z;{pnc=)mh&MD)J!yKH}VdrA!z6Ttys*8;4R{j zsfP27+%A8Ci)OM;^E7j_WBX4uJ(&VquwBg=>bmw;xSZLQ@NP^EPYD_c^i;K0&avcw za;_FB`Gh*GFJ=!U9OYni>@}co#S4Ikj)iuX3k_=2`P z)5iaAX&+6`VNe%akTmPLOlCx7M}pvyiIliMB$nr%_xeO#DLnkKZ-Z4-2fOjEU?_d6 zgE0l-LzC-{yY3KweR*+cYWIwhaf$^1lB0i}g8k}Igx?mD5n?!-0q(i+7MY5qhp5{tUo;16n*I88vtOL3%$JD#HaSDIV($vf zT?6r@UGHy0B~hUlPq?rOdzseNc{>DD*>D&Kbvcr4TNCCN@~rGh&O(6 z@3b9b2L3sB!ey>F1Gc26FBUYznH08_nDirfJpC;R+&XMC+XO3z*lnecUvk&qx*}S? zcXd#DiZM&usEei!g(9e(2#wwS3?@lSom{D(k)CJ9>%ZAiz5fR1-YELgE6v>2%YAC5 z*3Ix@Fn{#%A(hAdof%!>hW!yZu04EwW|Egu&h9EGw`nZc{CVbKzQGrpu_v|Dk2-iy zx%7Oz8~gV$`z+TcztGM~4{dH~zaW^_I^=UWFUxc`gge_jer|Q{e%Xsv;01nE11qIX z(xWr>{i_+w|1Ov*^ad8)VWd=br0Mrd;LH|vu%^OE)@3&(bCRZmM~!c^|Gq9ueLn9@ zK9%rp?NlOB*xK$)TXpl@eTOc~vZEF3XZQF=n-b;b{1Y#)P3f_bv7cCPMxB3H*CoYg zF-Hd)k)3=o)XJMYqcwlph#Dr{rl1Z})x89l(n`(;KeZOqETrueX@QGa4@Du{QSp>m z=el)MFDt&JIBLs1>Bx1AOLtOwXxpZ&M|>wmUe2P}>fFl)CTm`u6$%e60Hl-w-{GJ( zohK3z{lyDOYE{Bcl<{sdeNx@h5oaO6Gc&NYbC;r-V=j+X$gjG<%~Taq z(yV95?n_AJRpt5wFRztX&R_aT8I$o%38Ne4D8Te2&3V#lE#YkI+nJ>xTTb)6A1`Py zdexz6)H4&o`2IR6H8#&U}w>hwU!{3HOW&A2jNw8lY*Cx^lTB#gwXn zAgszjn~o9mhnJj?zw!$7IfF5K*p8ssa4Ohp0AaI}cAY+ADC>(AEY!PDY}%)G)(^T@hq$GC8{l-at(lctkN*`M%};y2I?-0! zX2H&XAK$o496~4S$S3;wM0?0GZO|QKAWHFmiyfvptX%b8h?7Ia*7Tm+0?~DYAakj` zV;=&|3fT=)o5%x(RR+Ss+S6eJ6@?_~aNBOpzDIjr96?M`eRUGF64dEo$*dSOg$ra6 zDvwt4q?O?670~u?mCN1rgj7tm^!t~46DP_Z=?K2A-2X2?TXh0~GnKY{3F!8meltSn z<6LxMrd1`3HB*ad0=#6Y5?hFKRmqIUhk%!oTr8?gWtJ`=6%#39ffVq`4Vm=fPzXLi z_JrcQGm+h$jc~XNQh)>RQu&|U7V}&0(>_%9uANA{cZq0Bt@&16wgc_e^Tp6|b_OGA z8G5fg%LA1;yJZh#Dsk9@rbI^_M|6FjbL~F$3g3v!dT=gnqDj`KYC)w( zb<)Xp-gk$vvp!CO(;pBM|IBE7u-hYq%r*2+|MkQE#$WjBUyFT~IZ%M?Z4Ox%$JF^15@iI%hK{Z;?iEWSB zjIAIjq7>%?+iD43`_w#Gee?H2t;0ZT5g%{)6$F8$D0udSWOVDEds?(v%6GJ;)>+4R z*>dq*ytpRPd)eg4xuZ2LX|Z=^LTYK!x^h4m%=_^QE?msT>G$wz7>|fc{$z>zc+^uK z?ki&z#`*2pzmV2B4i;CBu@b&+;lRx842WG*l!zPSRY(@_ z6j<&k)p{?d*;!{TV8$Gco7{qhQJ;~EyBLiM+Le$Oq@d(hA(Pv502=I;CB+989uk7GV!kF zM6X!_?eLMYIR7i?Atky4=?H*${$)A??XH)sdfh_b>ua(GpoK2Xv*`SC=Ah+1SeUTr z6Mq;qh4sZh8F^6?_S=)h>;h6XIFj#Z9-95-fuF@K!j)3$2fV^9&O+Lvznl=PFW?*k zF1z%p{gfqI?|yxFXm>!nf12m1CHllc>(_9R5YX*tiKfln<{}aYl$d!` zu75YFxXU9Q1$)(X`|a1I=l2QQ`Mj(0Px0)js^4_#3TgxA?KdMpYKklC_!j0p{_{Rx z+msP+;0P2&YAMC+=E4bNxY>@up{8YWcta+-3x&P58}~y}rzS8jJ6x&_v}kqiNdtyl z=?_1lnD5nuyoFnAIISsPEDGX(kNjq7{TuHxQ*Su-)W zhwM%#Arq!)(64%&S5lU!3v){STqleGLwp+JTMqMNy{U_Xb=e5iV&OZK3u0e2h%~O= z-c*fNld0bgXK8}niFARt(ig$8^zHTe3uO+rg*D+%k(dx2Ej2$CuCE2u^)i#(kZsLd zI~YN1TfDOf@nfr-4w1Hg_kyA1H{_;2dzWfnG>cD8Pev_3PdFpZ$9{3G`t<1+pJBX& zqgf_3)e>9P#>9J13}yk9s@|}6%j4pUx&K}B!S+iknK)(Y+{OtbIQlHjN`|GC6k^o@ zVI_+N0|$%%F{6)H_qP`noPhdw1>$u@|66INQb0|jLMw-CzR4gxeyt2+Vcoi+ZH;9W z8yViG4Z2!BIHz`e2t6{Pv4y{*bblwIQyE-(%EycS2N&)u{TkQ8Ll1)au1pAJ7mQ$Jm;9EN0e$^Cg+zEh`xg}mUA~r1Yw93R|y3+BXR?CSB zldxmTwUln zcAJ5<6}+^nXcc`9#G5vMv_9(fyiQ#kK29g#oX{awi|GT6vQ^3&)_Wx*yl$HMA%y+w zyS`=F{9e&M6V*{I^W$KqkhJ!#d)k3?Td2nTUE;7=9k{D`KR9In(3 z7;}6JwnHLI*}E0JKW2xbE`AvBg;k)u>S87rpK`5v)>dpHk{?}wCFMv1iRdA9G|hTe z%s-|qsA?m!&M*-(JJV)@zLYH`DxD6pF!~wY7-pm;8}FX!rl9ll!Om{m3-8Be)O-mi zYV)1N-NkkFhJ1KEGzx_2X-=v*&-CBX_t z5q=YP3Kc}hA2ZWTd{kl%>LsI2csx>0-QT)j5O0kc2gEu7MtkS$b{MFVl=Y#|A7B2c zlzF43H)(4C-%H4@-UVF>Uf3>`nQYOFWRapMhs`$%14An{Yx)&;ORdFuNc&e`N`4KN z_Av+NnT;BtU-_sQ?yqSdtJ!Y(Yx0w>7B{|Gc;y#POOlj90b#b`m7ZTKl9t}*J4@%y z))=Xp3`)q{$I*r6`oX2mm+|1GDW~9On5yLCwrq(v!PdiiRh@j~QwyXmWSQ&4G&W-W zL|aEvOo=I8;EqVeNr6A>k{h9Cl;2G`ME944&!@;V9%#f;hLZ@ntcc#ANR4SEZ+Z<+ z1RN1BbDSLRS=11w*fZYiS-7q*8va#Z#5dks8!(qf2f>}3g393=30DY0l)mdxqY{o?X+}Qy@61kLM&c<1`w=@8@Kiqe#tTuaFz(9b zoj>7*SLD3o2xJD>uY*D)PF-wliHLS;H{9BLN%5X*&lr^5qozqKn7HjI6t^} zh;M}AqShys0a#v~Ixk62*9BOS1={@$$i)|QzV9a=TO^fPKp9%aK{XF#f3nC2Z4nbB z*XRQB9hX*j^yTu#8y^;WwJwzR{@FnsM$t7k!v||)>R8q!=;CK(v(OVbtWU%4VKs)+ z4Dni|KLMod+YIA7{g<(O7R6g4d7M}2=g{{w8dhU>eQfxR>~87^2S7;VO4ftYGfuAt zeZqrU=gBrsik;j44Ih&~aUC0l5R8|r{1c>RCk>Pp>i5xmo@71pVMr_p^G5W$dg%g8 zn9pt8r5%<;u;qOKsTiG2-FuIgwHaX5QjLg426|m5y_(gfhV3#J63h3g4h#;kRY_2XT z^BKlu?Elx%_FDFU{~fmQ&kAh6MU~)ry!YuD_zqu)UjI$C&N`OWEyw)Z6z^N3atuo* z$YS%;FuWWO6F%RcdxyzbO*Ju=!(n*&%f7x~zxl+B7uQ^Hd)FGk6!ue#IkSl{DinRm zgA_6teo@c!r+$PO=Y50wXB$F+6HVA#%H<#`YOVR4FvVd$G^!mp#5Yn&o_ShL{BJ`p zqHj#*B~f_Oz34vsfs-Agj(UUs`u(PAu)Sa1v!#@kC6irN3-3afKQsx?_+Na9y?Q`f z5Nfe<%H-Z1QC0k*&Eiq*geg}Akl0=~zL;_g#&Q%W_bNb71n|0{@Rt^o9TkM2Aj$}q z7sO#}HwsO@4T{jXkNuAexDqhL6FNOr^G5Q!OvgbWqhPjmr6gzUFtxlT#}A?%$*O7+ zVrgz^a^^_Z2K_`iQo%+lx7v1O(<( ziza2bqjsCMKy$YBGph{+h|zA`*%8wIP1h}DYd&BxI_r$;P}0oKBl)3=_p#_XdTG>K zeKF{m&QmK4i$_7{aH@UktPj)bMu0L2Nt($a$(Q@2t_Nyck*P(3uZQ zqZPewIds#?Tez?X^B7bHRPEZv)7h@)rrM1~OZC{X;ae%4w@V+@lyx!oE_!^(hvoJX z%)99vq(obQoD6>x4oh)g$&VM6M4&XkCD09-xdJD~CN1Nx%~4mUsySZqRg_um%>$uH z@SQO&K-qsNj6Ra57M@V-4YTZVbt=M1nsO$Wzla7mPHIG))u`HByqc_T4$#4(FX|L* z6M6brX+D@iVUPJw#vM7)3yB>dF6;!UO}-3_)b#}UX6W%d&o!tHsE9sUAy#WJf7JSS z=n*B`lR{(BpND6gAbEGv=b=PsEH&h-cBYoePNxTu8P&(KS)Bni?-dBPKSX#b+0>Sa z)u5vpq{?O&=n1QAR%Iy+aHIuxF{WIr=I5tS*+}q*aS3KiuBE4KiL74Y;X@w5w-Y@7 zsQ72PHUS>=;WA*rcYIA7vq!PCKKq;`XrscC=T>+sFG5iDH^t4yslKzyyE2R+I&$~z ze;(sMZ%6S}NZhyCE{TSAeyj>NTXxfEMBRwK^_uWLva+u+?1Z(@j2od){O#lo(MhVN zsgeWxrx7w03sJEt(qZeDg^fp{S9b=h+tA{knrL>7DKp-lRgLfq0uM4OvU$QYR|}1h zI?}sh_!{4to?zh**9xm{A;Y<`lRd0VJod(|w?{y1RRB`QhwUpYF9)F~R9*f=}wK8llfV_yQe%>>IH z_rzoVNGim61jIA*4{Osw>MI!Jq>ibjpNHppt2iIg%PTPUx~XBBjoFU&Y`SFZ_NwA6 z$Z29beD#$kjC-zSg-L;=k;Z(aOwE^zS1bPHP0lfZ@Fx*@gPgfD8F(;9{yYJM1+|@> zNyGa?!SPVAZ<>yr)}GGc`z1=d>?1(TO7dA|H&?EqQZy^QEY2y|Ku44?;~N=dAO?}IdX?t+wa1Pvovu56Z`o6fC8O^aiKsr6iJk*? z(^~^i=VoVg{2~bkZ?f&P*s3ZrPC`zYFULkx zpPv%-6cj94BqhkmKUUwme;95pQkMe>{)~7GDDBp?UVhfnY+8{%>J!ISW@i$RzmQBS zs9H~sBS;Y?o=VER!4XC(D-TX+*=usaqJH{eu?152I4T9>xauKD;V$Y=b1Wk#t})UoVWHOF1ye5tg35;%iaQ)BD-{gl-oll z;xom3=I)wzuPkoyiv`U!ze(0hT)MbqA+>VM0O6*#YS#hZyeh$!-0Pxy1z8eyMT?sn z2%GjYx60pne^}uHv{=50v5OzjaC*uEJ;ZJL?K9K*-{R3}s$h_uz{v(Ht{2huy7336 z_}iAFN^Ci22j?-Nd|;R?|BTgLj|Lqv;2adx)Oel;j$YX;W&gsp+QN}G77!I?mp}C7 z=_lblkGY2Q1*F6+F&)MBm$I7{cy)7}CkNiam%Fb%ihW(3U5e`Sk|?czB|DBVV(*Ll z@`oAq3Fj*rlluar8O=R2#&KKp&2QWFI zpFj9st+bWXvDE4|79O^ZD@F2HVC#~sxxYqTs^>C1dRs)x0b6z`Mvg9FZjPx8<`}EB8CriF$F4>$)#~zX?A7`wR!`*xWAw*S2?-Mo3rwH!4f&prJZ! zl#AO~)F1AI0$gZjHj_aHA3~%Qa}z;X%Bd2l?BFjotei!gsghOK5@i)_oM0;3Aa+k= zufpbU4QWD>)Ns_r%_*bZNsOq!fW;GCoIdOM8Vg}D9>n-@Kq*GpGo5Wj97@hL21u;O%nm|hnEB^EOhtSu!3keq6g-*v=X@SXX|HAz9Koi(h? z^l3Z6(~|YFM{^)FO{r?xqz*`g(esxeW8}7Z?iVRx3;ly($IFWg0nu?YXVB~}>22W- z0UgDd@0J<@F?S-_ZZus5b#wxftoyD=h+km%^%Ji&*|gd7k;Jl9nTI>?n)=v`sF=@S+Ij?0bC*gOE_G99C&Q=>a>bW;G*hIr|`r58Q8G`7Ovc>@u-m+xP*>Y-FVRw*_(bFM=?uAieB zf_i#CRq_^|n3;W*{=J3cTf)1F2wCLaglwxu9k~)yZwVy3gz3--86 zq~D^t(DdfCETNq6hw9`IHQUuJscwF^gur#|WIgnfy7Wj0zZ0mXVo7RuBxs&PGun5S z$Wc`Ar~E_tV-l23&faQ_^{r}@ttb`A1Fdu3B9d6tF5PAzXO0eg)z~l9)JxcdmUHbu z*?2WR-jjxbk-q^7fw`YL#qJ|9=NZCfF4w1<<_{~9#8X9M4J?b7cf$;cpPMI&L?7zR zCTVe{sqLFKofKh6(8TI&bl==FxcOHZb7ht9&Cq&Kc(6#LuI#HYIh$cr*^MSG7KIyP zUmbDkYwKNdLIKO@*3}!l0EZux~|o)PKtBRIsByWaH>}-C_;CM_38%+vP@T2`E0zZ zPU#~4rq8N^QK7+VOm(xb;GE}#V2WzSo}4t2D=2_Cvi`fk-|hLpvp1$|@09ox8VbbI zDrqG(l`nVb47+|q+C?d1JlDS3fNj3Nyc%Z12V`BBV1Gn>YO8gT0`Nvn=bh& zeW;@Piq0lTfalU6ptgA;z{5|um@)UF&t^8c-?fCojc2v_})7cF7T&&5( z(7;0m1;iakDgNc7Vz~<^qBakio3c3%8-XWXw=}P20Gvo7D|E5b2g$rWDr3jw4JE%K zO^y1EyFM!{K7`LD>)~sA9)1E0T>|xdgF7EQXYCYQW?rpVqKuVN+&iP4^!f7r>(M_W zXLdw?f(4gWLZFH(#IsnvF53Ljpcf|C!ApPRDBfaSG<{!f_)E&1R2C4d$~hn|H~K{+ z%?b=cABSxOb^7>9%7h_!Wzpd_;;=pj*m&e)9v6w&91z~r7c1us7=qLGfygk{YWS8pTgjn17ES)>%MeBUQWIW%t$YRHFIUJb$(P4Q@ztdzkO zkv{Yh!XLN#TiM{XuEGMh#{H0QB&h){+bD#WHDe8prrR{7lNr{1YVuH6X7tyd8wfj% zCA4j~5!sEj5ip9fVK+^NVNehetC7A%RuB&xcn0RpdV+ zp<`!$*WzTP;!8pLUj&#sqN*46I=n$5|B+-@kZq>jUHKFCQz<2{S1I~}eM~|a!>uEe zh#uwA3JuQljaMbgswH#bjREnD{A^VAePc(KS6{G_h}~ZJjPN%x0?G4Yf3jFI0q|rn zu%<6COayQ@c$#}qB7<|PZ4F&)fb&+F&2yc~7^Pgwz6G4heL~-MiWg}VUcUiGbDNv` zy~s{n@jxHlf$aO9i-Pmtu)#E=5xB+9XB;!G4-rA2#TbXe=4ENt$uDI&+K)$7pClWW zu2#!U7U_ws*ak`~B57R*NYuT4K$0FKo;v6?T9-ullkvz8niZ%5s1WCOaZ_+cb8T}B z$z6DrJpyMK#hnwuopV&W5Wj(vMap$nB3b@*EJ9smjIzM;w^l2oV%KZM0q+}t4_^49 zkVd#=55OCinTQ%rhNlCt@C`wt1Hc4g`Qd>v5h!;>GeH3GZEhqh+L z{`%KJ`-k5<505dUGvdCuA1IV3DTw)+M>z_iPB*|-GGOHkHPg@jgXNv!jh6m<{l%}@ zemQ4sINla#-&x-KI8Y=LBJ84M0ikNa8`#YIRXU#li>P^w)z5hRE-yiSO&p8!9dsCT;FVes~PP zWtJ(iUJj%D7#go@0v94#JtmSImX|ij6f2|5RNK=G;-%Crx~K330j<c{NsO{-5y`RmmWDZgWaawhrtNV7h zjytQKROd5Him%cVxJIN0+hoK#Z;728(fSW^;`7#FrR6J@8JNu??W7ZM2lt64Y>13m zMRw043xXZG@h+t9@4TX9X*`;GP0$7ucj5UxD;82_psL> zc*Mulw^Dx8U=)A9k=d`kk}19!Kal)hE(>xB>FI79=lp&WId=C#VoY5t(vo}OQ_@^L zqQ=P0RVDnU^Up6of)tvzEMGc`zQd-*D~H_NS5@ZG7cC>-#M7hq>5$+z>c%l z#9eo?qKg&Aw2-vVfJGg7XLlwo7fqDxQBUhVA(xey1j$;G0O(l=#l&&uKm_c+x^{k? zJ^|VLPtCz(qoAy(t%&L*^K8{hM({^5dW9~TJv$tW2TyI%eSbpB>B3$0X|QpF3j$)Y zig4k6n={z?L?-Y&&lsd_)=bI$=iHB0MOIEfGybTt6E5SC_mYz&V%=LF6-j=0T^j80 z!VM`|U$$EDp)=4cGbBv7O%ELqR{I1Lm!q{oqJ*>PFz~2q*XN{lgQibeP@}jKJ>9SKl0!1?ul)*nJ-R zxc$W5uYHBo-4rl-pZl{s4vlIN1N9X53kCICeLAW)Te?Mj=fhk;_?&---{W;B_s7UJ zH`qvs+aK|soV7X#Dv@kZf6P$hAuEiV-4Y&@ zSeyF-2+1}ZO7oAT6OkOW_6M!&qH=qaFzz3#4tFY#?mU&|5-H%Rb#VwhQzRH{uVryj zxwb(#!9bbEFsY^$lQnLwdD&JB@YeSsGAa=I21~hf9iSWV%>1P59C2HDbWHwI=5=Ta z;JJzMEC^q_vOVlmbi_h`D1F6>S^b+j*l>-{(BY2Y2t*N3f9*qJww$N{x2a<;iw>&P zsaz%_-{Ec?PM~(A!Ke4^xG_=Ye~N7nD_H(B;X4mI-gZ?7-#cGd1rsL_0Lr$(0~J2b ze!0;`)!EwmW&adLcR>zRUe4as_fX9E71pO6#9eEniwwJp7Iz>+kNM(B826eCOg z83ZqZk9$0=u6R)d4Y!tQTA!di(V!L77%c*SDq|UhUCtv55RV7iD2BQM{p>=+xNc1| zqGk8sSF(qGVf}Bb@wRKiB!a%9{;7lZY)f$q71@msTIE8diMO>JA&K+e_{f11UC8M;@u~hmoT)#vR}voQAFgjAU+kF3^r)o|bxITF3H_YH2Jemude!J%H(r%A%^ zTj~nsoA;gyusfRzn{uz>LyGt$-dE_2b?CiueA;A1ofxK><9k=~^i8~;tDRuN zVOx+;n`HW}z*Kq#QGq&ksF?Xk2SNO{6NLAG^xuWTHpnF8l=SIsc?u$jvv&RndjXr2 z10hIbEkUv~FXwd2YKgIE6c*q-7FhUASGBI1~*5S+<*)=XkG!d{9`h`F5@Q+@Zsj;VN+kQi0J-IP1= zg+1l+z_E&|^+c(*6!G_f?~D1M$!qdnqAkNOs*Rpa3|lIDDPO!(w|;@WSo}C?6){{9 zdGEaci0I+-tCVPG_cRaIyx4pUgfU-_q>)cIyv*{Z$zJl*fZ$zv0vVDet9d>%!txg9 zGB}U-$>#$*KFU#Gfm7#BJQuj-(-Wm2GhX6$MW;qP$pdxz+ZrpcU9v7 zYK`40kkw3D$QU@z%>ZC+IT6Q1;`!l)=u-c74I|bsx@}EzvAGR zQ@ba9r`=1H`f9`Bvqr0s}WE{hLc7%cZ%?Ie3cK!~(aH zz~DkM=ihm(j=g-ezG3#MH9h%Qk%KnK=Jq0*;G+Q*#{uAar1c1OYZ-@lJ+08nPgRqs zDxq6-qJ52e%NEk1A6S$F0^()Sn}k?+_Mhk%rL#Trh|vMO0>DLtk!@q zCM=&PmMm$l0GKPKTq&7iK^@vzlF;{SFzKw5dPp*V@Hti&2|_yK5x zw|`nnGC)mKlo%L7w91QCrB35Vs1r%)3y|F(RY_BxE#A;|qqsaLQE8(cgV)LHA)%uS z`)ZR--hJDQx5fRx)Y)I7x_XDMa`dVqzZH172xVJ`J{%vuV}1S|L=o3_Tyrrn;8oi0 zd%z{r@Q-|_Y+8^NVX#Rp8)H3Sm4Ro6gdhzt2eOMJJ_okS$}^t1p}GPK4KQu{DpYBf z2Z2&9k?`0nwI^HKs1|yAiJANtEV8kY1tj?re@kA)Ar!v+Fkd1{eth_%Mtn~Hd=U$q znROp5uH$ssEi7`=&IY6;)V+oD%Pmk>~S8GnNC>hgZ#EB#leoW8njnn41(X>?&6f3ZW46?moKg||{iy7kN3Kesmi13#Oz zYZCwfpxJKaW$Y%8lT-j;?-ued=UTy|hAL`0`Y6dWJsSwM_W0hQsSx9;-ck74^`M3N zJ;mXQ09gSfdTgc!GrJnLb6~T4yq&SxQU4AzCw9i{| zyx>t&D`7cI+Tvoy%cz!n)ueExtJ)#tL-+((gU+#YS(*cb^p+!4oJWbrfoX3@F?iSA z8ErZbY5C;mzoTHgXTmvE%2MZEElT>SrYFLxvW>kTNk)!e&)08UfF>fly|ZnZ`Q(g9%Z`-QU5A;6kmxK#7e_$aYK<>kI+h6-kx| zdqoefHJWv9>83|>{5FX-?y zho+x&B#jcS&OdWgt(axu7Ds~W!eS6SP!x7O|3?IOr}~AuYx@bO9=wXFhOB5-k?w2x ziD&IybT?y9pY|u`tkQT2brA2a;FV>825_PJRsAity0kb-C z0(UCwbqj&xkQv0;A$dj$B={Es^%PkikwBNWMO<{M*$CGVOI9mHpC*qa)R7q_qHx<5 zw>w|yS9YeW7!{^CI(wZa-f>(cI3(7QCfvSruzzljJzZ??;KM<`a-Z@9xnv?9Zn zzS!6)xs#10Gt{3L=wwtSl-#`cNVh)e*^ThGiU%uUFu!|j(Ql1-8meTbYd7Rr2;ZR= zGp!6nXT)0I+dMz@cJl>$wKPvk?M$y>%6x<_Mn9fWmuH^gj|f+?8hKL7QGbxx3rRzq z?vU2lD8O|6vo^D_T{xO=_1{WaTVa-K$9uK~ces%6@XOA94?&`+Q7SQmiXhY}+Q(Eb(hgk6?ob<#U&*eygU>`NaP zByBTwsTA%Ma6teN0#SUwHu@Ta@7tsZ|62*U%Q`h}<7F1Y@36*1TJSdn=eP6}@pStm z<_M}tzruo?O!$WRhRSd&T>~RC3bIib@&I+%L6M8&de7%ur)kob-mLi|rCPzD=b#hT zTRVJq;q2~8PFZfMy-nG$->caM-@2_z=A|4O`9e#$|MmIpg=xj}dhgPzgi`SJtn0>7 zh0O1*G~v39cTenVjf8kA4Ur?g-J{+mH=Aude&*ZSn-cgruIF2H?-k<%dbU?cK$1z5 zRulK?UfgwUYn!=6k)QN7J2XbJicJx+qW_i0_$F`O(5dxqn>(Pv={SweVaO zv{O9YJ3N=QH=)1}31AhGr> zAEkMRAWEpd%0jPaZn^nZ-7Xx6v4h)Zn*%BI3bgX?UcgIiVcz(0m#yu8ojIOi$7bX9 zq{dO(r7}FI@LSv#=|Yz8+;YGrI5JY*@K|Nv_sj`aG_h*npvenO64!PudO~q|+dX@0 z+bgqp_4eikt&0ZrtH=82$9ITDj2>!RnBsRMQ)ScZnPT8^kq=qtUjBQ5swG(+k=SoU zD3z}bzZsF{{eZ=JJZP<0vhDhXa?g75jx|3E_lfkyV-0qPt_@E1=w|~2UT#ylB#FwU zUO;F#aUa6(C>mppc!%38W!;n~wNIlizlsM4ivq){qWr zqPOL_K1WgQARxQzOWiM&*tHA);Jj(43nl+2n@(ThyX3$haUXEcKADAr&2L>g4F+7XdD;O+(Upf$?PPkS>+L%5EzqrW<@mn# z*}kZM&B`5P$9_Y5vwT3TFA5AMruPGRuRpi!aL+Q9ga@KMj2*Nb^g9SmqE6O+_Qpgd zD0+)MC7ruQIx8c@d$uO(Cvh}5A;;+G-?pAjXx~(JH(*=ZIq(z8w%l2b`RIwc{@j!h z6xjxBtC)Cpg@s!`N5omSkiSlyqhyQ^P|(0jZ>-Nd;4LB6{S3+KgdFXc>($fSK;GrXm*SuGf+icz4%0^Oj8}ypwvYeZn%-^yhUWG6 zaYq!K+7tNOu%pAdgd-OwQ~n1U**IUC{yX$+Y@G4`|8M^v*1)6v$U+d%edgy$N4^_; zqvNhKGtLp;mo5?iDMc1|dB-SBT2|=GBNwm$Y%olIbKAKU`@cWSadqF&(t5%+G;|a0 ztj{+ZVq5O`5OskSgSR2Keupjf@7YlGovVhTz+<$n=>LEj?#_*QdD+%^b!$29y z&8qOKyi&HcgY53zNy|4!7VX|x?}*qhZ{s%;^Vnbi?O0{^-34-9}oYt;Z>vPlFg{`gZ=*Pi3v( z5Bo%=l?B(z-dxnudh@^DUeMlZs`#~FzBf+(3f`*VP#{M(Vpj>py^2ay;qDXVou76p zf?_*_D|gbLsx3zZ6+&U3fkzGO&v-wLnU2IL8!`5MK6p^Q?-4h(uPoiwl>~R33@Dq5 zZyjp?@&NVdNw2>4TQ9!usZQvu19Z=W(DvZRsPTsc~!Gb>?TfmQ0A- zlGmR%PMV}?&9S`>T($}UI8*9Sy76gE`=e%tcz4|1M0iizw2ze43_NRdwxuLnn6RKJ zw)nxHIt&Y)d|FilrwEMhnMkIQU0OGO3 z8Q_Wck7V2(C7q##BYdemdI$E{*@J2nxVQ9=+l_7lqBL74fHW)NP(Tp|-9jmR>NRxP&}lI|i<-&~&CUyI z7=$8LfB23N-8PisRXWZ_PXE1bnOFWErR?J6qk&^3Nv-eNl@*loIg4{DtS_gx-c_f( zI)Vs=g*lAglP*d8Wmx$s!XuMlc2EAv5L1Vhow>s|_9ewMphGDhaSz)P9sDMO1xPBp5*na&e!Ldy(}9Rp4#f-nk5$?SK*Y zTZf(w?F9MF?*8APKejHVddCzbqITzJWPbxcS=&~CE|+v1kdA7#Uog{~0%Q@FotJb- zvqnawHfaU0pykYc8Q+pZNO{C{LfRwvZKU5tcZ6^MlNS_ngVqxt`;U;UMM{p&AjLMc zd(UQ^zGZ+Yqa%ttcv^Wn!qdE6W1i`emKz^G$ zyFa+le#5H&kG=PNYif(Wg((6`5mb;S#YR;+K{|?nB2}bFCrEEnLrXvrR1naEU?9>& zdT&8m=pY@X1p?$yLyr(3KnS@T&wcOn{sH&H{qW<9JU(G%t-0r(YpprQ977E}Oa3W8 z1qlq@Kk|pb11^2V5INCHl1dySt6criIaKD}^GVZoH@mHxUlZjoU25m*kg6;U0hQ{H zM1pJwH&#Rj`b|efzyFw7-@2HHA5>|PklLq`HX}Ck?VXr%zH#T>5Gy=69BO8O(%0KZ zSnz+#8`s0JR3tPE2mW)jWBr;bMB19<=t#o>;$qx_AO9zoTbM^<&Ou^Gtqyfpf})QnP-~R506C636<5Jz%rI9jX5{h(B8jmMl=ay1>})j zzYVUX6WK@TdY(`^uryzXPyYZuFr&!h!BkCR!we)X?`)%M{c5>R_|XnepHRN`$N6!D zlQ?y18f!u^wz9$ArB43K#?Kb>ilhx;*O7~P{+s~~M!b3DBBkb>u&8HlOJXPCaszoa zsD2OIFt)kxC51B}euv^K4HHvtQCmLI<@HQk4zG<^V}*>9Wp#sxFcEk7=}c(vR(>9B?ozvfPtd&!QQO0f9x3?1gke6SKJBI(Lkuues%}?k zl#>cq@dm3%P!L>#X?V0N_Q5BIG>ZvSPnOG@?-ZRb+kG8s0s7JMTv5 za5#=?@O_6{Qp>lB2~M9ujY79t^0EgTJ*DOpL#A)7Kip8wYWu+~+n&LY;N~i$$6;b~ zWpq4?nC9(23S{pcOU!Erd@LaqGOmI8-bds7pIg`9!*nrG^kK)I8)*s&;=${+2{4ih zWGMZ`s_g3N9`5^fi*o<)?H@kVZ~U%NgsEFCgMy{`u6WCP-N_r2?^iR{V~Abr1&7|z zV_>Grv&U>9OMfVirfh!Cbqf1byZIBDOlYVXIIzH=nwwtORXULn;0e_*TctQe0EtVzVK^XgF*T8NLPdV-#sl2#0--K)V z(9f_rOle4#^Z+gw`XvU8@IsCUzARhx{3zu!X0XRFPyW%>6K8&RUkS#so%gC7<=BiQ zOIYzG6$uWQUS3m?-aHZ^CnSX38swGPFe;cVKML4xQtmyiicz&&deGY7e=;lIFTT3P z(YH)5$aTO)67)LmhzNR$f2|U0z>tmtY z?EyLmo?F-Yn-3~DC+a_X%y4E`-yov2>t7PwThQON`O}5xj$om`N>*Hx3SRP>No1`l zFfQK!UFXGxm2E_Me{I5-+_T*hUKl$ycjPCI`)K1QPl6sd+Qir^_fZXWJ0tRLh$!3Le}3s7Q9%+L z_0%S>9J{~T@fR8@n@6kQ>|U)|ItRu5Pvhl0;*k3UCtd@I&s;h!E{p5RUSIu4UY~Vi zd`*Y$zHTULc%Bz`-2YDqIn5}K`qvNAS0#tmqoj|;34EaM!_j?H1WYFj)N~MROsrZ6EjHHTG$YpKkxahUHwZFhlQ*`#70T?(XC0(Ci5$O*0;xQAq*035E+ted!E`P^*0E8 zj!Hl>QhD^swO4#KY5#7t$)tUcJ}dDabG4|L-;eaJpv~}Kd_g*w`kKGI^hAE(iS6>E zeqMgs(oZBaBv13?1i$j}6?u zPd{g6vGG=RX=Z%+@vJU)9+$iJA{~Zpp7FIaw*d{oc&P1d3p!4|VD>sHJ|l7R$lrK0 z?t^nO)nzR%z*QeWNAj-4IEj467{76>W1mMWyP7HTrg#Ph-^-&^NYouy{9BZU8*Y!T zLgIge8|GGo*@|Tge=+~^;(s7*%AU(%h?}^BFuTIS^Ht;zYTWEWWypojm3i5cBwlW5 z^Iil~)QTdT#|_9#*p2TB3_Q~}WpoZetP0WjEm>d4YP?x0=1gSH#N z$4!H_f>)JW(B0)0JApz7 zm*8)38DF1?**7u@(_;F<)Fq}x7fHSdvT=(eW0!$Mp1OQdcVsk2 zQHQrTN#q~@tXZVEUBKI|JKlC$X1uEj%0xyNRf^-)d*(dgKORSezp?3Zwz!SAuK^?#r+u>TnJ(Eh*}+pI zN|%-GGvo==<>QtgneLP5RI6`HQl2R>I`QxqsJG^|y+@g>o$geGPl$W3(*%@XO%I$N zl0(1O$$obJb4)S;R0J;jmi@STXT~=(;lbn4$5e3|4(JkM^3IM^m z>zRs5FO-;O*1rl6X}%vcxl%K8v>Je5Rm#lEELF((U;t3jJLeoIP2UfOLi#qsTpSbpl=|--Y-w&F&6!%V0*zm<9MPsT8i;jOlj4qTFbpuE{!a!GFk7`FDpq4WW%l@eeYR8zIa?h za|k2TZHSuEeZA z0~N12Ypq_DAhQhm3;Y+Glv33ny_6|ibV|GFu#7U9RvJ{0wz6g8wP#!M z1F*j61Ol2HQbsBCkRfQZ7{naGn#S{l&9#I%F@N@E9`8TN!+Y_Ztlo<-k@5M+*g7sX zu==fj*nUJq2{Pk>9UxJiqxzU*u+Mp&&7ZM*CGeR()uq59pSVl!>c8Br-Be&M7UPUP z68R)*)0q%T4KMC2D!x~UNnrB)u>~|<5JP!YxOiVcizEsfD-h43O##DkwnlB&&Fp%R zzZsF-MyP&lqcgs=af9o1JLK3=UG&8%g}nBQDjwUvslWJuN1R*h{l_bCTICI6N3JvY z;-SIRkTpfOX?@{ZN&2Zw%hT5bC-+;z>KD~!6^>h888Npujjn$109s}943J&n*w!@P z-SnU?%k^h1g$t_qnIO_aGybtJ>hZ}3IR}-m zS+<6+e0$u3lTV?X=A}IgcWd;=uZ3=Gat=(|;cVo~NB6>)0PH`gur+s5;CkL{fXr2V z@gd<4J|lq}8dUFJgbM3uO0?~1p78|0J7bc#L|)j~svR?2T#Kuw^nCPHGEs`ok@D)a z%dF>^KXL6FIKR~T5GSEDjQn*x_&fSZQk;j)9WkzcQ+u!Y6^}?Nn~~KUYv+ntvreE} z_?;ifyXJ)zvuO&Y1h4n{MHE3I$m#;e?_ooH2kAY4@j~5e4}HrtEv$eBzfUX=R(D(H zcsUdnyh=7)F8UI~;uEmxdaGsTd^)EEYFZ+hWbhn5`62>0m!!71krbkY9gEi43Gsch zQoFF|`)~?T=Uq=r90gC#~`9H`lERMBfINuhkYq+j|BxnMEDL%R#KS43u zfX)|W;Wl4lB-u6~)JpDD-*LEusqZA25v@6cjoJAc36(l4iiYQUp1;7#iQuIJ$fDv@E5u6xNtO zYnOsggz2kntTvKhy!x{t2^!46K}Ls=&-L|GLq}HE{$jz@i%+r5Q$|lS-Q{wwwZO9B zs}HoUPi&OJzW)>o$j)I(m_)z4jwn(Ix_D%5S2w3Q8n;{Q^+)hgIeFzz(@gZX)J@(5 zUCei;LkFOINxpNLvdK_?59`vwCtEExNf|2`m+6SvRKl-vO+dsr|73U!t{&+>NXi?O zhD`s7kTTLBh9ZFUmmj7?!}DX?6|*NvtPHI?g{RXh!!l!zq6&S5l5^~nonH9ra$cNdk zQNSza7c+4&7dqtt%P#H_%&cU z+Ldv^Y_1A3*JnLHc8xjDec^q`ndpW)bsti6AHPkd7bO8dt&DHmuq8G)s-Ny8wrsq+ znQ#x3(Go#9^*I?BJ{d4wyx1LDisvW~?rNEB4kMa?_#?cZ#g)>%0egHX>I4odLq_?t z-cwr7;^K9Z)BqL@56v>}!y09D=IyoB?}21Ab*sv~*=~C`x86A-irB2iQt%xPnGsgo zdEbA=vCI!>WO2SbI))J;5kOK|jah?gAnOKOW9P_|;&ErH*S^=;X(PKn#i+SYJQhJd z(03a>?RPb!wYM$wTXE%$GIIcgIMkmJ5AQ`)+P;D-69!}>2D)*U}`wU8GOTSU9iM)I`VJqpATPYfE`Va?~;ZNoN?4sPmfRVZAg zo)V6I=?O4$H?$v%cKigOAog1(!utY)11%GRJaqCb%nW?D*{Pg+(M6fl(SR)vymZxc zr6%Oqk{ATSU(O6pua)q?Z{Jn!nV_NrfAMUr@;H63C774xdCl@}no$BRKx0+nrIyus z@IoznSl6O}vC=~(@e&se+?=tqTV~-qdnz2d=_t@x-&@sj&N1p7c(>3Hx;_fB<&78m z+^Tnekh*T_YoqJN?J13c^vV#=Qq9C~7nWDgCh9fiBz`^7oAP`275ol(HAihcXsYkD zz7T%ew>v0wfth-sx)Q|jOyuAdz#cot>-}RMl6FV^krc>PFwZL(r;I&P`08ij%>U>D@%%O&-dDUTzK|MJJMTC>Y=0j&p z)oDRSzv|(v>cKq2oQGq8GaABm88Gaf7--iXD2K6V$VOX`-u|>yS8%2K(uYBN!4ve@qy)4&b(-tTyz@ zAc8mAK_*&5Z`Fhu{>`l4*4;7BIiAxC+6ef@a<2ORzRf}5+wwZ!5Td9+jew%N4t9C$ zEqtQcG6hg}%@O>Na~bfY7ztY*B1=hecE>>MbN?X>rT2iJ~frQ?Y*UBCvq7cbA=2zrSldt_E35#nHUp`LWFBXiB@oAJc0UZd96-0rXNU=YHN9?b zh%IU6_`hff3tp7d6}cge0exw%oTFP(T3bkReCa=S_4uuWFNxS+{;4&KQ2D}VqkXVv z@ALlkDw;m<`aixp{}}NRAnD@uY1)ARF((G=R<-&0b!89WXj_%0-%q>J0-7ioNmM;^ z=h*iu0Xne2+-OAo1&dk@-%CK;0o=u-;~@a>rj?F%P$&AU6Q};F^Z>BS4@S)JGF4=3 z)6EkYAD;5lSeb_ooEd(2{QN@aG;&DIhl}SgYhVQ^eAaAA>J@sJaxxrYMg&sXgD<7D zGr_F^vkt|86b@yQQ?kOikd|UM(>&_jeLt*KswVKPj&FZ=0+VJrDo4Bftcw$d=5_%r$_s-wcM31!^N929V7qsuuLRGRG3PY zY3MmufIs9VwK9tsutd0C$#PC~KfNbH2i8xsH~8&-(WpYi5_xO@h`0APk-KUzE5px` zyORp~J7LpSdJxXhy>5qezWQM+67D)sf^BtI&V|Fqx?R7%vM3pMpTeJR<59cqOp+&G zCR$I)fUruedD8-Rd#*WbhQl$!R*%TLGQQJ0%*wm+8;S99@wNOhB&3m}cY6B7r%o1> zIprF{+Ibu}q3*C1)GsmZpTLswCtK#d(dL3w7dyX=H$l?@KG!j&no?g358^2k%OfO( zOvYRQ%v$W>C{4zLgq{F7YQSQYeu0xVfpDj!WduFgd&GXT{1l)qUrH_yU#9eQZ#d`l z&Z^vGx4Ic?v{|t5S*o&6AE4T`*Q+C+R1Y_oY(DYqF8Fh>q*`t{d!KPaDb4U8UrMic zW9sYy4j^ek-9}mGODzFK1xmJ1|3#j6y;=IA!zzRUf$E*#s2gmw&Kj4t3v3qnK-Y_o zkI!EFI>jX*@cz0(51x=X;CO`wf>vn1Y5n+=4=^GS<7(|HS$IJJ!BwgQjkLleDWuzI zUWaGXwk(yb<5P01Vzi9_?BC|v#EnWrDD=x<7P8hAq-2ZH!iW-D5WSZD_#D6*(%hWF4S3qbs0dfspEd@3?ar@)Bmkp3kYdv%_bX$wJnWC= zl)WBdOqB9rTGI}^SUG0|;2BNM=bcs-FrPi-J5L4k?w8tvZ$7oyuS|q)uGpO}+o`Dn zhbx-OU>kryu%#>!nAf^VCUPemFKXWn1Q8@=tD`i<#^K8MKTkLufUQ5@6I+c%0-?8G zFJ30qUB4oA&9ZLD3_CS3EAZts2{q`9aq6nMotjTg!+t$+S<=U2ECeZ| zg5c#7hOwK5|M!}0Ob~}4D1tvo4RUoAARzH5fFBVBbuzL`)h^~#|UJfSqOCf$)+T;2X&5N*?o+6ow%^gU65of8?J!_ zLAOYKpc@n>AYZH$HpFY3A;t&~T3U);N94r6(!CP2HN9_&_-JaKX$cK)qZ+7U8hsi)|E6gwX7aAj-&qYEel^PboUb&yD9yF z<-fQURCr_kLewZKOP3i=t99s~>&?6^?vQE!;eYR<@u=#H_2X0n@j(~~nRk|-Gd;K( z9Z2lT?Ph6Q-YmrN$q5+3GX&`ql^Ad@-ivGeLXfL^p9+cr`2GP;3N{)%xMbQ3rZL;rTH2XZ&6GvFvpa%$ZuJ)|&@ zvb*;R+%TzKCoU^Vj7vKhf1Y}z%mU0upF9j!nr_fpw_?Z2r0LW67oLxrJN>4-ncFqX zhlHycR`-sN0?03}z~s_%ip?TR^@4f|Xx)1t37@KD4evO4EPb>izV4KIc4`892}tuU zz58a`+FT?ceYO@oeTKM~kD?_LF$=+ZWWeBlf%|93 zGSPuG^TO&nXHsOJg$9!*U|^{ zlOpByvW+kCH|s&Xwv1Zb%5ShCN*atdYrsmXvUUKXSmRF6eq_J0>HB4x-22+qO}`o@ zOLdE@J!SmT*NY3+1W7kJ#WK8skz0ccTJA}LN-1sPOw*%b5)437J2f|+ zHB_DN-WGjb$$C}YyZR_gw=T|ALeZvvC8_9PU-VNuWbe;tGe&p}x34j#NitQZ^{iEW ziL5D2u4I{Oci>@PC^VF#k)~qo_BK{`ENZIaHuqc%t1UeT9{p0xu6$kA@YPr|h#MnI z(01D{@WNgutHcem8lE)^*n?d6wB@zN@Dz80kl4Yi(e@GE;)80`Sx?{SC2}hGV1pW= z1wTxa`V=tCZdR9Gmp*&DB|Y%QS_FAp)(4=BUwRZ0$xJp0WTh#CB-WnH@z`O_&kZ6x)rdGrulf>(8~QKrKr zXPv-tpDrD*^T=^bKg($5WW|K#^5-;dTc^L>FK{2m0u)<%q#^F50meEXcaYvM8;ST|odh)ku3Njwn7mI!-ycA zKa4!}+d|g&>mSb_TvD{L8J( z9xna#R%HF92a>F)1FngZ+<_)*t%+NtqqKwLp5>E)rsDrLW57?={jx2}+~zNV>g~nq zPJI*A>Zfa3{P;Gop1@eZ54^!zB<~^?KRBR1EVPJB3lwl5+~iycaXB8Xn$X@q^H^YG zn0`8Va#qRmNCK*izhQ&y?`&M-+~87Dljo9$j?Z2ZDyr8&3@95=k!Mipr_a&Aols}y zxEEQ;5F~5ni@WAtMEQ_J{GvDto_dUI2?Q*Bl)w*~`O)F0g|96k(1 zG+DzH3Sli-!p4gfkddb}UYE5?exmT4V88vlg3tSN zmK$49P=9o+C8Wh(kF@=i*Gt)-Tm^8vr}4kj&ta{yt{x8IeevJw243x}@|)CtdR-{j zX>8%=4<8%wh3vs;l9@bn0bx5Mt?aL9C+Qgj=8ycK7Inz$rG2|X2u=$%quyxK;`^9P z4_HC#hMd(}o&NQY<~nQ8b+S8#B%i9%woCm_qq0f{jGpr?ZVsG2{$ko?+8ifuO>v(} zPtAuOs#z8Ck1eWQUaw)0_-iRDe@|#;4?w6T^0;3*sYYi`^iDO8%G= z9w4v!cX-V+VA+GW=@X4&g`CmX%rSosV)n5ZM{YWiBB7_bCN@CsPU{u=F6g->t{HR< zyJGERl_V5h^ssRv27*}Dxo)GcH0Le%cdSU$2}$-VbSC9>ZM3)Lo5QQ)ZBujiTiVQM zG923L8`}xOeD8WsE4F#*!!wW}`K6xzWuvEg&{`Xh4m!usA&jR|%{JofFa$U<$G(T! z8vAyEdoPKrn77wxT)#*vmQ?cewz=h&oZkLe?R;P}wzN77sGug?Up-m_;gR!&mIfR~ z?%N?{;|h<*l=jsYy(UOMO=AiRU6WR#P}t+rFgpta%-Z~-?ZzA{c_KAzD9d4`(bGx5 z72!V~LHe%)!v5S{-i&rpI$6f&O?KsPJ^h;egLnRY;R6?{=j#{=>LF*kVfBj^4+Cba zQNuOOJlE_kd(NU@*`zSd8MXKt5JH7os{r5mPo0i5GMnHcO$!9)y^*efU87n{!MB9&u{-@jk6@p z|2;pbo;w@OUXC|z?GUWg1W%C28+53Wk_^g%XE4_pz9F%4jygKh`zwh`C#{0xz1!X$kow ze<%6{<4=C(x;HOYafeH|w=An|niaWV_qxse66HgliHsZe^*QJJPAh&yFkGJs|1S+Y z@|ot{)_hP-DT?HITad&+yL-NCZ5dPJ;_SV^yOim@@tLQ z3Lx7xl6}52=<56^2MC2~?=CI5hVf6Z@qsJ-r5d6#xQNi#8+ z_}lGg@sP38*2_bfEh)!qS#@xoikhlh@Eo`fGku|RNv~PBS)rrzKf}cJ&{o!R@s}F+d&uwk4%hC#0@d;eS1)&3iAw9h%V^3-&nVVpqs|DJqX7c9P4yt8Mv(7bw!&HtR^ zmH!g{cP<7NwLH51X>LOiDbp?#sd!as9P1pX*@o8VaTu*|pTLCQtAitN5xm9o$T8>( zT>(KdK0ea3UhqInX&BeuEPw(;`%xI?O!Zi~shC#;rltY-k7yu@64kY*NGtWl0M(&} zwzW!MSJ) zeM6*Y!BivG+pI6+miEA*zYk{_;mX2Tw=XrAgCHv-Ev~}@pa^G9Z0b{c!PRJ+jlKjr ziTGM%Fzxfq&*7!gcEAqA?1#YHY^AcM&{z$2fN_Fb{(AWP`=o!{z@hC_19ba^rsu%s z(_oKU%YzKC-OST-jsR9+T%6rxxz7}o21Q=BpSdcO7Mhii1}l21+cTR&-!Cp`lKf^m z+20y?$YWvkzw%RD1x{S0XWgk*9$cb_R{Y2|yjz_U2i+0y%(Y_q@XH-iQ~pxZRNejb zFu$v&->jWg@Ic5)QB9)4`4zn2wchpVedOiuz+hRJ-mbl7dPr00<`pVgZ_JjHf(DLv zCcz7mGW*EkgOKX+cN&>!0Z!hE2jlO)O#NX`uyGWJZc~kBIirMcjb1{6(m$qBQB}t~ zC(YIv7JQ|3wk~!u=K`0SVan{~`gYRHbV!U{V^}mxj8;(LxG;rBP($CRIijJ|L8-ri z!RF5-z@y>8oRn{*pqeo0F1%Vapw|*nUYU#+1&F*vPNqB`xn&mMmn69&Qv(37msX<} zfNtRANTTQa@muO@lv(UrJ8qmsD0#1W+)RRTLn4J8{ME_g8RI3taT~_=fRoKTdBt&S z+E$7Qa+h;n)7wP0V9kz;pTtiy@B@c0%j|z$JO|I&5gR7?NM}j*UEz%2g)`a6A3JA> zShYdvp02d%$xKR! z9`r-$I0|B9*k(F??n%%4*Jb3153j4O+--S*;PERySF$oU_crNY2KB>;(0d$b(x&qQ zM?ZBWc+I8qG(2%b>5@$=X&!T?btvrV78Vv4&zcP^a`j1xx@jDDH~1gEEjt_8%ulqO zh7v!X?oVcsFVB8Wg){)3a|aX;QcMx6yiwqu?$;+OSsoT{-Dc6EF~A3z@m(y@GpR-I z(AztEHa0hz%{Y6srtIrX-4?cSAT}*XH&VVL$!Db#0X6rx@x8`fsA&hTsau-Tbo@l; z6#{2kR}yL9?V6z7@MF?dljV6c;H+$6Ej-Po3g96O4?@Go>`UAPuTBKQ<2fg9v@*Mu zW~Zn#wn+eWR_=t}MD|x`;cm_kwJ8NxdE*`@svS9Plr2qxN3;Jtq+B>Ih%vYxcZ{^$ zGG;cc&>YlRg#LR|VjEd2PE5smbWCN2sBU45bvj0I-^8@lK>^mQaIPi)Det!l()qZJ zX0;d9!->Bh!_*VbZ;Ul84hQ&lxa{Y?Z(L;vh=CED5Bi9 zKSauG21*H>v>`^y%RLelqHIKv%ZI0e&yYTwk<{>_D#D@JRL|ZFsdlui8Yn@fLiNUh z;`<>Vs0cZAajROjysC#q6A>T+V%lTckDf=G3*tCCc=lgfb9U;y3C#2$BaXIMHYPxN z>zn~zSpAgpn%`L$>d?V@q=)&&Vxuh@hon)p5kac@PtraY`u7(ycMv7Som{4JUe9n+ z02`3behmuJ4xpS!rOz(P2ZW0iaP<%u-&LRp$0a7(N45YG3Z*SO9`>t?Y?@+6NvTjf z`bXYBd09tupIky1-cRj8Fw1?a?r*5GApuCbuMJ!M_42ai+l}9b6=x-n`GX0eP@*4} zPZcjeVKs`+_&_*JzS7<>>{H=gO1z5LgxFmlk=U36Z2>pdlf?5%5_{bHq>PIMe*c~s z8wb)ul39)!?w~v(k<^SrLK_EHT+V$MwLs8HNJ?T_j?F3Skg;BJ3=8i?eC;LxAjo=! zenrcW5!|CZx8utz>GR--mEL|j2+flr}47N7W1!=L1-9M!3+RN|SQ|tF33GOk~(+HEF&V9#<)0vn@wRiDtZHG!Mg;P6Q=z-bkNxoJ#0f08g<+I!ri)pK&zsEq!%$z51pl5_8vd5=Hnc{Sc3 zAp^2OHJq4;b!frwG*IN443Yvjs((WO{(1d{M)d@Z%{PB|rtS1m!l(Q_I}!QTJhnb? z$D5}u6i?nw*=eV0Ul={T{6t>Tu5iSzFgAR{3L9$L;{5q{jp(w}D~n1NJY_FmovhER ze#}AZ+-m8nRH&h+FBBH?r!mY^&obyEtV>&d4QLl|j<`H{j{FURj)SxW^BjCxj4M@p zx!%)J8Rr$a_U$_HsNkOkdW62)6=h@7St)(#eajA*_5x={lv+e_e2`~+T!aq&mboUT z+|dP2X_a6Z=0jeneee$9DVP6TO$z?TnybWrEbHd(%8nF?O&IKH*v>LWfy(2u9yc@`4m`w&#ZupTU@7I(#aUl+T%RFP!^ljGHnf0 z$ont|n}b&KPGx{D7z0@iI?E2AICIRyJv0v#=G-x1?287{Zw zY&cEdBQIm)W{6J-9PVTB-k4!iT8T`iOTS^%TA3j&&cnwNc3(s8Hs{|TigR&ViRXED zB#r2U(ua9uU_YlNv071&iT^aaSLVN=8RSZp(BMqq#QY02*=Kyx#M%yZfGX)Mq0~<) zeRQBx*v9&6m{(HTIEbaQtT5ybG%=7P?v&roCkjHBg}cbB1X5M|j-`;N8KO?Hta@?( z!InNVICjkzs~!JJv{myo>EmYZe29;Rbw!Gbk***XjfEo)fASq{o2ZXL>Lpcu6QM7L zlR?^;RdY|dB*w0jcKO^D)tsPrb8&X;OA@b}?7ms&rXeRuZ{M>V_cs$A{`sxd8ISZc z{u-9vfHIN8OYr&x&gQ0+@22i+=w)spWC(9(SLE16VFo1sV$#4yf;1my-CUIOrQ8<6 z<>|uKB=KpFbLiI);l1hYnXVj~JRxj6;Vz|l{}5votBOXoExA6&lx`7CJ-WpRn34+WX=ufqS+11mnW$|EZ;#5 zghLH$BK*lV6U{oA>aM}iht9Ldwp#`%z0nn%mZ#*6nY>{UqT+^Zvk*b096jgOx~V*E z_N$-`{ed2w02p{pX>?`m3=o(|)}Yw6jC<~KqJm#-g#`^9agbx%w7g~0BEerTSi08FB_zpe;jLuG=H(}$%9SojN#F}&2~2ScHYgj z7wm}nC8TNrxkr7aE#@DqENkZE*aW}*%X)gF!^b3j*g?=pm1*a z+tWCugk#giV*&lAJCW3ir>D=dKgakf(FaIy>!`nMrjagfSrahz!=Yi6h@-FpGFDMH zxYU2t$C9Ay22BZ!kXr+kQ`_yMU%Bm1F)xYe?>a5I!U3N$0Y*%_F4@mcMM1`6YL@nZ~DC^>9zcYVyx4|6C=Uw zr~jM2FtTXF@JY)Ial`QzXUZxkDScuS>XAD0)Auy)s9?PvE_CybL7cgHp~*%7b{*`D z77Ez$U{*S|^A7N8{XBHKAF7W? zibtS))?j;psg{aVaPn+GIk`?1;!PAQ6{^XwrW#+G33VH6Kx9NT>E4rfe@l@mqc-uQ zM6m2qkcnTv@*IRUjM8r?2~M)K2}ZmdMfIg)opY-=SDkLO4M{;ia5$isWv{*KEi#(| z5P^t6Zq`nNJKkZ&PUs>!uyZMJd+L;`xvwehqlWf*8vShck+oXqvAbR5L^MYfJ=>%u z%!(9vC&P*o4?>g79wjxtl$5B1oF`(riq%%s`G?+*Lgc~;v4$Kp7N@a|tK-vxZ= zldLFNd;OJt&FX0blN*!!PrhB+T2C8e(dG1Zqf+RTTxq7MG>*#kE!ZegbQuwnZ)P5C zu_;@h2k}b$FPw&ung z{Kv?%rPR$KMO>3BlEn!R?oC#%xO&U*}BxNDLkyux@;203Z$Tw~gZtow+k2eMrndgp<-!DDF2A;L!L zOsz9l7jHV|6h_Ssp~IcIpT@6G8Z?%Lm%?DnD9GpZjq<#!+2}_xZfc5*rXRPe=HI&b zh?#3i^>uxdSYmKn_{^lBec_282VKXX)7hsT&mIrb*qmbxqgK3FKOAy78-Itp=AU0J zZ+GMdh+u2Z{OXsV5TpbJw+A8j2R#N64nxYv+xb1evdU!#T-1j?srldEtH2%jCJ26I zBE3~D^CLNbbpLT*TRMI@fsQo|u?rJebQ=>oZoQ?~WLI)uyn9_wk&RXgit+paTnDs* zP7|-y=9F;pb~VP~S`N@KmYxB%lh&}U1_t#PaId6`T@PD+6dM+dZ(6HBe5KaAz-4X* zHWU$;_z*JBFZ}o~IX$f}5=g(BW%}PD>c+nfKOc+^np$TlEo@i`9rdPHbX#nyuzOMe zveNEK-%QAgc87Z(ol*x3w;$10oa6@Y>zZ**T*$d|QH)6D107;8fA)Ls)-|j3;l5MK zsf4_~C4;@k>^-$ntZr9O2I)_%V^f_}XxXBKO0o}o=IOgVE+ zXAKJPGi`hDNNgluY^dm$?-`NYUG(I+#EX;MH{f_&@37*JoKT6dhyqDIS-bIX+Q7+k z=FatF`BAlT-_J}cs@F^`?yaNTi^_)6LW+^RrZ!3PP}>fEbN+h=>%yKRu=mdCLxyf9 z4M1n`e!2lI&lcXz%wULT>*l`4TF>tSgZ=@GPYx8PqiQ#9N)da(hb$6>c=HA+x6A-b z8;q#?3C)))w4xChyxIz9#(e&VQJSjwemaH-G$hXE!$wYbF%Wb!t(@00!JuBGI7x!o z`?fsug<#C3Qs+#dC2kB+yZUYE<^=Q-?&gIA<)1%o9s&If9m^7@b*vSqkE7*9ZP+IQ zQ7W!Iy-EK<0Yyua(Y+8~XVe^S!%L=Z%>9}p!SxzBOtfMoy6A02M*v(M-^A^9G8eYr z@@(>9*eAD$wV5nOQHA4w3;fCz2HBqCE4@4W5~l%r5`R45CP*!bvBvavzT8UU+N za@io|x~u#A!(`7K8(AX%vkFWlr9G*7I6p1)*?@1EGJtLBI{>YoqppoiTY6|v7J6d5Q_@a1hp5dr%zDs}syo19_8(D(2f4!EMQsLq%fa*3!{(flvYW@eU zbZV_5p`AaGg+y_mg<+K1x>1U&QXG#PFr6IEe<=&6~s27S@wnWFzdw?eQbj>q^+orx~atJplJkCee?c zPg-2<6iAZqRqNVgaxYVSD`CG^2KQH^``9!bdoWs^m@aXt=fL6K`!kAa}yA~{uFkD$mJO3Vb!6qKnHIgaT8?YmnnByK&{Y6d?-nmor zPXBgK7j0#OP(fo(;r0VFj#p71zzVE;wxf$vh?MSE&&$@9Ze*y}+5tyiy`E2LNulZy zfjz(WkwI%sWsC2HUWsD`^l{UDk@O8)oZTBI-(!`xdLAE`Ul>R?rj^FES@;sklQxi9 zT2P3|1$OEZemYae?o&d)I@Z7bFS7Z~)AYq2_IJ>Q+`2CuPUMLkjm8reup2h}29>{+0%0Sb7Ft(!j7BQ%n@mF51aAv*j}oxzU>JcDMst6!D^^Q$;q zxu)f7{R)oolLDICq-+2{zP9nRD}4Y08aUANkK2DcZAa}bVbP^n>m&K6$qxE7m3bU) z#fAF0=tp^%VwYiS{1AASUBjr|%YA}K_E<{V{?by>e4hR*IBFjDE8RO2Mr~}`)yUL}oTm4$!@T*R}Dn1X^a0qkZ#Eq~a%jU7d-4baE7rA;BVyeqSpbu zxY~$HH=C@bgi0;uu66C=Vx}K{&^>F9b6|rWg3Z~YMC-*WwTfc>>JvsF)9W!&QOro0 z3vTBNB5~6Leo=dpW_6US^D}w=A5Q@Wz$0BpDC_@X@4cg%+P;2olp`q8JgD^IQ32__ zLr_#yq$tt}Efl4Q(jf#y1XP+w1Vlmn z-v6IF?j7U#3zF=e?A7L4Yp&1yPDM2rvc4yu(qoB`-``@S6xt6K%4ezLjZVw+wu+YP zkffEgZ$XvbwR^G@sam*EE{R*<~Qz5S2xc8&>34rz30N$e8(joSqvrVzd zieA8;@!qn+_szWD)P%yb#J%h?XGS2u`78?O{p%LJvQKUE+#RWNAC?u~1rL9H++n-f z1ZVow#6%#jTD; zj|^rVQcVuLd5lr&S+~z{2Ex~_=yYJL_IkZZt#)axcPeO3qgLti3ngy=r1D;>tN)Fx zyo8I0wWY7^yYFczZyc#@2-^DoiAee#Qtgp)KFB~#5WLec&=$@SyBEFD-KRF-_2Ddm zzy6KhN`D!fZowaJ)aIF|Q=IG&zS3G&3j();$2!wTen+T zP-i4Q08e&B?amHE^^#*LiWxhMZfYrw>El!eIITu3h^87%EllH!!G;$Gc!DpYWX9YI zjty_D^1jq}(>TES#iwg^mnf%O@dJK0I&!ZJ< zWRaeN3lBc$J8d$(+5YHg6M%l(HD~mJv_k+(Evz8`-SBaSRwU2GvbkKGcIErBk_wM( zI`sG`GcYPOQDUlyu0Xb_Wl21jch~?*{krVOx@5o2-s;Iidk0ZkC4(R8XO226!1OQ( z?Fg;hM+uB6t~CF?&u?z3z`1w!uxcPO4z{>y^!OJ&ai2pj`&Inx2RVL$s`2ed`1Juj z)aqvFd?~~Py-@K;Kisk)YOg(n&YYkLyYvt37;M2(IBWeaWKX8`7G@+gJiG8d?Zuz( zeDT&V4Ss&aS*B&E-92n!D_yRI6rbl^>VaJ8iG7_cQS{>u>rOH{<;ff9^K@B>!wgvR z2^cTnGH_>~usq=^{J2NWBT3Z#?g6V;kf%P3{G~AjgIz z_%X0e!D?@>|b^hx|qGTr-A7Kv@|WT*Y0 zIbS38(Z;^)YGP*ER#r}hC#E>8G19TmoOTnVsz)hRze3ve<29?>acz_=?2{3LA)k6v zmXEXujYwa2j3BXEmV3evyd)qZ9vDq$w)Ig!t^SiD*}v{*muF06;=6o*+G)NQ z|0pLVW%4w%J+&O7o4DgupiGGtgHHt3RgH~2qLrAsWMi(0qI{&Pv(Z}}xU-@|$zIq| z2Y1W*sC!7`0O#d;IMzz^^U`zDFI5f7Ez8U8k3xM#?@?l?410fbn6HiC6lMiXD5~)G zzrzbdSaj3*Q3u_M&UGk;u7Ev;ze6f}9 z>Jja0^OhYrd&$E-C+7(o0j3CD9j!<+#Y~JF`W90iHdNQ&$XEJETYsWgr23Y)L!OK3 ziw5W)8q09ck1QzNC0ihQP?!*F>h-%gb43PP&TbU@%7MiRw61#Yq6|^;JJ1}ShpIDRUt~OenYHb#l`Y*HMz)fo%mXX-Hsn^w1BDIlJ;o%%gzwd>ksCrZsC67iOZzQLz+SLRPgvW z;e}BDh=3iP^*4w)PK{T!92FtVEgoi#GSOaxH{0I4JpTiV){3HKE9c|{2W-7L^MYaK z*gSjb!?gqphT(-I(*)-UBRO#&)wTd5vz!Ck4aB7i+4KD%nO&Zpqk;olU&*#!woyO* zaj-u{W#1_vS8y`OPYbKRp3+SvPqb0p%1chA5eUNX$BklBx>o>=1^ta3fkv*w^~LP| zcb%5BURV}7pP>CIW;S`4epJ!-{QdAgN?2$RYKSoF89wB@Ach=h zD5gn)6nP;ip`~fqN|x6GoG}tjMWw$i%Rh;j{Nf^qr2xr*=b3z+hZ`&nX5@>B>ivS( zn2hM7qJ!w8@$~IE`pR6236qHd?c*{gXJ+1Pj(9!%s)~nBDA@wZJT-O*I`>9`b*a7P zbzaQJ(f)f(4>(Ca0oHb9s6zYw`fa?-I$R`Wgr)(#GC(fry*;&<795(JRTng{gFJE% zvgD7=OP{KJ>-Mw$7EO}txo=8O7S|j*r0Mhgmb+--Ck<0{<5x_=bkP&^;=rZw#_ly1 zPpX#M*m&PYgg+;Qv~0_p69eT zs?nWF3D1I&q3J_227o|HRN!rl2C{Uwr99yk-Y0!Mo(zNAjrfGT#=k=aPWsYRP|>c! zxuQ@?VI72}I%>b%`G60!&Bt8SjNa8mwQ&@;A9X86Z8HyI#1CSa8%E;PpW@vd6|S%e z6JqCBZOu231L-I$I$O&XuDfHA(^$s`FJin8kiW=uX&rVTj`VpMy(@r{eS|k1md2Z}L(h8Qcmx!ry7vf-tV3{FN-bx|_s`R=l zj?7bL%D6=0+4f}jm?L79svCwDpS`O}xRe4d#p4@JxjmU0+V;cc(W7P>5p&g8Ws%Fh zv#I=6{n{O0iB~0gV>d#)h%kZ3D<8>%F}NCy2am;Hgm`)>%=8#?zSv0bCdYxiYRz5x ztvWD#6PIN^7G>Z#0Pd^7lKf|OC4xN5^5Ut1XndM!|AZt+^xJs(OT^+>WwnWj?e&dsetnF}<0 z$8P{}n!=B31%|Y>va(S71TS(4PM_@>&^SZ8GgR|fV7b&a&0lG1$@};zjL6);52qS8 z1CgaCSm^o&c0Dq4t*&V`JXWqhbFGv@Y?2)UGW6_TGH+Fsyjzho<@-_4>y0CLoI?fuE>a@x+4Fz+3% z({p)_V9bicO4dDa%4P~KZfC9QJM&k3u;6ro&Im`mIiXZhQMx}bV62ouQl2Zp|9f*{E))`l9kkJ^xWM`O6&(SDR%kMsuZt- z_;EB}k_{WgrmN#xJN(vuUHnHwUp*i^Tm4MWW@4;mBok4QEy?Skczog3TkmEqawE}? z?l88K+41po_w6bU^k&6(goB%S`S+UEGQ~#xtCJn}XMj-p7eNn*aS$&w<9(esBZL^| zmXvCd#MzT#)q&G3XX+`EGjptV<*fulylT!J!*!oCIMA3 zeC6?NJM6U1W6ARc=`s@i_7Gz!B-(o8&D}wuJ|u|qEO*iazlyA`xf!x?wyb)?iLMvif@tch;6xj z*sNlBFLP>!7V#y50olGEsqxB%mT??9|AKx`nr$z{8epMVzV-eG%M=hjE^-%??EUh62PKhOzTXlU%< zkwfdn1`NDA14s@ybewe%2O4tP@^{&7&^WIed91PgznX2NQ>w8+7eLWi&L2&@jAm5> z?rA^1q$c`kc>q=K6En{FFG2smr5yj$YyMk@83u@Gcrl*~mdE5CQMA*l&CS~x9`%t| zSsH%+ov_fh-XFdXl5#e`-)``H;YZkCTD&1ZP}^q|Cu*oV18>#8TfQvprpT!a?Z9|Q zlb3O4f3!c6cm5c8A!E4vP?gEJVQ7-JMR=l8Hp)UhR-DT?9f`s6Mh@>k}}%t7Tk zp>u5KV~sEKUy}jO1=Vq0i$#D+GR(~x!YhDHy?fym`1!cq_79nXmcPh1YA)$F)Cr5j zBp1RiywktvtN*XvzD+)AX|i_`fg-<;UxNOv?<5aT95GUK{bZ38S8Hs0;nmE;Q?BTR zy=T2vfWhPo?NQx6Yh@Vn7w+EijN+2VDXPK=%qSJ+R{cz|@5*_m(zgqU7aHpL8Xqyk z)ig>toKiq_!v5z{{QjK?E#7x;qx9p&#hT^j1xA4BC!eD;<&i56>UJ>yx5@u|l~Sdc z*yhPAqleJ}mG5D9$19@y0<4o7XrvzCD4O)LfHfXqW zdq{>CDor+*$Ow3ihw;PIW<`9H@&Mgx=b)x@-5AkyTI1aaZO~Wm1(v@Px z41YrOhfmBTmyZfLfPgg#i%XlzX{Z&EX(;E>L@C2L>(9K%16G8;?viTgVy_GR%Vzsy z1$R#QTm{0QUOfLHrok?M^9N-o7g+1Ca97-=*U&W_1ODdhVOYk)Iwp@tu1loCTO`%h z3*C`MT5EM9>7QgDNCXLKnw>=KF8mIUVuKut#nxL`0y9m|)r5OdUP+ybR{fbTWAB?H z&Si0C9odRRwzjrzhnhJGrk;{5ECwc?Bx7)>k6f*0PW}gA3G{Xybbhf$wN%+tdim|~ z9O@#nZb+2i&hmXv+E33vFd+I^#^A1ts7zc*co=24O&^OeBl7rHWEiQY(%tKGIE%p6 z^2T0bH23;xVT<;ZStoh(Q!Gks(V!kd?qw*0?0xu*h4%0Q_Y6OKc}bLmT_n@1yxQiJ zEW-H)qP%>0YXCw?u<*6sEb#FXgI;;%M!W_#7F0Of&GP#<^a*k7PvP-Bh9zUg?Fmu9kMKI+HcxDz~fu*ZJlf9oL;Vk}4M z^A1-d3w@`sD_s<K$?ySiaTq zdHlg+<}^;YAw79s1RbsRLb<773zr{{KJj9#SIq700K5n*Lgdr-Rq3%auISSg4Zl;} zoL>ng_lev{pT`NaYOUsRvbdzFu(W7>OsBcuSAF)qT~$!ykowy$BxE6YtX*(~Ge;IH zbzZmG<;e)|kdVGLHwQOe}vWcxq!+L}F1NW`rG-12-L zNLR8Dl2KvZc1p|;xeG7 z1UfJuGN2Jr?^E^nM=hiG>Hu9VCP|!{FD?|RfmW|1$s%kVh_W3~p^O2P=|43X4@Jbf z##YwnzRZx&#V>p|J$xO%thFOmgxe1jSE%0M4xNEdxqnOa|EA*H!~KACHZEX>HRosU z5!;1t#`-`K|RidykvIB`m2Gpi@Q0^sPpxL8?2=B!&yp(L{FaZw}g z#kiFv5jhs%Kfj$bzw;ij2Jt~roDxQ5glZH+_fvaxCzs|?TK-Ff4li`j$7$F6Q{FBs zXp7az3|G(~;9HOz<{=`%A2suL(^7b5&%4o!!b3V|H}Ey350Qv|z^n`WxTX9vDb32F zKJZ{`zFFLj=Uh_#nXX@P{7za+fQ#Za`24r=l9zdq=Z2m+dX90fB{>ulwGsu#5+LW$SMZcm>b${Bi=6tX%uPJf2yO`+ly`sW>7kQO+xstDpBj#aK^Hkx)Ep2>5^jCs~ zOcN?6IKgd7LfC89v#Tv~9aVSfU8eyl)z#fdT8h6n9^Iz272k)YNfMlGE?;RRSZ0-E)m{`C#L45|mH1dRAn)PkA#> zu5L$<`fPsN|IOy)W-)d8aT!GGYg>kwS0qy23@%=se)G0g-(&K z8|p^uv+WJ2V!9~NOv)UE#H6F554G_*yV#5RC=;b|bzUeSea74xckrhhawMp(K!Zp;N<~GlRgBpoS z7~jvHtSX7I4I84N8%ti}$|m?WL!zyk=2pBtWg9nzO$7X8yy%tnJ`CopyeHVej^qG2 z3|x4O*`HJWNo{@1(G~yrgW|7X&~o#3kHAXHM!7+;e=>P0620V=F|pEy@!iQOBZ+I8 zJJoIMM{X<~+Mu%lih4Q} zZ7Jqao$|Zfx{w7DyNMkz0;9iXGJZOviwA=vBDAP|@cukbn6U4}&t`RMe|*0KB={51 z?}}_^lzs(05AxB&{O}w4z4aIk?}t&VRzZ=CPrDRH1dxze0_n>&-hQX+josezb?|jU z$jnHkS88@UK1ft+?~HJOb3NDmy?Cu+?pwQuwRgm-+az0^`q{4os?rqLENy-@bb6=7 zeuXuY8hI|MgB%`J@ePP==>Q4I2u#%)%9YBpO}XN(31Q_DSLcG}7b09kVPvwvk4-IR zh}fu3f_X-f`OvI!YL|C*&tfqLy@pxrJeKxX2*=R6R-Nm(k}Eud z-DrtlN#6`^Mh4H+wJ6wIKDJIULVrPq(`2wT!wP&(8B}qw3N#lD*7RxUnW%7pMAnzz z6K`?{kO34Su*d*ylJ5nZu1XB)oZb}(K16;X$nVz-Eq+8fI}3kn=mk`B?W9cBU5GFe zbj}Z!@bIatL`H29Znw;1{?*BE-Xh_v0FQNrT*}#1`J5nWFe%Vknf?8=6Nv;8+z)Cwqt+% z?Q3#Bun`jR1hA$euW)UKeOgiQoUUE;Nb>^@mh~;j1Jlp_Um_h>7< zR<+yNL*;2{`=Fl?Iw`Lr`f;#+gBjH|8&7?;ZQF{uw!r4K zk461bsg2ES_*_dtM~#j0aQrAQ&1aZO*ibl?m9676ivMQd&)j+I z5sSleH`3%MeW&nBr;FZ69Syd264nlq0#m;XE67~=RRX@h{>BwoftWqkF}P~b_Ol}# zNq+P0c;|)uDqH_@F@vn+Y~nSm_0sg{7u78nA3TwM5HF7HKKfax5CL94W2uR|_{2NR zJ=W`ie;rzC>fp_HN_>T4hO60=$99(UdzU<7n@U6NHP%;7DKz^P@=80C;y+dg5qHAy zH&5=KeBBZg9g5j6Dhm9|nPxjcO4szK2j>$nEpIp1yXvNNd$S^_?aBm3Uke_rYv+Or zni;P{R*ifKY&IzJ_c@5lzJyI2Dh;Q3&uRyU%Y~XPBU+wzPzj6SwXSr6fzy;kYB%B| zYHpq8Tq@=@n{biKOKzro{WH{QqnEwlFpLYvJVABn``Kp)i+{+-j^6cW%wA-7TMIwo z`>S0Uvh=-*L0x@ptFScVB!rXKK2TM{Z7V34iOdx;xQJ<-JA&;7hV?@ z*>O&h&3rhl@X4i5ITliG(cb*AE*4)msdn20e-rzLGs7<0!PF>)@Yy3hP2rzN#X=vv z->_MpktyvlxFx6GRMlf;+wl>)W^|;C@cf6L{Ha#N)B z*77ZU_!#`C6V?D~4e5%M2kfs;`G%B|E#DFbl7vAf-sW)uEe$ETQhL5re|@4kv@ z-5bT`i!xd+ZwD$uAJ6Zq@Y3?;T2%6ms?ED(a_ae*rav997QQX7ciOHpr4`4@`{K>% zcMoKspKhx0V2L_cl!Pl%zeduZ6h4!jgJv4eFS*m}GII0fSyPKP9E>HbIf~b3!@*0t zUcw{0-WSwQJqH`*{lYBOBTAI*C-ERb`Z&`)b-(d%TD96z7&yk(TSv2XTJ=)|sIp*w zmg1oXztf-8`l#I;tV$P_&B$0t3HG8IsbKuu^utxj;Z`bD!PTPc$7v=8+IbQ z$;N%;XRF~FN>N`IN~%ZX8_FRiUYH8$)wir|$^U=rGFv@zH1lCwHAjs~uh0$A*YX@w z>xOcusvnyM@XszI?;()< zDGp5?FSxb=?-vrw)|WKDg@U{XVe6%SSv@e2+DwijD8`-cAJTgtgWHzo(Kfh9IMels zY>8d`KzppARC0E@+8$0H4N`DY!FzrBz6{J9YdPy9vip6jo3rt72Qupm`d@QU_-;`U zrYdo#`q$LrUV`-gFMJbgr$e|Y!p?{dBFx$K%bxYtZ%$)3Lu_;>;^1%p_FcRDF<`-X zt6ak8Zyny&XV<@F=ywM6F3GSW-psdlB=@!$Aw;YX=WXd<^9z4Ij?k#A`eY!8YU!lX z;tiwS zqOm$9@&n8tEq#V^>10dzJE`>eKb-!YDt~g?V?-nKWVy#sX%S15D6(GVgDkk8a9WvC zS2Sn3`+eY7{Si+=Hr43Eng^J2+6B-VXX8AVe=&p!(&sodZpKDUg z*tQ(QIOD#cCwO2xMF$5Xsog@SyEA@zk^7A84XR6HoG`u_&NgGJrK1v$TW3sb&nZpu ztuClnGN;_aO;yi~tGZV8n=B0czAD@We;qZ+{YGB2oc#-`Me3LDw@fsHPacaJm}#2z z$~-?lSO5GIFsI%BtStnqk1QM26Nadk3s20PYzUY`c^0Jhpg+!E`OCj z-Ss}tt;Q~vO?NK9Z1?qdqB0qTI>_CIz$1);_7@eyG1J&8`s4jcaYjM#(HYoV3*R$f zBScG;g+p-u>O z*^#rth7=E1-Qi8Qh zpPs8je+lya6!A_TYE}yL*SBE&O&+*OPKPeASO(HpFADk~4t% zJr3JoWj7Q}LNe(jCIW9jX|CkJ`rnmc6H(H3f9~X8MM7@vKi|QCgh|@rUSEUz6K6%3 zoth#J2W_new2UweeiEgSJyu+7%O9MZ<9N@AvNJbv)_oP!Zo>7_gZ16RXCrDAtM4yV zpsJ_&N!|xeo3vmywvKK&vo%$Ag_NU5aww8X@_b-Hw#Y9>j$Z*&8Ebd3 z#v{$!F}8G_hFjS#(&^ukmxf9k2+nclPoK@u?68mw{2eljJ}`eM?#y9()_yBT8!y2W zNT+V7xa8;N<{lU`SH;1UA-1%Xg8%l_UH5}8|x?t>dG}Me~Uhd6a z#4?xW4{j!2N)dcL6R<>%!}J>xtN0S(XtJ%BDS~)z-0LFR;2uz-L>3S@tipr^QJUwr zyMBN9U6w)I*Mx@kZ#6+V@uyi$@Q<#Y*DYSggjhxJfT1_k$GZC|+f}yJex&Yt(|hT9G4AG-(X=?|ih& z8KwGhA{Bn4x`1WB%mdaaaznd8N2Q#j@LmgfcJg9yY!ikvYp*W3`v>-dbwhKW%77hK zu9+R2=Y#EX>a~)2ZV?ciqwwY(+SM;k<;8frqkJXw371EfVMRuVZ4|woL~A$Q7oY{O zWC#7lmKU>wjw0=E%VVGu(?TxVT`I8%(qW~n`QSxu!Y6()ef7lUzw9hP*v3mzFK5R^ z-+F9W$#6&A75IeU7W`w7O>Mu69sSa3LQ&u49^RO{dcbFVzgdj^(qmHjAT(NUTz9)u zVDu{YPG_l)lr#S+_kB__=OEKgdWfe$0T9pWUlKE0&5yqfKV{65Z*wj^@`MIiw|59!S`RN@v8&a2uFEVXOB5|E$St;6{5w zV@A`ij3Q4KfyIhJhvJEshEf}<5yYQUl7+naq?(^CSTt>Mz^}iD6pUa{14}mtRWABr z60PER%S+I>@u-al&B;{9^34-qSJ!fnA>4{G-gB!wfJK`Gs{A%o=`4JiP;p{0?iCR*$fizqrt zt^4GiDnBJ#@|9uGL6}Lq8cnX}A~avNt{yEBbFlbDHn7wwjz1m2Q}2kCk)w5V+NN*9 z`^D;r$(fhJhYYjp(JCK@Tt`iB(Xr2?StjiqYDNHDVG{=bFvkWj2vK#}P4gQ@Vk>{9`w9 z=R3p{-jgZBt@vi}BX^g#|Jxl6&rZ*=&PEOOPRBL6f^biI%9AG_=PhOJqu21QIbm-Tjd~}rPJ@v5Km_Ud5 z=7|!!{iN9D&552PotLS<5cUmTj@P8=H#-Bfh2FzNy$=qn>QS=}VRFK#!r51L&c5mR z=ayk%!AZuMfn`BS8QR6!L#5_D5g!_6-nDhdpGHrWCCq1@oKOZA*9=;Vz1`>RnkCDk zc4VXXI(VYCQX8}cMl3lsd2G~L9c^(mwOgixiHcHxYA+ti2BG#z)dWt*qQc_3lY)NVI>n64->SRfF2UKg7M!Z&7^hgOJV#*^TP96oYe}y|b?h?kg%ca;~ z;y-?Ybu>_*Dvpkac`w>l3Uq?_uvw zm|8;kghUqC`JIs?gG+bF71>Zmj)!O*fOyMEbgkB*s_RB0K#A=ATQGYQHsPYc;v8AD zxXW|vaP}jKvqojm*)uj|kG)uq*Z<-ltB3h1iXb%3lJKX2gyY2QHfK@K;+F9Q0wf1DQF8i;>> zXh3>HzH}pA)Zf>Z^yT(E00u@wzGf0o>eNb9YK5ZpdT{LzpxGpf2+!{|!_bR+2Ah2M zDCt8R%mnq3pO_rpw8fA@Lzl^fZ}b*Fc<N zDhXBnV?K3)P+nPaiwr?gs5lincP9VK!U`3jE+zv7Fb$3>kiZ!H1r<8ETu8>p0m*#V*w_6at{ce@d-W>A$>Oa>Zsx;%_FS~1{@gywr z&BZu@3D%Xc4+(V*+Ns%jp?>K7M%YY#o5Hw*vm6Hf&}EpcHKw1s(>#Kf5qGdr-nz7@ z+Mj2ME|}5Yui~zrvSyo3ywPiv7A)>(%LMF!hIr0ED@d`xiSh-y`GqHIg&y@0%U3su zWGuu7SC^qRe*%gpLcY>2@ore>XnN?^@TZxE731mxv||X&!~1*2Qbk(C+m4G`&pq@U zVRlm%--!GB{-|e|?|2VPZN;sIIJ5g?K@(ioF4DK;gExEq8TXx3+AX7d>vk2BZB7?6 z;PlH!J&!5I2ZO`YVSPQ z&&2nvMLg@TY7B`S^6@ZFF+NN1FKhl#l^7seZ&yfI{q#7LYlXM`A*OA5R#W|GU|!ms zV_P0!J8NXXJ4gzr9)-!6VO;D<<-hhv_9y)c)(o^OG0iy`q{5`GSZA(fI7sK>#!zXh z;z3l^ZcJE+De4ybTD`tvX0YB5T4g&3i?{i)kryidQNy2>U6nYoKPQgiuGzs?rL4F# zZjeS>*q58Rsvl61Usov$euy6PkQi&HbFBNTj8D~vpK^~go3w@w?@|GkUMKpx4QlIO zZ`!|$TZgUDnL3OCw3o*ZWF^nMgu?&`RO7oqaR9@cw)5C-?E19ioA&Q1hdkcV{wRvg z%`Vn=V&SOVB2JrhxOZV2+3}G2%FSr?&)0<>HxO!y0)#K-1v-tiFgZ}2oyO3PK1dy} z-5>APoGiGI9&rQQ=rRN23eH^d?EuByZb}!i95FJO|2RPi& z`)jE`i(tV2*36@A0Z;31fc#Wx5M$-JUD41ZEDbU_s=dD*kF{N)haxjFA5aX4vU5JO z{H=~Qg@nJHFSnv7GydS018PeoiWS)5TY$=yCP}t;`M5N5gF|KnfJD>Uituv6ug50; zfJATeM{h41C2-SSIHgE^H$Ko!r9)mk%Ew)>k|tYJ497W#El(=YT=Brut z%hETTkOx!ofJm`s1n-?JSr#YyC+_gpXspM(!9hm^woMiI7c9;9LjLGi(*doIwB?@F zXaha%!^5iNRpQLo`$n5f?nqdQj~JT3Zp%d2`je81sBU2IOTo%rP{)wqP#&WyvtCGq}^yaY({`j#vqeryNT2yDeu z&veBjsx0K7vdiK_b3)MQo7Fng*ZRJ1POPp@{%!@uKk=Q_uxnAf5rFd8+9BJ*O$W8Y z|CMY{vNee?PbElGDT}_y!|a0V$BE7CmO9m zA=F}@I`iD7&<1(M1=?UcLfLx70krC@HqR-_POm#XTgy5;sf8Oma4A1LGNFx)_Ue7A z96~hJd>p&1Wm+9-i9Y+7WU2hZD=%EKok>G6+aFq}0~R)$KWUlz6uCd{5pgiIPHj9@ z!`9l0X!?o$26&Obq;BTeH|X(B0W@#x%j*Ne26kBUHCbVMcq$Uqkjd3in>S3XdA}y4 z4IU%O$L5`ttcHcH@2}gd?0Tv=-bQXFN)akMUllI|bu1Wvfv!H(@jUzlUI&leqv=() zc>M~R0d8SBp%Hz-j_P%@fl>rmK@>?!qJ)W;b0l8E|yy^vO9d#P6HIlabc5M(yz#VG5xQ8qvwU~5D6|)vmMS2PA zSGS2!KO|T1Aj0n*-io_iVFvms#R`V`s6lFx;euDhcCF#>GgCepg!CV!d@OEfrbj7` zQ9cJdIeb&vp^R!PzaSl?kizEwqv3U&Rjhw^F4bmsD_%3QhPqRXIO${Sd=Y2Ev+bf3 zxx+#&N?24K%e;KO&&we2mg9L&Hz}4?p9!5P+RPV=B-MHVI*5OtT!a$-aAaQ}siSrJ zl^gppJcAoDYm@qzj_i7>v;AYAQlwj=@!%MAutjph{`kY|hm+e$Wt^K-m7e;W(aw$v zzLN;oV}9ZHZ|a82I6#6%0Ng`{i~W#RI9N&g7<3VFoUrueOWXA=&w26N zD3NdN$4rk~sOHnR&vn&(YbW`oUUvNF5&B&}pw2C(1B8F}E7)U|q#Z8hhhtD?W;~Em z;h6I4x#W|W{Cpfp=LrC=2G(&mLapb2u6lwiW_NSPpeTJ?!Q%%`GA3UXwoEN${<*k$ ziJkrS1#Z#u_M}quD7uWIHz^iCIF=%OvE55`6Hft>DG9!YX}w^DglbkL0k6c%X9-pv zKlFjhCICc(`wBwY6w16akC2MJ{9uZ1ywaAcE(9 zMAJ-Z6wdRwg2V51e{Yx_^l002pcs2fC`{&s0sDeDnpxDM?qHLz&VshkA_dytt(j z)>Hx@RoRy~3*J~HHJimj&Y=O0qX)<0iVOJ<+`3%zgY%L?X$Btc4Rft65$w^sp={sh zntrZ0A{8L6E@k#`n7*vZVYxY2BQJF)0@&2V3x!2J!An9CK;8_@!jkX+RNh(C-6P6eEAfD|DA_RNQfG{2T-3j zZi)v#mQk;GlcHX^$yIXn4JC4-hu&sd<&SGx-R*NuUinPCN$i0?!qD&UPh+f^QTIX&&P?MHz~xzGY|u zku^53zU5x`to@tFoF)~qvt(p0P{36w)*8Hd20*f&8rx}Gm5fCcOF5&I>^@fllf=eR ze)?UOkS9486sIPVWzFcJ+NZOnfH(qRZgv>MCX{~$Os4aH=v8;#kO%dV5MmsFJ8r4q z4juvIKJk?2&zsV>eTrY!)GhSL{GlrZy@U%_Zc7LaT?o_T6R*5c%hzH3AA9P4aBQPr z+1AfhXP}L6*8%Gv2bkoyxw&D1`*54Ch#6F?aOm3g2IMhs|4nZ~miQh=xX^HS0zzyo zUmiJ8e5U*Hqc~SIa8t&=A4r9SE|B@Elb|mv{}uhcr%;h$bF%9{r}qjCxS0w(2D4f?lyKGnFaYPI6%+$piG8S_aQ)hz1inUBd#NW zz#8j8#Gvjml1g@Tl%Erv0`d!q3}toKItzVU1@8?v6#@bO7mwdHDfgq_uOIa1*0^`9 zr0;^-bJ&6kuMYvFtIHt*}g9Nq^? z4U(P!7yn|rNr@bKecd3c(%wK+@CDfCIpf+{j@7N`smTojeR)fKZq(R#s?xGn56$uKGdckf23I;5jZCZDNU=FHaX z+{H}3VO|v&1sb3&-`>DcJ^@(r*`{-L0w0cCb=A0s^PVsnsOeMPxZT!1shPA z`F94UwaI?S@4$&H0=20ZISX{o3v3(bg8Qy!9;HWbD>AzK;b9AJK=gFy!*op4DiFC> zV4HuTTCv|MTI)rn+S=8gtN?*!^GD9r9Bjkt=ldsJ&^mgcSOihfS_T(=xCEsDJyq$S zj)*rNXNj57hndx_>t*Hb_WlR;)i6K3gWs{@VL-Sb%$d{Il%N}38+qwP<(Ev{4Vy=_ z)we)PbpkZ0ex!<5Zd;{&Re6Tg**TOp&{gxfewoOzmcbcoa0YbbgjHn(u?6sF#~PUq z?2qjavbl{AXoqm-A?fwJBY{`Y=ZYB<0SB3Kcs*=;A*U3$H zZXY~1CUBEWFlq2?m$!{GY#(qH05I)5%*90HtCJhv0N>WMLP+h|$BQR+I)`3~uN8j& z707#X40`6%X$8{`#D96z9V|pb@DYrTjix8A=zG0(IX(p77S-ANE?~yy7LnKy()<_q zYm=d7HO5S1lw{Pp|13YL=|F$Gd@P!Y0Z2B^E361e$FFh0xB3^L`aPat5x~@0u+e^o zhlXnAMXZ;`o!ZPi(=*BDwg#j?Ww>z?gjsPyV;jFSv{-6wa2O;p-%`K32u6ylPIYh! zf^tgu=Q3e6Uk&Qa6&809q5(M1i%g8+=KDS%$X%+x0HHauBGaIqkmMu^t$Q{U|FU_L zlSllcZ)eMLgQ$X@6DiHAAM$+PUDq>p&@yeK7dC>&Epdw0seUm~3ZDtOUhgQ3nPqQo zr%9OzHzULEr{3Oe-v8~6G%CZa^+OH<*B{UCi{;vd!pt&f12coM=xFOXALZ1w|;)e1Ixu)o)uJq@B zt@_R6U0uPyPL(c1VJbtg)95t-x1a?8l{(Y=!U|voIRFv!B zLoyD^ks6vC{593L4G)bnRTIztFlCz{2~IU_XZRWhm=e{;1CfmvqktgEDT%d}k0|;G ze%u1xU{H*wU($w}Eidtdzaw-#8Tz8nke;=SK!++_0_%XTQ@GsvGk^PdY4n%YU+sNZ z`!V$~vR>YdVzYiII$&oq4D!L~TSV@Y-reermSQ-TLOu04wk7>ghS#M=o4NOdD_@r3W>1q5a+1{W8&o}eD*q3o!4az);EnkbKl^_b8-Q7 zR>7&lltHepUubv~knr>LG7B(v)VUxLSF9Cx{N1=E3_fDMHQ}NT?a&&i(KP& zSWa{F__@~}HECRnox|pvX`J;`jZM{Oj*^ecW8bw;Ia7uR5x%?h73~R1&Ja8rvX?gX zX=+;ik1Quj+hTBZr%tPrk*QIPjWTBwcRDSP{ai6?zXATnoP}m|NY-w2R&a47<}jv1 zMO=qv%Qu!QH;25P8hvD*z|lixb4vv=4&XiB74Gm8xf_K1G!WxGrK>Tg?m)f`zw}wO zl}vv&V(Nh3>U(%OOf^6bo(y*8hh217h` zB;Qga-uc4vq)qVRwk@YDD+0N+QMG>6)}=35d={qL4^Bs z{qFn8{qTNpzg~Y2Jn>3$<~&cwc^u#4{ds@G*Hm%`;s=8@w6}8>pc4@_X~o>MDncGT zD%wS@VE^h1m9TD5X4doo5p^+4kAMG{RbaK-&u0_!;X7w#x>tE86jDf)ZPS3?r*w@D#^XuJNnaZ_Ky$GOzxsI z1x!yK;>Hq>IWnqxx*hVGIMnVow&z^9g=Lmx_8;Q_K7M+VLY_s?7xy*NB!Z_au8T!F*(4*Wp{ z57*gv-E+6L^9KTB1JhH9FVZ&mkim1SQ!$mh2DNnE-essmC!UJ>0e+(W;|72S`+wRF z^EN<5pYWUr#2}`_5=Z%^seiL|co%<4isPZ?ni8A0P6z5L`fz4Yr@-!OI}zVPbS83F zDsi7;#0IqXc}?o5wxH^y3rCKc*vOn%+QjG+$MFW~T>SoRx1Fhnd-}6$w(_4jT!SRKGH6=q zA#XWQ9>6FtdIrTi^<*m(C?d78J9H|5KJ?`j&l^DiKDEUCcl4~$sO`?CPnA~sv$hFv zZLUxf?h};vOA`T)XtKd;n_#4xHc}vvbc3W`ca8PKsF+r##PbZhINyD?$5-B&+!Pbo zQ>yKgQ$}+~3j^_$F)IP{7QBJ}=9;B6P^}5QEXH(5mu8PE?+tj}NFsFQ(A7i>)|GhA zinU6QUyU~$>NHF=x5u+ZM~9R}noXpvHILIsDS?EkCyr-*IYqKG_R~JGK)q4Ui2E?t z0N^WaOoCLsyd}>vw3lZ-P@RMbu(*RL#kA3w^Lj_|-%I3{23^~;qxy|Wb|qo%tf zAM2}%Y^N;1zdjfo%k#?Z6O*gp`8Md!nHuJc05lOHsdcw)tjdBT9G(mfyRKZH_>Is` zFbNx|DQa3ILCS_NT~0M3xD^TJ_L&a9qa%B{U|~-6^0^X8^ELFfF>L+nRtwlcliGA7 z_$r<{2z*zYMT{scu4dX>K6Ori1)GA0jq<;#?C8mX)1nQh$)Ch5Omv1x{8LZ37yT23 zoh#lBZVpB;Oh60-qR{p}!4;9rebjy7D1hYt-uzax%2vSq0sl>TOFpf)sYZVr$E<|C zHnQjM2)g7#6&;}|tTpQC{X<@Nu<4dwmac0}4;#?6JBN4nEGV}h`dNlm>B|+Ons5`$ zi*~VToasU-&{JRn`dDTx*7x3!v~s)_G@Y?v^a;cI)Lhb5TQP`N-sJbs#nj?8tT+Rk z7(W+=n&(OLqgwO75~vEC@p+69>6<97Eo6>eQ&lNmC;0pQBRivMKxU)fE#b4;fUHxe zHK;Ic!d~30`W$zR|u>lfr8P;;BjIIwW#YVB+!lOfJ~V zej(+t##jly3eR^y2cu8=6+P4UtHYI5>*Ic4wat@P`Zz-PaBJ=8+6@6Jqa6jWyZ|KLMpG;TTxPWFU z(FxDz4KZZ24v#{T67%jt{tCo2>_HNWVGH*E%6azp5Ur{7eCvSLu-O*>Zg9ix9PgieepA0gK5I2Jz=oF&q z2R=Lxps)vES96uV>`4G01=c4;#!mjD9$+nDGc@-!*SV1J@8D}K<{Yg-^X4)m1x*c| zJ9^@IX`O7t+l|fqM&gELqmwl_uL8phK?zDwNj<6MR4yKG=6wsG-_z$)wN(b>9Zh2J zRG30PkxvS8Woly89x06oJzZq`$mrOwsHHWI=b_+i_T&?J#JXm=|DS%6O~}Mbwze~Y z2+`)TR?qB{wy+32v}G8-(Dm@j6UUSXmy^cijVxt+M>NzRn+r@8sK;QVM4|R z-vb+Krypthn#XaX*~=R^7=p9O_`GLgK+KytXkv1{VB4L8<#-7@+JiBJnkB>oqJgY(ii5 zKWWnXO3V5H{a|4?RXQM}{|iWLsi^TA0eLpIWl$9}Zo1HR_JE??$~=2}N67=SFD}B% zNCg13Ugx9mJ4^l7TC850!yhb9`UF|jhRhx1A-ZBNsG2E!=xWOMKW?&s{iS@$^J=~$ zL7YguwBCt|Fmt$@To?FGCvjp!wcoim3fV(yZ*`UnN}i6^I9z=cfFcjFceJXe#qt64 zXB)lHse|~9r*m%+35T zCCF}FltN?b+8E_-2(H*6I2JU=3JGYt{>(P;)N@qwlGg7E({F9pKmK?eP>494sdQhp z|6a~BTZWXwthX7dwnu;I*3Sve1T)hL;d(STVV9|ysLl;H;w?2mZHqmWl$^NcAfg2c zay%Cuu$ke8d&jC;_D}XL!HR^``<(DhprLgx5rSQ=ju;z9InM@7Pcm+Y)#{3|9CVCz zDZxEWkKi-cWKw-iNi{1c^*}u3NXy=J_vC~V-~7^CWmT2a*JOZU`ZVgW-bPQ}pL6)e zCvMYhSS~*ae{s8(hHph#ozHBaNM)1$6ne;f{Qy-vOXKM>FZ;dwQ;A&Q*zTVht)$G| z(}(khB+a&}32IVwHO@k~N3^`KHf9N{jIks(X=X*TSo>ShNJ`hc~T!v^ws-tUv!Sy{P&QTnqW1hED?XBn~b)_w2RP{);|9J*3ZWjmZhxdo70jC|<+XQM=Uz_wc z-gj-p{nk^!!eb5=^m#n3d&3WYzyMIS(wTX@f>5yHRQC^1iM#(Gq9NsuFN}D+{{Gc``@1G}gMXVYGhl;QKdqB@uJ&R4 z)>M#4;=0cWT4WA`c;aHaso2IfVaQtQ7>kX<_qk;T$;2oyOE30$ z+Qe!S_K?}$(h}B=!pKV5zJl7Ru76_cbcSWNpcyb^0Tf4(#L!YP;KnJ9yB|BFw;lbz=N$mTk-OB~q^L+3xhb0k-()_N{#}41 z4`2FxRefP7t$0JpVDlEy5{hC8j}l97IF9R8@E90Qz^K;bW6(TO96VH7?zC%FbF9UX z_}A215cp$7pnFqc0zJ8ZUWtFp=ZVd@ci1&n)eb;8<-pC%@L@+^fxtlI#V}f%`+xA_ z=~wLuR_#Eo%YWhT#tWmD_8Lc&65SuYo#J~X=U-4{tJ-G7y=8T#Q=YrO$ECzc`03r^ z$!r%(wJ*a`csudtW0Dd4V!}1ng9qSZe1g-926KFI7{86e@;+8)X$|U)ZxyL)@`=kD zD4X<#-b_9vLDLpvr>qNW>FN<4a>A8dm(1VkrY;9aIW8V*T8wu8hw@kx|3V&jiD^WD z=7i{_KSwzf=S@a92@ArZd7Wa-qzS_tP;$7gHn_rg~Lkscsv0tFQfoIQ%J85MCND?=cvqLeb;=Hnc;wDaD=6fM{5s=zusWUd%kD`1XlKGOM5nEw!dMvkGa5svWK}Y zQRDWgt(Ov*tV01W62T@RCp_~yShZZ=Ry2bl0XecYttkxpvr$Pv%Ru?Sja?E9(Pjua z!xLN*tShu%0W0GfIpI(19f?it)_&EhBY$#7_;($(qNbuQ+*)a#dH6`V>f$YJ;%ZX` z5FNx68WvGR>y~m1{%HdaP2b>d}yOQbeF;dO>ZO8xnXp0It%JGZi;c1@xUNy5E^wVJE-&IlVwF zHX0cv+x%UoB6s1WN z&^@~>)Fmj^r~2_xQh4X=946H(wa_X;)UUb`hRR@bdIgL}r>#RZz-$ znnD{}rne(-yFW`eoI^!%p9+?`tT|69y|TLSp} zsY=X1K=nBLr^jElKqmDYI*}z8%^Qe(od=G6oK2-Z2`Y=eNACJH>EKU_+5}RivB)Tq z@%&z=s{Ic$q0X$_E6(*5+9gqOewxP@i7v1=L%7yJzR|hKJ}^W(x3f~4Hze{2X+}O9 z!K$%s;SO987F<&4KWo=iKc^}m8prVwlVzF-hxQX<`UhZaB1~?#t<3Da>aqit;;{FF zgID|U1W@HBwEDDLMBo8w#07uy9Q_CR1OpB18wRQ`>4wi1H9);3Nh1Cm7Qn2$VJ@C> z$cR|H)XM5_6`k;jP+LLVL@}nOq{c0!GLzw4S>vPs+Dq^o8MI^tEP3;ybf`5moL|0T{g3l-a1&(x?dgELP5j=OpI&-m zxrlnR_v}H{$+I9X%97tU?%GrIfTln7EzncZOhog^e7p(X4yX^+p6o7+-C59!X+Pz> zx}P*Xc@BaOuK~yko_EsxT1d{W(Q(N7BsCu}vaY3e9ngbzsou*du5V4FVh(9j()`U| zIJ-=b7}+tG(unT{67Z;bYe$G3@wa^m^PZ^f#>hr`Rx@sVRJRY>_$AAuC(VLJpDF8!&cdk4p}9fn!1LzD^JnEF zwXFH#)IGl;W+LY|^Sq+;RPD9zt*O(qy8pgvKbzBcbNeykVS=+@?7<_mU{NsBh^J!m zkNy&%j|CVm#3laT1bfv&^j*_q7J1S~B0_5Ryxh7DRun2xIz9O%rS(qP%QKhxUE^eFMiF2X{ zZtkokZ<5KN?%8@UhB zyB;4S-fgQcpBVUa(}kmVl?JT5b}Vk#%G((PIB=l88waTEc`Ck&>XZzXG9w5s_=Rym zzf6b2JNk*$&L#DBPT;b_Z3PJ=(<8siv$p&gqo`*`0fuOPv^83rxVU5NM0Q!oruc81 zypa3G6M5-LgSsiNWZsILl9M+AA3h6MaJhO5?_L>FX)(m95w`WYT1kT%Iv7&Zs4ZbL zGr??t(bA$`$Swre6z*hK{$Y!ydh;gshqb7x2JLJSsPqjCWlufSMdVEgzFL$1Z-GT=Mb4TwWx2LEdWC9fj3OV~jeE+X_+;%;5qP{>#b^mSa z*4x%?Q)``hn9}C#RT3& zh4rp7FDa!~`rSMM07utl8ziK9^o=7!6i;;Vd<1rc&seGMJy4X*DN8$qXf6?G z<1<8?hUJ_&3+n!T>cV-cZObu*>Jf<|d}lj)>6Xa3SKNTtaOtiMez#fqG9T#GR_&Nf z3*Oi7b-QkLv#^?#OI=+x;nS+h$YvW)k% zI;(r#ktt)1O88!9cdz^-PrE|C|Bm~TY16IFz9Rs*H_dqjwE^B52x~LDwgBFGf3mt~ z1VAb#=GXihi@VlIVjz8oR&hw@aTlSP5A^NOLrV@;QcvQ}Bw^s3-6WAq*UK)IoNnV3 z)IGUmqY+>(cMUWz!SU(Z8PfjpgS%WjFA-%{@wXzn?#*82SbSAk_<(CHs4xj5=I;0U zkiMQq`sz^dDh0y-s{N38{4FH1;}hi1(+bi|@UyXK0o{|b=0eT;9*EJjG|FTSu7 z0V6X7KtIVy6Rb1;af@T_t!LYwS;XJQrD*e`(5C^Bjq=HFk2psGO0m(!pLnbMRvzZwvzvx4L0`i$%MqN05b zfwWMsy}-<0?<4;dMsBqTgG)KPt6s5@g`R`A00i+sg|)it7v-5RsJL_v;NjvdU`|6oWmL!2ZNv;X=gTK4J=fYu8_VOv4ycPTj z*8nqUqr~*sFKLnHE2DY{bAdX_0STjjPVlCq@^(}CQ`r~QvOBQNj+Y{Rm-vforO!8C zWF%P61vKAvS1HWqXScc>VlV#>Mf{=vXWPPsmEhr0mAU^f&b%u) zqwsdB;S=W_`Q*I|#Me`=aOF=Z(*TcX#8=n)A0ktvU2pIFK+YRSde@7VOfAsa%;##W zk+kKB?>oP=peXxc@1;A}rvoAp^LVL6S{Cx~%wwJX;W+v?7xrRW_zZf4iM52%%`U#E zmgZBE4T<}Y0IGI5+4Yn(zzHxlAc6k?+W#|&G~n?6PXy`yKOc4h)tJq6;Raq0STzf_ znXu#4fqR$h(hmfD0AKq5)}junbk+SoIO5d*e3}~vcK zmS-OL_hws%EJoHMYhymy14AwF^F)Gw!ZgdOw|^vLJD)nKV`n`*6OXGZ%S=1exAS=E zC}HRkA`_4wgPvB_0uyIU77s2*CkJ>Ukc`c`B4=4h1B7REFa~A+$meg%wPlB=s0s)V z72{&&2Ans27?2OS<|JC0xY=*PyWT5ia(tV2>r^?VFp{ws2>~2}Qy%ncAXgrJ9VGB# zS9JZEpGL$YieTN#0nj!ZqgVL-l|BcJcBN6q{TdZ`9;s)tD37O6G5A2p6VZZQ<1{m4 zj5R|*G5sG`k(F28$K5x^+B5 z$jy?Uj}MO_*Y=U62TMK#6|q>D!cztPmJQef&1fX>m}9O7?0yw3n&0R0XG2rF4qzw0f)}A z^}Xkkb>Z32BFPgSBARvdiWvZ5fj~09_}@59u5Mj7*a7_9m*R2A0}n39wzy~2r4 z^cJ@)M@_D0dz>$zyM0M`1Qk>356@-EN)ARUCKM=NB+rceib06wAe!+g`;3-5Aub~m z+71SfbJi=K=;dIKg-)p%7b-#X*LIsF#(N0aBq|!Gg-6BknxOx|TQuqJD_$X5@(sa))CNRHoMgE47>f=Y*QL=jRD}$Go|8#^atLXEb zD|Ao`nb5AET}!}4xCgn2_-$)0ENURp2x4GE3zdc~S+#c(s)Y`7Pv2(!1w;Z=%fePW z2V@|}HRjziw_NTOweB3M@A70el4s~ZfEQEH^iM9W4;&h?k%df~z|6-!pridIX&9|c zE%KTzWmfzxiB3s_11Bh68@aCDuT?liwV6=-YTX1ICWIG80QuU83073zGM+xdVvo*a z$w;{tXW$FfbokFUHBaivE419nTUWvN#^B%n02$MZ({UQihZCfSaPr-4G3s(@=P7`p zqWQb;p$c@XBJS=(?y@fowLq3PFw*tQ%V~jf>?(-z!AytiV_AubU=R9?LEx(E}H6XRcguN)7eZxm%n!sN?^O ztxG>7{wg-?T2S`xQWL)4Ab4j?o>R+vN`zfC?dU>)1d^0UDt2T(@$ZK~`VTFnMu!Ey zA2Ww_Ke2&7-*vkA%KXxm`eq|r3wxMZ39ORewWKO}92mN$(dB&Ch5Lv;bYTIJ6iP>C z@h{9TXDu;y=U4l}3Hl=AQYfUhHY_wf@iIPb+Vm0aI!+oQU#S)I9pXs_=TGde%*P=s z0Wn)>NIRZ+(y7!WQE1)6tc=Kl5+Iu++%)>XCFvyErO-kc;pgA^IOh^i`lVh3nvUw9 zAyUSMspe!?8AAOM^f(A4qynVPgr(7O>fQ$D_uJUssPPN;1?yV;=1>*IPEr|hFV)!X zWQGdQ8RtuhfAW(kl%1gbsHFsTv|CoEzhTi&YH1y{yMu|2_#VJ9Tc;j6k*FDK*X&(4 zo#&>X<)&)FJ$Qk^LKlmZfk~3 zeZ0Mv+feHUL@})Zjb(iBi9kN$9JW{?s8X3n;|brvo5T53wWGC-ak~FP=#AL3 zRHwu+g&$y)7@BGi<@RQ`;!5kv%2sTCFoR{sbI!dQsr@0AV;>}srVd`mgd767Rz<0F zh1kvj(hwj|>KZ^4y0Jb>8&FM{h^^6~M+JH}$wL3#yT=7q-Mt5QyF%2@AxOnYk24)1 z%GRe9;gbsJB3qr8HOq{Qjx?)$hcKsdyT+iD+ZyV9SNxxP76&u(`8uSkXNTwst>B-zM4gMvoE?Xvtby{5Up7T0i`h^T zs%`L@7`jyMbIzx~nXIrb#5)ibYw>{v{Z4C*6*F<;^i~b*gZvWB|VenIDw#Pcmec z1U{sMaToNhR(>_XX(D7gGl9%$Ro9M1S?M=zcxbDMLbg2?r#tPru=PaHa8gpaAObPY zV+D|Y&^Mo$zf{!`6kRAF+^tGW8s6#Sx8=OHSm+g~Lt5RPq_4-S^)a{tPmg6L{JS z>PisAqB}7#VXV)->t|1%@aT|AOfM`a@K(KOi3(c?aLHOOmW^$W{~%;fF?8cQSOJi% zO`b4>AC~|%nTtCw zXq@P0Zux*wz9RFSWp8Ndo)gQkc9$|s%K~yW%G;dP*MM}u`kGO#qmXx(OS1Q$ZSUo8 zZ?QWsr?%3(FBX2mGU1C5Sn@9Y70%Q#YEgz|nfE}JB3@(nSyQ;e1O_szfV`uatNm3DDVr|s@$YupwQiE1QXJ9jL!hkJY4LTBbEo( zzfSn2d|59J*GHnC!_bNPI<%+IM{|Fp1-yo~t((HBQ*>BBWQ+ettHoa}KYINA0wo-> z0khqs@Bbcgsy}Dj3S^76(~yEMbrg1@G#MQz1!L$@+Z^d5&7U|Moa@jI(EjrQ3Vai2 z2RSW>3)YW1ypP?%jH~I0NR}(4F0Xv+w6bM5Slweht`3qF($%P_dYEP)J)sIJp{3Re zHmNVSclh_8n)MQHA-=2Xto2<_7)Ho^*6=apF1&4!RrM&cpa^1=9{plN^xr%^HLb7Z zE0kcqjKeK0cbXh~8}Yh&Xl0criMrOn&|4>7F}Nq@cW*}<9ws|fT(yBVG2pcjs~|ex zT2DyHJiHlg{#??#P))&6X45D494UREY@>UAZav;pFDHVVqT;ytklJ}g-S#>N5>>l9 zGsgP8duC)U`IjD`6Ek+6Ig>`{YgGaI)yY4-0mP+#83cQu@qqdQ&1T9EqqM;E*wD*6 zS|5<_kr_xk48vPKTAdUs{RB)4HlkbNFZ85{fuMK)}M`kmBemu{+)kkxG-9FZz zORwk7Rb{(5+l<)o`9LxAg~=c55hJIMIFdpf#-&X%KOCqE4GFPBOCQj!#~0VX@>lhr zqO!O$T3g*9J29)Fd>JeChJ0=9?>f)B2$jC5Hj+rKG)(YNNEtdr>`KFdG<)3MAE@{p zkGnN%1siRyMxiwChF?m7s+?!}OqvIFzITdQaYr^v%v_Fsgc&R%9V6n6%!JU;3=Hi7x!JD&TYi+ZwS%NE;<9nVdBhv}Ex>~09 zF)eiG3Gf=-iu;m(lZ(RXJ0;rZybLO%sNN(!uf*m#dfFiU8If1Dgg%50+t1a#cXiV+ zAauVc*AagG@aAznHM6?XLJ#-W&BG4X3$gwuE-wWM#*Eayun~OTpXN3uBhg!0PKis! zOlN|UuXAb95DlIYYR)cl68F};`u39yPql>MTH+P5ad8{b<_ zo;5YiT%f7Yhu|z+3yZx3{p>rKY92W>7t_xSlVwEfsKr->x#lmv_Ffdq0&~4;Iy-i} zu2c1;+ZBaE*;Xex7|*nTBG{;-TFE#%RkV4&IG6=UtaUn2%RjxhJcPG#ai;LXse(M+ zD5RcM(7OGQA*Rtw?=SuiE}+w=PS@_LaW*s{`h>*WjKz8t?jqTvB`l4njy475T#6|; zXMAPY5%*fIuIt|(0XRpvr+#L8gwAKg^1Iue7wbCtifmzoY|QcWgp>hcX_%pbnvr-( zA48RP-BK48;q;YeXPxM=&05jxCRNXb4Zog9e_`+PPxoBM7>ixdd8Pg;UtZ=!=IJa) ze$Z*4_t#g>LS2h^xB9yQz;35pym4V;*W>BljBHvolr>p_!>s29!MK(kHz3;a_4Bf`-KRzH$$*F|C|=E57s`uSRgS zIorwEd}AcI4Xt&4Z54(b-G){lyOfFP1iCmjAUX4DrqCH~V@a2^nuBA^-|gS4zGlD! z%a;^Bg)679gT0E?giow@loE?luj);=Ml6!iZq{XPo00h%FGHg5nW&l$pLQSxuAvSR#vA8li@1MbXWxJf$qGpP8}^ML9RhMd0?{Z?<>||80b+5kFS>>yJFU{m3POTO+;5 zw4p4<)q<>tnb8=DQsCi-ju9ZJB$nWC6A2BU1n2U^hu63*o!!MZ~ z4k~`9q~&#iHAYUcY#)mQ8jLvTvVTMxRa?WS(};Pqb2*-0b|m42Hi_PW#@h@{lqS~Wx)x*ia*0{8%TNMORWfxWJGanOQysNvj3@6@wdy1ZXZ}09Ej+9 z#K4Cn=B8}z75GmNV}rgmk~L?V?zsw!aVp&2*(t-zH}LdsIwyrdppBd=)S^R%sFN*3 znMCvBfr3~UaR}ko5WP)xQ-%4K9orgz?Z>zyONTFyG1zlhf(nlpWRr^&x%$?C4xHI^K<8jl8-2gMx@fTP_O!VS|(qVMa zh|Zc}@>jNUp4-AsZ)ntn!9>g1)Z0;7fP-zB39dy9LZH&tT3|OULz)38^B$ITM-5__ zi#f>J#jM0liE$c!@MA)j$(Ny{dj5y|CV!n zH{mLf73~f9yqtB%r|d>!ikN$2oP4%j(+WD^eAdmUC3ix~B>Qi0a-F@poTYkWu*Q&Z zFRy6X#89Q#Ja4^DEK_T_CV>#+(N61&-5>7(e)A16V&iVOEl5LBGNP$+=+^rC3j*Ri zm@ZdgPoF36ydU*3B?Mk@0BZ9(rrXRjOH6&<5irdHAmq5Kn-T~$xqDKNhGsk&X}bYZ zc-4zyL4WPxc0Ka)0eDTJPsipTG*3?%CCW{Dwn`3u+%8fkXor4(qZnE=s} z1KH1Rv@Vwk4^1WHK}{Dr)9Vgd9rHFjE2gHrY78eAMahS8^BG~;Id;mv{Nw(DvKg8lje^bDx z(uclZ{K{@@oI;6dyhIMq(=C2$3j1k*Cy%@9=wusW&Z6DO@Mk$d0qvbNCwR?%K{LN; zV>e1C)H)bZa~H1($)m(B{(Dmuy84drQ!~19Zd21zz)sV22%TQ{p1DR0{fK{u8>AI04ATM==~%MK;nkO*WhiF1imAIW z*h0Rx$@M-kgLIbwQif?k5QJ+Pdi>amJ@C zEq&7h=K~|l9=ehDd4>;X*Bb9QK{ZBU|6{+{^`PYK&O2+V;{mV4Q$x;YuBFb&!&3Bq zu0zmsDj%ALF~mD?LEZK2O30S2Gky^D=fxh}+Zl=S&FhD|x#G z>U54*yQJn%BAk^h=LR15JC*(kn<2tWvs-SF8tyYo0>~ITS*#6f5r31{$|dDnqz;g$ zcD+(~(0cYf%Nxb?U0zy^54h|kTuSRUP88psG(a_=3gQ$amUSK=OO^9K8A)057RkmR zrF|iz4T+HjfyN@a+J&r@^|Ux|FvS*Ot;1jMJ2oznP==LfJRc{nEA1ChqZ6*8C?-o=%~u{0m9@;gvlOjb{HHeCzQJ}?=gku-qyU_2Pe5~>YQg1jslq3imgZj zwNYsU)ugwxX}bvC(Y0dQ_Tm%-mr3mQ3xkFZ_38^m&zeo z$wc18D`$dZm!{GP*WRirKD{rBy3~FKSWpvS4cAw=j&0^2CF7sY??|bEO2XunVP~WS`iPe4nphDqNVG&xnOIq?kNzT0LLxWNtj?}yBx_%C08U~s5 zUD17~Wc=2veg(vMf%lx0QVE9`E#QYP6(*g9@+l1FQ+dMPPr$+kkV~j~WNCf#GGp}F zSc#vprvi3zv}9%~97a_sq!$O#TS|Bp#s|)7R4(WaQ)BgJqZ(6qB~`C7gPbEwHbLi> zD<<`}Zw!*9diEo2pR+Gm>x!BIm>~m_R{|VPJ*YNS#$3;6p z&_-;I)kAWNGC_o@lOV<9uxx@;!bH?^ddls*ri0)y^Qoky9yts~-Kl!JS6f>G+~UL2 zY}7oqb@83Y&~%JgY0I4*5qdNXXg7^a9gLl~SpWMhfv^D4CE$E>ShjmhVZqg$OIfq5 zK4&WQdEd1qaa+KrV62q%FXMFq->ZJbq>!WIFWqZ*I{{yEt`OnMb_+r;-E9nIi$&Q8 zk;c4F7+(cxNyVq2ZxmkmUZ*T!$c+meBZPJ8)g$|{(IJc8qjn&u{UaL6r@lYz1T2;09phroYiHmvZt@Ww5>sh1MFPU-br05Ybaw z@9R@y;w5)*RPFUX?JO{?GX_uJXCcG<{6Q9ez1T79&+e_Fh}u93=|LV`z|=J7huC@l ztStQgD$sjA>T34qU7^aA?KGD>#*?97%(QKan%VoF)^l0i8w(pu4Ladj_Q2lh?b`lz z=c&X}jNj~+{bkSE>4i!?8}U|l#8TS{e9;8K&%TwOUJ%+`Iw*T&tpH0J+NFc@L!?8R zBWz=r#KK1L%u0%S>>JNxZ-CzRhmtyi&d#KNxiK)&$QbvpUl~Kq7;JBY0rTL7w;qca z)cq=HI#S_oX!}Jk^7zwrit8N4d*3jW9e5)LiVu3bd_yjQyX#{X*C8<{bic3=sJuZd zg|~IRzI)xA$X%R%qaF9@m}LIro7$`;G$C;O>w?L?SSu4lhlWxI3ijD;&p7m63l#ua zaeBl49t$b0K~fvgq#-6g=?cC%`M?YIAYj1bilu+o{U0hq$z!prEo0(bm9T|0#aA`4 zR-``APa``OAM@bjBg2D^dNOrlk-wiAjUKaH;1J(x3tF0LW;Rti)MGFC0g!ufs%|ti zaRxeHlqlAuz6d^aIQwQ-eSyN!DTMMAuipiJ@bGBWRwnzYvgt8fu1fk1tXkIXH39La?%=}TWMQ>JYa7K;o^NjmyA)H_6RoU6vpUfZAd5>j`AHhN zs!=QVC;ptGH-~vHY1fXP^(8ti&AR&Bymn{dlX`4M?~Pm!4n@eCcUwVMgZ2({g$Hlp z%QX^^oa>>{k-#mi8a`2);)*alpBY`y3-W8W(U+@wqk-}t%J}OIl>2*CT^g_N4UJN^ zdP8BUrwJn;JUwL)jg2;Gm-)e#KS)1kgSIyR<;afPmRQdn#bw=%YqyoRTy>e*aw-2j z!STgCbojC`K}08d;{lSLaI(7`&{5pg+xd0sASp8S*7u@<96{quXPeLUw zAv=~AL-9+a6-(Tf8kOp(N-ST}x~Dh_ba`B9-QPs{hXHusxvf%Y;^d`ADiO2V{01mYwgFyTRd2d<&4}(@dNzVoty`$G$aO{w?6WS z=@>a3&%DFN4hi*1RG1CCzWAG{Lo;EhVW394#N*6L%a>v6q}DHzZ+w;iB)p(@t+={M zcL`Wqzo;$k_OM{{MvT#$K&}sGEw^{Eai3>VT-fWN=9rNN^`_(Q`>IdN{gBZ=uBK~+ zFEx~&fJ+}Wx03EIk>WFw?sDbie>Qd(oM6TMZEt6F?~73|es6o_j^dFbfzI;l8CObM zd{VfoZLbSV2zJU;GnVV|Va$Yee)nz{e?J}SJ>Nd9hTxi$+fqQ9_5lGuWAeR$0~Y% z)y1RKa)X|>__g12G8w~O7~g#SP3zWCoV~Cb?(}NT(_jUtDU72 z;I4s;?(*yh=QAzew0n$<%j()Z z-`xp{k-={5Ie@9n@^T#&3SIlU%FX~q>M3GbH_@>LzL zFyr~JDfYVCMGFw^M7M;os*5IPWRpJn~+lz%8J0UL2fRUryiO z*xo8Al~1{Z6i>M}_Al3eU0S)|qDjj{bSd4n;>5R=!pG;2_2rx<6ijZS^w_^i>b_2K zfdWc=z0@$sayk&j-(fH!QQdo<|AW5-0InLwnxyU#{E-;OOgUa7d})mA4BZ^zJ+7a; zx44R}N)3u-Wp4dgi2K*7mID>ztcJwyz0nWo{vU2w5H@N@kJ*||L_%l|Wcw)TKRcVW z{e^=OnVsWZxSay}(qn>m#{ByrZ2fQYxylVO+7+1}dlPod4X$rOUHeDe(f@VfUZ3{9 zc0ur!&f51&guuy$zgfj*+xb_4^Ab*fdZt#DZ0{T5as-T-El<}n!+h-(Gm<&u>1o0u z?CdIKRf;6uiP_nsF_@T{y5U1Wu+ zYNdCvQMZ5O+c)~JTm4!WxP3komy>?yTf3|40T7za8+Q(D=?#2ryKCRZ{p^o-E@4G4 ztu>oD)zn<{P$MeHzCh*6=UnIOEz7{{_V7L`@Ht=i=^x}Vjsw6F_~Z%xHeC)>Hwgow z6Vcvy%o#?3rJ{9Im$B>RJE})lzta+Jgnl#qT42yl>?+p5Iy%YfUTH zw7325ZghTqZC;H}rlT4n-?G{P(cA7ZGYwT_gn-hN2YEIdFV51n!|o%DRhRzM^xIT! z2mn_D9~(j0l&JbA=r#NQd6zr7(2#ff0k{-VW@XHv&o}<#V896OksL$p#_`Zo$;j%9 zo(ut+1P2PC-OKs4Y?b%#O-026v1(r;f?wD!2;p`ZrKW1?t(eISkhKceZ+t1>;0rR2 zO*=;bLWrn!Q2W>mUfsyxVY4)7HctjNV)#M@=DGl__M)c)<054C=a?z735IhyW^fJq zF9g^{=0(|YOUUS!p9Eg2#m47j|XqT{3ppuJu6m6v~5^`!2hET zxQ|=@jSl;eIu-sn!no)T_-e`ZvOJaly&Qkrq~d{>YuN1f_Xbnpf)acV(0u9kv4b|Q zucC7_PY!u%bXRjy`~;)f_^hnr_klErwV6p(_%qwIvb0*hH(~!*dsqIJu-sj1YA3tSpvsK6#H10yoqUrw|cS zm1aZW(qlH_Qa1rP*wNKjfBWRwXNFDRH(Y8||4K()KjCWB0Iv3RMNbN>i(n1MaidIYb-B8 z0`H8GKJe)Ub^v~B)4}jM`Jr3bx594IJhCJw#8cE<@vZ+|!;42NY9C^6W9N4eGc1b0 zSE;?CC_{Y!;hMconfP4Abg4<(iavdFtNt-T!6T;_wqE7HOxyZR+gA?Q2Q;xchv`hY z(Hky=?e+c%lX{Y4uwza;zYRZXW%d85bh@ za_*WA>U0LGZ4C~4eQEBL;r0!9fV|*J-5i&z`vF4MX>hlL(U*S*p!hiLAdh>YPO5;; z*t)`@ZJqK9f#!>|_A7T^xcy!!jB?WW^ZBjHNhOMj%AY;%ofA?Dfu)QjE0Tv^0M3mk z1f}jm2JgEoqB~K>RWo@hB=NSedc5%KfvNo<(5z709Hsv;@X^%zPY*OV)X+AwGI4a% zBFA`>DY>9Z{pCk5+nDz*?q61*MNghIiB{=PAo>s!iD-Qco57uNVTK$9n~-;bTpNL%j4j`#8ut5bF34lx3~MT1K65 zXe|FL8r>XdX#)RFjb!ZfaoFoA3RXLK6-eJ@mu@qrlApEDd!dq6@5So2EICTy}I$mx9LKZn;-9ZpyHL+5V?$eKU*waIYRKYBH)4hhZF;97=U zi{N)PS82u^&Fi?cnDv-I>yzpF+p{hHorq=Ue-UFDFe$Q+z$SHgr8ZIs=pZ7qwsu=-o;S;*jx~ zKDQKC?2%OiiqV#>+Q`=fWv~jEUTHaInUQS?S5gw^@62-d3pE0w#8mQo`7hE5vSD4> zP-LpH%d)LJjnaDQ*?V;mC|RSifCA28Sm6>dDbjtb(i!FKU8M%&eE&BcJvyZlpURE9 zx&oJdd%KzS@2dif6+rKf`N62M`R)TJkD3?QcUxk z_mI3o?~i*y)(rR1m!NXfujuMD=IW-!|9pR6#8_0vxhN4c<_A@LH@4J##|&HunO?xw zksWw}>QsOS)ZD$D9e?QQx?Wgwlpnn3s;wXRa}nZe54^c{#(D4MPp}|MlZoq;&SfUy zRYGFxn{fv9Tl}Mm;0y%*C_H*t>i$pzhfXwDi{zqJUX;I~lO9X#k-!u9cT(gyI7`nI zzKr)~I5cDHroWN5aGsjTzF=A3eh&ry&IgRxY_&^>+k<z0N-Y%-)2J-nPK7myH>uY7||N z0}|iti!k=I>po*}Y(I(4qw_wJ4Ay}SBH>XPqDmIgSc++KmPBfSEuy(7y`c`~17{e^ zXI;-ES%vr3QD#5&pcT(kgcd4-qTRz+YdKX_gm%2 zy03=DopJKA?3qBRC;{!)O@Zwbdg>puPNw;X^3qObGOmU|lnm}(v)oN}g^K<(REPD( z2hSnv&sWIcV1BMzv!+(6+>sD#wfTuL^HwFDkK;uMosTo>m|ck*1J}mQU9rt#x2p8} zH}g_EEe2X##%7=xcv;qDeMEt8tefM!+`XAPsxpx7Y^&|DahJZib_s1qb1|fSO)IwU zUL(l77}4`mk8J=$4zos0`#Yelv;fgk%)zG`9TN}tcqvQ5a~iT=cIIxsze)j152VrL zId6Wf@It~=JtIEJQ$nAPwly!F48Mx}39esB=0#H~41qF%%RT!}p<#B6ve<>cdC?Ln zRyD|y24HE*)0Xl)#SybWtOR)n!^R<-Y~b~zOd~=o>2;f$dIymji8z19O_(-J{-~nG zuBL(caDQc!!NHL#0S*!@@N}zoiySC7ZY?@W&EUsfy36k4KjVu>0?-{Ey$Qi)HABOq z^PNt6uK4kKx~T!O7gCF-#(A^3N$< zKZE{>>#P$!!+P*Af)&6J$lDuXOMkov3AvBia6I*xRdLW|4jTx|%FN>KpvNgS_r(*W z;flm6?Ne#BmZaKxQ}kr$0J?1fI6v+rF4zn49}Om#c@jbF0!lSu8{{|JO_oP4LTQ^t z7{p#IrKY#;7@LM-s_FWIz~mgB>VnN<+9Ik*zN;NIzw{8nwp8Jc#CclGhpH|B3=Z$V ze;ksPCw%@b;(yQ)Dl1Q!m;-RJfRpcA%L4&|>VFAXxA*_cvikZj1qyZS|INVcT>oFw z(q4mrFtval-jQ{$*< + + + diff --git a/app/src/main/res/drawable/ic_chrome_comparison_chart.png b/app/src/main/res/drawable/ic_chrome_comparison_chart.png new file mode 100644 index 0000000000000000000000000000000000000000..f5768b392e98d86429a6f994ce188a4fd3cbfc19 GIT binary patch literal 13013 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 XVS^&Ab`#2i00000NkvXXu0mjf(6=sq literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/ic_ddg_comparison_chart.png b/app/src/main/res/drawable/ic_ddg_comparison_chart.png new file mode 100644 index 0000000000000000000000000000000000000000..ed48d2d11f421f175e2bcc3c9484236013c76806 GIT binary patch literal 10924 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 O0000 + + + diff --git a/app/src/main/res/layout/content_onboarding_welcome_experiment.xml b/app/src/main/res/layout/content_onboarding_welcome_experiment.xml index d9b92bd652fe..bc58af148d82 100644 --- a/app/src/main/res/layout/content_onboarding_welcome_experiment.xml +++ b/app/src/main/res/layout/content_onboarding_welcome_experiment.xml @@ -83,7 +83,8 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" - app:layout_constraintWidth_max="600dp" /> + app:layout_constraintWidth_max="600dp" + app:layout_constraintHeight_max="672dp"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From 429a0ae37dafeaa442e5d91db29eeb95e9f0b61b Mon Sep 17 00:00:00 2001 From: Josh Leibstein Date: Fri, 24 May 2024 10:32:13 +0100 Subject: [PATCH 09/17] Add additional intent flags for external apps (#4578) --- .../app/browser/SpecialUrlDetector.kt | 8 +++++ .../applinks/ExternalAppIntentFlagsFeature.kt | 33 +++++++++++++++++++ .../app/browser/di/BrowserModule.kt | 4 ++- .../app/browser/SpecialUrlDetectorImplTest.kt | 26 ++++++++++++++- 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/duckduckgo/app/browser/applinks/ExternalAppIntentFlagsFeature.kt diff --git a/app/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt b/app/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt index 7d6d79f3c175..2a5583425e7b 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt @@ -24,6 +24,7 @@ import android.content.pm.PackageManager import android.content.pm.ResolveInfo import android.net.Uri import com.duckduckgo.app.browser.SpecialUrlDetector.UrlType +import com.duckduckgo.app.browser.applinks.ExternalAppIntentFlagsFeature import com.duckduckgo.privacy.config.api.AmpLinkType import com.duckduckgo.privacy.config.api.AmpLinks import com.duckduckgo.privacy.config.api.TrackingParameters @@ -36,6 +37,7 @@ class SpecialUrlDetectorImpl( private val ampLinks: AmpLinks, private val trackingParameters: TrackingParameters, private val subscriptions: Subscriptions, + private val externalAppIntentFlagsFeature: ExternalAppIntentFlagsFeature, ) : SpecialUrlDetector { override fun determineType(initiatingUrl: String?, uri: Uri): UrlType { @@ -154,6 +156,12 @@ class SpecialUrlDetectorImpl( private fun buildIntent(uriString: String): UrlType { return try { val intent = Intent.parseUri(uriString, URI_ANDROID_APP_SCHEME) + + if (externalAppIntentFlagsFeature.self().isEnabled()) { + intent.addCategory(Intent.CATEGORY_BROWSABLE) + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + val fallbackUrl = intent.getStringExtra(EXTRA_FALLBACK_URL) val fallbackIntent = buildFallbackIntent(fallbackUrl) UrlType.NonHttpAppLink(uriString = uriString, intent = intent, fallbackUrl = fallbackUrl, fallbackIntent = fallbackIntent) diff --git a/app/src/main/java/com/duckduckgo/app/browser/applinks/ExternalAppIntentFlagsFeature.kt b/app/src/main/java/com/duckduckgo/app/browser/applinks/ExternalAppIntentFlagsFeature.kt new file mode 100644 index 000000000000..7a2d76eb1875 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/applinks/ExternalAppIntentFlagsFeature.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.browser.applinks + +import com.duckduckgo.anvil.annotations.ContributesRemoteFeature +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.Toggle + +@ContributesRemoteFeature( + scope = AppScope::class, + featureName = "externalAppIntentFlags", +) + +// A safeguard for this fix: https://app.asana.com/0/0/1207374732742336/1207383486029585/f +interface ExternalAppIntentFlagsFeature { + + @Toggle.DefaultValue(true) + fun self(): Toggle +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt b/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt index bfa3c40280da..645c4e8dd97d 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt @@ -25,6 +25,7 @@ import com.duckduckgo.adclick.api.AdClickManager import com.duckduckgo.app.browser.* import com.duckduckgo.app.browser.addtohome.AddToHomeCapabilityDetector import com.duckduckgo.app.browser.addtohome.AddToHomeSystemCapabilityDetector +import com.duckduckgo.app.browser.applinks.ExternalAppIntentFlagsFeature import com.duckduckgo.app.browser.certificates.rootstore.TrustedCertificateStore import com.duckduckgo.app.browser.cookies.AppThirdPartyCookieManager import com.duckduckgo.app.browser.cookies.ThirdPartyCookieManager @@ -178,7 +179,8 @@ class BrowserModule { ampLinks: AmpLinks, trackingParameters: TrackingParameters, subscriptions: Subscriptions, - ): SpecialUrlDetector = SpecialUrlDetectorImpl(packageManager, ampLinks, trackingParameters, subscriptions) + externalAppIntentFlagsFeature: ExternalAppIntentFlagsFeature, + ): SpecialUrlDetector = SpecialUrlDetectorImpl(packageManager, ampLinks, trackingParameters, subscriptions, externalAppIntentFlagsFeature) @Provides fun webViewRequestInterceptor( diff --git a/app/src/test/java/com/duckduckgo/app/browser/SpecialUrlDetectorImplTest.kt b/app/src/test/java/com/duckduckgo/app/browser/SpecialUrlDetectorImplTest.kt index 4a2f2d0edeae..281bd53c8935 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/SpecialUrlDetectorImplTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/SpecialUrlDetectorImplTest.kt @@ -27,11 +27,14 @@ import com.duckduckgo.app.browser.SpecialUrlDetector.UrlType.* import com.duckduckgo.app.browser.SpecialUrlDetectorImpl.Companion.EMAIL_MAX_LENGTH import com.duckduckgo.app.browser.SpecialUrlDetectorImpl.Companion.PHONE_MAX_LENGTH import com.duckduckgo.app.browser.SpecialUrlDetectorImpl.Companion.SMS_MAX_LENGTH +import com.duckduckgo.app.browser.applinks.ExternalAppIntentFlagsFeature +import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.privacy.config.api.AmpLinkType import com.duckduckgo.privacy.config.api.AmpLinks import com.duckduckgo.privacy.config.api.TrackingParameters import com.duckduckgo.subscriptions.api.Subscriptions import java.net.URISyntaxException +import junit.framework.TestCase.assertNull import junit.framework.TestCase.assertTrue import org.junit.Assert.assertEquals import org.junit.Before @@ -60,6 +63,12 @@ class SpecialUrlDetectorImplTest { @Mock lateinit var subscriptions: Subscriptions + @Mock + lateinit var externalAppIntentFlagsFeature: ExternalAppIntentFlagsFeature + + @Mock + lateinit var mockToggle: Toggle + @Before fun setup() { MockitoAnnotations.openMocks(this) @@ -68,6 +77,7 @@ class SpecialUrlDetectorImplTest { ampLinks = mockAmpLinks, trackingParameters = mockTrackingParameters, subscriptions = subscriptions, + externalAppIntentFlagsFeature = externalAppIntentFlagsFeature, ) whenever(mockPackageManager.queryIntentActivities(any(), anyInt())).thenReturn(emptyList()) } @@ -247,9 +257,23 @@ class SpecialUrlDetectorImplTest { } @Test - fun whenUrlIsCustomUriSchemeThenNonHttpAppLinkTypeDetected() { + fun whenUrlIsCustomUriSchemeThenNonHttpAppLinkTypeDetectedWithAdditionalIntentFlags() { + whenever(mockToggle.isEnabled()).thenReturn(true) + whenever(externalAppIntentFlagsFeature.self()).thenReturn(mockToggle) + val type = testee.determineType("myapp:foo bar") as NonHttpAppLink + assertEquals("myapp:foo bar", type.uriString) + assertEquals(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP, type.intent.flags) + assertEquals(Intent.CATEGORY_BROWSABLE, type.intent.categories.first()) + } + + @Test + fun whenUrlIsCustomUriSchemeThenNonHttpAppLinkTypeDetectedWithoutAdditionalIntentFlags() { + whenever(mockToggle.isEnabled()).thenReturn(false) + whenever(externalAppIntentFlagsFeature.self()).thenReturn(mockToggle) val type = testee.determineType("myapp:foo bar") as NonHttpAppLink assertEquals("myapp:foo bar", type.uriString) + assertEquals(0, type.intent.flags) + assertNull(type.intent.categories) } @Test From b154321125e75ffd33f13e72e9867abd0c227dd8 Mon Sep 17 00:00:00 2001 From: Aitor Viana Date: Fri, 24 May 2024 10:57:16 +0100 Subject: [PATCH 10/17] Change VPN FAQ copy (#4579) Task/Issue URL: https://app.asana.com/0/488551667048375/1207390073646402/f ### Description Change copy according to asana task ### Steps to test this PR QA optional --- .../src/main/res/values/donottranslate.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/network-protection/network-protection-impl/src/main/res/values/donottranslate.xml b/network-protection/network-protection-impl/src/main/res/values/donottranslate.xml index 4fb0b5edea1b..43c78c06e85b 100644 --- a/network-protection/network-protection-impl/src/main/res/values/donottranslate.xml +++ b/network-protection/network-protection-impl/src/main/res/values/donottranslate.xml @@ -153,11 +153,11 @@ About - Frequently Asked Questions + FAQs and Support Share VPN Feedback - Frequently Asked Questions + FAQs and Support How does DuckDuckGo VPN work? The VPN uses the open source WireGuard protocol to configure a virtual private network (VPN) on your device. When enabled, all of your device traffic is first encrypted and then routed through the nearest VPN server to protect your browsing activity from Internet service providers and hide your location from apps and sites you visit. Is only my web browsing protected? From 0aee785e50056312457661a6783eac1d9cf6577c Mon Sep 17 00:00:00 2001 From: Marcos Date: Fri, 24 May 2024 13:15:49 +0100 Subject: [PATCH 11/17] Catch security exception when connecting to billing (#4573) Task/Issue URL: https://app.asana.com/0/488551667048375/1207335546470325/f ### Description Catch security exception when trying to connect to billing ### Steps to test this PR - [x] Code check - [x] Can also try to make a purchase and make sure it works as expected --- .../impl/billing/RealBillingClientAdapter.kt | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/RealBillingClientAdapter.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/RealBillingClientAdapter.kt index 1febcd31b551..a3b3988e9064 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/RealBillingClientAdapter.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/RealBillingClientAdapter.kt @@ -65,7 +65,6 @@ class RealBillingClientAdapter @Inject constructor( disconnectionListener: () -> Unit, ): BillingInitResult { reset() - billingClient = BillingClient.newBuilder(context) .enablePendingPurchases() .setListener { billingResult, purchases -> @@ -75,26 +74,31 @@ class RealBillingClientAdapter @Inject constructor( .build() return suspendCoroutine { continuation -> - billingClient?.startConnection( - object : BillingClientStateListener { - override fun onBillingServiceDisconnected() { - disconnectionListener.invoke() - } - - override fun onBillingSetupFinished(p0: BillingResult) { - val result = when (p0.responseCode) { - BillingResponseCode.OK -> Success - else -> Failure(billingError = p0.responseCode.toBillingError()) + try { + billingClient?.startConnection( + object : BillingClientStateListener { + override fun onBillingServiceDisconnected() { + disconnectionListener.invoke() } - try { - continuation.resume(result) - } catch (e: IllegalStateException) { - logcat(priority = WARN) { "onBillingSetupFinished() invoked more than once" } + override fun onBillingSetupFinished(p0: BillingResult) { + val result = when (p0.responseCode) { + BillingResponseCode.OK -> Success + else -> Failure(billingError = p0.responseCode.toBillingError()) + } + + try { + continuation.resume(result) + } catch (e: IllegalStateException) { + logcat(priority = WARN) { "onBillingSetupFinished() invoked more than once" } + } } - } - }, - ) + }, + ) + } catch (e: SecurityException) { + logcat(priority = WARN) { "Exception when starting a connection to billing, ignoring" } + continuation.resume(Failure(BILLING_CRASH_ERROR)) + } } } From b38abc82e06770119700dad75f1387e728dbfdbb Mon Sep 17 00:00:00 2001 From: Aitor Viana Date: Fri, 24 May 2024 18:43:03 +0100 Subject: [PATCH 12/17] Fix priority in active plugin points (#4581) Task/Issue URL: https://app.asana.com/0/1198194956794324/1207400224799050/f ### Description Ensure that `priority` don't have grouping separators ### Steps to test this PR _Test_ - [ ] Apply changes in `ContributesActivePluginPointCodeGeneratorTest` and `BarActivePlugin` to develop - [ ] verify `ContributesActivePluginPointCodeGeneratorTest` fails - [ ] verify `ContributesActivePluginPointCodeGeneratorTest` passes in this branch --- .../anvil/compiler/ContributesActivePluginPointCodeGenerator.kt | 2 +- .../codegen/ContributesActivePluginPointCodeGeneratorTest.kt | 2 +- .../com/duckduckgo/feature/toggles/codegen/TestActivePlugins.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/anvil/anvil-compiler/src/main/java/com/duckduckgo/anvil/compiler/ContributesActivePluginPointCodeGenerator.kt b/anvil/anvil-compiler/src/main/java/com/duckduckgo/anvil/compiler/ContributesActivePluginPointCodeGenerator.kt index a242b6290d38..e4c69a8df5f2 100644 --- a/anvil/anvil-compiler/src/main/java/com/duckduckgo/anvil/compiler/ContributesActivePluginPointCodeGenerator.kt +++ b/anvil/anvil-compiler/src/main/java/com/duckduckgo/anvil/compiler/ContributesActivePluginPointCodeGenerator.kt @@ -320,7 +320,7 @@ class ContributesActivePluginPointCodeGenerator : CodeGenerator { pluginPriority?.let { addAnnotation( AnnotationSpec.builder(PriorityKey::class) - .addMember("%L", it) + .addMember("%L", it.toString()) .build(), ) } diff --git a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/ContributesActivePluginPointCodeGeneratorTest.kt b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/ContributesActivePluginPointCodeGeneratorTest.kt index 7cc3b92fdf66..b2257fbf1ae1 100644 --- a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/ContributesActivePluginPointCodeGeneratorTest.kt +++ b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/ContributesActivePluginPointCodeGeneratorTest.kt @@ -55,7 +55,7 @@ class ContributesActivePluginPointCodeGeneratorTest { val priorityAnnotation = clazz.java.getAnnotation(PriorityKey::class.java)!! assertNotNull(priorityAnnotation) - assertEquals(100, priorityAnnotation.priority) + assertEquals(1000, priorityAnnotation.priority) } @Test diff --git a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/TestActivePlugins.kt b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/TestActivePlugins.kt index 7769287e0322..ce47b3796bd7 100644 --- a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/TestActivePlugins.kt +++ b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/TestActivePlugins.kt @@ -62,7 +62,7 @@ class FooActivePlugin @Inject constructor() : MyPlugin { @ContributesActivePlugin( scope = AppScope::class, boundType = MyPlugin::class, - priority = 100, + priority = 1000, ) class BarActivePlugin @Inject constructor() : MyPlugin { override fun doSomething() { From 775fe4b889019fb2aa3a49e82b793a7cac2cfd0f Mon Sep 17 00:00:00 2001 From: Lukasz Macionczyk Date: Fri, 24 May 2024 22:12:30 +0200 Subject: [PATCH 13/17] Expired Subscription State (#4548) Task/Issue URL: https://app.asana.com/0/1205648422731273/1207313382804224/f ### Description - Show expired subscription state in settings. - Allow purchasing subscription when in expired state. ### Steps to test this PR See task. ### UI changes | App settings | Subscription settings | | ------ | ----- | |![Screenshot_20240510_132903](https://github.com/duckduckgo/Android/assets/4212474/da1bda83-7824-42bc-a43f-f765e29f5679) |![Screenshot_20240510_132919](https://github.com/duckduckgo/Android/assets/4212474/9e2494e7-369a-4aca-aaa3-91c1e6793316)| --- .../common/ui/view/listitem/CheckListItem.kt | 4 + .../res/drawable/ic_exclamation_red_16.xml | 32 +++++ .../values/attrs-settings-check-list-item.xml | 4 +- .../impl/SubscriptionsChecker.kt | 8 +- .../impl/SubscriptionsManager.kt | 47 +++++-- .../SubscriptionMessagingInterface.kt | 48 +++++++- .../impl/repository/AuthRepository.kt | 5 + .../impl/settings/views/ProSettingView.kt | 62 ++++++---- .../impl/ui/RestoreSubscriptionActivity.kt | 31 ++++- .../impl/ui/RestoreSubscriptionViewModel.kt | 43 +++++-- .../impl/ui/SubscriptionSettingsActivity.kt | 90 +++++++++----- .../impl/ui/SubscriptionWebViewViewModel.kt | 22 ++-- .../impl/ui/SubscriptionsWebViewActivity.kt | 22 +--- .../layout/activity_subscription_settings.xml | 43 +++++-- .../src/main/res/layout/view_settings.xml | 39 +++--- .../src/main/res/values/donottranslate.xml | 6 +- .../impl/RealSubscriptionsManagerTest.kt | 12 +- .../SubscriptionMessagingInterfaceTest.kt | 116 +++++++++++++++++- .../ui/RestoreSubscriptionViewModelTest.kt | 89 +++++++++----- .../ui/SubscriptionWebViewViewModelTest.kt | 93 ++++++++------ 20 files changed, 596 insertions(+), 220 deletions(-) create mode 100644 common/common-ui/src/main/res/drawable/ic_exclamation_red_16.xml diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/listitem/CheckListItem.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/listitem/CheckListItem.kt index 0b7a8e24e5e8..97b09d0d8921 100644 --- a/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/listitem/CheckListItem.kt +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/listitem/CheckListItem.kt @@ -24,6 +24,7 @@ import android.view.ViewGroup import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.children import com.duckduckgo.common.ui.view.gone +import com.duckduckgo.common.ui.view.listitem.CheckListItem.CheckItemStatus.ALERT import com.duckduckgo.common.ui.view.listitem.CheckListItem.CheckItemStatus.DISABLED import com.duckduckgo.common.ui.view.listitem.CheckListItem.CheckItemStatus.ENABLED import com.duckduckgo.common.ui.view.listitem.CheckListItem.CheckItemStatus.WARNING @@ -112,6 +113,7 @@ class CheckListItem @JvmOverloads constructor( DISABLED -> binding.leadingIcon.setImageResource(CommonR.drawable.ic_check_grey_round_16) ENABLED -> binding.leadingIcon.setImageResource(CommonR.drawable.ic_check_green_round_16) WARNING -> binding.leadingIcon.setImageResource(CommonR.drawable.ic_exclamation_yellow_16) + ALERT -> binding.leadingIcon.setImageResource(CommonR.drawable.ic_exclamation_red_16) } } @@ -132,6 +134,7 @@ class CheckListItem @JvmOverloads constructor( DISABLED, ENABLED, WARNING, + ALERT, ; companion object { @@ -141,6 +144,7 @@ class CheckListItem @JvmOverloads constructor( 0 -> DISABLED 1 -> ENABLED 2 -> WARNING + 3 -> ALERT else -> DISABLED } } diff --git a/common/common-ui/src/main/res/drawable/ic_exclamation_red_16.xml b/common/common-ui/src/main/res/drawable/ic_exclamation_red_16.xml new file mode 100644 index 000000000000..5a1d5f369857 --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_exclamation_red_16.xml @@ -0,0 +1,32 @@ + + + + + + + diff --git a/common/common-ui/src/main/res/values/attrs-settings-check-list-item.xml b/common/common-ui/src/main/res/values/attrs-settings-check-list-item.xml index 12cd67f38d9f..46bfb8e69f39 100644 --- a/common/common-ui/src/main/res/values/attrs-settings-check-list-item.xml +++ b/common/common-ui/src/main/res/values/attrs-settings-check-list-item.xml @@ -21,8 +21,10 @@ - + + + diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsChecker.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsChecker.kt index fff8f1fa28ed..376d22111688 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsChecker.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsChecker.kt @@ -31,8 +31,8 @@ import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.subscriptions.api.SubscriptionStatus.UNKNOWN import com.duckduckgo.subscriptions.impl.RealSubscriptionsChecker.Companion.TAG_WORKER_SUBSCRIPTION_CHECK -import com.duckduckgo.subscriptions.impl.repository.isActiveOrWaiting import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesMultibinding import java.util.concurrent.TimeUnit.HOURS @@ -66,7 +66,7 @@ class RealSubscriptionsChecker @Inject constructor( } override suspend fun runChecker() { - if (subscriptionsManager.subscriptionStatus().isActiveOrWaiting()) { + if (subscriptionsManager.subscriptionStatus() != UNKNOWN) { PeriodicWorkRequestBuilder(1, HOURS) .addTag(TAG_WORKER_SUBSCRIPTION_CHECK) .setConstraints( @@ -101,9 +101,9 @@ class SubscriptionsCheckWorker( override suspend fun doWork(): Result { return try { - if (subscriptionsManager.subscriptionStatus().isActiveOrWaiting()) { + if (subscriptionsManager.subscriptionStatus() != UNKNOWN) { val subscription = subscriptionsManager.fetchAndStoreAllData() - if (subscription?.status?.isActiveOrWaiting() != true) { + if (subscription?.status == null || subscription.status == UNKNOWN) { workManager.cancelAllWorkByTag(TAG_WORKER_SUBSCRIPTION_CHECK) } } else { diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt index 8b92cfd5c612..11f713a689b9 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt @@ -43,6 +43,7 @@ import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.repository.Account import com.duckduckgo.subscriptions.impl.repository.AuthRepository import com.duckduckgo.subscriptions.impl.repository.Subscription +import com.duckduckgo.subscriptions.impl.repository.isExpired import com.duckduckgo.subscriptions.impl.repository.toProductList import com.duckduckgo.subscriptions.impl.services.AuthService import com.duckduckgo.subscriptions.impl.services.ConfirmationBody @@ -204,6 +205,9 @@ class RealSubscriptionsManager @Inject constructor( override val entitlements = _entitlements.onSubscription { emitEntitlementsValues() } private var purchaseStateJob: Job? = null + + private var removeExpiredSubscriptionOnCancelledPurchase: Boolean = false + private suspend fun isUserAuthenticated(): Boolean = authRepository.isUserAuthenticated() private suspend fun emitEntitlementsValues() { @@ -233,6 +237,12 @@ class RealSubscriptionsManager @Inject constructor( is PurchaseState.Purchased -> checkPurchase(it.packageName, it.purchaseToken) is PurchaseState.Canceled -> { _currentPurchaseState.emit(CurrentPurchase.Canceled) + if (removeExpiredSubscriptionOnCancelledPurchase) { + if (subscriptionStatus().isExpired()) { + signOut() + } + removeExpiredSubscriptionOnCancelledPurchase = false + } } else -> { // NOOP @@ -451,17 +461,25 @@ class RealSubscriptionsManager @Inject constructor( ) { try { _currentPurchaseState.emit(CurrentPurchase.PreFlowInProgress) - val subscription: Subscription? = - if (isUserAuthenticated()) { - fetchAndStoreAllData() - } else { - val recovered = recoverSubscriptionFromStore() - if (recovered is RecoverSubscriptionResult.Success) { - recovered.subscription - } else { - null + + // refresh any existing account / subscription data + fetchAndStoreAllData() + + if (!isUserAuthenticated()) { + recoverSubscriptionFromStore() + } else { + authRepository.getSubscription()?.run { + if (status.isExpired() && platform == "google") { + // re-authenticate in case previous subscription was bought using different google account + val accountId = authRepository.getAccount()?.externalId + recoverSubscriptionFromStore() + removeExpiredSubscriptionOnCancelledPurchase = + accountId != null && accountId != authRepository.getAccount()?.externalId } } + } + + val subscription = authRepository.getSubscription() if (subscription?.isActive() == true) { pixelSender.reportSubscriptionActivated() @@ -497,7 +515,7 @@ class RealSubscriptionsManager @Inject constructor( validateToken(authRepository.getAuthToken()!!) AuthToken.Success(authRepository.getAuthToken()!!) } else { - AuthToken.Failure("") + AuthToken.Failure.UnknownError } } catch (e: Exception) { return when (extractError(e)) { @@ -507,11 +525,11 @@ class RealSubscriptionsManager @Inject constructor( if (result is RecoverSubscriptionResult.Success) { AuthToken.Success(authRepository.getAuthToken()!!) } else { - AuthToken.Failure("") + AuthToken.Failure.TokenExpired(authRepository.getAuthToken()!!) } } else -> { - AuthToken.Failure("") + AuthToken.Failure.UnknownError } } } @@ -568,7 +586,10 @@ sealed class AccessToken { sealed class AuthToken { data class Success(val authToken: String) : AuthToken() - data class Failure(val message: String) : AuthToken() + sealed class Failure : AuthToken() { + data class TokenExpired(val authToken: String) : Failure() + data object UnknownError : Failure() + } } fun String.toStatus(): SubscriptionStatus { diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterface.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterface.kt index 4436e4643faf..179afc263894 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterface.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterface.kt @@ -32,6 +32,7 @@ import com.duckduckgo.js.messaging.api.JsMessaging import com.duckduckgo.js.messaging.api.JsRequestResponse import com.duckduckgo.js.messaging.api.SubscriptionEvent import com.duckduckgo.js.messaging.api.SubscriptionEventData +import com.duckduckgo.subscriptions.impl.AccessToken import com.duckduckgo.subscriptions.impl.AuthToken import com.duckduckgo.subscriptions.impl.JSONObjectAdapter import com.duckduckgo.subscriptions.impl.SubscriptionsChecker @@ -67,6 +68,7 @@ class SubscriptionMessagingInterface @Inject constructor( GetSubscriptionMessage(subscriptionsManager, dispatcherProvider), SetSubscriptionMessage(subscriptionsManager, appCoroutineScope, dispatcherProvider, pixelSender, subscriptionsChecker), InformationalEventsMessage(appCoroutineScope, pixelSender), + GetAccessTokenMessage(subscriptionsManager), ) @JavascriptInterface @@ -78,6 +80,7 @@ class SubscriptionMessagingInterface @Inject constructor( webView.url?.toUri()?.host } jsMessage?.let { + logcat { jsMessage.toString() } if (this.secret == secret && context == jsMessage.context && isUrlAllowed(url)) { handlers.firstOrNull { it.methods.contains(jsMessage.method) && it.featureName == jsMessage.featureName @@ -156,10 +159,10 @@ class SubscriptionMessagingInterface @Inject constructor( val authToken: String? = runBlocking(dispatcherProvider.io()) { val pat = subscriptionsManager.getAuthToken() - if (pat is AuthToken.Success && subscriptionsManager.getSubscription()?.isActive() == true) { - return@runBlocking pat.authToken - } else { - return@runBlocking null + when (pat) { + is AuthToken.Success -> pat.authToken + is AuthToken.Failure.TokenExpired -> pat.authToken + else -> null } } @@ -205,7 +208,6 @@ class SubscriptionMessagingInterface @Inject constructor( subscriptionsChecker.runChecker() pixelSender.reportRestoreUsingEmailSuccess() pixelSender.reportSubscriptionActivated() - jsMessageCallback?.process(featureName, jsMessage.method, jsMessage.id, jsMessage.params) } } catch (e: Exception) { logcat { "Error parsing the token" } @@ -253,4 +255,40 @@ class SubscriptionMessagingInterface @Inject constructor( "subscriptionsWelcomeFaqClicked", ) } + + private inner class GetAccessTokenMessage( + private val subscriptionsManager: SubscriptionsManager, + ) : JsMessageHandler { + + override fun process( + jsMessage: JsMessage, + secret: String, + jsMessageCallback: JsMessageCallback?, + ) { + val jsMessageId = jsMessage.id ?: return + + val pat: AccessToken = runBlocking { + subscriptionsManager.getAccessToken() + } + + val resultJson = when (pat) { + is AccessToken.Success -> """{"token":"${pat.accessToken}"}""" + is AccessToken.Failure -> """{ }""" + } + + val response = JsRequestResponse.Success( + context = jsMessage.context, + featureName = featureName, + method = jsMessage.method, + id = jsMessageId, + result = JSONObject(resultJson), + ) + + jsMessageHelper.sendJsResponse(response, callbackName, secret, webView) + } + + override val allowedDomains: List = emptyList() + override val featureName: String = "useSubscription" + override val methods: List = listOf("getAccessToken") + } } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt index d9ef6e48b1e8..9a7ebad9e7b4 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt @@ -21,7 +21,9 @@ import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.subscriptions.api.Product import com.duckduckgo.subscriptions.api.SubscriptionStatus import com.duckduckgo.subscriptions.api.SubscriptionStatus.AUTO_RENEWABLE +import com.duckduckgo.subscriptions.api.SubscriptionStatus.EXPIRED import com.duckduckgo.subscriptions.api.SubscriptionStatus.GRACE_PERIOD +import com.duckduckgo.subscriptions.api.SubscriptionStatus.INACTIVE import com.duckduckgo.subscriptions.api.SubscriptionStatus.NOT_AUTO_RENEWABLE import com.duckduckgo.subscriptions.api.SubscriptionStatus.UNKNOWN import com.duckduckgo.subscriptions.api.SubscriptionStatus.WAITING @@ -204,6 +206,9 @@ fun SubscriptionStatus.isActive(): Boolean { } } +fun SubscriptionStatus.isExpired(): Boolean = + this == EXPIRED || this == INACTIVE + fun SubscriptionStatus.isActiveOrWaiting(): Boolean { return this.isActive() || this == WAITING } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingView.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingView.kt index 8dbeb822c9ca..3f2deaa4a7ec 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingView.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingView.kt @@ -27,6 +27,8 @@ import androidx.lifecycle.ViewTreeLifecycleOwner import androidx.lifecycle.findViewTreeViewModelStoreOwner import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.common.ui.view.gone +import com.duckduckgo.common.ui.view.listitem.CheckListItem.CheckItemStatus.ALERT +import com.duckduckgo.common.ui.view.listitem.CheckListItem.CheckItemStatus.DISABLED import com.duckduckgo.common.ui.view.show import com.duckduckgo.common.ui.viewbinding.viewBinding import com.duckduckgo.common.utils.ConflatedJob @@ -35,7 +37,9 @@ import com.duckduckgo.common.utils.extensions.html import com.duckduckgo.di.scopes.ViewScope import com.duckduckgo.navigation.api.GlobalActivityStarter import com.duckduckgo.subscriptions.api.SubscriptionStatus.AUTO_RENEWABLE +import com.duckduckgo.subscriptions.api.SubscriptionStatus.EXPIRED import com.duckduckgo.subscriptions.api.SubscriptionStatus.GRACE_PERIOD +import com.duckduckgo.subscriptions.api.SubscriptionStatus.INACTIVE import com.duckduckgo.subscriptions.api.SubscriptionStatus.NOT_AUTO_RENEWABLE import com.duckduckgo.subscriptions.api.SubscriptionStatus.WAITING import com.duckduckgo.subscriptions.impl.R @@ -97,6 +101,27 @@ class ProSettingView @JvmOverloads constructor( viewModel.viewState .onEach { renderView(it) } .launchIn(coroutineScope!!) + + binding.subscriptionSetting.setOnClickListener(null) + binding.subscriptionSetting.setOnTouchListener(null) + binding.subscriptionBuy.setOnClickListener(null) + binding.subscriptionBuy.setOnTouchListener(null) + binding.subscriptionGet.setOnClickListener(null) + binding.subscriptionGet.setOnTouchListener(null) + binding.subscriptionRestore.setOnTouchListener(null) + binding.subscriptionRestore.setOnClickListener(null) + + binding.subscriptionSettingContainer.setOnClickListener { + viewModel.onSettings() + } + + binding.subscriptionRestoreContainer.setOnClickListener { + viewModel.onRestore() + } + + binding.subscriptionBuyContainer.setOnClickListener { + viewModel.onBuy() + } } override fun onDetachedFromWindow() { @@ -109,47 +134,42 @@ class ProSettingView @JvmOverloads constructor( @SuppressLint("ClickableViewAccessibility") private fun renderView(viewState: ViewState) { - binding.subscriptionSetting.setOnClickListener(null) - binding.subscriptionSetting.setOnTouchListener(null) - binding.subscriptionBuy.setOnClickListener(null) - binding.subscriptionBuy.setOnTouchListener(null) - binding.subscriptionGet.setOnClickListener(null) - binding.subscriptionGet.setOnTouchListener(null) - binding.subscriptionRestore.setOnTouchListener(null) - binding.subscriptionRestore.setOnClickListener(null) - when (viewState.status) { AUTO_RENEWABLE, NOT_AUTO_RENEWABLE, GRACE_PERIOD -> { binding.subscriptionBuyContainer.gone() binding.subscriptionRestoreContainer.gone() binding.subscriptionWaitingContainer.gone() binding.subscriptionSettingContainer.show() - binding.subscriptionSettingContainer.setOnClickListener { - viewModel.onSettings() - } } WAITING -> { binding.subscriptionBuyContainer.gone() binding.subscriptionWaitingContainer.show() binding.subscriptionSettingContainer.gone() binding.subscriptionRestoreContainer.show() - binding.subscriptionRestoreContainer.setOnClickListener { - viewModel.onRestore() - } + } + EXPIRED, INACTIVE -> { + binding.subscriptionBuy.setPrimaryText(context.getString(R.string.subscriptionSettingExpired)) + binding.subscriptionBuy.setSecondaryText(context.getString(R.string.subscriptionSettingExpiredSubtitle)) + binding.subscriptionBuy.setItemStatus(ALERT) + binding.subscriptionGet.setText(R.string.subscriptionSettingExpiredViewPlans) + binding.subscribeSecondary.gone() + binding.subscriptionBuyContainer.show() + binding.subscriptionSettingContainer.show() + binding.subscriptionWaitingContainer.gone() + binding.subscriptionRestoreContainer.gone() } else -> { + binding.subscriptionBuy.setPrimaryText(context.getString(R.string.subscriptionSettingSubscribe)) + binding.subscriptionBuy.setSecondaryText(context.getString(R.string.subscriptionSettingSubscribeSubtitle)) + binding.subscriptionBuy.setItemStatus(DISABLED) + binding.subscriptionGet.setText(R.string.subscriptionSettingGet) val htmlText = context.getString(R.string.subscriptionSettingFeaturesList).html(context) binding.subscribeSecondary.text = htmlText.noTrailingWhiteLines() + binding.subscribeSecondary.show() binding.subscriptionBuyContainer.show() binding.subscriptionSettingContainer.gone() binding.subscriptionWaitingContainer.gone() binding.subscriptionRestoreContainer.show() - binding.subscriptionBuyContainer.setOnClickListener { - viewModel.onBuy() - } - binding.subscriptionRestoreContainer.setOnClickListener { - viewModel.onRestore() - } } } } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionActivity.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionActivity.kt index 0b333c679e6e..3b73336dd823 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionActivity.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionActivity.kt @@ -37,9 +37,12 @@ import com.duckduckgo.subscriptions.impl.databinding.ActivityRestoreSubscription import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionActivity.Companion.RestoreSubscriptionScreenWithParams import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.Error +import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.FinishAndGoToOnboarding +import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.FinishAndGoToSubscriptionSettings import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.RestoreFromEmail import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.SubscriptionNotFound import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.Success +import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsActivity.Companion.SubscriptionsSettingsScreenWithEmptyParams import javax.inject.Inject import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -67,6 +70,8 @@ class RestoreSubscriptionActivity : DuckDuckGoActivity() { setContentView(binding.root) setupToolbar(toolbar) + viewModel.init() + viewModel.commands() .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) .onEach { processCommand(it) } @@ -87,12 +92,7 @@ class RestoreSubscriptionActivity : DuckDuckGoActivity() { private val startForResultRestore = registerForActivityResult(StartActivityForResult()) { result: ActivityResult -> if (result.resultCode == RESULT_OK) { - if (isOriginWeb) { - setResult(RESULT_OK) - } else { - goToSubscriptions() - } - finish() + viewModel.onSubscriptionRestoredFromEmail() } } @@ -165,12 +165,31 @@ class RestoreSubscriptionActivity : DuckDuckGoActivity() { .show() } + private fun finishAndGoToOnboarding() { + if (isOriginWeb) { + setResult(RESULT_OK) + } else { + goToSubscriptions() + } + finish() + } + + private fun finishAndGoToSubscriptionSettings() { + if (isOriginWeb) { + setResult(RESULT_OK) + } + globalActivityStarter.start(this, SubscriptionsSettingsScreenWithEmptyParams) + finish() + } + private fun processCommand(command: Command) { when (command) { is RestoreFromEmail -> goToRestore() is Success -> onPurchaseRestored() is SubscriptionNotFound -> subscriptionNotFound() is Error -> showError() + is FinishAndGoToOnboarding -> finishAndGoToOnboarding() + is FinishAndGoToSubscriptionSettings -> finishAndGoToSubscriptionSettings() } } companion object { diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionViewModel.kt index 06d5c4724ac4..6418ece3a51b 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionViewModel.kt @@ -21,12 +21,16 @@ import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.subscriptions.api.SubscriptionStatus import com.duckduckgo.subscriptions.impl.RealSubscriptionsManager.Companion.SUBSCRIPTION_NOT_FOUND_ERROR import com.duckduckgo.subscriptions.impl.RealSubscriptionsManager.RecoverSubscriptionResult import com.duckduckgo.subscriptions.impl.SubscriptionsChecker import com.duckduckgo.subscriptions.impl.SubscriptionsManager import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender +import com.duckduckgo.subscriptions.impl.repository.isExpired import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.Error +import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.FinishAndGoToOnboarding +import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.FinishAndGoToSubscriptionSettings import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.RestoreFromEmail import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.SubscriptionNotFound import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.Success @@ -36,6 +40,8 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch @@ -56,28 +62,35 @@ class RestoreSubscriptionViewModel @Inject constructor( val email: String? = null, ) + private lateinit var subscriptionStatus: SubscriptionStatus + + fun init() { + subscriptionsManager.subscriptionStatus + .onEach { subscriptionStatus = it } + .launchIn(viewModelScope) + } + fun restoreFromStore() { pixelSender.reportActivateSubscriptionRestorePurchaseClick() viewModelScope.launch(dispatcherProvider.io()) { when (val subscription = subscriptionsManager.recoverSubscriptionFromStore()) { is RecoverSubscriptionResult.Success -> { - if (subscription.subscription.isActive()) { - subscriptionsChecker.runChecker() - pixelSender.reportRestoreUsingStoreSuccess() - pixelSender.reportSubscriptionActivated() - command.send(Success) - } else { - pixelSender.reportRestoreUsingStoreFailureSubscriptionNotFound() - subscriptionsManager.signOut() - command.send(SubscriptionNotFound) - } + subscriptionsChecker.runChecker() + pixelSender.reportRestoreUsingStoreSuccess() + pixelSender.reportSubscriptionActivated() + command.send(Success) } + is RecoverSubscriptionResult.Failure -> { when (subscription.message) { SUBSCRIPTION_NOT_FOUND_ERROR -> { + if (subscriptionStatus.isExpired()) { + subscriptionsManager.signOut() + } pixelSender.reportRestoreUsingStoreFailureSubscriptionNotFound() command.send(SubscriptionNotFound) } + else -> { pixelSender.reportRestoreUsingStoreFailureOther() command.send(Error) @@ -95,10 +108,20 @@ class RestoreSubscriptionViewModel @Inject constructor( } } + fun onSubscriptionRestoredFromEmail() = viewModelScope.launch { + if (subscriptionStatus.isExpired()) { + command.send(FinishAndGoToSubscriptionSettings) + } else { + command.send(FinishAndGoToOnboarding) + } + } + sealed class Command { data object RestoreFromEmail : Command() data object Success : Command() data object SubscriptionNotFound : Command() data object Error : Command() + data object FinishAndGoToSubscriptionSettings : Command() + data object FinishAndGoToOnboarding : Command() } } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsActivity.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsActivity.kt index fd49405bb615..8a91750863dc 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsActivity.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsActivity.kt @@ -20,6 +20,7 @@ import android.content.Intent import android.net.Uri import android.os.Bundle import android.widget.Toast +import androidx.core.view.isVisible import androidx.lifecycle.Lifecycle import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope @@ -31,7 +32,10 @@ import com.duckduckgo.common.ui.viewbinding.viewBinding import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.navigation.api.GlobalActivityStarter import com.duckduckgo.subscriptions.api.SubscriptionStatus.AUTO_RENEWABLE +import com.duckduckgo.subscriptions.api.SubscriptionStatus.EXPIRED +import com.duckduckgo.subscriptions.api.SubscriptionStatus.INACTIVE import com.duckduckgo.subscriptions.impl.R.string +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.BASIC_SUBSCRIPTION import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.FAQS_URL import com.duckduckgo.subscriptions.impl.databinding.ActivitySubscriptionSettingsBinding @@ -110,6 +114,10 @@ class SubscriptionSettingsActivity : DuckDuckGoActivity() { goToFaqs() } + binding.viewPlans.setClickListener { + goToPurchasePage() + } + if (savedInstanceState == null) { pixelSender.reportSubscriptionSettingsShown() } @@ -121,35 +129,50 @@ class SubscriptionSettingsActivity : DuckDuckGoActivity() { } private fun renderView(viewState: ViewState) { - binding.subscriptionDuration.setText( - if (viewState.duration is Monthly) string.monthlySubscription else string.yearlySubscription, - ) - - val status = when (viewState.status) { - AUTO_RENEWABLE -> getString(string.renews) - else -> getString(string.expires) - } - binding.description.text = getString(string.subscriptionsData, status, viewState.date) - - when (viewState.platform?.lowercase()) { - "apple", "ios" -> - binding.changePlan.setClickListener { - pixelSender.reportSubscriptionSettingsChangePlanOrBillingClick() - globalActivityStarter.start(this, ChangePlanScreenWithEmptyParams) - } - "stripe" -> { - binding.changePlan.setClickListener { - pixelSender.reportSubscriptionSettingsChangePlanOrBillingClick() - viewModel.goToStripe() - } + if (viewState.status in listOf(INACTIVE, EXPIRED)) { + binding.subscriptionDuration.isVisible = false + binding.descriptionExpiredIcon.isVisible = true + binding.viewPlans.isVisible = true + binding.changePlan.isVisible = false + binding.description.text = getString(string.subscriptionsExpiredData, viewState.date) + } else { + binding.subscriptionDuration.isVisible = true + binding.descriptionExpiredIcon.isVisible = false + binding.viewPlans.isVisible = false + binding.changePlan.isVisible = true + + binding.subscriptionDuration.setText( + if (viewState.duration is Monthly) string.monthlySubscription else string.yearlySubscription, + ) + + val status = when (viewState.status) { + AUTO_RENEWABLE -> getString(string.renews) + else -> getString(string.expires) } - else -> { - binding.changePlan.setClickListener { - pixelSender.reportSubscriptionSettingsChangePlanOrBillingClick() - val url = String.format(URL, BASIC_SUBSCRIPTION, applicationContext.packageName) - val intent = Intent(Intent.ACTION_VIEW) - intent.setData(Uri.parse(url)) - startActivity(intent) + binding.description.text = getString(string.subscriptionsData, status, viewState.date) + + when (viewState.platform?.lowercase()) { + "apple", "ios" -> + binding.changePlan.setClickListener { + pixelSender.reportSubscriptionSettingsChangePlanOrBillingClick() + globalActivityStarter.start(this, ChangePlanScreenWithEmptyParams) + } + + "stripe" -> { + binding.changePlan.setClickListener { + pixelSender.reportSubscriptionSettingsChangePlanOrBillingClick() + viewModel.goToStripe() + } + } + + else -> { + binding.changePlan.setClickListener { + pixelSender.reportSubscriptionSettingsChangePlanOrBillingClick() + val url = String.format(URL, BASIC_SUBSCRIPTION, applicationContext.packageName) + val intent = Intent(Intent.ACTION_VIEW) + intent.setData(Uri.parse(url)) + startActivity(intent) + } } } } @@ -185,6 +208,17 @@ class SubscriptionSettingsActivity : DuckDuckGoActivity() { ) } + private fun goToPurchasePage() { + globalActivityStarter.start( + context = this, + params = SubscriptionsWebViewActivityWithParams( + url = SubscriptionsConstants.BUY_URL, + screenTitle = "", + defaultToolbar = true, + ), + ) + } + companion object { const val URL = "https://play.google.com/store/account/subscriptions?sku=%s&package=%s" data object SubscriptionsSettingsScreenWithEmptyParams : GlobalActivityStarter.ActivityParams diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt index aad8483b9bd3..40f467cbbf26 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt @@ -26,6 +26,7 @@ import com.duckduckgo.js.messaging.api.JsCallbackData import com.duckduckgo.js.messaging.api.SubscriptionEventData import com.duckduckgo.navigation.api.GlobalActivityStarter.ActivityParams import com.duckduckgo.networkprotection.api.NetworkProtectionAccessState +import com.duckduckgo.subscriptions.api.SubscriptionStatus import com.duckduckgo.subscriptions.impl.CurrentPurchase import com.duckduckgo.subscriptions.impl.JSONObjectAdapter import com.duckduckgo.subscriptions.impl.PrivacyProFeature @@ -39,7 +40,7 @@ import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY import com.duckduckgo.subscriptions.impl.SubscriptionsManager import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.repository.isActive -import com.duckduckgo.subscriptions.impl.repository.isActiveOrWaiting +import com.duckduckgo.subscriptions.impl.repository.isExpired import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command.* import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.PurchaseStateView.Failure import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.PurchaseStateView.InProgress @@ -83,6 +84,8 @@ class SubscriptionWebViewViewModel @Inject constructor( private val _currentPurchaseViewState = MutableStateFlow(CurrentPurchaseViewState()) val currentPurchaseViewState = _currentPurchaseViewState.asStateFlow() + private lateinit var subscriptionStatus: SubscriptionStatus + fun start() { subscriptionsManager.currentPurchaseState.onEach { val state = when (it) { @@ -115,6 +118,10 @@ class SubscriptionWebViewViewModel @Inject constructor( _currentPurchaseViewState.emit(currentPurchaseViewState.value.copy(purchaseState = state)) }.flowOn(dispatcherProvider.io()) .launchIn(viewModelScope) + + subscriptionsManager.subscriptionStatus + .onEach { subscriptionStatus = it } + .launchIn(viewModelScope) } fun processJsCallbackMessage(featureName: String, method: String, id: String?, data: JSONObject?) { @@ -124,7 +131,6 @@ class SubscriptionWebViewViewModel @Inject constructor( "getSubscriptionOptions" -> id?.let { getSubscriptionOptions(featureName, method, it) } "subscriptionSelected" -> subscriptionSelected(data) "activateSubscription" -> activateSubscription() - "setSubscription" -> setSubscription() "featureSelected" -> data?.let { featureSelected(data) } "subscriptionsWelcomeFaqClicked" -> subscriptionsWelcomeFaqClicked() "subscriptionsWelcomeAddEmailClicked" -> subscriptionsWelcomeAddEmailClicked() @@ -134,11 +140,11 @@ class SubscriptionWebViewViewModel @Inject constructor( } } - private fun setSubscription() { - viewModelScope.launch { - if (!subscriptionsManager.subscriptionStatus().isActiveOrWaiting()) { - command.send(SubscriptionRecoveredExpired) - } + fun onSubscriptionRestored() = viewModelScope.launch { + if (subscriptionStatus.isExpired()) { + command.send(BackToSettings) + } else { + command.send(Reload) } } @@ -299,7 +305,6 @@ class SubscriptionWebViewViewModel @Inject constructor( sealed class Command { data object BackToSettings : Command() - data object SubscriptionRecoveredExpired : Command() data object BackToSettingsActivateSuccess : Command() data class SendJsEvent(val event: SubscriptionEventData) : Command() data class SendResponseToJs(val data: JsCallbackData) : Command() @@ -308,6 +313,7 @@ class SubscriptionWebViewViewModel @Inject constructor( data object GoToITR : Command() data object GoToPIR : Command() data class GoToNetP(val activityParams: ActivityParams) : Command() + data object Reload : Command() } companion object { diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionsWebViewActivity.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionsWebViewActivity.kt index fdf20f8b1503..c61c87431891 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionsWebViewActivity.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionsWebViewActivity.kt @@ -81,10 +81,10 @@ import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command.GoToITR import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command.GoToNetP import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command.GoToPIR +import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command.Reload import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command.RestoreSubscription import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command.SendJsEvent import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command.SendResponseToJs -import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command.SubscriptionRecoveredExpired import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command.SubscriptionSelected import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.PurchaseStateView import com.duckduckgo.user.agent.api.UserAgentProvider @@ -403,7 +403,7 @@ class SubscriptionsWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD is GoToITR -> goToITR() is GoToPIR -> goToPIR() is GoToNetP -> goToNetP(command.activityParams) - is SubscriptionRecoveredExpired -> subscriptionNotFound() + Reload -> binding.webview.reload() } } @@ -500,22 +500,6 @@ class SubscriptionsWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD .show() } - private fun subscriptionNotFound() { - TextAlertDialogBuilder(this) - .setTitle(string.subscriptionNotFound) - .setMessage(string.subscriptionNotFoundEmailDescription) - .setDestructiveButtons(false) - .setPositiveButton(string.ok) - .addEventListener( - object : TextAlertDialogBuilder.EventListener() { - override fun onPositiveButtonClicked() { - finish() - } - }, - ) - .show() - } - private fun selectSubscription(id: String) { viewModel.purchaseSubscription(this, id) } @@ -533,7 +517,7 @@ class SubscriptionsWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD private val startForResultRestore = registerForActivityResult(StartActivityForResult()) { result: ActivityResult -> if (result.resultCode == RESULT_OK) { - binding.webview.reload() + viewModel.onSubscriptionRestored() } } diff --git a/subscriptions/subscriptions-impl/src/main/res/layout/activity_subscription_settings.xml b/subscriptions/subscriptions-impl/src/main/res/layout/activity_subscription_settings.xml index 7a21d377d7b1..03f8a23ee040 100644 --- a/subscriptions/subscriptions-impl/src/main/res/layout/activity_subscription_settings.xml +++ b/subscriptions/subscriptions-impl/src/main/res/layout/activity_subscription_settings.xml @@ -62,17 +62,33 @@ app:typography="body1_bold" tools:text="Monthly Subscription"/> - + android:orientation="horizontal" + android:gravity="center"> + + + + + + + android:visibility="gone" + app:primaryText="@string/changePlanTitle" + tools:visibility="visible" /> + + - - - - - - - + + + + + + View Plans Subscription Not Found The subscription associated with this Google Play account is no longer active. - The subscription associated with this email is no longer active, or we could not find it. Your purchases have been restored. Activate your subscription on this device Access your Privacy Pro subscription on this device via Google Play or an email address. @@ -58,6 +57,7 @@ Remove from this device? You will no longer be able to access your Privacy Pro subscription on this device. This will not cancel your subscription, and it will remain active on your other devices. Your subscription %1$s on %2$s. + Your subscription expired on %1$s. Monthly Subscription Yearly Subscription renews @@ -72,6 +72,10 @@ I Have a Subscription Your Subscription is Being Activated This is taking longer than usual, please check back later. + Your Privacy Pro subscription expired + Subscribe again to continue using Privacy\u00A0Pro + View Plans + You\'re all set. diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt index 9c96480f8c5c..bbedebbf10cb 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt @@ -618,7 +618,8 @@ class RealSubscriptionsManagerTest { val result = subscriptionsManager.getAuthToken() verify(authService).storeLogin(any()) - assertTrue(result is AuthToken.Failure) + assertTrue(result is AuthToken.Failure.TokenExpired) + assertEquals("authToken", (result as AuthToken.Failure.TokenExpired).authToken) } @Test @@ -633,7 +634,8 @@ class RealSubscriptionsManagerTest { val result = subscriptionsManager.getAuthToken() verify(authService).storeLogin(any()) - assertTrue(result is AuthToken.Failure) + assertTrue(result is AuthToken.Failure.TokenExpired) + assertEquals("authToken", (result as AuthToken.Failure.TokenExpired).authToken) } @Test @@ -644,7 +646,8 @@ class RealSubscriptionsManagerTest { val result = subscriptionsManager.getAuthToken() verify(authService, never()).storeLogin(any()) - assertTrue(result is AuthToken.Failure) + assertTrue(result is AuthToken.Failure.TokenExpired) + assertEquals("authToken", (result as AuthToken.Failure.TokenExpired).authToken) } @Test @@ -657,7 +660,8 @@ class RealSubscriptionsManagerTest { val result = subscriptionsManager.getAuthToken() verify(authService).storeLogin(any()) - assertTrue(result is AuthToken.Failure) + assertTrue(result is AuthToken.Failure.TokenExpired) + assertEquals("authToken", (result as AuthToken.Failure.TokenExpired).authToken) } @Test diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterfaceTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterfaceTest.kt index 286aae56cd09..5661168fe8a4 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterfaceTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterfaceTest.kt @@ -7,6 +7,7 @@ import com.duckduckgo.js.messaging.api.JsMessageCallback import com.duckduckgo.js.messaging.api.JsMessageHelper import com.duckduckgo.js.messaging.api.JsRequestResponse import com.duckduckgo.subscriptions.api.SubscriptionStatus.AUTO_RENEWABLE +import com.duckduckgo.subscriptions.impl.AccessToken import com.duckduckgo.subscriptions.impl.AuthToken import com.duckduckgo.subscriptions.impl.SubscriptionsChecker import com.duckduckgo.subscriptions.impl.SubscriptionsManager @@ -152,7 +153,7 @@ class SubscriptionMessagingInterfaceTest { } @Test - fun whenProcessAndGetSubscriptionsMessageIfNotActiveThenReturnError() = runTest { + fun whenProcessAndGetSubscriptionsMessageIfNotActiveThenReturnResponse() = runTest { givenInterfaceIsRegistered() givenAuthTokenIsSuccess() @@ -161,7 +162,7 @@ class SubscriptionMessagingInterfaceTest { featureName = "useSubscription", method = "getSubscription", id = "myId", - result = JSONObject("""{ }"""), + result = JSONObject("""{ "token":"authToken"}"""), ) val message = """ @@ -205,6 +206,33 @@ class SubscriptionMessagingInterfaceTest { checkEquals(expected, jsMessage) } + @Test + fun whenProcessAndGetSubscriptionsMessageIfTokenExpiredThenReturnResponse() = runTest { + givenInterfaceIsRegistered() + givenAuthTokenIsExpired() + + val expected = JsRequestResponse.Success( + context = "subscriptionPages", + featureName = "useSubscription", + method = "getSubscription", + id = "myId", + result = JSONObject("""{ "token":"authToken"}"""), + ) + + val message = """ + {"context":"subscriptionPages","featureName":"useSubscription","method":"getSubscription","id":"myId","params":{}} + """.trimIndent() + + messagingInterface.process(message, "duckduckgo-android-messaging-secret") + + val captor = argumentCaptor() + verify(jsMessageHelper).sendJsResponse(captor.capture(), eq(CALLBACK_NAME), eq(SECRET), eq(webView)) + val jsMessage = captor.firstValue + + assertTrue(jsMessage is JsRequestResponse.Success) + checkEquals(expected, jsMessage) + } + @Test fun whenProcessAndGetSubscriptionsIfFeatureNameDoesNotMatchDoNothing() = runTest { givenInterfaceIsRegistered() @@ -233,6 +261,74 @@ class SubscriptionMessagingInterfaceTest { verifyNoInteractions(jsMessageHelper) } + @Test + fun whenProcessAndGetAccessTokenThenReturnResponse() = runTest { + givenInterfaceIsRegistered() + givenAccessTokenIsSuccess() + + val expected = JsRequestResponse.Success( + context = "subscriptionPages", + featureName = "useSubscription", + method = "getAccessToken", + id = "myId", + result = JSONObject("""{ token:"accessToken" }"""), + ) + + val message = """ + {"context":"subscriptionPages","featureName":"useSubscription","method":"getAccessToken","id":"myId","params":{}} + """.trimIndent() + + messagingInterface.process(message, "duckduckgo-android-messaging-secret") + + val captor = argumentCaptor() + verify(jsMessageHelper).sendJsResponse(captor.capture(), eq(CALLBACK_NAME), eq(SECRET), eq(webView)) + val jsMessage = captor.firstValue + + assertTrue(jsMessage is JsRequestResponse.Success) + checkEquals(expected, jsMessage) + } + + @Test + fun whenProcessAndGetAccessTokenMessageErrorThenReturnResponse() = runTest { + givenInterfaceIsRegistered() + givenAccessTokenIsFailure() + + val expected = JsRequestResponse.Success( + context = "subscriptionPages", + featureName = "useSubscription", + method = "getAccessToken", + id = "myId", + result = JSONObject("""{ }"""), + ) + + val message = """ + {"context":"subscriptionPages","featureName":"useSubscription","method":"getAccessToken","id":"myId","params":{}} + """.trimIndent() + + messagingInterface.process(message, "duckduckgo-android-messaging-secret") + + val captor = argumentCaptor() + verify(jsMessageHelper).sendJsResponse(captor.capture(), eq(CALLBACK_NAME), eq(SECRET), eq(webView)) + val jsMessage = captor.firstValue + + assertTrue(jsMessage is JsRequestResponse.Success) + checkEquals(expected, jsMessage) + } + + @Test + fun whenProcessAndGetAccessTokenIfNoIdDoNothing() = runTest { + givenInterfaceIsRegistered() + givenAuthTokenIsSuccess() + + val message = """ + {"context":"subscriptionPages","featureName":"useSubscription","method":"getAccessToken","params":{}} + """.trimIndent() + + messagingInterface.process(message, "duckduckgo-android-messaging-secret") + + verifyNoInteractions(jsMessageHelper) + } + @Test fun whenProcessAndBackToSettingsIfFeatureNameDoesNotMatchDoNothing() = runTest { givenInterfaceIsRegistered() @@ -286,7 +382,7 @@ class SubscriptionMessagingInterfaceTest { verify(subscriptionsManager).exchangeAuthToken("authToken") verify(pixelSender).reportRestoreUsingEmailSuccess() verify(pixelSender).reportSubscriptionActivated() - assertEquals(1, callback.counter) + assertEquals(0, callback.counter) } @Test @@ -556,7 +652,19 @@ class SubscriptionMessagingInterfaceTest { } private suspend fun givenAuthTokenIsFailure() { - whenever(subscriptionsManager.getAuthToken()).thenReturn(AuthToken.Failure(message = "something happened")) + whenever(subscriptionsManager.getAuthToken()).thenReturn(AuthToken.Failure.UnknownError) + } + + private suspend fun givenAuthTokenIsExpired() { + whenever(subscriptionsManager.getAuthToken()).thenReturn(AuthToken.Failure.TokenExpired(authToken = "authToken")) + } + + private suspend fun givenAccessTokenIsSuccess() { + whenever(subscriptionsManager.getAccessToken()).thenReturn(AccessToken.Success(accessToken = "accessToken")) + } + + private suspend fun givenAccessTokenIsFailure() { + whenever(subscriptionsManager.getAccessToken()).thenReturn(AccessToken.Failure(message = "something happened")) } private fun checkEquals(expected: JsRequestResponse, actual: JsRequestResponse) { diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionViewModelTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionViewModelTest.kt index cf9b392fc38b..8c13a0477fd0 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionViewModelTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionViewModelTest.kt @@ -2,8 +2,10 @@ package com.duckduckgo.subscriptions.impl.ui import app.cash.turbine.test import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.subscriptions.api.SubscriptionStatus import com.duckduckgo.subscriptions.api.SubscriptionStatus.AUTO_RENEWABLE import com.duckduckgo.subscriptions.api.SubscriptionStatus.EXPIRED +import com.duckduckgo.subscriptions.api.SubscriptionStatus.UNKNOWN import com.duckduckgo.subscriptions.impl.RealSubscriptionsManager.Companion.SUBSCRIPTION_NOT_FOUND_ERROR import com.duckduckgo.subscriptions.impl.RealSubscriptionsManager.RecoverSubscriptionResult import com.duckduckgo.subscriptions.impl.SubscriptionsChecker @@ -12,9 +14,13 @@ import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.repository.Entitlement import com.duckduckgo.subscriptions.impl.repository.Subscription import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.Error +import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.FinishAndGoToOnboarding +import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.FinishAndGoToSubscriptionSettings import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.RestoreFromEmail import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.SubscriptionNotFound import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.Success +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.Assert.* import org.junit.Before @@ -57,6 +63,8 @@ class RestoreSubscriptionViewModelTest { whenever(subscriptionsManager.recoverSubscriptionFromStore()).thenReturn( RecoverSubscriptionResult.Failure("error"), ) + givenSubscriptionStatus(UNKNOWN) + viewModel.init() viewModel.commands().test { viewModel.restoreFromStore() @@ -71,18 +79,9 @@ class RestoreSubscriptionViewModelTest { RecoverSubscriptionResult.Failure(SUBSCRIPTION_NOT_FOUND_ERROR), ) - viewModel.commands().test { - viewModel.restoreFromStore() - val result = awaitItem() - assertTrue(result is SubscriptionNotFound) - } - } + givenSubscriptionStatus(UNKNOWN) - @Test - fun whenRestoreFromStoreIfNotActiveThenReturnNotFound() = runTest { - whenever(subscriptionsManager.recoverSubscriptionFromStore()).thenReturn( - RecoverSubscriptionResult.Success(subscriptionNotActive()), - ) + viewModel.init() viewModel.commands().test { viewModel.restoreFromStore() @@ -92,7 +91,7 @@ class RestoreSubscriptionViewModelTest { } @Test - fun whenRestoreFromStoreIfActiveThenReturnSuccess() = runTest { + fun whenRestoreFromStoreThenReturnSuccess() = runTest { whenever(subscriptionsManager.recoverSubscriptionFromStore()).thenReturn( RecoverSubscriptionResult.Success(subscriptionActive()), ) @@ -126,21 +125,13 @@ class RestoreSubscriptionViewModelTest { verify(pixelSender).reportRestoreUsingStoreSuccess() } - @Test - fun whenRestoreFromStoreFailsBecauseThereAreNoEntitlementsThenPixelIsSent() = runTest { - whenever(subscriptionsManager.recoverSubscriptionFromStore()).thenReturn( - RecoverSubscriptionResult.Success(subscriptionNotActive()), - ) - - viewModel.restoreFromStore() - verify(pixelSender).reportRestoreUsingStoreFailureSubscriptionNotFound() - } - @Test fun whenRestoreFromStoreFailsBecauseThereIsNoSubscriptionThenPixelIsSent() = runTest { whenever(subscriptionsManager.recoverSubscriptionFromStore()).thenReturn( RecoverSubscriptionResult.Failure(SUBSCRIPTION_NOT_FOUND_ERROR), ) + givenSubscriptionStatus(UNKNOWN) + viewModel.init() viewModel.restoreFromStore() verify(pixelSender).reportRestoreUsingStoreFailureSubscriptionNotFound() @@ -151,20 +142,53 @@ class RestoreSubscriptionViewModelTest { whenever(subscriptionsManager.recoverSubscriptionFromStore()).thenReturn( RecoverSubscriptionResult.Failure("bad stuff happened"), ) + givenSubscriptionStatus(UNKNOWN) + viewModel.init() viewModel.restoreFromStore() verify(pixelSender).reportRestoreUsingStoreFailureOther() } - private fun subscriptionNotActive(): Subscription { - return Subscription( - productId = "productId", - startedAt = 10000L, - expiresOrRenewsAt = 10000L, - status = EXPIRED, - platform = "google", - entitlements = emptyList(), + @Test + fun whenOnSubscriptionRestoredFromEmailAndSubscriptionExpiredThenCommandIsSent() = runTest { + whenever(subscriptionsManager.subscriptionStatus).thenReturn(flowOf(EXPIRED)) + + viewModel.init() + + viewModel.commands().test { + viewModel.onSubscriptionRestoredFromEmail() + val result = awaitItem() + assertTrue(result is FinishAndGoToSubscriptionSettings) + } + } + + @Test + fun whenOnSubscriptionRestoredFromEmailAndSubscriptionActiveThenCommandIsSent() = runTest { + whenever(subscriptionsManager.subscriptionStatus).thenReturn(flowOf(AUTO_RENEWABLE)) + + viewModel.init() + + viewModel.commands().test { + viewModel.onSubscriptionRestoredFromEmail() + val result = awaitItem() + assertTrue(result is FinishAndGoToOnboarding) + } + } + + @Test + fun whenRestoreFromStoreFailsBecauseOfExpiredSubscriptionThenSignsUserOut() = runTest { + whenever(subscriptionsManager.recoverSubscriptionFromStore()).thenReturn( + RecoverSubscriptionResult.Failure(SUBSCRIPTION_NOT_FOUND_ERROR), ) + givenSubscriptionStatus(EXPIRED) + viewModel.init() + + viewModel.commands().test { + viewModel.restoreFromStore() + val result = awaitItem() + assertTrue(result is SubscriptionNotFound) + verify(subscriptionsManager).signOut() + } } private fun subscriptionActive(): Subscription { @@ -177,4 +201,9 @@ class RestoreSubscriptionViewModelTest { entitlements = listOf(Entitlement("name", "product")), ) } + + private fun givenSubscriptionStatus(subscriptionStatus: SubscriptionStatus) = runBlocking { + whenever(subscriptionsManager.subscriptionStatus()).thenReturn(subscriptionStatus) + whenever(subscriptionsManager.subscriptionStatus).thenReturn(flowOf(subscriptionStatus)) + } } diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModelTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModelTest.kt index 1547851ab746..178c65dc4699 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModelTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModelTest.kt @@ -8,9 +8,11 @@ import com.duckduckgo.feature.toggles.api.FakeToggleStore import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.networkprotection.api.NetworkProtectionAccessState import com.duckduckgo.networkprotection.api.NetworkProtectionScreens.NetworkProtectionManagementScreenNoParams +import com.duckduckgo.subscriptions.api.SubscriptionStatus import com.duckduckgo.subscriptions.api.SubscriptionStatus.AUTO_RENEWABLE import com.duckduckgo.subscriptions.api.SubscriptionStatus.EXPIRED import com.duckduckgo.subscriptions.api.SubscriptionStatus.INACTIVE +import com.duckduckgo.subscriptions.api.SubscriptionStatus.UNKNOWN import com.duckduckgo.subscriptions.impl.CurrentPurchase import com.duckduckgo.subscriptions.impl.JSONObjectAdapter import com.duckduckgo.subscriptions.impl.PrivacyProFeature @@ -20,6 +22,8 @@ import com.duckduckgo.subscriptions.impl.SubscriptionsConstants import com.duckduckgo.subscriptions.impl.SubscriptionsManager import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command +import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command.BackToSettings +import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command.Reload import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Companion import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.PurchaseStateView import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.PurchaseStateView.Success @@ -28,6 +32,7 @@ import com.squareup.moshi.JsonAdapter import com.squareup.moshi.Moshi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.json.JSONObject import org.junit.Assert.* @@ -66,6 +71,7 @@ class SubscriptionWebViewViewModelTest { pixelSender, privacyProFeature, ) + givenSubscriptionStatus(UNKNOWN) } @Test @@ -268,7 +274,7 @@ class SubscriptionWebViewViewModelTest { @Test fun whenActivateSubscriptionAndSubscriptionActiveThenNoCommandSent() = runTest { - whenever(subscriptionsManager.subscriptionStatus()).thenReturn(AUTO_RENEWABLE) + givenSubscriptionStatus(AUTO_RENEWABLE) viewModel.commands().test { expectNoEvents() } @@ -276,34 +282,16 @@ class SubscriptionWebViewViewModelTest { @Test fun whenActivateSubscriptionAndSubscriptionInactiveThenCommandSent() = runTest { - whenever(subscriptionsManager.subscriptionStatus()).thenReturn(INACTIVE) + givenSubscriptionStatus(INACTIVE) viewModel.commands().test { viewModel.processJsCallbackMessage("test", "activateSubscription", null, null) assertTrue(awaitItem() is Command.RestoreSubscription) } } - @Test - fun whenSetSubscriptionAndExpiredSubscriptionThenCommandNotSent() = runTest { - whenever(subscriptionsManager.subscriptionStatus()).thenReturn(EXPIRED) - viewModel.commands().test { - viewModel.processJsCallbackMessage("test", "setSubscription", null, null) - assertTrue(awaitItem() is Command.SubscriptionRecoveredExpired) - } - } - - @Test - fun whenSetSubscriptionAndActiveSubscriptionThenCommandNotSent() = runTest { - whenever(subscriptionsManager.subscriptionStatus()).thenReturn(AUTO_RENEWABLE) - viewModel.commands().test { - viewModel.processJsCallbackMessage("test", "setSubscription", null, null) - ensureAllEventsConsumed() - } - } - @Test fun whenFeatureSelectedAndNoDataThenCommandNotSent() = runTest { - whenever(subscriptionsManager.subscriptionStatus()).thenReturn(EXPIRED) + givenSubscriptionStatus(EXPIRED) viewModel.commands().test { viewModel.processJsCallbackMessage("test", "featureSelected", null, null) ensureAllEventsConsumed() @@ -312,7 +300,7 @@ class SubscriptionWebViewViewModelTest { @Test fun whenFeatureSelectedAndInvalidDataThenCommandNotSent() = runTest { - whenever(subscriptionsManager.subscriptionStatus()).thenReturn(EXPIRED) + givenSubscriptionStatus(EXPIRED) viewModel.commands().test { viewModel.processJsCallbackMessage("test", "featureSelected", null, JSONObject("{}")) ensureAllEventsConsumed() @@ -321,7 +309,7 @@ class SubscriptionWebViewViewModelTest { @Test fun whenFeatureSelectedAndInvalidFeatureThenCommandNotSent() = runTest { - whenever(subscriptionsManager.subscriptionStatus()).thenReturn(EXPIRED) + givenSubscriptionStatus(EXPIRED) viewModel.commands().test { viewModel.processJsCallbackMessage("test", "featureSelected", null, JSONObject("""{"feature":"test"}""")) ensureAllEventsConsumed() @@ -330,7 +318,7 @@ class SubscriptionWebViewViewModelTest { @Test fun whenFeatureSelectedAndFeatureIsNetPThenCommandSent() = runTest { - whenever(subscriptionsManager.subscriptionStatus()).thenReturn(EXPIRED) + givenSubscriptionStatus(EXPIRED) viewModel.commands().test { viewModel.processJsCallbackMessage( "test", @@ -344,7 +332,7 @@ class SubscriptionWebViewViewModelTest { @Test fun whenFeatureSelectedAndFeatureIsItrThenCommandSent() = runTest { - whenever(subscriptionsManager.subscriptionStatus()).thenReturn(EXPIRED) + givenSubscriptionStatus(EXPIRED) viewModel.commands().test { viewModel.processJsCallbackMessage( "test", @@ -358,7 +346,7 @@ class SubscriptionWebViewViewModelTest { @Test fun whenFeatureSelectedAndFeatureIsPirThenCommandSent() = runTest { - whenever(subscriptionsManager.subscriptionStatus()).thenReturn(EXPIRED) + givenSubscriptionStatus(EXPIRED) viewModel.commands().test { viewModel.processJsCallbackMessage( "test", @@ -383,7 +371,7 @@ class SubscriptionWebViewViewModelTest { @Test fun whenRestorePurchaseClickedThenPixelIsSent() = runTest { - whenever(subscriptionsManager.subscriptionStatus()).thenReturn(EXPIRED) + givenSubscriptionStatus(EXPIRED) viewModel.processJsCallbackMessage( featureName = "test", method = "activateSubscription", @@ -395,7 +383,7 @@ class SubscriptionWebViewViewModelTest { @Test fun whenAddEmailClickedAndInPurchaseFlowThenPixelIsSent() = runTest { - whenever(subscriptionsManager.subscriptionStatus()).thenReturn(AUTO_RENEWABLE) + givenSubscriptionStatus(AUTO_RENEWABLE) whenever(subscriptionsManager.currentPurchaseState).thenReturn(flowOf(CurrentPurchase.Success)) viewModel.start() @@ -410,7 +398,7 @@ class SubscriptionWebViewViewModelTest { @Test fun whenAddEmailClickedAndNotInPurchaseFlowThenPixelIsNotSent() = runTest { - whenever(subscriptionsManager.subscriptionStatus()).thenReturn(AUTO_RENEWABLE) + givenSubscriptionStatus(AUTO_RENEWABLE) viewModel.processJsCallbackMessage( featureName = "test", @@ -423,7 +411,7 @@ class SubscriptionWebViewViewModelTest { @Test fun whenFeatureSelectedAndFeatureIsNetPAndInPurchaseFlowThenPixelIsSent() = runTest { - whenever(subscriptionsManager.subscriptionStatus()).thenReturn(AUTO_RENEWABLE) + givenSubscriptionStatus(AUTO_RENEWABLE) whenever(subscriptionsManager.currentPurchaseState).thenReturn(flowOf(CurrentPurchase.Success)) viewModel.start() @@ -438,7 +426,7 @@ class SubscriptionWebViewViewModelTest { @Test fun whenFeatureSelectedAndFeatureIsNetPAndNotInPurchaseFlowThenPixelIsNotSent() = runTest { - whenever(subscriptionsManager.subscriptionStatus()).thenReturn(AUTO_RENEWABLE) + givenSubscriptionStatus(AUTO_RENEWABLE) viewModel.processJsCallbackMessage( featureName = "test", @@ -451,7 +439,7 @@ class SubscriptionWebViewViewModelTest { @Test fun whenFeatureSelectedAndFeatureIsItrAndInPurchaseFlowThenPixelIsSent() = runTest { - whenever(subscriptionsManager.subscriptionStatus()).thenReturn(AUTO_RENEWABLE) + givenSubscriptionStatus(AUTO_RENEWABLE) whenever(subscriptionsManager.currentPurchaseState).thenReturn(flowOf(CurrentPurchase.Success)) viewModel.start() @@ -466,7 +454,7 @@ class SubscriptionWebViewViewModelTest { @Test fun whenFeatureSelectedAndFeatureIsItrAndNotInPurchaseFlowThenPixelIsNotSent() = runTest { - whenever(subscriptionsManager.subscriptionStatus()).thenReturn(AUTO_RENEWABLE) + givenSubscriptionStatus(AUTO_RENEWABLE) viewModel.processJsCallbackMessage( featureName = "test", @@ -479,7 +467,7 @@ class SubscriptionWebViewViewModelTest { @Test fun whenFeatureSelectedAndFeatureIsPirAndInPurchaseFlowThenPixelIsSent() = runTest { - whenever(subscriptionsManager.subscriptionStatus()).thenReturn(AUTO_RENEWABLE) + givenSubscriptionStatus(AUTO_RENEWABLE) whenever(subscriptionsManager.currentPurchaseState).thenReturn(flowOf(CurrentPurchase.Success)) viewModel.start() @@ -494,7 +482,7 @@ class SubscriptionWebViewViewModelTest { @Test fun whenFeatureSelectedAndFeatureIsPirAndNotInPurchaseFlowThenPixelIsNotSent() = runTest { - whenever(subscriptionsManager.subscriptionStatus()).thenReturn(AUTO_RENEWABLE) + givenSubscriptionStatus(AUTO_RENEWABLE) viewModel.processJsCallbackMessage( featureName = "test", @@ -507,7 +495,7 @@ class SubscriptionWebViewViewModelTest { @Test fun whenSubscriptionsWelcomeFaqClickedAndInPurchaseFlowThenPixelIsSent() = runTest { - whenever(subscriptionsManager.subscriptionStatus()).thenReturn(AUTO_RENEWABLE) + givenSubscriptionStatus(AUTO_RENEWABLE) whenever(subscriptionsManager.currentPurchaseState).thenReturn(flowOf(CurrentPurchase.Success)) viewModel.start() @@ -522,7 +510,7 @@ class SubscriptionWebViewViewModelTest { @Test fun whenSubscriptionsWelcomeFaqClickedAndNotInPurchaseFlowThenPixelIsNotSent() = runTest { - whenever(subscriptionsManager.subscriptionStatus()).thenReturn(AUTO_RENEWABLE) + givenSubscriptionStatus(AUTO_RENEWABLE) viewModel.processJsCallbackMessage( featureName = "test", @@ -532,4 +520,35 @@ class SubscriptionWebViewViewModelTest { ) verifyNoInteractions(pixelSender) } + + @Test + fun whenOnSubscriptionRestoredFromEmailAndSubscriptionExpiredThenCommandIsSent() = runTest { + givenSubscriptionStatus(EXPIRED) + whenever(subscriptionsManager.currentPurchaseState).thenReturn(flowOf(CurrentPurchase.Success)) + viewModel.start() + + viewModel.commands().test { + viewModel.onSubscriptionRestored() + val result = awaitItem() + assertTrue(result is BackToSettings) + } + } + + @Test + fun whenOnSubscriptionRestoredFromEmailAndSubscriptionActiveThenCommandIsSent() = runTest { + givenSubscriptionStatus(AUTO_RENEWABLE) + whenever(subscriptionsManager.currentPurchaseState).thenReturn(flowOf(CurrentPurchase.Success)) + viewModel.start() + + viewModel.commands().test { + viewModel.onSubscriptionRestored() + val result = awaitItem() + assertTrue(result is Reload) + } + } + + private fun givenSubscriptionStatus(subscriptionStatus: SubscriptionStatus) = runBlocking { + whenever(subscriptionsManager.subscriptionStatus()).thenReturn(subscriptionStatus) + whenever(subscriptionsManager.subscriptionStatus).thenReturn(flowOf(subscriptionStatus)) + } } From 4232a820ee32a0cd31f25d8ec64797501fb8f369 Mon Sep 17 00:00:00 2001 From: Noelia Alcala Date: Mon, 27 May 2024 11:29:09 +0100 Subject: [PATCH 14/17] Update pixels in onboarding experiment (#4584) Task/Issue URL: https://app.asana.com/0/1201807753394693/1207407312592951/f ### Description Add three unique pixels when pre-onboarding dialogs are shown ### Steps to test this PR _Pre steps_ - [x] Make `fun isComparisonChartEnabled(): Boolean` return always `true` in `ExtendedOnboardingExperimentVariantManagerImpl` _Feature 1_ - [x] Fresh install - [x] When first dialog is displayed, check `m_preonboarding_intro_shown_unique` is fired - [x] When comparison chart is displayed, check `m_preonboarding_comparison_chart_shown_unique` is fired - [x] Set DDG as default browser - [x] When affirmation dialog is displayed, check `m_preonboarding_affirmation_shown_unique` is fired ### No UI changes --- .../ExperimentWelcomePageViewModel.kt | 19 ++++++++++++++----- .../experiment/OnboardingExperimentPixel.kt | 3 +++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/experiment/ExperimentWelcomePageViewModel.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/experiment/ExperimentWelcomePageViewModel.kt index 7e8cca0dca48..c76dc1777c76 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/experiment/ExperimentWelcomePageViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/experiment/ExperimentWelcomePageViewModel.kt @@ -34,6 +34,7 @@ import com.duckduckgo.app.onboarding.ui.page.experiment.ExperimentWelcomePageVie import com.duckduckgo.app.onboarding.ui.page.experiment.ExperimentWelcomePageViewModel.Command.ShowSuccessDialog import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.UNIQUE import com.duckduckgo.di.scopes.FragmentScope import javax.inject.Inject import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST @@ -132,11 +133,19 @@ class ExperimentWelcomePageViewModel @Inject constructor( } fun onDialogShown(onboardingDialogType: PreOnboardingDialogType) { - val pixelName = when (onboardingDialogType) { - INITIAL -> OnboardingExperimentPixel.PixelName.PREONBOARDING_INTRO_SHOWN - COMPARISON_CHART -> OnboardingExperimentPixel.PixelName.PREONBOARDING_COMPARISON_CHART_SHOWN - CELEBRATION -> OnboardingExperimentPixel.PixelName.PREONBOARDING_AFFIRMATION_SHOWN + when (onboardingDialogType) { + INITIAL -> { + pixel.fire(OnboardingExperimentPixel.PixelName.PREONBOARDING_INTRO_SHOWN) + pixel.fire(OnboardingExperimentPixel.PixelName.PREONBOARDING_INTRO_SHOWN_UNIQUE, type = UNIQUE) + } + COMPARISON_CHART -> { + pixel.fire(OnboardingExperimentPixel.PixelName.PREONBOARDING_COMPARISON_CHART_SHOWN) + pixel.fire(OnboardingExperimentPixel.PixelName.PREONBOARDING_COMPARISON_CHART_SHOWN_UNIQUE, type = UNIQUE) + } + CELEBRATION -> { + pixel.fire(OnboardingExperimentPixel.PixelName.PREONBOARDING_AFFIRMATION_SHOWN) + pixel.fire(OnboardingExperimentPixel.PixelName.PREONBOARDING_AFFIRMATION_SHOWN_UNIQUE, type = UNIQUE) + } } - pixel.fire(pixelName) } } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/experiment/OnboardingExperimentPixel.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/experiment/OnboardingExperimentPixel.kt index 715dc46994cd..e8426b5110e0 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/experiment/OnboardingExperimentPixel.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/experiment/OnboardingExperimentPixel.kt @@ -23,9 +23,12 @@ class OnboardingExperimentPixel { enum class PixelName(override val pixelName: String) : Pixel.PixelName { NOTIFICATION_RUNTIME_PERMISSION_SHOWN("m_notification_runtime_permission_shown"), PREONBOARDING_INTRO_SHOWN("m_preonboarding_intro_shown"), + PREONBOARDING_INTRO_SHOWN_UNIQUE("m_preonboarding_intro_shown_unique"), PREONBOARDING_COMPARISON_CHART_SHOWN("m_preonboarding_comparison_chart_shown"), + PREONBOARDING_COMPARISON_CHART_SHOWN_UNIQUE("m_preonboarding_comparison_chart_shown_unique"), PREONBOARDING_CHOOSE_BROWSER_PRESSED("m_preonboarding_choose_browser_pressed"), PREONBOARDING_AFFIRMATION_SHOWN("m_preonboarding_affirmation_shown"), + PREONBOARDING_AFFIRMATION_SHOWN_UNIQUE("m_preonboarding_affirmation_shown_unique"), ONBOARDING_SEARCH_SAY_DUCK("m_onboarding_search_say_duck"), ONBOARDING_SEARCH_MIGHTY_DUCK("m_onboarding_search_mighty_duck"), ONBOARDING_SEARCH_WEATHER("m_onboarding_search_weather"), From 3fe27ce7f7cb7e647a7ca52d019bab05d60f2949 Mon Sep 17 00:00:00 2001 From: Aitor Viana Date: Mon, 27 May 2024 12:32:11 +0100 Subject: [PATCH 15/17] Enforce that Active plugins can't be collected by normal plugin point (#4585) Task/Issue URL: https://app.asana.com/0/488551667048375/1207400224799044/f ### Description Ensure that normal plugin points can't collect active plugins ### Steps to test this PR See lint test. You can also try to collect an ActivePlugin (aka, plugin interface that extends from ActivePlugin) in a normal `PluginPoint` and see lint failing --- .../annotations/ContributesActivePlugin.kt | 2 +- .../ContributesActivePluginPoint.kt | 6 +- ...ntributesActivePluginPointCodeGenerator.kt | 2 +- .../common/utils/plugins/ActivePluginPoint.kt | 42 ++++- .../toggles/codegen/TestActivePlugins.kt | 6 +- .../lint/WrongPluginPointCollectorDetector.kt | 140 ++++++++++++++++ .../lint/registry/DuckDuckGoIssueRegistry.kt | 2 + .../WrongPluginPointCollectorDetectorTest.kt | 157 ++++++++++++++++++ 8 files changed, 344 insertions(+), 13 deletions(-) create mode 100644 lint-rules/src/main/java/com/duckduckgo/lint/WrongPluginPointCollectorDetector.kt create mode 100644 lint-rules/src/test/java/com/duckduckgo/lint/WrongPluginPointCollectorDetectorTest.kt diff --git a/anvil/anvil-annotations/src/main/java/com/duckduckgo/anvil/annotations/ContributesActivePlugin.kt b/anvil/anvil-annotations/src/main/java/com/duckduckgo/anvil/annotations/ContributesActivePlugin.kt index 0ffde057864a..cd14bc3e4952 100644 --- a/anvil/anvil-annotations/src/main/java/com/duckduckgo/anvil/annotations/ContributesActivePlugin.kt +++ b/anvil/anvil-annotations/src/main/java/com/duckduckgo/anvil/annotations/ContributesActivePlugin.kt @@ -31,7 +31,7 @@ import kotlin.reflect.KClass * * } * - * interface MyPlugin : ActivePluginPoint.ActivePlugin {...} + * interface MyPlugin : ActivePlugin {...} * ``` */ @Target(AnnotationTarget.CLASS) diff --git a/anvil/anvil-annotations/src/main/java/com/duckduckgo/anvil/annotations/ContributesActivePluginPoint.kt b/anvil/anvil-annotations/src/main/java/com/duckduckgo/anvil/annotations/ContributesActivePluginPoint.kt index 9b9d42a9b4d2..19380ad694c4 100644 --- a/anvil/anvil-annotations/src/main/java/com/duckduckgo/anvil/annotations/ContributesActivePluginPoint.kt +++ b/anvil/anvil-annotations/src/main/java/com/duckduckgo/anvil/annotations/ContributesActivePluginPoint.kt @@ -21,12 +21,12 @@ import kotlin.reflect.KClass /** * Anvil annotation to generate plugin points that are guarded by a remote feature flag. * - * Active plugins need to extend from [ActivePluginPoint.ActivePlugin] + * Active plugins need to extend from [ActivePlugin] * * Usage: * ```kotlin * @ContributesActivePluginPoint(SomeDaggerScope::class) - * interface MyPlugin : ActivePluginPoint.ActivePlugin { + * interface MyPlugin : ActivePlugin { * * } * ``` @@ -49,7 +49,7 @@ annotation class ContributesActivePluginPoint( * ) * interface MyPluginPoint : MyPlugin * - * interface MyPlugin : ActivePluginPoint.ActivePlugin {...} + * interface MyPlugin : ActivePlugin {...} * ``` */ val boundType: KClass<*> = Unit::class, diff --git a/anvil/anvil-compiler/src/main/java/com/duckduckgo/anvil/compiler/ContributesActivePluginPointCodeGenerator.kt b/anvil/anvil-compiler/src/main/java/com/duckduckgo/anvil/compiler/ContributesActivePluginPointCodeGenerator.kt index e4c69a8df5f2..07c7b7874246 100644 --- a/anvil/anvil-compiler/src/main/java/com/duckduckgo/anvil/compiler/ContributesActivePluginPointCodeGenerator.kt +++ b/anvil/anvil-compiler/src/main/java/com/duckduckgo/anvil/compiler/ContributesActivePluginPointCodeGenerator.kt @@ -572,7 +572,7 @@ class ContributesActivePluginPointCodeGenerator : CodeGenerator { companion object { private val pluginPointFqName = FqName("com.duckduckgo.common.utils.plugins.PluginPoint") private val dispatcherProviderFqName = FqName("com.duckduckgo.common.utils.DispatcherProvider") - private val activePluginPointFqName = FqName("com.duckduckgo.common.utils.plugins.ActivePluginPoint") + private val activePluginPointFqName = FqName("com.duckduckgo.common.utils.plugins.InternalActivePluginPoint") private val coroutineScopeFqName = FqName("kotlinx.coroutines.CoroutineScope") private val sharedPreferencesProviderFqName = FqName("com.duckduckgo.data.store.api.SharedPreferencesProvider") private val moshiFqName = FqName("com.squareup.moshi.Moshi") diff --git a/common/common-utils/src/main/java/com/duckduckgo/common/utils/plugins/ActivePluginPoint.kt b/common/common-utils/src/main/java/com/duckduckgo/common/utils/plugins/ActivePluginPoint.kt index e4b0bffb1b60..77cf190ba77b 100644 --- a/common/common-utils/src/main/java/com/duckduckgo/common/utils/plugins/ActivePluginPoint.kt +++ b/common/common-utils/src/main/java/com/duckduckgo/common/utils/plugins/ActivePluginPoint.kt @@ -16,12 +16,44 @@ package com.duckduckgo.common.utils.plugins -/** A PluginPoint provides a list of plugins of a particular type T */ -interface ActivePluginPoint { +@Deprecated( + message = "Use \"ActivePluginPoint\" instead to ensure \"JvmSuppressWildcards\" is not forgotten", + replaceWith = ReplaceWith("ActivePluginPoint"), +) +interface InternalActivePluginPoint { /** @return the list of plugins of type */ suspend fun getPlugins(): Collection +} - interface ActivePlugin { - suspend fun isActive(): Boolean = true - } +/** + * Active plugins SHALL extend from [ActivePlugin] + * + * Usage: + * ```kotlin + * @ContributesActivePluginPoint( + * scope = SomeScope::class, + * ) + * interface MyActivePlugin : ActivePlugin {...} + * + * @ContributesActivePlugin( + * scope = SomeScope::class, + * boundType = MyActivePlugin::class, + * ) + * class FooMyActivePlugin @Inject constructor() : MyActivePlugin {...} + * ``` + */ +interface ActivePlugin { + suspend fun isActive(): Boolean = true } + +/** + * Use this typealias to collect your [ActivePlugin]s + * + * Usage: + * ```kotlin + * class MyClass @Inject constructor( + * private val pp: ActivePluginPoint, + * ) {...} + * ``` + */ +typealias ActivePluginPoint = InternalActivePluginPoint<@JvmSuppressWildcards T> diff --git a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/TestActivePlugins.kt b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/TestActivePlugins.kt index ce47b3796bd7..164e3998e715 100644 --- a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/TestActivePlugins.kt +++ b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/TestActivePlugins.kt @@ -18,18 +18,18 @@ package com.duckduckgo.feature.toggles.codegen import com.duckduckgo.anvil.annotations.ContributesActivePlugin import com.duckduckgo.anvil.annotations.ContributesActivePluginPoint -import com.duckduckgo.common.utils.plugins.ActivePluginPoint +import com.duckduckgo.common.utils.plugins.ActivePlugin import com.duckduckgo.di.scopes.AppScope import javax.inject.Inject @ContributesActivePluginPoint( scope = AppScope::class, ) -interface MyPlugin : ActivePluginPoint.ActivePlugin { +interface MyPlugin : ActivePlugin { fun doSomething() } -interface TriggeredMyPlugin : ActivePluginPoint.ActivePlugin { +interface TriggeredMyPlugin : ActivePlugin { fun doSomething() } diff --git a/lint-rules/src/main/java/com/duckduckgo/lint/WrongPluginPointCollectorDetector.kt b/lint-rules/src/main/java/com/duckduckgo/lint/WrongPluginPointCollectorDetector.kt new file mode 100644 index 000000000000..f60e7af6f4b2 --- /dev/null +++ b/lint-rules/src/main/java/com/duckduckgo/lint/WrongPluginPointCollectorDetector.kt @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2022 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.lint + +import com.android.tools.lint.client.api.UElementHandler +import com.android.tools.lint.detector.api.Category +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Implementation +import com.android.tools.lint.detector.api.Issue +import com.android.tools.lint.detector.api.JavaContext +import com.android.tools.lint.detector.api.Scope.JAVA_FILE +import com.android.tools.lint.detector.api.Scope.TEST_SOURCES +import com.android.tools.lint.detector.api.Severity.ERROR +import com.android.tools.lint.detector.api.SourceCodeScanner +import com.android.tools.lint.detector.api.TextFormat.TEXT +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiClassType +import com.intellij.psi.PsiModifierListOwner +import com.intellij.psi.PsiType +import com.intellij.psi.search.GlobalSearchScope +import org.jetbrains.uast.UClass +import org.jetbrains.uast.UElement +import org.jetbrains.uast.UField +import org.jetbrains.uast.UMethod +import org.jetbrains.uast.UParameter +import org.jetbrains.uast.visitor.AbstractUastVisitor +import java.util.* + +@Suppress("UnstableApiUsage") +class WrongPluginPointCollectorDetector : Detector(), SourceCodeScanner { + override fun getApplicableUastTypes() = listOf(UClass::class.java) + + override fun createUastHandler(context: JavaContext): UElementHandler = NoInternalImportHandler(context) + + internal class NoInternalImportHandler(private val context: JavaContext) : UElementHandler() { + + override fun visitClass(node: UClass) { + // travers class to find constructors + node.accept( + object : AbstractUastVisitor() { + override fun visitMethod(node: UMethod): Boolean { + if (node.isConstructor) { + handleConstructor(node) + } + return super.visitMethod(node) + } + + override fun visitField(node: UField): Boolean { + // handle field + handleField(node) + return super.visitField(node) + } + }, + ) + } + private fun handleConstructor(node: UMethod) { + node.parameterList.parameters.map { it.type }.forEach { psiType -> + if (psiType is PsiClassType) { + val resolvedClass = psiType.resolve() + if (resolvedClass?.qualifiedName == "com.duckduckgo.common.utils.plugins.PluginPoint") { + psiType.parameters.forEach { typeArgument -> + val typeArgumentClass = (typeArgument as? PsiClassType)?.resolve() + if (typeArgumentClass?.isActivePlugin() == true) { + context.reportError(node, WRONG_PLUGIN_POINT_ISSUE) + } + } + } + } + } + } + + private fun PsiClass.isActivePlugin(): Boolean { + return this.isSubtypeOf("com.duckduckgo.common.utils.plugins.ActivePluginPoint.ActivePlugin") + } + private fun handleField(node: UField) { + node.type.let { psiType -> + if (psiType is PsiClassType) { + val resolvedClass = psiType.resolve() + if (resolvedClass?.qualifiedName == "com.duckduckgo.common.utils.plugins.PluginPoint") { + val typeArguments = psiType.parameters + for (typeArgument in typeArguments) { + val typeArgumentClass = (typeArgument as? PsiClassType)?.resolve() + if (typeArgumentClass?.isSubtypeOf( + "com.duckduckgo.common.utils.plugins.ActivePluginPoint.ActivePlugin" + ) == true) { + context.reportError(node, WRONG_PLUGIN_POINT_ISSUE) + } + } + } + } + } + } + private fun PsiClass.isSubtypeOf(superClassQualifiedName: String): Boolean { + if (this.qualifiedName == superClassQualifiedName) { + return true + } + for (superType in this.supers) { + if (superType.isSubtypeOf(superClassQualifiedName)) { + return true + } + } + return false + } + + private fun JavaContext.reportError(node: UElement, issue: Issue) { + report( + issue, + node, + context.getNameLocation(node), + issue.getBriefDescription(TEXT), + ) + + } + } + + companion object { + val WRONG_PLUGIN_POINT_ISSUE = Issue.create("WrongPluginPointCollectorDetector", + "PluginPoint cannot be collector of ActivePlugin(s)", + """ + PluginPoint cannot be collector of ActivePlugin(s). Use ActivePluginPoint instead + """.trimIndent(), + Category.CORRECTNESS, 10, ERROR, + Implementation(WrongPluginPointCollectorDetector::class.java, EnumSet.of(JAVA_FILE, TEST_SOURCES)) + ) + } +} diff --git a/lint-rules/src/main/java/com/duckduckgo/lint/registry/DuckDuckGoIssueRegistry.kt b/lint-rules/src/main/java/com/duckduckgo/lint/registry/DuckDuckGoIssueRegistry.kt index 2d951f8ee6d3..d3c4b4618389 100644 --- a/lint-rules/src/main/java/com/duckduckgo/lint/registry/DuckDuckGoIssueRegistry.kt +++ b/lint-rules/src/main/java/com/duckduckgo/lint/registry/DuckDuckGoIssueRegistry.kt @@ -30,6 +30,7 @@ import com.duckduckgo.lint.NoRetrofitCreateMethodCallDetector.Companion.NO_RETRO import com.duckduckgo.lint.NoRobolectricTestRunnerDetector.Companion.NO_ROBOLECTRIC_TEST_RUNNER_ISSUE import com.duckduckgo.lint.NoSingletonDetector.Companion.NO_SINGLETON_ISSUE import com.duckduckgo.lint.NoSystemLoadLibraryDetector.Companion.NO_SYSTEM_LOAD_LIBRARY +import com.duckduckgo.lint.WrongPluginPointCollectorDetector.Companion.WRONG_PLUGIN_POINT_ISSUE import com.duckduckgo.lint.strings.MissingInstructionDetector.Companion.MISSING_INSTRUCTION import com.duckduckgo.lint.strings.PlaceholderDetector.Companion.PLACEHOLDER_MISSING_POSITION import com.duckduckgo.lint.ui.ColorAttributeInXmlDetector.Companion.INVALID_COLOR_ATTRIBUTE @@ -49,6 +50,7 @@ import com.duckduckgo.lint.ui.WrongStyleDetector.Companion.WRONG_STYLE_PARAMETER class DuckDuckGoIssueRegistry : IssueRegistry() { override val issues: List get() = listOf( + WRONG_PLUGIN_POINT_ISSUE, NO_SINGLETON_ISSUE, NO_LIFECYCLE_OBSERVER_ISSUE, NO_FRAGMENT_ISSUE, diff --git a/lint-rules/src/test/java/com/duckduckgo/lint/WrongPluginPointCollectorDetectorTest.kt b/lint-rules/src/test/java/com/duckduckgo/lint/WrongPluginPointCollectorDetectorTest.kt new file mode 100644 index 000000000000..dbed099b65ea --- /dev/null +++ b/lint-rules/src/test/java/com/duckduckgo/lint/WrongPluginPointCollectorDetectorTest.kt @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2022 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.lint + +import com.android.tools.lint.checks.infrastructure.TestFiles.kt +import com.android.tools.lint.checks.infrastructure.TestLintTask.lint +import com.duckduckgo.lint.WrongPluginPointCollectorDetector.Companion.WRONG_PLUGIN_POINT_ISSUE +import org.junit.Test + +class WrongPluginPointCollectorDetectorTest { + @Test + fun `test normal plugin point constructor parameter collecting active plugins`() { + lint() + .files(kt(""" + package com.duckduckgo.common.utils.plugins + + interface PluginPoint { + fun getPlugins(): Collection + } + + interface ActivePluginPoint { + interface ActivePlugin { + suspend fun isActive(): Boolean = true + } + } + + interface MyPlugin + interface MyPluginActivePlugin : ActivePluginPoint.ActivePlugin + + class Duck(private val pp: PluginPoint) { + fun quack() { + } + } + """).indented()) + .issues(WRONG_PLUGIN_POINT_ISSUE) + .run() + .expect(""" + src/com/duckduckgo/common/utils/plugins/PluginPoint.kt:16: Error: PluginPoint cannot be collector of ActivePlugin(s) [WrongPluginPointCollectorDetector] + class Duck(private val pp: PluginPoint) { + ~~~~ + src/com/duckduckgo/common/utils/plugins/PluginPoint.kt:16: Error: PluginPoint cannot be collector of ActivePlugin(s) [WrongPluginPointCollectorDetector] + class Duck(private val pp: PluginPoint) { + ~~ + 2 errors, 0 warnings + """.trimMargin()) + } + + @Test + fun `test active plugin point constructor parameter collecting active plugins`() { + lint() + .files(kt(""" + package com.duckduckgo.common.utils.plugins + + interface PluginPoint { + fun getPlugins(): Collection + } + + interface ActivePluginPoint { + interface ActivePlugin { + suspend fun isActive(): Boolean = true + } + } + + interface MyPlugin + interface MyPluginActivePlugin : ActivePluginPoint.ActivePlugin + + class Duck(private val pp: PluginPoint) { + fun quack() { + } + } + """).indented()) + .issues(WRONG_PLUGIN_POINT_ISSUE) + .run() + .expectClean() + } + + @Test + fun `test normal plugin point field collecting active plugins`() { + lint() + .files(kt(""" + package com.duckduckgo.common.utils.plugins + + interface PluginPoint { + fun getPlugins(): Collection + } + + interface ActivePluginPoint { + interface ActivePlugin { + suspend fun isActive(): Boolean = true + } + } + + interface MyPlugin + interface MyPluginActivePlugin : ActivePluginPoint.ActivePlugin + + class Duck { + private val pp: PluginPoint + + fun quack() { + } + } + """).indented()) + .issues(WRONG_PLUGIN_POINT_ISSUE) + .run() + .expect(""" + src/com/duckduckgo/common/utils/plugins/PluginPoint.kt:17: Error: PluginPoint cannot be collector of ActivePlugin(s) [WrongPluginPointCollectorDetector] + private val pp: PluginPoint + ~~ + 1 errors, 0 warnings + """.trimMargin()) + } + + @Test + fun `test active plugin point field collecting active plugins`() { + lint() + .files(kt(""" + package com.duckduckgo.common.utils.plugins + + interface PluginPoint { + fun getPlugins(): Collection + } + + interface ActivePluginPoint { + interface ActivePlugin { + suspend fun isActive(): Boolean = true + } + } + + interface MyPlugin + interface MyPluginActivePlugin : ActivePluginPoint.ActivePlugin + + class Duck { + private val pp: PluginPoint + + fun quack() { + } + } + """).indented()) + .issues(WRONG_PLUGIN_POINT_ISSUE) + .run() + .expectClean() + } +} From 873600dfd516e0201ec5f98ccfd0e5c262b77ceb Mon Sep 17 00:00:00 2001 From: Cristian Monforte Date: Mon, 27 May 2024 14:36:31 +0200 Subject: [PATCH 16/17] Notify users of async errors (#4569) Task/Issue URL: https://app.asana.com/0/1149059203486286/1207045332341271/f ### Description Should notify users about async errors when syncing. This PR includes: - notifying about bad request / bad format errors It will also include stacked PRs once they are merged onto this one. ### Steps to test this PR _Feature 1_ - [x] Fresh install - [x] create sync account - [x] add few bookmarks and logins - [x] no warnings inside sync settings, no notifications - [x] If you don't have notifications permissions enabled, enable them - [x] apply patch attached to the asana task - [x] install the version - [x] make some changes on bookmarks and logins - [x] ensure warning message is shown in settings and you received a notification - [x] every time you enter in sync settings, we trigger sync, that should produce a random error which will replace existing one if any. - [x] Validate error copies If limit exceeded: - message should say: Sync paused - You've reached the maximum number of (bookmarks|passwords). Please delete some to resume sync. - notification should say: (bookmarks|passwords) Sync is Paused - You've reached the maximum number of (bookmarks|passwords). Please delete some (bookmarks|passwords) to resume sync. If it's bad request: - message should say: Sync paused - Some (bookmarks|passwords) are formatted incorrectly or too long and were not synced. - notification should say: (bookmarks|passwords) Sync is Paused - Some (bookmarks|passwords) are formatted incorrectly or too long and were not synced. ### UI changes | Before | After | | ------ | ----- | !(Upload before screenshot)|(Upload after screenshot)| --- .../sync/CredentialsSyncFeatureListener.kt | 23 +- .../CredentialsSyncNotificationBuilder.kt | 12 ++ ...CredentialsSyncPausedSyncMessagePlugin.kt} | 4 +- ...itView.kt => CredentialsSyncPausedView.kt} | 45 ++-- ...l.kt => CredentialsSyncPausedViewModel.kt} | 35 ++-- .../autofill/sync/CredentialsSyncStore.kt | 8 + .../autofill/sync/SyncMessagePluginModule.kt | 2 +- ...edentials_notification_invalid_request.xml | 34 +++ .../view_credentials_sync_paused_warning.xml | 25 +++ .../res/values-bg/strings-autofill-impl.xml | 6 +- .../res/values-cs/strings-autofill-impl.xml | 6 +- .../res/values-da/strings-autofill-impl.xml | 6 +- .../res/values-de/strings-autofill-impl.xml | 6 +- .../res/values-el/strings-autofill-impl.xml | 6 +- .../res/values-es/strings-autofill-impl.xml | 6 +- .../res/values-et/strings-autofill-impl.xml | 6 +- .../res/values-fi/strings-autofill-impl.xml | 6 +- .../res/values-fr/strings-autofill-impl.xml | 6 +- .../res/values-hr/strings-autofill-impl.xml | 6 +- .../res/values-hu/strings-autofill-impl.xml | 6 +- .../res/values-it/strings-autofill-impl.xml | 6 +- .../res/values-lt/strings-autofill-impl.xml | 6 +- .../res/values-lv/strings-autofill-impl.xml | 6 +- .../res/values-nb/strings-autofill-impl.xml | 6 +- .../res/values-nl/strings-autofill-impl.xml | 6 +- .../res/values-pl/strings-autofill-impl.xml | 6 +- .../res/values-pt/strings-autofill-impl.xml | 6 +- .../res/values-ro/strings-autofill-impl.xml | 6 +- .../res/values-ru/strings-autofill-impl.xml | 6 +- .../res/values-sk/strings-autofill-impl.xml | 6 +- .../res/values-sl/strings-autofill-impl.xml | 6 +- .../res/values-sv/strings-autofill-impl.xml | 6 +- .../res/values-tr/strings-autofill-impl.xml | 6 +- .../main/res/values/strings-autofill-impl.xml | 6 +- .../AppCredentialsSyncFeatureListenerTest.kt | 10 +- ... => CredentialsSyncPausedViewModelTest.kt} | 28 ++- .../autofill/sync/FakeCredentialsSyncStore.kt | 1 + .../duckduckgo/common/test/api/FakeChain.kt | 2 +- .../impl/sync/RealSavedSitesSyncStore.kt | 8 + .../impl/sync/SavedSiteInvalidItemsView.kt | 6 +- ...imitView.kt => SavedSiteSyncPausedView.kt} | 49 +++-- ...del.kt => SavedSiteSyncPausedViewModel.kt} | 37 ++-- .../sync/SavedSitesSyncFeatureListener.kt | 23 +- .../sync/SavedSitesSyncNotificationBuilder.kt | 12 ++ ... SavedSitesSyncPausedSyncMessagePlugin.kt} | 4 +- .../layout/notification_invalid_request.xml | 34 +++ ...w_save_site_sync_invalid_items_warning.xml | 24 +++ ...=> view_save_site_sync_paused_warning.xml} | 0 .../res/values-bg/strings-saved-sites.xml | 6 +- .../res/values-cs/strings-saved-sites.xml | 6 +- .../res/values-da/strings-saved-sites.xml | 6 +- .../res/values-de/strings-saved-sites.xml | 6 +- .../res/values-el/strings-saved-sites.xml | 6 +- .../res/values-es/strings-saved-sites.xml | 6 +- .../res/values-et/strings-saved-sites.xml | 6 +- .../res/values-fi/strings-saved-sites.xml | 6 +- .../res/values-fr/strings-saved-sites.xml | 6 +- .../res/values-hr/strings-saved-sites.xml | 6 +- .../res/values-hu/strings-saved-sites.xml | 6 +- .../res/values-it/strings-saved-sites.xml | 6 +- .../res/values-lt/strings-saved-sites.xml | 6 +- .../res/values-lv/strings-saved-sites.xml | 6 +- .../res/values-nb/strings-saved-sites.xml | 6 +- .../res/values-nl/strings-saved-sites.xml | 6 +- .../res/values-pl/strings-saved-sites.xml | 6 +- .../res/values-pt/strings-saved-sites.xml | 6 +- .../res/values-ro/strings-saved-sites.xml | 6 +- .../res/values-ru/strings-saved-sites.xml | 6 +- .../res/values-sk/strings-saved-sites.xml | 6 +- .../res/values-sl/strings-saved-sites.xml | 6 +- .../res/values-sv/strings-saved-sites.xml | 6 +- .../res/values-tr/strings-saved-sites.xml | 6 +- .../main/res/values/strings-saved-sites.xml | 6 +- .../AppSavedSitesSyncFeatureListenerTest.kt | 20 +- ...kt => SavedSiteSyncPausedViewModelTest.kt} | 41 +++- .../com/duckduckgo/sync/api/engine/Models.kt | 1 + sync/sync-impl/build.gradle | 1 + .../com/duckduckgo/sync/impl/di/SyncModule.kt | 10 + .../sync/impl/engine/RealSyncEngine.kt | 9 + .../sync/impl/engine/SyncEngineLifecycle.kt | 26 +++ .../engine/SyncInvalidTokenInterceptor.kt | 70 +++++++ .../impl/engine/SyncNotificationBuilder.kt | 27 +++ .../SyncServerUnavailableInterceptor.kt | 66 ++++++ .../impl/error/SyncUnavailableRepository.kt | 185 ++++++++++++++++ .../sync/impl/ui/SyncErrorMessagePlugin.kt | 31 +++ .../duckduckgo/sync/impl/ui/SyncErrorView.kt | 86 ++++++++ .../sync/impl/ui/SyncErrorViewModel.kt | 69 ++++++ .../res/layout/notification_sync_error.xml | 34 +++ .../layout/notification_sync_signed_out.xml | 34 +++ .../res/layout/view_sync_error_warning.xml | 2 +- .../src/main/res/values-bg/strings-sync.xml | 3 + .../src/main/res/values-cs/strings-sync.xml | 3 + .../src/main/res/values-da/strings-sync.xml | 3 + .../src/main/res/values-de/strings-sync.xml | 3 + .../src/main/res/values-el/strings-sync.xml | 3 + .../src/main/res/values-es/strings-sync.xml | 3 + .../src/main/res/values-et/strings-sync.xml | 3 + .../src/main/res/values-fi/strings-sync.xml | 3 + .../src/main/res/values-fr/strings-sync.xml | 3 + .../src/main/res/values-hr/strings-sync.xml | 3 + .../src/main/res/values-hu/strings-sync.xml | 3 + .../src/main/res/values-it/strings-sync.xml | 3 + .../src/main/res/values-lt/strings-sync.xml | 3 + .../src/main/res/values-lv/strings-sync.xml | 3 + .../src/main/res/values-nb/strings-sync.xml | 3 + .../src/main/res/values-nl/strings-sync.xml | 3 + .../src/main/res/values-pl/strings-sync.xml | 3 + .../src/main/res/values-pt/strings-sync.xml | 3 + .../src/main/res/values-ro/strings-sync.xml | 3 + .../src/main/res/values-ru/strings-sync.xml | 3 + .../src/main/res/values-sk/strings-sync.xml | 3 + .../src/main/res/values-sl/strings-sync.xml | 3 + .../src/main/res/values-sv/strings-sync.xml | 3 + .../src/main/res/values-tr/strings-sync.xml | 3 + .../src/main/res/values/strings-sync.xml | 3 + .../impl/engine/FakeNotificationBuilder.kt | 37 ++++ .../sync/impl/engine/SyncEngineTest.kt | 5 +- .../engine/SyncInvalidTokenInterceptorTest.kt | 114 ++++++++++ .../SyncServerUnavailableInterceptorTest.kt | 93 +++++++++ .../RealSyncUnavailableRepositoryTest.kt | 197 ++++++++++++++++++ .../sync/store/SyncUnavailableStore.kt | 92 ++++++++ .../SyncUnavailableSharedPrefsStoreTest.kt | 70 +++++++ 122 files changed, 1890 insertions(+), 231 deletions(-) rename autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/{CredentialsRateLimitSyncMessagePlugin.kt => CredentialsSyncPausedSyncMessagePlugin.kt} (85%) rename autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/{CredentialsRateLimitView.kt => CredentialsSyncPausedView.kt} (74%) rename autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/{CredentialsRateLimitViewModel.kt => CredentialsSyncPausedViewModel.kt} (68%) create mode 100644 autofill/autofill-impl/src/main/res/layout/credentials_notification_invalid_request.xml create mode 100644 autofill/autofill-impl/src/main/res/layout/view_credentials_sync_paused_warning.xml rename autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/sync/{CredentialsRateLimitViewModelTest.kt => CredentialsSyncPausedViewModelTest.kt} (64%) rename saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/{SavedSiteRateLimitView.kt => SavedSiteSyncPausedView.kt} (72%) rename saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/{SavedSiteRateLimitViewModel.kt => SavedSiteSyncPausedViewModel.kt} (65%) rename saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/{SavedSitesRateLimitSyncMessagePlugin.kt => SavedSitesSyncPausedSyncMessagePlugin.kt} (88%) create mode 100644 saved-sites/saved-sites-impl/src/main/res/layout/notification_invalid_request.xml create mode 100644 saved-sites/saved-sites-impl/src/main/res/layout/view_save_site_sync_invalid_items_warning.xml rename saved-sites/saved-sites-impl/src/main/res/layout/{view_save_site_rate_limit_warning.xml => view_save_site_sync_paused_warning.xml} (100%) rename saved-sites/saved-sites-impl/src/test/java/com/duckduckgo/savedsites/impl/sync/{SavedSiteRateLimitViewModelTest.kt => SavedSiteSyncPausedViewModelTest.kt} (57%) create mode 100644 sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/engine/SyncEngineLifecycle.kt create mode 100644 sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/engine/SyncInvalidTokenInterceptor.kt create mode 100644 sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/engine/SyncServerUnavailableInterceptor.kt create mode 100644 sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/error/SyncUnavailableRepository.kt create mode 100644 sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncErrorMessagePlugin.kt create mode 100644 sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncErrorView.kt create mode 100644 sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncErrorViewModel.kt create mode 100644 sync/sync-impl/src/main/res/layout/notification_sync_error.xml create mode 100644 sync/sync-impl/src/main/res/layout/notification_sync_signed_out.xml rename autofill/autofill-impl/src/main/res/layout/view_credentials_rate_limit_warning.xml => sync/sync-impl/src/main/res/layout/view_sync_error_warning.xml (95%) create mode 100644 sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/engine/FakeNotificationBuilder.kt create mode 100644 sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/engine/SyncInvalidTokenInterceptorTest.kt create mode 100644 sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/engine/SyncServerUnavailableInterceptorTest.kt create mode 100644 sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/error/RealSyncUnavailableRepositoryTest.kt create mode 100644 sync/sync-store/src/main/java/com/duckduckgo/sync/store/SyncUnavailableStore.kt create mode 100644 sync/sync-store/src/test/java/com/duckduckgo/sync/store/SyncUnavailableSharedPrefsStoreTest.kt diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsSyncFeatureListener.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsSyncFeatureListener.kt index 8694138316a3..a38f41349aab 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsSyncFeatureListener.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsSyncFeatureListener.kt @@ -22,9 +22,11 @@ import com.duckduckgo.common.utils.notification.checkPermissionAndNotify import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.sync.api.engine.FeatureSyncError import com.duckduckgo.sync.api.engine.FeatureSyncError.COLLECTION_LIMIT_REACHED +import com.duckduckgo.sync.api.engine.FeatureSyncError.INVALID_REQUEST import com.duckduckgo.sync.api.engine.SyncChangesResponse import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject +import timber.log.Timber interface CredentialsSyncFeatureListener { fun onSuccess(changes: SyncChangesResponse) @@ -45,31 +47,42 @@ class AppCredentialsSyncFeatureListener @Inject constructor( if (credentialsSyncStore.isSyncPaused) { credentialsSyncStore.isSyncPaused = false + credentialsSyncStore.syncPausedReason = "" cancelNotification() } } override fun onError(syncError: FeatureSyncError) { + Timber.d("Sync-autofill: $syncError received, current state isPaused:${credentialsSyncStore.isSyncPaused}") when (syncError) { - COLLECTION_LIMIT_REACHED -> { - if (!credentialsSyncStore.isSyncPaused) { - triggerNotification() + COLLECTION_LIMIT_REACHED, + INVALID_REQUEST, + -> { + if (!credentialsSyncStore.isSyncPaused || credentialsSyncStore.syncPausedReason != syncError.name) { + Timber.i("Sync-autofill: should trigger notification for $syncError") + triggerNotification(syncError) } credentialsSyncStore.isSyncPaused = true + credentialsSyncStore.syncPausedReason = syncError.name } } } override fun onSyncDisabled() { credentialsSyncStore.isSyncPaused = false + credentialsSyncStore.syncPausedReason = "" cancelNotification() } - private fun triggerNotification() { + private fun triggerNotification(syncError: FeatureSyncError) { + val notification = when (syncError) { + COLLECTION_LIMIT_REACHED -> notificationBuilder.buildRateLimitNotification(context) + INVALID_REQUEST -> notificationBuilder.buildInvalidRequestNotification(context) + } notificationManager.checkPermissionAndNotify( context, SYNC_PAUSED_CREDENTIALS_NOTIFICATION_ID, - notificationBuilder.buildRateLimitNotification(context), + notification, ) } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsSyncNotificationBuilder.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsSyncNotificationBuilder.kt index 272a8d972b94..695aff2210b7 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsSyncNotificationBuilder.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsSyncNotificationBuilder.kt @@ -32,6 +32,7 @@ import javax.inject.Inject interface CredentialsSyncNotificationBuilder { fun buildRateLimitNotification(context: Context): Notification + fun buildInvalidRequestNotification(context: Context): Notification } @ContributesBinding(AppScope::class) @@ -49,6 +50,17 @@ class AppCredentialsSyncNotificationBuilder @Inject constructor( .build() } + override fun buildInvalidRequestNotification(context: Context): Notification { + return NotificationCompat.Builder(context, SYNC_NOTIFICATION_CHANNEL_ID) + .setSmallIcon(com.duckduckgo.mobile.android.R.drawable.notification_logo) + .setStyle(NotificationCompat.DecoratedCustomViewStyle()) + .setContentIntent(getPendingIntent(context)) + .setCustomContentView(RemoteViews(context.packageName, R.layout.credentials_notification_invalid_request)) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .build() + } + private fun getPendingIntent(context: Context): PendingIntent? = TaskStackBuilder.create(context).run { addNextIntentWithParentStack( globalGlobalActivityStarter.startIntent(context, SyncActivityWithEmptyParams)!!, diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsRateLimitSyncMessagePlugin.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsSyncPausedSyncMessagePlugin.kt similarity index 85% rename from autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsRateLimitSyncMessagePlugin.kt rename to autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsSyncPausedSyncMessagePlugin.kt index af21a3864cb8..d76ff41d036a 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsRateLimitSyncMessagePlugin.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsSyncPausedSyncMessagePlugin.kt @@ -21,8 +21,8 @@ import android.view.View import com.duckduckgo.sync.api.SyncMessagePlugin import javax.inject.Inject -class CredentialsRateLimitSyncMessagePlugin @Inject constructor() : SyncMessagePlugin { +class CredentialsSyncPausedSyncMessagePlugin @Inject constructor() : SyncMessagePlugin { override fun getView(context: Context): View { - return CredentialsRateLimitView(context) + return CredentialsSyncPausedView(context) } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsRateLimitView.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsSyncPausedView.kt similarity index 74% rename from autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsRateLimitView.kt rename to autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsSyncPausedView.kt index 47e31d5574c7..d0731bf65643 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsRateLimitView.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsSyncPausedView.kt @@ -26,13 +26,13 @@ import androidx.lifecycle.ViewTreeLifecycleOwner import androidx.lifecycle.findViewTreeViewModelStoreOwner import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.autofill.api.AutofillScreens.AutofillSettingsScreenNoParams -import com.duckduckgo.autofill.impl.R -import com.duckduckgo.autofill.impl.databinding.ViewCredentialsRateLimitWarningBinding -import com.duckduckgo.autofill.sync.CredentialsRateLimitViewModel.Command -import com.duckduckgo.autofill.sync.CredentialsRateLimitViewModel.Command.NavigateToCredentials -import com.duckduckgo.autofill.sync.CredentialsRateLimitViewModel.ViewState +import com.duckduckgo.autofill.impl.databinding.ViewCredentialsSyncPausedWarningBinding +import com.duckduckgo.autofill.sync.CredentialsSyncPausedViewModel.Command +import com.duckduckgo.autofill.sync.CredentialsSyncPausedViewModel.Command.NavigateToCredentials +import com.duckduckgo.autofill.sync.CredentialsSyncPausedViewModel.ViewState import com.duckduckgo.common.ui.viewbinding.viewBinding import com.duckduckgo.common.utils.ConflatedJob +import com.duckduckgo.common.utils.ViewViewModelFactory import com.duckduckgo.di.scopes.ViewScope import com.duckduckgo.navigation.api.GlobalActivityStarter import dagger.android.support.AndroidSupportInjection @@ -45,7 +45,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @InjectWith(ViewScope::class) -class CredentialsRateLimitView @JvmOverloads constructor( +class CredentialsSyncPausedView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyle: Int = 0, @@ -55,16 +55,16 @@ class CredentialsRateLimitView @JvmOverloads constructor( lateinit var globalActivityStarter: GlobalActivityStarter @Inject - lateinit var viewModelFactory: CredentialsRateLimitViewModel.Factory + lateinit var viewModelFactory: ViewViewModelFactory private var coroutineScope: CoroutineScope? = null private var job: ConflatedJob = ConflatedJob() - private val binding: ViewCredentialsRateLimitWarningBinding by viewBinding() + private val binding: ViewCredentialsSyncPausedWarningBinding by viewBinding() - private val viewModel: CredentialsRateLimitViewModel by lazy { - ViewModelProvider(findViewTreeViewModelStoreOwner()!!, viewModelFactory)[CredentialsRateLimitViewModel::class.java] + private val viewModel: CredentialsSyncPausedViewModel by lazy { + ViewModelProvider(findViewTreeViewModelStoreOwner()!!, viewModelFactory)[CredentialsSyncPausedViewModel::class.java] } override fun onAttachedToWindow() { @@ -73,8 +73,6 @@ class CredentialsRateLimitView @JvmOverloads constructor( ViewTreeLifecycleOwner.get(this)?.lifecycle?.addObserver(viewModel) - configureViewListeners() - @SuppressLint("NoHardcodedCoroutineDispatcher") coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) @@ -102,17 +100,18 @@ class CredentialsRateLimitView @JvmOverloads constructor( } private fun render(viewState: ViewState) { - this.isVisible = viewState.warningVisible - } - - private fun configureViewListeners() { - binding.credentialsRateLimitWarning.setClickableLink( - WARNING_ACTION_ANNOTATION, - context.getText(R.string.credentials_limit_warning), - onClick = { - viewModel.onWarningActionClicked() - }, - ) + if (viewState.message != null) { + this.isVisible = true + binding.credentialsSyncPausedWarning.setClickableLink( + WARNING_ACTION_ANNOTATION, + context.getText(viewState.message), + onClick = { + viewModel.onWarningActionClicked() + }, + ) + } else { + this.isVisible = false + } } private fun navigateToCredentials() { diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsRateLimitViewModel.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsSyncPausedViewModel.kt similarity index 68% rename from autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsRateLimitViewModel.kt rename to autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsSyncPausedViewModel.kt index 9016998aa6a9..65b449ff761a 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsRateLimitViewModel.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsSyncPausedViewModel.kt @@ -19,9 +19,12 @@ package com.duckduckgo.autofill.sync import android.annotation.SuppressLint import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.autofill.impl.R import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.ViewScope +import com.duckduckgo.sync.api.engine.FeatureSyncError import javax.inject.Inject import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST import kotlinx.coroutines.channels.Channel @@ -33,13 +36,14 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @SuppressLint("NoLifecycleObserver") // does not subscribe to app lifecycle -class CredentialsRateLimitViewModel( +@ContributesViewModel(ViewScope::class) +class CredentialsSyncPausedViewModel @Inject constructor( private val credentialsSyncStore: CredentialsSyncStore, private val dispatcherProvider: DispatcherProvider, ) : ViewModel(), DefaultLifecycleObserver { data class ViewState( - val warningVisible: Boolean = false, + val message: Int? = null, ) sealed class Command { @@ -50,8 +54,13 @@ class CredentialsRateLimitViewModel( fun viewState(): Flow = credentialsSyncStore.isSyncPausedFlow() .map { syncPaused -> + val message = when (credentialsSyncStore.syncPausedReason) { + FeatureSyncError.COLLECTION_LIMIT_REACHED.name -> R.string.credentials_limit_warning + FeatureSyncError.INVALID_REQUEST.name -> R.string.credentials_invalid_request_warning + else -> null + } ViewState( - warningVisible = syncPaused, + message = message, ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), ViewState()) @@ -62,22 +71,4 @@ class CredentialsRateLimitViewModel( command.send(Command.NavigateToCredentials) } } - - @Suppress("UNCHECKED_CAST") - class Factory @Inject constructor( - private val credentialsSyncStore: CredentialsSyncStore, - private val dispatcherProvider: DispatcherProvider, - ) : ViewModelProvider.NewInstanceFactory() { - override fun create(modelClass: Class): T { - return with(modelClass) { - when { - isAssignableFrom(CredentialsRateLimitViewModel::class.java) -> CredentialsRateLimitViewModel( - credentialsSyncStore, - dispatcherProvider, - ) - else -> throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") - } - } as T - } - } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsSyncStore.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsSyncStore.kt index 60f3e4a68074..cd6909567ce6 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsSyncStore.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsSyncStore.kt @@ -36,6 +36,7 @@ interface CredentialsSyncStore { var startTimeStamp: String var clientModifiedSince: String var isSyncPaused: Boolean + var syncPausedReason: String fun isSyncPausedFlow(): Flow var invalidEntitiesIds: List } @@ -77,6 +78,12 @@ class RealCredentialsSyncStore @Inject constructor( putStringSet(KEY_CLIENT_INVALID_IDS, value.toSet()) } } + override var syncPausedReason: String + get() = preferences.getString(KEY_CLIENT_SYNC_PAUSED_REASON, "") ?: "" + set(value) { + preferences.edit(true) { putString(KEY_CLIENT_SYNC_PAUSED_REASON, value) } + emitNewValue() + } override fun isSyncPausedFlow(): Flow = syncPausedSharedFlow @@ -96,5 +103,6 @@ class RealCredentialsSyncStore @Inject constructor( private const val KEY_END_TIMESTAMP = "KEY_END_TIMESTAMP" private const val KEY_CLIENT_LIMIT_EXCEEDED = "KEY_CLIENT_LIMIT_EXCEEDED" private const val KEY_CLIENT_INVALID_IDS = "KEY_CLIENT_INVALID_IDS" + private const val KEY_CLIENT_SYNC_PAUSED_REASON = "KEY_CLIENT_SYNC_PAUSED_REASON" } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/SyncMessagePluginModule.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/SyncMessagePluginModule.kt index 6f432c0605b6..035ef468ccd1 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/SyncMessagePluginModule.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/SyncMessagePluginModule.kt @@ -31,5 +31,5 @@ import dagger.multibindings.IntoSet @ContributesTo(ActivityScope::class) abstract class SyncMessagePluginModule { @Binds @IntoSet - abstract fun providesCredentialsRateLimitSyncMessagePlugin(messagePlugin: CredentialsRateLimitSyncMessagePlugin): SyncMessagePlugin + abstract fun providesCredentialsSyncPausedSyncMessagePlugin(messagePlugin: CredentialsSyncPausedSyncMessagePlugin): SyncMessagePlugin } diff --git a/autofill/autofill-impl/src/main/res/layout/credentials_notification_invalid_request.xml b/autofill/autofill-impl/src/main/res/layout/credentials_notification_invalid_request.xml new file mode 100644 index 000000000000..d4a9fba2ed45 --- /dev/null +++ b/autofill/autofill-impl/src/main/res/layout/credentials_notification_invalid_request.xml @@ -0,0 +1,34 @@ + + + + + + + \ No newline at end of file diff --git a/autofill/autofill-impl/src/main/res/layout/view_credentials_sync_paused_warning.xml b/autofill/autofill-impl/src/main/res/layout/view_credentials_sync_paused_warning.xml new file mode 100644 index 000000000000..0d30341cafb8 --- /dev/null +++ b/autofill/autofill-impl/src/main/res/layout/view_credentials_sync_paused_warning.xml @@ -0,0 +1,25 @@ + + + + + \ No newline at end of file diff --git a/autofill/autofill-impl/src/main/res/values-bg/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-bg/strings-autofill-impl.xml index aeb1f457e086..30cc04e0c5b7 100644 --- a/autofill/autofill-impl/src/main/res/values-bg/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-bg/strings-autofill-impl.xml @@ -139,8 +139,10 @@ За защита на паролите е необходимо да зададете модел за заключване, ПИН код или биометрични данни. Редактиране на парола - Синхронизирането е спряно\nПревишихте лимита за данни за вход. Изтрийте някои от тях, за да възобновите синхронизирането.Управление на данните за вход - Синхронизирането на пароли е спряно\nПревишихте ограничението за синхронизиране на пароли. Опитайте да изтриете някои от паролите. Докато този проблем не бъде разрешен, паролите няма да бъдат архивирани. + Синхронизирането е на пауза\nДостигнахте максималния брой пароли. Изтрийте някои от тях, за да възобновите синхронизирането.\n\nУправление на пароли + Синхронизирането на пароли е на пауза\nДостигнахте максималния брой пароли. Изтрийте някои пароли, за да възобновите синхронизирането. + Синхронизирането е на пауза\nНякои пароли са форматирани неправилно или са твърде дълги и не бяха синхронизирани.\n\nУправление на пароли + \nСинхронизирането на пароли е на пауза\nНякои пароли са форматирани неправилно или са твърде дълги и не бяха синхронизирани. Никога не питай за този сайт Ако нулирате изключените сайтове, при следващото влизане в някой от тези сайтове ще бъдете подканени да запазите паролата за вход. diff --git a/autofill/autofill-impl/src/main/res/values-cs/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-cs/strings-autofill-impl.xml index ab6afeaea61c..bda9d0ad955b 100644 --- a/autofill/autofill-impl/src/main/res/values-cs/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-cs/strings-autofill-impl.xml @@ -139,8 +139,10 @@ Na ochranu hesel se vyžaduje gesto zámku obrazovky, PIN nebo biometrické ověření. Upravit heslo - Synchronizace je pozastavená\nJe překročený limit počtu přihlášení. Jestli chceš synchronizaci obnovit, některá odstraň.\n\nSpravovat přihlašovací údaje - Synchronizace hesel je pozastavená\nDošlo k překročení limitu pro synchronizaci hesel. Zkus pár hesel smazat. Dokud tenhle problém nevyřešíš, hesla se nebudou zálohovat. + Synchronizace je pozastavená\nUž máš maximální počet hesel. Jestli chceš synchronizaci obnovit, některá smaž.\n\nSpravovat hesla + Synchronizace hesel je pozastavená\nUž máš maximální počet hesel. Jestli chceš synchronizaci obnovit, některá hesla smaž. + Synchronizace je pozastavená\nNěkterá hesla jsou nesprávně naformátovaná nebo moc dlouhá, takže jsme je nemohli synchronizovat.\n\nSpravovat hesla + Synchronizace hesel je pozastavená\nNěkterá hesla jsou nesprávně naformátovaná nebo moc dlouhá, takže jsme je nemohli synchronizovat. Na téhle stránce už se neptat Pokud vyloučené weby resetuješ, při příštím přihlašování na některý z nich se ti zobrazí výzva k uložení hesla. diff --git a/autofill/autofill-impl/src/main/res/values-da/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-da/strings-autofill-impl.xml index 8fcddccf6210..4a0e04c02501 100644 --- a/autofill/autofill-impl/src/main/res/values-da/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-da/strings-autofill-impl.xml @@ -139,8 +139,10 @@ Der kræves et låsemønster, en pinkode eller biometri for at beskytte dine adgangskoder. Rediger adgangskode - Synkronisering sat på pause\nGrænse for logins overskredet. Slet nogle for at genoptage synkroniseringen.\n\nAdministrer logins - Synkronisering af adgangskoder sat på pause\nDu har overskredet grænsen for synkronisering af adgangskoder. Prøv at slette nogle adgangskoder. Dine adgangskoder vil ikke blive sikkerhedskopieret, før dette er løst. + Synkronisering sat på pause\nDu har nået det maksimale antal adgangskoder. Slet nogle af dem for at genoptage synkroniseringen.Administrer adgangskoder + Synkronisering af adgangskoder er sat på pause\nDu har nået det maksimale antal adgangskoder. Slet nogle adgangskoder for at genoptage synkroniseringen. + Synkronisering sat på pause\nNogle adgangskoder er formateret forkert eller er for lange og blev ikke synkroniseret.\n\nAdministrer adgangskoder + Synkronisering af adgangskoder er sat på pause\nNogle adgangskoder er formateret forkert eller er for lange og blev ikke synkroniseret. Spørg aldrig på dette websted Hvis du nulstiller ekskluderede websteder, vil du blive bedt om at gemme din adgangskode, næste gang du logger ind på en af disse sider. diff --git a/autofill/autofill-impl/src/main/res/values-de/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-de/strings-autofill-impl.xml index c8f35c4da949..8a735af08b69 100644 --- a/autofill/autofill-impl/src/main/res/values-de/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-de/strings-autofill-impl.xml @@ -139,8 +139,10 @@ Es sind ein Muster, eine PIN oder biometrische Daten erforderlich, um deine Passwörter zu schützen. Passwort bearbeiten - Synchronisierung angehalten\nAnmeldelimit überschritten. Lösche Lesezeichen, um die Synchronisierung fortzusetzen.\n\nAnmeldedaten verwalten - Die Passwort-Synchronisierung wurde pausiert\nDu hast das Limit für die Synchronisierung von Passwörtern überschritten. Versuche, einige Passwörter zu löschen. Bis dieses Problem behoben ist, werden deine Passwörter nicht gesichert. + Synchronisierung angehalten\nDu hast die maximale Anzahl von Passwörtern erreicht. Lösche einige, um die Synchronisierung fortzusetzen.\n\nPasswörter verwalten + Die Passwort-Synchronisierung wurde angehalten\nDu hast die maximale Anzahl von Passwörtern erreicht. Lösche Passwörter, um die Synchronisierung fortzusetzen. + Synchronisierung angehalten\nEinige Passwörter sind falsch formatiert oder zu lang und wurden nicht synchronisiert.\n\nPasswörter verwalten + Die Passwort-Synchronisierung wurde angehalten\nEinige Passwörter sind falsch formatiert oder zu lang und wurden nicht synchronisiert.\n\n Für diese Website niemals fragen Wenn du die ausgeschlossenen Websites zurücksetzt, wirst du aufgefordert, dein Passwort zu speichern, wenn du dich das nächste Mal auf einer dieser Websites anmeldest. diff --git a/autofill/autofill-impl/src/main/res/values-el/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-el/strings-autofill-impl.xml index 8534eb27f080..c70f97f1c3ec 100644 --- a/autofill/autofill-impl/src/main/res/values-el/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-el/strings-autofill-impl.xml @@ -139,8 +139,10 @@ Για προστασία των κωδικών πρόσβασής σας απαιτείται μοτίβο κλειδώματος συσκευής, PIN ή βιομετρικά στοιχεία. Επεξεργασία κωδικού πρόσβασης - Ο Συγχρονισμός τέθηκε σε παύση\nΥπέρβαση του ορίου συνδέσεων. Διαγραφή ορισμένων για συνέχιση του συγχρονισμού.\n\nΔιαχείριση συνδέσεων - Ο Συγχρονισμός κωδικών πρόσβασης τέθηκε σε παύση\nΈχετε υπερβεί το όριο συγχρονισμού κωδικών πρόσβασης. Προσπαθήστε να διαγράψετε κάποιους κωδικούς πρόσβασης. Μέχρι να επιλυθεί το πρόβλημα αυτό, δεν θα δημιουργηθούν αντίγραφα ασφάλειας για τους κωδικούς πρόσβασής σας. + Ο συγχρονισμός έχει τεθεί σε παύση\nΈχετε φτάσει στον μέγιστο αριθμό κωδικών πρόσβασης. Διαγράψτε ορισμένους για να συνεχίσετε τον συγχρονισμό.\n\nΔιαχείριση κωδικών πρόσβασης + Ο συγχρονισμός κωδικών πρόσβασης έχει τεθεί σε παύση\nΈχετε φτάσει στον μέγιστο αριθμό κωδικών πρόσβασης. Διαγράψτε ορισμένους κωδικούς πρόσβασης για να συνεχίσετε τον συγχρονισμό. + Ο συγχρονισμός έχει τεθεί σε παύση\nΟρισμένοι κωδικοί πρόσβασης δεν έχουν μορφοποιηθεί σωστά ή είναι πολύ μεγάλοι και δεν συγχρονίστηκαν.\n\nΔιαχείριση κωδικών πρόσβασης + Ο συγχρονισμός κωδικών πρόσβασης έχει τεθεί σε παύση\nΟρισμένοι κωδικοί πρόσβασης δεν έχουν μορφοποιηθεί σωστά ή είναι πολύ μεγάλοι και δεν συγχρονίστηκαν. Μην ζητάτε ποτέ αυτόν τον ιστότοπο Εάν κάνετε επαναφορά των αποκλεισμένων ιστότοπων, θα σας ζητηθεί να αποθηκεύσετε τον κωδικό πρόσβασής σας την επόμενη φορά που θα συνδεθείτε σε οποιονδήποτε από τους ιστότοπους αυτούς. diff --git a/autofill/autofill-impl/src/main/res/values-es/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-es/strings-autofill-impl.xml index ffd8beacd164..41226cbc371a 100644 --- a/autofill/autofill-impl/src/main/res/values-es/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-es/strings-autofill-impl.xml @@ -139,8 +139,10 @@ Se requiere un patrón de bloqueo del dispositivo, un PIN o datos biométricos para proteger tus contraseñas. Editar contraseña - Sincronización en pausa\nSe ha superado el límite de inicios de sesión. Elimina algunos para reanudar la sincronización.\n\nGestionar inicios de sesión - La sincronización de contraseñas está en pausa\nHas superado el límite de sincronización de contraseñas. Prueba a eliminar algunas contraseñas. No se realizará una copia de seguridad de las contraseñas hasta que se resuelva este problema. + Sincronización en pausa\nHas alcanzado el número máximo de contraseñas. Elimina algunas para reanudar la sincronización.\n\nGestionar contraseñas + La sincronización de contraseñas está en pausa\nHas alcanzado el número máximo de contraseñas. Elimina algunas contraseñas para reanudar la sincronización. + Sincronización en pausa\nAlgunas contraseñas tienen un formato incorrecto o demasiado largo y no se han sincronizado.\n\nGestionar contraseñas + La sincronización de contraseña está en pausa\nAlgunas contraseñas tienen un formato incorrecto o son demasiado largas y no se han sincronizado. No preguntar nunca para esta página Si restableces los sitios excluidos, se te pedirá que guardes tu contraseña la próxima vez que accedas a cualquiera de estos sitios. diff --git a/autofill/autofill-impl/src/main/res/values-et/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-et/strings-autofill-impl.xml index 2d161acd5015..8b30cdf8e4fd 100644 --- a/autofill/autofill-impl/src/main/res/values-et/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-et/strings-autofill-impl.xml @@ -139,8 +139,10 @@ Paroolide kaitsmiseks on vaja seadme lukustusmustrit, PIN-koodi või biomeetriat. Parooli muutmine - Sünkroonimine peatatud\nSisselogimisandmete limiit on täis. Kustuta mõni, et jätkata sünkroonimist.\n\nHalda sisselogimisandmeid - Paroolide sünkroonimine on peatatud\nOled paroolide sünkroonimise limiidi ületanud. Proovi mõned paroolid kustutada. Kuni see pole lahendatud, ei varundata sinu paroole. + Sünkroonimine peatatud\nOled saavutanud maksimaalse paroolide arvu. Kustuta mõni, et jätkata sünkroonimisega.\n\nHalda paroole + Paroolide sünkroonimine on peatatud\nOled saavutanud maksimaalse paroolide arvu. Sünkroonimise jätkamiseks kustuta mõned paroolid. + Sünkroonimine peatatud\nMõned paroolid on valesti vormindatud või liiga pikad ja neid ei sünkroonitud.\n\nHalda paroole + Paroolide sünkroonimine on peatatud\nMõned paroolid on valesti vormindatud või liiga pikad ja neid ei sünkroonitud. Ära selle saidi kohta rohkem küsi Kui lähtestad välistatud saidid, pakutakse sulle järgmisel korral, kui logid neile saitidele sisse, võimalust parool salvestada. diff --git a/autofill/autofill-impl/src/main/res/values-fi/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-fi/strings-autofill-impl.xml index 581e38c4374f..b34a59fb8377 100644 --- a/autofill/autofill-impl/src/main/res/values-fi/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-fi/strings-autofill-impl.xml @@ -139,8 +139,10 @@ Salasanojen suojaamiseen tarvitaan laitteen lukituskuvio, PIN-koodi tai biometriset tiedot. Muokkaa salasanaa - Synkronointi keskeytetty\nKirjautumisten enimmäismäärä ylitetty. Poista jotain jatkaaksesi synkronointia.\n\nHallitse kirjautumisia - Salasanojen synkronointi on keskeytetty\nOlet ylittänyt salasanojen synkronointirajan. Yritä poistaa joitakin salasanoja. Salasanojasi ei varmuuskopioida ennen kuin tämä ongelma on ratkaistu. + Synkronointi keskeytetty\nSalasanakiintiösi on täynnä. Poista joitain salasanoja jatkaaksesi synkronointia.\n\nHallitse salasanoja + Salasanojen synkronointi on keskeytetty\nSalasanakiintiösi on täynnä. Poista joitakin salasanoja jatkaaksesi synkronointia. + Synkronointi keskeytetty\nJotkin salasanat on muotoiltu väärin tai ovat liian pitkiä, eikä niitä synkronoitu.\n\nHallitse salasanoja + Salasanojen synkronointi keskeytetty\nSalasanojen synkronointi on keskeytetty.\n\nJotkin salasanat on muotoiltu väärin tai ovat liian pitkiä, eikä niitä synkronoitu. Älä kysy enää tällä sivustolla Jos nollaat poissuljetut sivustot, sinua pyydetään tallentamaan salasanasi, kun seuraavan kerran kirjaudut sisään jollekin näistä sivustoista. diff --git a/autofill/autofill-impl/src/main/res/values-fr/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-fr/strings-autofill-impl.xml index f1d00baaa39a..6e7552a2e5d5 100644 --- a/autofill/autofill-impl/src/main/res/values-fr/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-fr/strings-autofill-impl.xml @@ -139,8 +139,10 @@ Un schéma de verrouillage de l\'appareil, un code PIN ou des données biométriques sont nécessaires pour protéger vos mots de passe. Modifier le mot de passe - Synchronisation suspendue\nLimite d\'identifiants dépassée. Supprimez-en quelques-uns pour reprendre la synchronisation.\n\nGérer les identifiants - La synchronisation des mots de passe est suspendue\nVous avez dépassé la limite de synchronisation des mots de passe. Veuillez en supprimer quelques-uns pour pouvoir sauvegarder vos mots de passe. + Synchronisation suspendue\nVous avez atteint le nombre maximal de mots de passe. Veuillez en supprimer quelques-uns pour reprendre la synchronisation.\n\nGérer les mots de passe + La synchronisation des mots de passe est suspendue\nVous avez atteint le nombre maximal de mots de passe. Veuillez en supprimer quelques-uns pour reprendre la synchronisation. + Synchronisation suspendue\nCertains mots de passe sont mal formatés ou trop longs et n\'ont pas été synchronisés.\n\nGérer les mots de passe + La synchronisation des mots de passe est suspendue\nCertains mots de passe sont mal formatés ou trop longs et n\'ont pas été synchronisés. Ne jamais demander pour ce site Si vous réinitialisez les sites exclus, vous serez invité(e) à enregistrer votre mot de passe lors de votre prochaine connexion à l\'un de ces sites. diff --git a/autofill/autofill-impl/src/main/res/values-hr/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-hr/strings-autofill-impl.xml index 8934c8ff4525..c445d6ec5e89 100644 --- a/autofill/autofill-impl/src/main/res/values-hr/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-hr/strings-autofill-impl.xml @@ -139,8 +139,10 @@ Uzorak za zaključavanje uređaja, PIN ili biometrija potrebni su za zaštitu tvojih lozinki. Uredi lozinku - Sinkronizacija pauzirana\nOgraničenje prijava premašeno. Izbriši neke za nastavak sinkronizacije.\n\nupravljanje prijavama - Sinkronizacija lozinki je pauzirana\nPremašeno je ograničenje sinkronizacije lozinki. Pokušaj izbrisati neke lozinke. Dok se to ne riješi, tvoje se lozinke neće sigurnosno kopirati. + Sinkronizacija je pauzirana\nDosegnut je maksimalni broj lozinki. Izbriši neke za nastavak sinkronizacije.\n\nUpravljanje lozinkama + Sinkronizacija lozinki je pauzirana\nDosegnut je maksimalni broj lozinki. Izbriši neke lozinke za nastavak sinkronizacije. + Sinkronizacija je pauzirana\nNeke su lozinke formatirane pogrešno, ili su preduge i nisu sinkronizirane.\n\nUpravljanje lozinkama + Sinkronizacija lozinki je pauzirana\nNeke su lozinke formatirane pogrešno, ili su preduge i nisu sinkronizirane.\n\n Nikada ne traži za ovu web lokaciju Ako resetiraš isključene web lokacije, od tebe će se zatražiti da spremiš svoju lozinku sljedeći put kad se prijaviš na bilo koju od ovih lokacija. diff --git a/autofill/autofill-impl/src/main/res/values-hu/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-hu/strings-autofill-impl.xml index 5a6856792633..7252cc0cc410 100644 --- a/autofill/autofill-impl/src/main/res/values-hu/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-hu/strings-autofill-impl.xml @@ -139,8 +139,10 @@ A jelszavak védelméhez az eszköz zárolási mintája, PIN-kód vagy biometrikus adatok szükségesek. Jelszó szerkesztése - A szinkronizálás szünetel\nTúllépted a bejelentkezések maximális számát. A szinkronizálás folytatásához törölj néhányat.\n\nBejelentkezések kezelése - A jelszavak szinkronizálása szünetel\nTúllépted a szinkronizálandó jelszavak maximális számát. Próbálj törölni néhány jelszót. Amíg ezt nem sikerül megoldani, a jelszavakról nem készül biztonsági másolat. + A szinkronizálás szünetel\nElérted a jelszavak maximális számát. A szinkronizálás folytatásához törölj néhányat.\n\nJelszavak kezelése + Jelszó-szinkronizálás szüneteltetve\nElérted a jelszavak maximális számát. A szinkronizálás folytatásához törölj néhány jelszót. + Szinkronizálás szüneteltetve\nEgyes jelszavak helytelen formátumúak vagy túl hosszúak, és nem lettek szinkronizálva.Jelszavak kezelése + Jelszó-szinkronizálás szüneteltetve\nEgyes jelszavak helytelen formátumúak vagy túl hosszúak, és nem lettek szinkronizálva. Soha ne kérdezzen rá ennél a webhelynél A kizárt webhelyek visszaállítása esetén a rendszer a jelszavad mentését kéri, amikor legközelebb bejelentkezel ezen webhelyek bármelyikére. diff --git a/autofill/autofill-impl/src/main/res/values-it/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-it/strings-autofill-impl.xml index 2bbf09d4439b..1e0968dd9c58 100644 --- a/autofill/autofill-impl/src/main/res/values-it/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-it/strings-autofill-impl.xml @@ -139,8 +139,10 @@ Per proteggere le password sono necessari una sequenza di blocco del dispositivo, un PIN o dati biometrici. Modifica password - Sincronizzazione interrotta\nLimite di accessi superato. Eliminane alcuni per riprendere la sincronizzazione.\n\nGestisci gli accessi - La sincronizzazione con password è sospesa\nHai superato il limite di sincronizzazione con password. Prova a eliminare qualche password. Fino a quando il problema non verrà risolto, non sarà eseguito il backup delle password. + Sincronizzazione in pausa\nHai raggiunto il numero massimo di password. Eliminane alcune per riprendere la sincronizzazione.Gestisci password + Sincronizzazione password in pausa\nHai raggiunto il numero massimo di password. Eliminane alcune per riprendere la sincronizzazione. + Sincronizzazione in pausa\nAlcune password sono formattate in modo errato o sono troppo lunghe e non sono state sincronizzate.\n\nGestisci password + Sincronizzazione password in pausa\nAlcune password sono formattate in modo errato o sono troppo lunghe e non sono state sincronizzate. Non chiedere mai per questo sito Se ripristini i siti esclusi, ti verrà richiesto di salvare la password la prossima volta che accederai a uno di questi siti. diff --git a/autofill/autofill-impl/src/main/res/values-lt/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-lt/strings-autofill-impl.xml index 9f1bfbddce29..948544841c34 100644 --- a/autofill/autofill-impl/src/main/res/values-lt/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-lt/strings-autofill-impl.xml @@ -139,8 +139,10 @@ Slaptažodžiams apsaugoti reikia naudoti įrenginio užrakinimo šabloną, PIN kodą arba biometrinius duomenis. Redaguoti slaptažodį - Sinchronizavimas pristabdytas\nViršyta prisijungimų riba. Ištrinkite kai kuriuos, kad tęstumėte sinchronizavimą.\n\nValdyti prisijungimus - Slaptažodžių sinchronizavimas pristabdytas\nJūs viršijote slaptažodžių sinchronizavimo ribą. Pabandykite ištrinti kai kuriuos slaptažodžius. Kol ši problema nebus išspręsta, nebus kuriamos atsarginės jūsų slaptažodžių kopijos. + Sinchronizavimas pristabdytas\nPasiekėte maksimalų slaptažodžių skaičių. Ištrinkite kai kuriuos slaptažodžius, kad tęstumėte sinchronizavimą.\n\nTvarkyti slaptažodžius + Slaptažodžių sinchronizavimas pristabdytas\nPasiekėte maksimalų slaptažodžių skaičių. Ištrinkite kai kuriuos slaptažodžius, kad tęstumėte sinchronizavimą. + Sinchronizavimas pristabdytas\nKai kurie slaptažodžiai suformatuoti neteisingai arba yra per ilgi ir nebuvo sinchronizuoti.\n\nTvarkyti slaptažodžius + Slaptažodžių sinchronizavimas pristabdytas\nKai kurie slaptažodžiai suformatuoti neteisingai arba yra per ilgi ir nebuvo sinchronizuoti. Niekada neklauskite šioje svetainėje Jei iš naujo nustatysite neįtrauktas svetaines, kitą kartą, kai prisijungsite prie bet kurios iš šių svetainių, būsite paraginti išsaugoti savo slaptažodį. diff --git a/autofill/autofill-impl/src/main/res/values-lv/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-lv/strings-autofill-impl.xml index 084e091065cf..9e2f198ef53b 100644 --- a/autofill/autofill-impl/src/main/res/values-lv/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-lv/strings-autofill-impl.xml @@ -139,8 +139,10 @@ Lai aizsargātu tavus pieteikšanās datus, ir nepieciešama ierīces bloķēšanas figūra, PIN kods vai biometriskā aizsardzība. Paroles rediģēšana - Sinhronizācija pārtraukta\nPārsniegts pieteikšanās datu ierobežojums. Izdzēsiet dažus, lai atsāktu sinhronizāciju.\n\nPārvaldīt pieteikšanās datus - Paroļu sinhronizācija ir pārtraukta\nIr pārsniegts paroļu sinhronizācijas ierobežojums. Mēģini izdzēst dažas paroles. Kamēr šī problēma nebūs atrisināta, paroles netiks dublētas. + Sinhronizācija pārtraukta\nTu esi sasniedzis maksimālo paroļu skaitu. Lūdzu, izdzēs dažas, lai atsāktu sinhronizāciju.\n\nPārvaldīt paroles + Paroļu sinhronizācija ir pārtraukta\nTu esi sasniedzis maksimālo paroļu skaitu. Lūdzu, izdzēs dažas paroles, lai atsāktu sinhronizāciju. + Sinhronizācija pārtraukta\nDažas paroles ir formatētas nepareizi vai ir pārāk garas un netika sinhronizētas.\n\nPārvaldīt paroles + Paroļu sinhronizācija ir pārtraukta\nDažas paroles ir formatētas nepareizi vai ir pārāk garas un netika sinhronizētas. Nekad nejautāt par šo vietni Ja atiestatīsi izslēgtās vietnes, tev tiks piedāvāts saglabāt savus pieteikšanās datus, kad nākamreiz tajās pierakstīsies. diff --git a/autofill/autofill-impl/src/main/res/values-nb/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-nb/strings-autofill-impl.xml index cadfa61ff2f0..6ca5722c152d 100644 --- a/autofill/autofill-impl/src/main/res/values-nb/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-nb/strings-autofill-impl.xml @@ -139,8 +139,10 @@ Det kreves et låsemønster, en PIN-kode eller biometri for å beskytte passordene dine. Rediger passordet - Synkronisering er satt på pause\nGrensen for pålogginger er overskredet. Slett noen for å gjenoppta synkroniseringen.\n\nAdministrer pålogginger - Synkronisering av passord er satt på pause\nDu har overskredet grensen for passordsynkronisering. Prøv å slette noen passord. Inntil dette er løst, blir ikke passordene sikkerhetskopiert. + Synkronisering satt på pause\nDu har nådd maksimalt antall passord. Slett noen for å gjenoppta synkronisering.\n\nAdministrer passord + Synkronisering av passord er satt på pause\nDu har nådd maksimalt antall passord. Slett noen passord for å gjenoppta synkronisering. + Synkronisering satt på pause\nNoen passord er formatert feil eller for lange og ble ikke synkronisert.\n\nAdministrer passord + Synkronisering av passord er satt på pause\nNoen passord er formatert feil eller for lange og ble ikke synkronisert. Aldri spør for dette nettstedet Hvis du tilbakestiller ekskluderte nettsteder, blir du bedt om å lagre passordet ditt neste gang du logger på disse nettstedene. diff --git a/autofill/autofill-impl/src/main/res/values-nl/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-nl/strings-autofill-impl.xml index 5f8c1b32424c..8f3c954de6a0 100644 --- a/autofill/autofill-impl/src/main/res/values-nl/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-nl/strings-autofill-impl.xml @@ -139,8 +139,10 @@ Je hebt een vergrendelingspatroon, pincode of biometrische gegevens nodig om je aanmeldgegevens te beschermen. Wachtwoord bewerken - Synchronisatie onderbroken\nDe limiet voor het aantal aanmeldingsgegevens is overschreden. Verwijder er een paar om de synchronisatie te hervatten.\n\nAanmeldingsgegevens beheren - De synchronisatie van wachtwoorden is onderbroken\nJe hebt de synchronisatielimiet voor wachtwoorden overschreden. Probeer enkele wachtwoorden te verwijderen. Er wordt geen back-up van je wachtwoorden gemaakt totdat dit probleem is opgelost. + Synchronisatie onderbroken\nJe hebt het maximale aantal wachtwoorden bereikt. Verwijder er een paar om de synchronisatie te hervatten.\n\nWachtwoorden beheren + \nJe hebt het maximale aantal wachtwoorden bereikt. Verwijder enkele wachtwoorden om de synchronisatie te hervatten. + Synchronisatie onderbroken\nSommige wachtwoorden hebben de verkeerde indeling of zijn te lang en werden niet gesynchroniseerd.\n\nWachtwoorden beheren + Synchronisatie onderbroken\nSommige wachtwoorden hebben de verkeerde indeling of zijn te lang en werden niet gesynchroniseerd. Nooit vragen voor deze site Als je uitgesloten sites opnieuw instelt, zie je de volgende keer dat je bij een van deze sites inlogt een melding om je wachtwoord op te slaan. diff --git a/autofill/autofill-impl/src/main/res/values-pl/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-pl/strings-autofill-impl.xml index 5ec32fb8a91b..f08ebfb86ef2 100644 --- a/autofill/autofill-impl/src/main/res/values-pl/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-pl/strings-autofill-impl.xml @@ -139,8 +139,10 @@ Aby chronić hasła, korzystaj z blokady urządzenia za pomocą wzoru, kodu PIN lub danych biometrycznych. Edytuj hasło - Synchronizacja wstrzymana\nPrzekroczono limit logowań. Usuń niektóre, aby wznowić synchronizację.\n\nZarządzaj loginami - Synchronizacja haseł wstrzymana\nPrzekroczono limit synchronizacji haseł. Spróbuj usunąć niektóre hasła. Dopóki ten problem nie zostanie rozwiązany, nie będzie tworzona kopia zapasowa haseł. + Synchronizacja wstrzymana\nOsiągnięto maksymalną liczbę haseł. Usuń część z nich, aby wznowić synchronizację.\n\nZarządzaj hasłami + Synchronizacja haseł wstrzymana\nOsiągnięto maksymalną liczbę haseł. Usuń część haseł, aby wznowić synchronizację. + Synchronizacja wstrzymana\nNiektóre hasła mają niepoprawny format lub są za długie i nie zostały zsynchronizowane.\n\nZarządzaj hasłami + Synchronizacja haseł wstrzymana\nNiektóre hasła mają niepoprawny format lub są za długie i nie zostały zsynchronizowane. Nigdy nie pytaj o tę witrynę Jeśli zresetujesz wykluczone witryny, przy następnym logowaniu do dowolnej z nich pojawi się monit o zapisanie hasła. diff --git a/autofill/autofill-impl/src/main/res/values-pt/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-pt/strings-autofill-impl.xml index ec415aeda579..4ce154035d7a 100644 --- a/autofill/autofill-impl/src/main/res/values-pt/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-pt/strings-autofill-impl.xml @@ -139,8 +139,10 @@ É necessário um padrão de bloqueio de dispositivo, PIN ou biometria para proteger as tuas palavras-passe. Editar palavra-passe - Sincronização em pausa\nLimite de inícios de sessão excedido. Elimina alguns para retomar a sincronização.\n\nGerir inícios de sessão - A sincronização de palavras-passe está em pausa\nExcedeste o limite de sincronização de palavras-passe. Experimenta eliminar algumas palavras-passe. Não é possível fazer uma cópia de segurança das palavras-passe até este problema estar resolvido. + Sincronização em pausa\nAtingiste o número máximo de palavras-passe. Elimina algumas para retomar a sincronização.\n\nGerir palavra-passes + A sincronização de palavras-passe está em pausa\nAtingiste o número máximo de palavras-passe. Elimina algumas palavras-passe para retomar a sincronização. + Sincronização em pausa\nAlgumas palavras-passe estão formatadas incorretamente ou são demasiado longas e não foram sincronizadas.\n\nGerir palavra-passes + A sincronização de palavras-passe está em pausa\nAlgumas palavras-passe estão formatadas incorretamente ou são demasiado longas e não foram sincronizadas. Nunca pedir para este site Se repuseres os sites excluídos, ser-te-á pedido que guardes a tua palavra-passe da próxima vez que iniciares sessão num destes sites. diff --git a/autofill/autofill-impl/src/main/res/values-ro/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-ro/strings-autofill-impl.xml index 0f81744b866c..dc635070746f 100644 --- a/autofill/autofill-impl/src/main/res/values-ro/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-ro/strings-autofill-impl.xml @@ -139,8 +139,10 @@ Este necesar un model de blocare a dispozitivului, un cod PIN sau date biometrice pentru a-ți proteja parolele. Editează parola - Sincronizare întreruptă\nLimita de conectări a fost depășită. Șterge câteva pentru a relua sincronizarea.\n\nGestionează conectările - Sincronizarea parolelor este întreruptă\nAi depășit limita de sincronizare a parolelor. Încearcă să ștergi câteva parole. Până la rezolvarea acestei probleme, nu se va efectua backupul pentru parolele tale. + Sincronizare întreruptă\nAi atins numărul maxim de parole. Șterge câteva pentru a relua sincronizarea.\n\nGestionează parolele + Sincronizarea parolelor este întreruptă\nAi atins numărul maxim de parole. Șterge câteva parole pentru a relua sincronizarea. + \nUnele parole sunt formatate incorect sau sunt prea lungi și nu au fost sincronizate.\n\nGestionează parolele + Sincronizarea parolelor este întreruptă\nUnele parole sunt formatate incorect sau sunt prea lungi și nu au fost sincronizate. Nu solicita niciodată pentru acest site Dacă resetezi site-urile excluse, ți se va solicita să îți salvezi parola data viitoare când te conectezi la oricare dintre aceste site-uri. diff --git a/autofill/autofill-impl/src/main/res/values-ru/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-ru/strings-autofill-impl.xml index b05c8b9e7b15..6ac5672c8147 100644 --- a/autofill/autofill-impl/src/main/res/values-ru/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-ru/strings-autofill-impl.xml @@ -139,8 +139,10 @@ Для защиты паролей требуются биометрические данные, графический ключ или ПИН-код. Редактирование пароля - Синхронизация приостановлена\nПревышен лимит по количеству логинов. Удалите некоторые из них, чтобы возобновить синхронизацию.\n\nУправление логинами - Синхронизация паролей приостановлена\nВы превысили лимит синхронизации паролей. Удалите несколько из них. Пока вы не устраните эту проблему, резервная копия закладок создаваться не будет. + Синхронизация приостановлена\nДостигнуто максимальное количество паролей. Чтобы возобновить синхронизацию, удалите некоторые из них.\n\nУправление паролями + Синхронизация паролей на паузе\nДостигнуто максимальное число паролей. Чтобы возобновить синхронизацию, удалите некоторые из них. + Синхронизация на паузе\nНекоторые пароли не синхронизируются из-за неверного формата или превышения ограничений по длине.\n\nУправление паролями + Синхронизация паролей на паузе\nНекоторые пароли не синхронизируются из-за неверного формата или превышения ограничений по длине. Больше не спрашивать на этом сайте Если вы очистите список исключений, при следующем входе на любой из этих сайтов система предложит вам сохранить пароль. diff --git a/autofill/autofill-impl/src/main/res/values-sk/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-sk/strings-autofill-impl.xml index 6330ce8321e3..0ffbe0cb2600 100644 --- a/autofill/autofill-impl/src/main/res/values-sk/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-sk/strings-autofill-impl.xml @@ -139,8 +139,10 @@ Na ochranu vašich prihlasovacích údajov sa vyžaduje vzor uzamknutia zariadenia, kód PIN alebo biometrické údaje. Upraviť heslo - Synchronizácia bola pozastavená\nBol dosiahnutý limit pre počet prihlásení. Ak chcete obnoviť synchronizáciu, odstráňte niektoré záložky.\n\nSpráva prihlásení - Synchronizácia hesiel bola pozastavená\nPrekročili ste limit synchronizácie hesiel. Skúste niektoré heslá odstrániť. Kým to nevyriešite, vaše heslá sa nebudú zálohovať. + Synchronizácia bola pozastavená\nDosiahli ste maximálny počet hesiel. Ak chcete obnoviť synchronizáciu, odstráňte niektoré.\n\nSpráva hesiel + Synchronizácia hesiel bola pozastavená\nDosiahli ste maximálny počet hesiel. Ak chcete obnoviť synchronizáciu, odstráňte niektoré heslá. + \nNiektoré heslá sú nesprávne naformátované alebo sú príliš dlhé, a neboli synchronizované.\n\nSpravovať heslá + Heslása nesynchronizujú\nNiektoré heslá sú nesprávne naformátované alebo sú príliš dlhé, a neboli synchronizované. Nikdy sa nepýtať na túto stránku Ak obnovíte vylúčené lokality, pri ďalšom prihlásení na niektorú z týchto lokalít dostanete výzvu na uloženie svojho hesla. diff --git a/autofill/autofill-impl/src/main/res/values-sl/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-sl/strings-autofill-impl.xml index e4cb1c4a8540..5d17b40a3f14 100644 --- a/autofill/autofill-impl/src/main/res/values-sl/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-sl/strings-autofill-impl.xml @@ -139,8 +139,10 @@ Za zaščito gesel je potreben vzorec za zaklepanje naprave, koda PIN ali biometrični podatki. Uredite geslo - Sinhronizacija je začasno zaustavljena\nOmejitev prijav je presežena. Izbrišite jih nekaj, če želite nadaljevati sinhronizacijo.\n\nUpravljanje prijav - Sinhronizacija gesel je začasno zaustavljena\nPresegli ste omejitev za sinhronizacijo gesel. Poskusite izbrisati nekaj gesel. Dokler ta težava ne bo odpravljena, vaša gesla ne bodo varnostno kopirana. + Sinhronizacija je začasno zaustavljena\nDosegli ste največje število gesel. Izbrišite jih nekaj, če želite nadaljevati sinhronizacijo.\n\nUpravljanje gesel + Sinhronizacija gesel je začasno zaustavljena\nDosegli ste največje število gesel. Izbrišite nekaj gesel, če želite nadaljevati sinhronizacijo. + Sinhronizacija je začasno zaustavljena\nNekatera gesla so napačno oblikovana ali predolga in niso bila sinhronizirana.\n\nUpravljanje gesel + Sinhronizacija gesel je začasno zaustavljena\nNekatera gesla so napačno oblikovana ali predolga in niso bila sinhronizirana. Nikoli ne vprašaj za to spletno mesto Če ponastavite izključena spletna mesta, boste ob naslednji prijavi na katero koli od teh spletnih mest pozvani, da shranite svojo prijavo. diff --git a/autofill/autofill-impl/src/main/res/values-sv/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-sv/strings-autofill-impl.xml index 7ff0320fc3d5..ce3fe1e1d21b 100644 --- a/autofill/autofill-impl/src/main/res/values-sv/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-sv/strings-autofill-impl.xml @@ -139,8 +139,10 @@ För att skydda dina lösenord krävs ett enhetslåsmönster, en PIN-kod eller biometri. Redigera lösenord - Synkronisering pausad\nGränsen för antalet inloggningar har överskridits. Ta bort några för att återuppta synkronisering.\n\nHantera inloggningar - Synkronisering av lösenord pausad\nDu har överskridit gränsen för antalet lösenords som kan synkroniseras. Ta bort några lösenord. Dina lösenord kommer inte att säkerhetskopieras förrän detta har åtgärdats. + Synkroniseringen har pausats\nDu har nått det maximala antalet lösenord. Ta bort några för att återuppta synkroniseringen.\n\nHantera lösenord + Lösenordssynkroniseringen har pausats\nDu har nått det maximala antalet lösenord. Ta bort några lösenord för att återuppta synkroniseringen. + Synkroniseringen har pausats\nVissa lösenord är felaktigt formaterade eller för långa och har inte synkroniserats.\n\nHantera lösenord + Lösenordssynkroniseringen har pausats\nVissa lösenord är felaktigt formaterade eller för långa och har inte synkroniserats. Fråga aldrig för den här webbplatsen Om du återställer exkluderade webbplatser kommer du att uppmanas att spara ditt lösenord nästa gång du loggar in på någon av dessa webbplatser. diff --git a/autofill/autofill-impl/src/main/res/values-tr/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values-tr/strings-autofill-impl.xml index 847d708be282..050e8708f509 100644 --- a/autofill/autofill-impl/src/main/res/values-tr/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values-tr/strings-autofill-impl.xml @@ -139,8 +139,10 @@ Şifrelerinizin güvenliğini sağlamak için bir cihaz kilidi deseni, PIN veya biyometrik veri gereklidir. Şifreyi Düzenle - Senkronizasyon Duraklatıldı\nGiriş sınırı aşıldı. Senkronizasyona devam etmek için bazılarını silin.\n\nGirişleri Yönet - Şifre Senkronizasyonu Duraklatıldı\nŞifre senkronizasyon sınırını aştınız. Bazı şifreleri silmeyi deneyin. Bu sorun çözülene kadar şifreleriniz yedeklenmeyecektir. + Senkronizasyon Duraklatıldı\nMaksimum şifre sayısına ulaştınız. Senkronizasyona devam etmek için bazılarını silin.\n\nŞifreleri Yönet + Şifre Senkronizasyonu Duraklatıldı\nMaksimum şifre sayısına ulaştınız. Senkronizasyona devam etmek için lütfen bazı şifreleri silin. + \nBazı şifreler yanlış biçimlendirilmiş veya çok uzun olduğu için senkronize edilmedi.\n\nŞifreleri Yönet + Şifre Senkronizasyonu Durduruldu\nBazı şifreler yanlış biçimlendirilmiş veya çok uzun olduğu için senkronize edilmedi. Bu Site için Hiçbir Zaman Sorma Hariç tutulan siteleri sıfırlarsanız, bu sitelerden herhangi birinde bir sonraki oturum açtığınızda şifrenizi kaydetmeniz istenecektir. diff --git a/autofill/autofill-impl/src/main/res/values/strings-autofill-impl.xml b/autofill/autofill-impl/src/main/res/values/strings-autofill-impl.xml index a73324261046..8d3bfcd22371 100644 --- a/autofill/autofill-impl/src/main/res/values/strings-autofill-impl.xml +++ b/autofill/autofill-impl/src/main/res/values/strings-autofill-impl.xml @@ -139,8 +139,10 @@ A device lock pattern, PIN, or biometrics is required to protect your passwords. Edit Password - Sync Paused\nLogins limit exceeded. Delete some to resume syncing.\n\nManage Logins - Passwords Sync is Paused\nYou have exceeded the passwords sync limit. Try deleting some passwords. Until this is resolved your passwords will not be backed up. + Sync Paused\nYou\'ve reached the maximum number of passwords. Please delete some to resume sync.\n\nManage Passwords + Passwords Sync is Paused\nYou\'ve reached the maximum number of passwords. Please delete some passwords to resume sync. + Sync Paused\nSome passwords are formatted incorrectly or too long and were not synced.\n\nManage Passwords + Password Sync is Paused\nSome passwords are formatted incorrectly or too long and were not synced. Never Ask for This Site If you reset excluded sites, you will be prompted to save your password next time you sign in to any of these sites. diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/sync/AppCredentialsSyncFeatureListenerTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/sync/AppCredentialsSyncFeatureListenerTest.kt index 725de4424cf9..5d87786bdda5 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/sync/AppCredentialsSyncFeatureListenerTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/sync/AppCredentialsSyncFeatureListenerTest.kt @@ -24,7 +24,7 @@ import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.FileUtilities import com.duckduckgo.navigation.api.GlobalActivityStarter import com.duckduckgo.sync.api.SyncActivityWithEmptyParams -import com.duckduckgo.sync.api.engine.FeatureSyncError +import com.duckduckgo.sync.api.engine.FeatureSyncError.COLLECTION_LIMIT_REACHED import com.duckduckgo.sync.api.engine.SyncChangesResponse import com.duckduckgo.sync.api.engine.SyncableType.BOOKMARKS import com.duckduckgo.sync.api.engine.SyncableType.CREDENTIALS @@ -68,6 +68,7 @@ class AppCredentialsSyncFeatureListenerTest { testee.onSuccess(validChanges) assertFalse(credentialsSyncStore.isSyncPaused) + assertTrue(credentialsSyncStore.syncPausedReason.isEmpty()) } @Test @@ -84,18 +85,20 @@ class AppCredentialsSyncFeatureListenerTest { fun whenSyncPausedAndOnErrorThenSyncPaused() { credentialsSyncStore.isSyncPaused = true - testee.onError(FeatureSyncError.COLLECTION_LIMIT_REACHED) + testee.onError(COLLECTION_LIMIT_REACHED) assertTrue(credentialsSyncStore.isSyncPaused) + assertEquals(COLLECTION_LIMIT_REACHED.name, credentialsSyncStore.syncPausedReason) } @Test fun whenSyncActiveAndOnErrorThenSyncPaused() { credentialsSyncStore.isSyncPaused = false - testee.onError(FeatureSyncError.COLLECTION_LIMIT_REACHED) + testee.onError(COLLECTION_LIMIT_REACHED) assertTrue(credentialsSyncStore.isSyncPaused) + assertEquals(COLLECTION_LIMIT_REACHED.name, credentialsSyncStore.syncPausedReason) } @Test @@ -105,5 +108,6 @@ class AppCredentialsSyncFeatureListenerTest { testee.onSyncDisabled() assertFalse(credentialsSyncStore.isSyncPaused) + assertTrue(credentialsSyncStore.syncPausedReason.isEmpty()) } } diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/sync/CredentialsRateLimitViewModelTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/sync/CredentialsSyncPausedViewModelTest.kt similarity index 64% rename from autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/sync/CredentialsRateLimitViewModelTest.kt rename to autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/sync/CredentialsSyncPausedViewModelTest.kt index 306a2f76b64c..d13c37436117 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/sync/CredentialsRateLimitViewModelTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/sync/CredentialsSyncPausedViewModelTest.kt @@ -19,8 +19,10 @@ package com.duckduckgo.autofill.sync import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import app.cash.turbine.test -import com.duckduckgo.autofill.sync.CredentialsRateLimitViewModel.Command.NavigateToCredentials +import com.duckduckgo.autofill.impl.R +import com.duckduckgo.autofill.sync.CredentialsSyncPausedViewModel.Command.NavigateToCredentials import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.sync.api.engine.FeatureSyncError import kotlinx.coroutines.test.runTest import org.junit.Assert.* import org.junit.Rule @@ -28,23 +30,32 @@ import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) -class CredentialsRateLimitViewModelTest { +class CredentialsSyncPausedViewModelTest { @get:Rule var coroutineRule = CoroutineTestRule() private val realContext = InstrumentationRegistry.getInstrumentation().targetContext val credentialsSyncStore = RealCredentialsSyncStore(realContext, coroutineRule.testScope, coroutineRule.testDispatcherProvider) - val testee = CredentialsRateLimitViewModel( + val testee = CredentialsSyncPausedViewModel( credentialsSyncStore, coroutineRule.testDispatcherProvider, ) @Test - fun whenSyncPausedThenWarningVisible() = runTest { - credentialsSyncStore.isSyncPaused = true + fun whenSyncPausedBecauseOfCollectionLimitReachedThenShowWarningMessage() = runTest { + givenError(FeatureSyncError.COLLECTION_LIMIT_REACHED) + testee.viewState().test { + assertEquals(R.string.credentials_limit_warning, awaitItem().message) + cancelAndConsumeRemainingEvents() + } + } + + @Test + fun whenSyncPausedBecauseOfInvalidRequestThenShowWarningMessage() = runTest { + givenError(FeatureSyncError.INVALID_REQUEST) testee.viewState().test { - assertTrue(awaitItem().warningVisible) + assertEquals(R.string.credentials_invalid_request_warning, awaitItem().message) cancelAndConsumeRemainingEvents() } } @@ -57,4 +68,9 @@ class CredentialsRateLimitViewModelTest { cancelAndConsumeRemainingEvents() } } + + private fun givenError(collectionLimitReached: FeatureSyncError) { + credentialsSyncStore.isSyncPaused = true + credentialsSyncStore.syncPausedReason = collectionLimitReached.name + } } diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/sync/FakeCredentialsSyncStore.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/sync/FakeCredentialsSyncStore.kt index d5b03d5df71e..3c6734451011 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/sync/FakeCredentialsSyncStore.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/sync/FakeCredentialsSyncStore.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow class FakeCredentialsSyncStore : CredentialsSyncStore { + override var syncPausedReason: String = "" override var serverModifiedSince: String = "0" override var startTimeStamp: String = "0" override var clientModifiedSince: String = "0" diff --git a/common/common-test/src/main/java/com/duckduckgo/common/test/api/FakeChain.kt b/common/common-test/src/main/java/com/duckduckgo/common/test/api/FakeChain.kt index 98c36efa960a..cf404c4b31c0 100644 --- a/common/common-test/src/main/java/com/duckduckgo/common/test/api/FakeChain.kt +++ b/common/common-test/src/main/java/com/duckduckgo/common/test/api/FakeChain.kt @@ -19,7 +19,7 @@ package com.duckduckgo.common.test.api import java.util.concurrent.TimeUnit import okhttp3.* -class FakeChain( +open class FakeChain( private val url: String, private val expectedResponseCode: Int? = null, ) : Interceptor.Chain { diff --git a/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/RealSavedSitesSyncStore.kt b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/RealSavedSitesSyncStore.kt index febd7a87387e..0c2acca94038 100644 --- a/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/RealSavedSitesSyncStore.kt +++ b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/RealSavedSitesSyncStore.kt @@ -36,6 +36,7 @@ interface SavedSitesSyncStore { var startTimeStamp: String var clientModifiedSince: String var isSyncPaused: Boolean + var syncPausedReason: String fun isSyncPausedFlow(): Flow } @@ -68,6 +69,12 @@ class RealSavedSitesSyncStore @Inject constructor( preferences.edit(true) { putBoolean(KEY_CLIENT_LIMIT_EXCEEDED, value) } emitNewValue() } + override var syncPausedReason: String + get() = preferences.getString(KEY_CLIENT_SYNC_PAUSED_REASON, "") ?: "" + set(value) { + preferences.edit(true) { putString(KEY_CLIENT_SYNC_PAUSED_REASON, value) } + emitNewValue() + } override fun isSyncPausedFlow(): Flow = syncPausedSharedFlow @@ -86,5 +93,6 @@ class RealSavedSitesSyncStore @Inject constructor( private const val KEY_START_TIMESTAMP = "KEY_START_TIMESTAMP" private const val KEY_CLIENT_MODIFIED_SINCE = "KEY_CLIENT_MODIFIED_SINCE" private const val KEY_CLIENT_LIMIT_EXCEEDED = "KEY_CLIENT_LIMIT_EXCEEDED" + private const val KEY_CLIENT_SYNC_PAUSED_REASON = "KEY_CLIENT_SYNC_PAUSED_REASON" } } diff --git a/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSiteInvalidItemsView.kt b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSiteInvalidItemsView.kt index 1188484e2168..5e643adca867 100644 --- a/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSiteInvalidItemsView.kt +++ b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSiteInvalidItemsView.kt @@ -33,7 +33,7 @@ import com.duckduckgo.common.utils.ViewViewModelFactory import com.duckduckgo.di.scopes.ViewScope import com.duckduckgo.navigation.api.GlobalActivityStarter import com.duckduckgo.saved.sites.impl.R -import com.duckduckgo.saved.sites.impl.databinding.ViewSaveSiteRateLimitWarningBinding +import com.duckduckgo.saved.sites.impl.databinding.ViewSaveSiteSyncInvalidItemsWarningBinding import com.duckduckgo.savedsites.impl.sync.SavedSiteInvalidItemsViewModel.Command import com.duckduckgo.savedsites.impl.sync.SavedSiteInvalidItemsViewModel.Command.NavigateToBookmarks import com.duckduckgo.savedsites.impl.sync.SavedSiteInvalidItemsViewModel.ViewState @@ -63,7 +63,7 @@ class SavedSiteInvalidItemsView @JvmOverloads constructor( private var job: ConflatedJob = ConflatedJob() - private val binding: ViewSaveSiteRateLimitWarningBinding by viewBinding() + private val binding: ViewSaveSiteSyncInvalidItemsWarningBinding by viewBinding() private val viewModel: SavedSiteInvalidItemsViewModel by lazy { ViewModelProvider(findViewTreeViewModelStoreOwner()!!, viewModelFactory)[SavedSiteInvalidItemsViewModel::class.java] @@ -113,7 +113,7 @@ class SavedSiteInvalidItemsView @JvmOverloads constructor( ), ).append(context.getText(R.string.saved_site_invalid_items_warning_link)) - binding.saveSiteRateLimitWarning.setClickableLink( + binding.savedSitesInvalidItemsWarning.setClickableLink( "manage_bookmarks", spannable, onClick = { diff --git a/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSiteRateLimitView.kt b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSiteSyncPausedView.kt similarity index 72% rename from saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSiteRateLimitView.kt rename to saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSiteSyncPausedView.kt index 1ece29aedad2..43032632829a 100644 --- a/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSiteRateLimitView.kt +++ b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSiteSyncPausedView.kt @@ -28,13 +28,13 @@ import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.browser.api.ui.BrowserScreens.BookmarksScreenNoParams import com.duckduckgo.common.ui.viewbinding.viewBinding import com.duckduckgo.common.utils.ConflatedJob +import com.duckduckgo.common.utils.ViewViewModelFactory import com.duckduckgo.di.scopes.ViewScope import com.duckduckgo.navigation.api.GlobalActivityStarter -import com.duckduckgo.saved.sites.impl.R -import com.duckduckgo.saved.sites.impl.databinding.ViewSaveSiteRateLimitWarningBinding -import com.duckduckgo.savedsites.impl.sync.SavedSiteRateLimitViewModel.Command -import com.duckduckgo.savedsites.impl.sync.SavedSiteRateLimitViewModel.Command.NavigateToBookmarks -import com.duckduckgo.savedsites.impl.sync.SavedSiteRateLimitViewModel.ViewState +import com.duckduckgo.saved.sites.impl.databinding.ViewSaveSiteSyncPausedWarningBinding +import com.duckduckgo.savedsites.impl.sync.SavedSiteSyncPausedViewModel.Command +import com.duckduckgo.savedsites.impl.sync.SavedSiteSyncPausedViewModel.Command.NavigateToBookmarks +import com.duckduckgo.savedsites.impl.sync.SavedSiteSyncPausedViewModel.ViewState import dagger.android.support.AndroidSupportInjection import javax.inject.Inject import kotlinx.coroutines.CoroutineScope @@ -45,7 +45,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @InjectWith(ViewScope::class) -class SavedSiteRateLimitView @JvmOverloads constructor( +class SavedSiteSyncPausedView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyle: Int = 0, @@ -55,16 +55,16 @@ class SavedSiteRateLimitView @JvmOverloads constructor( lateinit var globalActivityStarter: GlobalActivityStarter @Inject - lateinit var viewModelFactory: SavedSiteRateLimitViewModel.Factory + lateinit var viewModelFactory: ViewViewModelFactory private var coroutineScope: CoroutineScope? = null private var job: ConflatedJob = ConflatedJob() - private val binding: ViewSaveSiteRateLimitWarningBinding by viewBinding() + private val binding: ViewSaveSiteSyncPausedWarningBinding by viewBinding() - private val viewModel: SavedSiteRateLimitViewModel by lazy { - ViewModelProvider(findViewTreeViewModelStoreOwner()!!, viewModelFactory)[SavedSiteRateLimitViewModel::class.java] + private val viewModel: SavedSiteSyncPausedViewModel by lazy { + ViewModelProvider(findViewTreeViewModelStoreOwner()!!, viewModelFactory)[SavedSiteSyncPausedViewModel::class.java] } override fun onAttachedToWindow() { @@ -73,8 +73,6 @@ class SavedSiteRateLimitView @JvmOverloads constructor( ViewTreeLifecycleOwner.get(this)?.lifecycle?.addObserver(viewModel) - configureViewListeners() - @SuppressLint("NoHardcodedCoroutineDispatcher") coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) @@ -102,20 +100,25 @@ class SavedSiteRateLimitView @JvmOverloads constructor( } private fun render(viewState: ViewState) { - this.isVisible = viewState.warningVisible - } - - private fun configureViewListeners() { - binding.saveSiteRateLimitWarning.setClickableLink( - "manage_bookmarks", - context.getText(R.string.saved_site_limit_warning), - onClick = { - viewModel.onWarningActionClicked() - }, - ) + if (viewState.message != null) { + this.isVisible = true + binding.saveSiteRateLimitWarning.setClickableLink( + WARNING_ACTION_ANNOTATION, + context.getText(viewState.message), + onClick = { + viewModel.onWarningActionClicked() + }, + ) + } else { + this.isVisible = false + } } private fun navigateToBookmarks() { globalActivityStarter.start(this.context, BookmarksScreenNoParams) } + + companion object { + const val WARNING_ACTION_ANNOTATION = "manage_bookmarks" + } } diff --git a/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSiteRateLimitViewModel.kt b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSiteSyncPausedViewModel.kt similarity index 65% rename from saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSiteRateLimitViewModel.kt rename to saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSiteSyncPausedViewModel.kt index 700ee69afa3d..bc09cc3f9521 100644 --- a/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSiteRateLimitViewModel.kt +++ b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSiteSyncPausedViewModel.kt @@ -19,11 +19,12 @@ package com.duckduckgo.savedsites.impl.sync import android.annotation.SuppressLint import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import com.duckduckgo.common.ui.notifyme.NotifyMeViewModel.Command +import com.duckduckgo.anvil.annotations.ContributesViewModel import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.savedsites.impl.sync.DisplayModeViewModel.ViewState +import com.duckduckgo.di.scopes.ViewScope +import com.duckduckgo.saved.sites.impl.R +import com.duckduckgo.sync.api.engine.FeatureSyncError import javax.inject.Inject import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST import kotlinx.coroutines.channels.Channel @@ -35,13 +36,14 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @SuppressLint("NoLifecycleObserver") // we don't observe app lifecycle -class SavedSiteRateLimitViewModel( +@ContributesViewModel(ViewScope::class) +class SavedSiteSyncPausedViewModel @Inject constructor( private val savedSitesSyncStore: SavedSitesSyncStore, private val dispatcherProvider: DispatcherProvider, ) : ViewModel(), DefaultLifecycleObserver { data class ViewState( - val warningVisible: Boolean = false, + val message: Int? = null, ) sealed class Command { @@ -52,8 +54,13 @@ class SavedSiteRateLimitViewModel( fun viewState(): Flow = savedSitesSyncStore.isSyncPausedFlow() .map { syncPaused -> + val message = when (savedSitesSyncStore.syncPausedReason) { + FeatureSyncError.INVALID_REQUEST.name -> R.string.saved_site_invalid_warning + FeatureSyncError.COLLECTION_LIMIT_REACHED.name -> R.string.saved_site_limit_warning + else -> null + } ViewState( - warningVisible = syncPaused, + message = message, ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), ViewState()) @@ -64,22 +71,4 @@ class SavedSiteRateLimitViewModel( command.send(Command.NavigateToBookmarks) } } - - @Suppress("UNCHECKED_CAST") - class Factory @Inject constructor( - private val savedSitesSyncStore: SavedSitesSyncStore, - private val dispatcherProvider: DispatcherProvider, - ) : ViewModelProvider.NewInstanceFactory() { - override fun create(modelClass: Class): T { - return with(modelClass) { - when { - isAssignableFrom(SavedSiteRateLimitViewModel::class.java) -> SavedSiteRateLimitViewModel( - savedSitesSyncStore, - dispatcherProvider, - ) - else -> throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") - } - } as T - } - } } diff --git a/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSitesSyncFeatureListener.kt b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSitesSyncFeatureListener.kt index 5223d32b71e4..5df1232050bf 100644 --- a/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSitesSyncFeatureListener.kt +++ b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSitesSyncFeatureListener.kt @@ -22,9 +22,11 @@ import com.duckduckgo.common.utils.notification.checkPermissionAndNotify import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.sync.api.engine.FeatureSyncError import com.duckduckgo.sync.api.engine.FeatureSyncError.COLLECTION_LIMIT_REACHED +import com.duckduckgo.sync.api.engine.FeatureSyncError.INVALID_REQUEST import com.duckduckgo.sync.api.engine.SyncChangesResponse import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject +import timber.log.Timber interface SavedSitesSyncFeatureListener { fun onSuccess(changes: SyncChangesResponse) @@ -45,31 +47,42 @@ class AppSavedSitesSyncFeatureListener @Inject constructor( if (savedSitesSyncStore.isSyncPaused) { savedSitesSyncStore.isSyncPaused = false + savedSitesSyncStore.syncPausedReason = "" cancelNotification() } } override fun onError(syncError: FeatureSyncError) { + Timber.d("Sync-Bookmarks: $syncError received, current state isPaused:${savedSitesSyncStore.isSyncPaused}") when (syncError) { - COLLECTION_LIMIT_REACHED -> { - if (!savedSitesSyncStore.isSyncPaused) { - triggerNotification() + COLLECTION_LIMIT_REACHED, + INVALID_REQUEST, + -> { + if (!savedSitesSyncStore.isSyncPaused || savedSitesSyncStore.syncPausedReason != syncError.name) { + Timber.i("Sync-Bookmarks: should trigger notification for $syncError") + triggerNotification(syncError) } savedSitesSyncStore.isSyncPaused = true + savedSitesSyncStore.syncPausedReason = syncError.name } } } override fun onSyncDisabled() { savedSitesSyncStore.isSyncPaused = false + savedSitesSyncStore.syncPausedReason = "" cancelNotification() } - private fun triggerNotification() { + private fun triggerNotification(syncError: FeatureSyncError) { + val notification = when (syncError) { + COLLECTION_LIMIT_REACHED -> notificationBuilder.buildRateLimitNotification(context) + INVALID_REQUEST -> notificationBuilder.buildInvalidRequestNotification(context) + } notificationManager.checkPermissionAndNotify( context, SYNC_PAUSED_SAVED_SITES_NOTIFICATION_ID, - notificationBuilder.buildRateLimitNotification(context), + notification, ) } diff --git a/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSitesSyncNotificationBuilder.kt b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSitesSyncNotificationBuilder.kt index 4efee8f61d3b..5bfd90685a1b 100644 --- a/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSitesSyncNotificationBuilder.kt +++ b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSitesSyncNotificationBuilder.kt @@ -32,6 +32,7 @@ import javax.inject.Inject interface SavedSitesSyncNotificationBuilder { fun buildRateLimitNotification(context: Context): Notification + fun buildInvalidRequestNotification(context: Context): Notification } @ContributesBinding(AppScope::class) @@ -49,6 +50,17 @@ class AppSavedSitesSyncNotificationBuilder @Inject constructor( .build() } + override fun buildInvalidRequestNotification(context: Context): Notification { + return NotificationCompat.Builder(context, SYNC_NOTIFICATION_CHANNEL_ID) + .setSmallIcon(com.duckduckgo.mobile.android.R.drawable.notification_logo) + .setStyle(NotificationCompat.DecoratedCustomViewStyle()) + .setContentIntent(getPendingIntent(context)) + .setCustomContentView(RemoteViews(context.packageName, layout.notification_invalid_request)) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .build() + } + private fun getPendingIntent(context: Context): PendingIntent? = TaskStackBuilder.create(context).run { addNextIntentWithParentStack( globalGlobalActivityStarter.startIntent(context, SyncActivityWithEmptyParams)!!, diff --git a/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSitesRateLimitSyncMessagePlugin.kt b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSitesSyncPausedSyncMessagePlugin.kt similarity index 88% rename from saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSitesRateLimitSyncMessagePlugin.kt rename to saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSitesSyncPausedSyncMessagePlugin.kt index d05535b4dba5..09bbdd8aa315 100644 --- a/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSitesRateLimitSyncMessagePlugin.kt +++ b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSitesSyncPausedSyncMessagePlugin.kt @@ -24,8 +24,8 @@ import com.squareup.anvil.annotations.ContributesMultibinding import javax.inject.Inject @ContributesMultibinding(scope = ActivityScope::class) -class SavedSitesRateLimitSyncMessagePlugin @Inject constructor() : SyncMessagePlugin { +class SavedSitesSyncPausedSyncMessagePlugin @Inject constructor() : SyncMessagePlugin { override fun getView(context: Context): View { - return SavedSiteRateLimitView(context) + return SavedSiteSyncPausedView(context) } } diff --git a/saved-sites/saved-sites-impl/src/main/res/layout/notification_invalid_request.xml b/saved-sites/saved-sites-impl/src/main/res/layout/notification_invalid_request.xml new file mode 100644 index 000000000000..e945b2ccae05 --- /dev/null +++ b/saved-sites/saved-sites-impl/src/main/res/layout/notification_invalid_request.xml @@ -0,0 +1,34 @@ + + + + + + + \ No newline at end of file diff --git a/saved-sites/saved-sites-impl/src/main/res/layout/view_save_site_sync_invalid_items_warning.xml b/saved-sites/saved-sites-impl/src/main/res/layout/view_save_site_sync_invalid_items_warning.xml new file mode 100644 index 000000000000..ffad06376adc --- /dev/null +++ b/saved-sites/saved-sites-impl/src/main/res/layout/view_save_site_sync_invalid_items_warning.xml @@ -0,0 +1,24 @@ + + + + \ No newline at end of file diff --git a/saved-sites/saved-sites-impl/src/main/res/layout/view_save_site_rate_limit_warning.xml b/saved-sites/saved-sites-impl/src/main/res/layout/view_save_site_sync_paused_warning.xml similarity index 100% rename from saved-sites/saved-sites-impl/src/main/res/layout/view_save_site_rate_limit_warning.xml rename to saved-sites/saved-sites-impl/src/main/res/layout/view_save_site_sync_paused_warning.xml diff --git a/saved-sites/saved-sites-impl/src/main/res/values-bg/strings-saved-sites.xml b/saved-sites/saved-sites-impl/src/main/res/values-bg/strings-saved-sites.xml index 1e052654c225..4617dd6bc83d 100644 --- a/saved-sites/saved-sites-impl/src/main/res/values-bg/strings-saved-sites.xml +++ b/saved-sites/saved-sites-impl/src/main/res/values-bg/strings-saved-sites.xml @@ -20,8 +20,10 @@ Споделяне на любими Използвайте едни и същи любими елементи на всички устройства. Оставете изключено, ако имате различни предпочитания за мобилни устройства и настолни компютри. - Синхронизирането е спряно\nПревишихте лимита за отметки. Изтрийте някои от тях, за да възобновите синхронизирането.\n\nУправление на отметките - Синхронизирането на отметките е спряно\nПревишихте ограничението за синхронизиране на отметки. Опитайте да изтриете някои от отметките. Докато този проблем не бъде разрешен, отметките няма да бъдат архивирани. + Синхронизирането е на пауза\nДостигнахте максималния брой отметки. Изтрийте някои от тях, за да възобновите синхронизирането.\n\nУправление на отметките + Синхронизирането на отметки е на пауза\nДостигнахте максималния брой отметки. Изтрийте някои отметки, за да възобновите синхронизирането. + Синхронизирането е на пауза\nНякои отметки са форматирани неправилно или са твърде дълги и не бяха синхронизирани.\n\nУправление на отметките + Синхронизирането на отметки е на пауза\nНякои отметки са форматирани неправилно или са твърде дълги и не бяха синхронизирани.\n\n Вашата отметка за %1$s не може да се синхронизира, защото някои от нейните полета надвишават ограничението за знаци. Вашите отметки за %1$s и %2$d други сайта не могат да се синхронизират, защото някои от техните полета надвишават ограничението за знаци. diff --git a/saved-sites/saved-sites-impl/src/main/res/values-cs/strings-saved-sites.xml b/saved-sites/saved-sites-impl/src/main/res/values-cs/strings-saved-sites.xml index 2bee36f6dbd5..3df89f5e99c4 100644 --- a/saved-sites/saved-sites-impl/src/main/res/values-cs/strings-saved-sites.xml +++ b/saved-sites/saved-sites-impl/src/main/res/values-cs/strings-saved-sites.xml @@ -20,8 +20,10 @@ Sdílet oblíbené Používej na všech zařízeních stejné oblíbené položky. Nech volbu vypnutou, pokud chceš mít záložky na mobilu a na počítači oddělené. - Synchronizace je pozastavená\nJe překročený limit počtu záložek. Jestli chceš synchronizaci obnovit, některé odstraň.Spravovat záložky - Synchronizace záložek je pozastavená\nMáš překročený limit pro synchronizaci záložek. Zkus pár záložek smazat. Dokud problém nevyřešíš, záložky se nebudou zálohovat. + Synchronizace je pozastavená\nUž máš maximální počet záložek. Jestli chceš synchronizaci obnovit, některé smaž.\n\nSpravovat záložky + Synchronizace záložek je pozastavená\nUž máš maximální počet záložek. Jestli chceš synchronizaci obnovit, některé záložky smaž. + Synchronizace je pozastavená\nNěkteré záložky jsou nesprávně naformátované nebo moc dlouhé, takže jsme je nemohli synchronizovat.\n\nSpravovat záložky + Synchronizace záložek je pozastavená\nNěkteré záložky jsou nesprávně naformátované nebo moc dlouhé, takže jsme je nemohli synchronizovat. Tvoje záložka pro %1$s se nedá synchronizovat, protože jedno z jejich polí překračuje maximální počet znaků. Tvoje záložky pro %1$s a %2$d další weby se nedají synchronizovat, protože některá z jejich polí překračují maximální počet znaků. diff --git a/saved-sites/saved-sites-impl/src/main/res/values-da/strings-saved-sites.xml b/saved-sites/saved-sites-impl/src/main/res/values-da/strings-saved-sites.xml index d73564e83053..2c115df2eb9a 100644 --- a/saved-sites/saved-sites-impl/src/main/res/values-da/strings-saved-sites.xml +++ b/saved-sites/saved-sites-impl/src/main/res/values-da/strings-saved-sites.xml @@ -20,8 +20,10 @@ Del favoritter Brug de samme favoritter på alle enheder. Slå fra for at holde mobil- og computerfavoritter adskilt. - Synkronisering sat på pause\nGrænse for bogmærker overskredet. Slet nogle for at genoptage synkroniseringen.\n\nAdministrer bogmærker - Synkronisering af bogmærker sat på pause\nDu har overskredet grænsen for bogmærkesynkronisering. Prøv at slette nogle bogmærker. Dine bogmærker vil ikke blive sikkerhedskopieret, før dette er løst. + Synkronisering sat på pause\nDu har nået det maksimale antal adgangskoder. Slet nogle af dem for at genoptage synkroniseringen.\n\nAdministrer bogmærker + Bogmærkesynkronisering er sat på pause\nDu har nået det maksimale antal adgangskoder. Slet nogle bogmærker for at genoptage synkroniseringen. + Synkronisering sat på pause\nNogle bogmærker er formateret forkert eller er for lange og blev ikke synkroniseret.\n\nAdministrer bogmærker + Bogmærkesynkronisering sat på pause\nNogle bogmærker er formateret forkert eller er for lange og blev ikke synkroniseret. Dit bogmærke for %1$s kan ikke synkroniseres, fordi et af dets felter overskrider tegngrænsen. Dine bogmærker for %1$s og %2$d andre sider kan ikke synkroniseres, fordi nogle af deres felter overskrider tegngrænsen. diff --git a/saved-sites/saved-sites-impl/src/main/res/values-de/strings-saved-sites.xml b/saved-sites/saved-sites-impl/src/main/res/values-de/strings-saved-sites.xml index ae5b602f3aa5..c9afa7b4bef4 100644 --- a/saved-sites/saved-sites-impl/src/main/res/values-de/strings-saved-sites.xml +++ b/saved-sites/saved-sites-impl/src/main/res/values-de/strings-saved-sites.xml @@ -20,8 +20,10 @@ Favoriten teilen Verwende auf allen Geräten dieselben Favoriten. Lass diese Option deaktiviert, um deine Favoriten auf deinem Handy und deinem Desktop getrennt zu halten. - Synchronisierung angehalten\nLesezeichenlimit überschritten. Lösche Anmeldedaten, um die Synchronisierung fortzusetzen.\n\nLesezeichen verwalten - Die Lesezeichen-Synchronisierung wurde pausiert\nDu hast das Limit für die Synchronisierung von Lesezeichen überschritten. Versuche, einige Lesezeichen zu löschen. Bis dieses Problem behoben ist, werden deine Lesezeichen nicht gesichert. + Synchronisierung angehalten\nDu hast die maximale Anzahl von Lesezeichen erreicht. Lösche einige, um die Synchronisierung fortzusetzen.\n\nLesezeichen verwalten + Die Lesezeichen-Synchronisierung wurde angehalten\nDu hast die maximale Anzahl von Lesezeichen erreicht. Bitte lösche einige Lesezeichen, um die Synchronisierung fortzusetzen. + Synchronisierung angehalten\nEinige Lesezeichen sind falsch formatiert oder zu lang und wurden nicht synchronisiert.\n\nLesezeichen verwalten + Die Lesezeichen-Synchronisierung wurde angehalten\nEinige Lesezeichen sind falsch formatiert oder zu lang und wurden nicht synchronisiert.\n\n Dein Lesezeichen für %1$s kann nicht synchronisiert werden, weil eines seiner Felder das Zeichenlimit überschreitet. Deine Lesezeichen für %1$s und %2$d andere Websites können nicht synchronisiert werden, weil einige ihrer Felder das Zeichenlimit überschreiten. diff --git a/saved-sites/saved-sites-impl/src/main/res/values-el/strings-saved-sites.xml b/saved-sites/saved-sites-impl/src/main/res/values-el/strings-saved-sites.xml index a06941edc327..9174b60fdd41 100644 --- a/saved-sites/saved-sites-impl/src/main/res/values-el/strings-saved-sites.xml +++ b/saved-sites/saved-sites-impl/src/main/res/values-el/strings-saved-sites.xml @@ -20,8 +20,10 @@ Κοινοποιήστε τα Αγαπημένα Χρησιμοποιήστε τα ίδια αγαπημένα σε όλες τις συσκευές. Διακόψτε τη διαδικασία για να διατηρήσετε τα Αγαπημένα σας για κινητά και υπολογιστές ξεχωριστά. - Ο Συγχρονισμός τέθηκε σε παύση\nΥπέρβαση του ορίου σελιδοδεικτών. Διαγραφή ορισμένων για συνέχιση του συγχρονισμού.\n\nΔιαχείριση σελιδοδεικτών - Ο Συγχρονισμός σελιδοδεικτών τέθηκε σε παύση\nΈχετε υπερβεί το όριο συγχρονισμού σελιδοδεικτών. Προσπαθήστε να διαγράψετε ορισμένους σελιδοδείκτες. Μέχρι να επιλυθεί το πρόβλημα αυτό, δεν θα δημιουργηθούν αντίγραφα ασφαλείας για τους σελιδοδείκτες σας. + Ο συγχρονισμός έχει τεθεί σε παύση\nΈχετε φτάσει στον μέγιστο αριθμό σελιδοδεικτών. Διαγράψτε ορισμένους για να συνεχίσετε τον συγχρονισμό.Διαχείριση σελιδοδεικτών + Ο συγχρονισμός σελιδοδεικτών έχει τεθεί σε παύση\nΈχετε φτάσει στον μέγιστο αριθμό σελιδοδεικτών. Διαγράψτε ορισμένους σελιδοδείκτες για να συνεχίσετε τον συγχρονισμό. + Ο συγχρονισμός έχει τεθεί σε παύση\nΟρισμένοι σελιδοδείκτες δεν έχουν μορφοποιηθεί σωστά ή είναι πολύ μεγάλοι και δεν συγχρονίστηκαν.\n\nΔιαχείριση σελιδοδεικτών + Ο συγχρονισμός σελιδοδεικτών έχει τεθεί σε παύση\nΟρισμένοι σελιδοδείκτες δεν έχουν μορφοποιηθεί σωστά ή είναι πολύ μεγάλοι και δεν συγχρονίστηκαν. Ο σελιδοδείκτης σας για τον %1$s δεν μπορεί να συγχρονιστεί επειδή ορισμένα από τα πεδία του υπερβαίνει το όριο χαρακτήρων. Οι σελιδοδείκτες σας για τον %1$s και %2$d ακόμα ιστότοπους δεν μπορούν να συγχρονιστούν επειδή ορισμένα από τα πεδία τους υπερβαίνουν το όριο χαρακτήρων. diff --git a/saved-sites/saved-sites-impl/src/main/res/values-es/strings-saved-sites.xml b/saved-sites/saved-sites-impl/src/main/res/values-es/strings-saved-sites.xml index be42d46c9875..e98c9dfbbbec 100644 --- a/saved-sites/saved-sites-impl/src/main/res/values-es/strings-saved-sites.xml +++ b/saved-sites/saved-sites-impl/src/main/res/values-es/strings-saved-sites.xml @@ -20,8 +20,10 @@ Compartir favoritos Usa los mismos favoritos en todos los dispositivos. Deja de tener diferentes favoritos en tus dispositivos móviles y de escritorio. - Sincronización en pausa\nSe ha superado el límite de marcadores. Elimina algunos para reanudar la sincronización.\n\nGestionar marcadores - La sincronización de marcadores está en pausa\nHas superado el límite de sincronización de marcadores. Prueba a eliminar algunos marcadores. No se realizará una copia de seguridad de los marcadores hasta que se resuelva este problema. + Sincronización en pausa\nHas alcanzado el número máximo de marcadores. Elimina algunas para reanudar la sincronización.\n\nGestionar marcadores + La sincronización de marcadores está en pausa\nHas alcanzado el número máximo de marcadores. Elimina algunos marcadores para reanudar la sincronización. + Sincronización en pausa\nAlgunos marcadores tienen un formato incorrecto o demasiado largo y no se han sincronizado.\n\nGestionar marcadores + Sincronización de marcadores en pausa\nAlgunos marcadores tienen un formato incorrecto o demasiado largo y no se han sincronizado. El marcador de %1$s no se puede sincronizar porque algunos de sus campos superan el límite de caracteres. Los marcadores de %1$s y %2$d otros sitios más no se pueden sincronizar porque algunos de sus campos superan el límite de caracteres. diff --git a/saved-sites/saved-sites-impl/src/main/res/values-et/strings-saved-sites.xml b/saved-sites/saved-sites-impl/src/main/res/values-et/strings-saved-sites.xml index c93d9510119a..1863466b16ae 100644 --- a/saved-sites/saved-sites-impl/src/main/res/values-et/strings-saved-sites.xml +++ b/saved-sites/saved-sites-impl/src/main/res/values-et/strings-saved-sites.xml @@ -20,8 +20,10 @@ Jaga lemmikuid Kasuta kõigis seadmetes samu lemmikuid. Kui soovid mobiili ja arvuti lemmikud lahus hoida, siis ära seda sisse lülita. - Sünkroonimine peatatud\njärjehoidjate limiit on täis. Kustuta mõni, et jätkata sünkroonimist.\n\nHalda järjehoidjaid - Järjehoidjate sünkroonimine on peatatud\nOled järjehoidjate sünkroonimise limiidi ületanud. Proovi mõned järjehoidjad kustutada. Kuni see pole lahendatud, ei varundata sinu järjehoidjaid. + Sünkroonimine peatatud\nOled saavutanud maksimaalse järjehoidjate arvu. Kustuta mõni, et jätkata sünkroonimisega.\n\nHalda järjehoidjaid + Järjehoidjate sünkroonimine on peatatud\nOled saavutanud maksimaalse järjehoidjate arvu. Sünkroonimise jätkamiseks kustuta mõned järjehoidjad. + Sünkroonimine peatatud\nMõned järjehoidjad on valesti vormindatud või liiga pikad ja neid ei sünkroonitud.\n\nHalda järjehoidjaid + Järjehoidjate sünkroonimine on peatatud\nMõned järjehoidjad on valesti vormindatud või liiga pikad ja neid ei sünkroonitud. Sinu järjehoidjat %1$s jaoks ei saa sünkroonida, sest üks selle väljadest ületab tähemärgipiirangu. Sinu järjehoidjaid %1$s ja %2$d muu saidi jaoks ei saa sünkroonida, kuna mõned nende väljad ületavad tähemärgipiirangu. diff --git a/saved-sites/saved-sites-impl/src/main/res/values-fi/strings-saved-sites.xml b/saved-sites/saved-sites-impl/src/main/res/values-fi/strings-saved-sites.xml index 2fe49f001bf2..75ee69884341 100644 --- a/saved-sites/saved-sites-impl/src/main/res/values-fi/strings-saved-sites.xml +++ b/saved-sites/saved-sites-impl/src/main/res/values-fi/strings-saved-sites.xml @@ -20,8 +20,10 @@ Jaa suosikit Käytä samoja suosikkeja kaikilla laitteilla. Jätä pois, jos haluat pitää mobiili- ja työpöytäsuosikit erillään. - Synkronointi keskeytetty\nKirjanmerkkien enimmäismäärä ylitetty. Poista jotain jatkaaksesi synkronointia.\n\nHallitse kirjanmerkkejä - Kirjanmerkkien synkronointi on keskeytetty\nOlet ylittänyt kirjanmerkkien synkronointirajan. Yritä poistaa joitakin kirjanmerkkejä. Kirjanmerkkejäsi ei varmuuskopioida ennen kuin tämä ongelma on ratkaistu. + Synkronointi keskeytetty\nKirjanmerkkikiintiösi on täynnä. Poista joitain kirjanmerkkejä jatkaaksesi synkronointia.\n\nHallitse kirjanmerkkejä + Kirjanmerkkien synkronointi on keskeytetty\nKirjanmerkkikiintiösi on täynnä. Poista joitakin kirjanmerkkejä jatkaaksesi synkronointia. + Synkronointi keskeytetty\nJotkin kirjanmerkit on muotoiltu väärin tai ovat liian pitkiä, eikä niitä synkronoitu.\n\nHallitse kirjanmerkkejä + Kirjanmerkkien synkronointi keskeytetty\nJotkin kirjanmerkit on muotoiltu väärin tai ovat liian pitkiä, eikä niitä synkronoitu. Sivuston %1$s kirjanmerkkejä ei voi synkronoida, koska jotkin niiden kentät ylittävät merkkirajoituksen. Sivuston %1$s ja %2$d muun kirjanmerkkejä ei voi synkronoida, koska jotkin niiden kentät ylittävät merkkirajoituksen. diff --git a/saved-sites/saved-sites-impl/src/main/res/values-fr/strings-saved-sites.xml b/saved-sites/saved-sites-impl/src/main/res/values-fr/strings-saved-sites.xml index 989f2b585335..647f9b457bf6 100644 --- a/saved-sites/saved-sites-impl/src/main/res/values-fr/strings-saved-sites.xml +++ b/saved-sites/saved-sites-impl/src/main/res/values-fr/strings-saved-sites.xml @@ -20,8 +20,10 @@ Partager les favoris Utilisez les mêmes favoris sur tous les appareils. Laissez désactivé pour séparer les favoris sur mobile et ceux sur ordinateur de bureau. - Synchronisation suspendue\nLimite de favoris dépassée. Supprimez-en quelques-uns pour reprendre la synchronisation.\n\nGérer les favoris - La synchronisation des favoris est suspendue\nVous avez dépassé la limite de synchronisation des favoris. Veuillez en supprimer quelques-uns pour pouvoir sauvegarder vos favoris. + Synchronisation suspendue\nVous avez atteint le nombre maximal de favoris. Veuillez en supprimer quelques-uns pour reprendre la synchronisation.\n\nGérer les favoris + La synchronisation des favoris est suspendue\nVous avez atteint le nombre maximal de favoris. Veuillez en supprimer quelques-uns pour reprendre la synchronisation. + Synchronisation suspendue\nCertains favoris sont mal formatés ou trop longs et n\'ont pas été synchronisés.\n\nGérer les favoris + La synchronisation des favoris est suspendue\nCertains favoris sont mal formatés ou trop longs et n\'ont pas été synchronisés.\n\n Votre favori pour %1$s ne peut pas être synchronisé car l\'un de ses champs dépasse la limite de caractères. Vos favoris pour %1$s et %2$d autres sites ne peuvent pas être synchronisés car certains de leurs champs dépassent la limite de caractères. diff --git a/saved-sites/saved-sites-impl/src/main/res/values-hr/strings-saved-sites.xml b/saved-sites/saved-sites-impl/src/main/res/values-hr/strings-saved-sites.xml index 725e45446ab7..0a66960f9a15 100644 --- a/saved-sites/saved-sites-impl/src/main/res/values-hr/strings-saved-sites.xml +++ b/saved-sites/saved-sites-impl/src/main/res/values-hr/strings-saved-sites.xml @@ -20,8 +20,10 @@ Dijeljenje favorita Koristi iste favorite na svim uređajima. Isključi za odvajanje favorita za mobilne uređaje i stolna računala. - Sinkronizacija pauzirana\nOgraničenje oznaka je premašeno. Izbriši neke za nastavak sinkronizacije.\n\nUpravljanje knjižnim oznakama - Sinkronizacija knjižnih oznaka je pauzirana\nPremašeno je ograničenje sinkronizacije knjižnih oznaka. Pokušaj izbrisati neke oznake. Dok se to ne riješi, tvoje se oznake neće sigurnosno kopirati. + Sinkronizacija je pauzirana\nDosegnut je maksimalni broj knjižnih oznaka. Izbriši neke za nastavak sinkronizacije.\n\nUpravljanje knjižnim oznakama + Sinkronizacija knjižnih oznaka je pauzirana\nDosegnut je maksimalni broj knjižnih oznaka. Izbriši neke oznake za nastavak sinkronizacije. + Sinkronizacija pauzirana\nNeke su oznake formatirane pogrešno, ili su preduge i nisu sinkronizirane.\n\nUpravljanje knjižnim oznakama + Sinkronizacija oznaka je pauzirana\nNeke su oznake formatirane pogrešno, ili su preduge i nisu sinkronizirane. Tvoje oznake za %1$s ne mogu se sinkronizirati jer neka njihova polja premašuju ograničenje broja znakova. Tvoje oznake za %1$s i %2$d drugih web-mjesta ne mogu se sinkronizirati jer neka njihova polja premašuju ograničenje broja znakova. diff --git a/saved-sites/saved-sites-impl/src/main/res/values-hu/strings-saved-sites.xml b/saved-sites/saved-sites-impl/src/main/res/values-hu/strings-saved-sites.xml index 40d7d208812b..5b7e0189b075 100644 --- a/saved-sites/saved-sites-impl/src/main/res/values-hu/strings-saved-sites.xml +++ b/saved-sites/saved-sites-impl/src/main/res/values-hu/strings-saved-sites.xml @@ -20,8 +20,10 @@ Kedvencek megosztása Használd ugyanazokat a kedvenceket minden eszközön. Hagyd kikapcsolva a mobil és asztali kedvencek különválasztásához. - A szinkronizálás szünetel\nTúllépted a könyvjelzők maximális számát. A szinkronizálás folytatásához törölj néhányat.\n\nKönyvjelzők kezelése - A könyvjelzők szinkronizálása szünetel\nTúllépted a szinkronizálandó könyvjelzők maximális számát. Próbálj törölni néhány könyvjelzőt. Amíg ezt nem sikerül megoldani, a könyvjelzőkről nem készül biztonsági másolat. + Szinkronizálás szüneteltetve\nElérted a könyvjelzők maximális számát. A szinkronizálás folytatásához törölj néhányat.\n\nKönyvjelzők kezelése + Könyvjelző-szinkronizálás szüneteltetve\nElérted a könyvjelzők maximális számát. A szinkronizálás folytatásához törölj néhány könyvjelzőt. + Szinkronizálás szüneteltetve\nEgyes könyvjelzők helytelen formátumúak vagy túl hosszúak, és nem lettek szinkronizálva.\n\nKönyvjelzők kezelése + Könyvjelző-szinkronizálás szüneteltetve\nEgyes könyvjelzők helytelen formátumúak vagy túl hosszúak, és nem lettek szinkronizálva. A(z) %1$s webhely könyvjelzője nem szinkronizálható, mert egyik mezője meghaladja a karakterkorlátot. A(z) %1$s és %2$d másik webhely könyvjelzői nem szinkronizálhatók, mert egyes mezőik meghaladják a karakterkorlátot. diff --git a/saved-sites/saved-sites-impl/src/main/res/values-it/strings-saved-sites.xml b/saved-sites/saved-sites-impl/src/main/res/values-it/strings-saved-sites.xml index 138bc2cd672a..daf28d79a7f5 100644 --- a/saved-sites/saved-sites-impl/src/main/res/values-it/strings-saved-sites.xml +++ b/saved-sites/saved-sites-impl/src/main/res/values-it/strings-saved-sites.xml @@ -20,8 +20,10 @@ Condividi preferiti Usa gli stessi preferiti su tutti i dispositivi. Lascialo disattivato per tenere separati i preferiti per dispositivi mobili e desktop. - Sincronizzazione interrotta\nLimite di segnalibri superato. Eliminane alcuni per riprendere la sincronizzazione.\n\nGestisci segnalibri - Sincronizzazione segnalibri sospesa\nÈ stato superato il limite di sincronizzazione dei segnalibri. Prova a eliminarne qualcuno. Fino a quando il problema non sarà risolto, non sarà eseguito il backup dei segnalibri. + Sincronizzazione in pausa\nHai raggiunto il numero massimo di segnalibri. Eliminane alcuni per riprendere la sincronizzazione.Gestisci segnalibri + Sincronizzazione segnalibri in pausa\nHai raggiunto il numero massimo di segnalibri. Eliminane alcuni per riprendere la sincronizzazione. + Sincronizzazione in pausa\nAlcuni segnalibri sono formattati in modo errato o sono troppo lunghi e non sono stati sincronizzati.\n\nGestisci segnalibri + Sincronizzazione dei segnalibri in pausa\nAlcuni segnalibri sono formattati in modo errato o sono troppo lunghi e non sono stati sincronizzati.\n\n Il tuo segnalibro per %1$s non può essere sincronizzato perché uno dei suoi campi supera il limite di caratteri. I tuoi segnalibri per %1$s e altri %2$d siti non possono essere sincronizzati perché alcuni dei loro campi superano il limite di caratteri. diff --git a/saved-sites/saved-sites-impl/src/main/res/values-lt/strings-saved-sites.xml b/saved-sites/saved-sites-impl/src/main/res/values-lt/strings-saved-sites.xml index 4b590b017626..0574e2951cf1 100644 --- a/saved-sites/saved-sites-impl/src/main/res/values-lt/strings-saved-sites.xml +++ b/saved-sites/saved-sites-impl/src/main/res/values-lt/strings-saved-sites.xml @@ -20,8 +20,10 @@ Bendrinti adresyną Naudokite tą patį adresyną visuose įrenginiuose. Palikite išjungtą, kad mobiliojo ir stacionaraus įrenginių adresynai būtų atskirti. - Sinchronizavimas pristabdytas\nViršyta žymių riba. Ištrinkite kai kurias, kad tęstumėte sinchronizavimą.\n\nTvarkyti žymes - Žymių sinchronizavimas pristabdytas\nViršijote žymių sinchronizavimo ribą. Pabandykite ištrinti kai kurias žymes. Kol ši problema nebus išspręsta, nebus kuriamos atsarginės jūsų žymių kopijos. + Sinchronizavimas pristabdytas\nPasiekėte maksimalų žymių skaičių. Ištrinkite kai kurias žymes, kad tęstumėte sinchronizavimą.\n\nTvarkyti žymes + Žymių sinchronizavimas pristabdytas\nPasiekėte maksimalų žymių skaičių. Ištrinkite kai kurias žymes, kad tęstumėte sinchronizavimą. + Sinchronizavimas pristabdytas\nKai kurios žymės suformatuotos neteisingai arba yra per ilgos ir nebuvo sinchronizuotos.\n\nTvarkyti žymes + Žymių sinchronizavimas pristabdytas\nKai kurios žymės suformatuotos neteisingai arba yra per ilgos ir nebuvo sinchronizuotos. Jūsų %1$s žymė negali būti sinchronizuojama, nes tekstas viename iš laukų viršija simbolių limitą. Jūsų %1$s ir %2$d kitų svetainių žymės negali būti sinchronizuojamos, nes tekstas kai kuriuose laukuose viršija simbolių limitą. diff --git a/saved-sites/saved-sites-impl/src/main/res/values-lv/strings-saved-sites.xml b/saved-sites/saved-sites-impl/src/main/res/values-lv/strings-saved-sites.xml index 42d164893d19..3807b6679052 100644 --- a/saved-sites/saved-sites-impl/src/main/res/values-lv/strings-saved-sites.xml +++ b/saved-sites/saved-sites-impl/src/main/res/values-lv/strings-saved-sites.xml @@ -20,8 +20,10 @@ Kopīgot izlasi Izmanto vienu izlasi visās ierīcēs. Atstāj izslēgtu, lai nodalītu mobilās un galddatora izlases. - Sinhronizācija pārtraukta\nPārsniegts grāmatzīmju ierobežojums. Izdzēs dažas, lai atsāktu sinhronizāciju.\n\nPārvaldīt grāmatzīmes - Grāmatzīmju sinhronizācija ir pārtraukta\nPārsniegts grāmatzīmju sinhronizācijas ierobežojums. Mēģini izdzēst dažas grāmatzīmes. Kamēr šī problēma nebūs atrisināta, grāmatzīmes netiks dublētas. + Sinhronizācija pārtraukta\nTu esi sasniedzis maksimālo grāmatzīmju skaitu. Lūdzu, izdzēs dažas, lai atsāktu sinhronizāciju.\n\nPārvaldīt grāmatzīmes + Grāmatzīmju sinhronizācija ir pārtraukta\nTu esi sasniedzis maksimālo grāmatzīmju skaitu. Lūdzu, izdzēs dažas grāmatzīmes, lai atsāktu sinhronizāciju. + Sinhronizācija pārtraukta\nDažas grāmatzīmes ir formatētas nepareizi vai ir pārāk garas un netika sinhronizētas.\n\nPārvaldīt grāmatzīmes + Grāmatzīmju sinhronizācija ir pārtraukta\nDažas grāmatzīmes ir formatētas nepareizi vai ir pārāk garas un netika sinhronizētas. Tavas grāmatzīmes vietnei %1$s un vēl %2$d vietnēm nevar sinhronizēt, jo daži to lauki pārsniedz rakstzīmju ierobežojumu. Tavu grāmatzīmi vietnei %1$s nevar sinhronizēt, jo kāds no tās laukiem pārsniedz rakstzīmju ierobežojumu. diff --git a/saved-sites/saved-sites-impl/src/main/res/values-nb/strings-saved-sites.xml b/saved-sites/saved-sites-impl/src/main/res/values-nb/strings-saved-sites.xml index 9af1da213f48..25809f66e392 100644 --- a/saved-sites/saved-sites-impl/src/main/res/values-nb/strings-saved-sites.xml +++ b/saved-sites/saved-sites-impl/src/main/res/values-nb/strings-saved-sites.xml @@ -20,8 +20,10 @@ Del favoritter Bruk de samme favorittene på alle enheter. La være av for å holde mobil- og skrivebordsfavoritter adskilt. - Synkronisering er satt på pause\nGrensen for bokmerker er overskredet. Slett noen for å gjenoppta synkroniseringen.\n\nAdministrer bokmerker - Synkronisering av bokmerker er satt på pause\nDu har overskredet grensen for synkronisering av bokmerker. Prøv å slette noen bokmerker. Inntil dette er løst, blir ikke bokmerkene sikkerhetskopiert. + Synkronisering satt på pause\nDu har nådd maksimalt antall bokmerker. Slett noen for å gjenoppta synkronisering.\n\nAdministrer bokmerker + Synkronisering av bokmerker er satt på pause\nDu har nådd maksimalt antall bokmerker. Slett noen bokmerker for å gjenoppta synkronisering. + Synkronisering satt på pause\nNoen bokmerker er formatert feil eller for lange og ble ikke synkronisert.\n\nAdministrer bokmerker + Synkronisering av bokmerker er satt på pause\nNoen bokmerker er formatert feil eller for lange og ble ikke synkronisert. Bokmerket for %1$s kan ikke synkroniseres fordi ett av feltene overskrider tegngrensen. Bokmerkene for %1$s og %2$d andre steder kan ikke synkroniseres fordi noen av feltene overskrider tegngrensen. diff --git a/saved-sites/saved-sites-impl/src/main/res/values-nl/strings-saved-sites.xml b/saved-sites/saved-sites-impl/src/main/res/values-nl/strings-saved-sites.xml index 9b928411a9ba..e3ae7014392c 100644 --- a/saved-sites/saved-sites-impl/src/main/res/values-nl/strings-saved-sites.xml +++ b/saved-sites/saved-sites-impl/src/main/res/values-nl/strings-saved-sites.xml @@ -20,8 +20,10 @@ Favorieten delen Gebruik dezelfde favorieten op alle apparaten. Schakel deze optie uit als je je favoriete bladwijzers niet wilt synchroniseren tussen je mobiele apparaat en pc. - Synchronisatie onderbroken\nDe limiet voor het aantal bladwijzers is overschreden. Verwijder er een paar om de synchronisatie te hervatten.\n\nBladwijzers beheren - De synchronisatie van bladwijzers is onderbroken\nJe hebt de synchronisatielimiet voor bladwijzers overschreden. Probeer enkele bladwijzers te verwijderen. Er wordt geen back-up van je bladwijzers gemaakt totdat dit probleem is opgelost. + Synchronisatie gepauzeerd\nJe hebt het maximum aantal bladwijzers bereikt. Verwijder er een paar om de synchronisatie te hervatten.\n\nBladwijzers beheren + Synchronisatie bladwijzers gepauzeerd\nJe hebt het maximum aantal bladwijzers bereikt. Verwijder enkele bladwijzers om de synchronisatie te hervatten. + Synchronisatie gepauzeerd\nSommige bladwijzers hebben de verkeerde indeling of zijn te lang en werden niet gesynchroniseerd.\n\nBladwijzers beheren + Synchronisatie bladwijzers gepauzeerd\nSommige bladwijzers hebben de verkeerde indeling of zijn te lang en werden niet gesynchroniseerd. Je bladwijzer voor %1$s kan niet worden gesynchroniseerd omdat sommige velden de tekenlimiet overschrijden. Je bladwijzers voor %1$s en %2$d andere websites kunnen niet worden gesynchroniseerd omdat sommige velden de tekenlimiet overschrijden. diff --git a/saved-sites/saved-sites-impl/src/main/res/values-pl/strings-saved-sites.xml b/saved-sites/saved-sites-impl/src/main/res/values-pl/strings-saved-sites.xml index 76e9c0a03908..32d4d6cd7ea4 100644 --- a/saved-sites/saved-sites-impl/src/main/res/values-pl/strings-saved-sites.xml +++ b/saved-sites/saved-sites-impl/src/main/res/values-pl/strings-saved-sites.xml @@ -20,8 +20,10 @@ Udostępnij ulubione Korzystaj z tych samych ulubionych na wszystkich urządzeniach. Wyłącz, aby oddzielić ulubione na urządzenia mobilne i stacjonarne. - Synchronizacja wstrzymana\nPrzekroczono limit zakładek. Usuń niektóre, aby wznowić synchronizację.\n\nZarządzaj zakładkami - Synchronizacja zakładek wstrzymana\nPrzekroczono limit synchronizacji zakładek. Spróbuj usunąć niektóre zakładki. Dopóki ten problem nie zostanie rozwiązany, nie będzie tworzona kopia zapasowa zakładek. + Synchronizacja wstrzymana\nOsiągnięto maksymalną liczbę zakładek. Usuń część z nich, aby wznowić synchronizację.\n\nZarządzaj zakładkami + Synchronizacja zakładek wstrzymana\nOsiągnięto maksymalną liczbę zakładek. Usuń część zakładek, aby wznowić synchronizację. + Synchronizacja wstrzymana\nNiektóre zakładki mają niepoprawny format lub są zbyt długie i nie zostały zsynchronizowane.\n\nZarządzaj zakładkami + Synchronizacja zakładek jest wstrzymana\nNiektóre zakładki mają niepoprawny format lub są zbyt długie i nie zostały zsynchronizowane. Nie można zsynchronizować zakładki dotyczącej %1$s, ponieważ jedno z jej pól przekracza limit znaków. Nie można zsynchronizować zakładek dotyczących %1$s i %2$d innych witryn, ponieważ niektóre ich pola przekraczają limit znaków. diff --git a/saved-sites/saved-sites-impl/src/main/res/values-pt/strings-saved-sites.xml b/saved-sites/saved-sites-impl/src/main/res/values-pt/strings-saved-sites.xml index abcdf4274c33..23196d1baced 100644 --- a/saved-sites/saved-sites-impl/src/main/res/values-pt/strings-saved-sites.xml +++ b/saved-sites/saved-sites-impl/src/main/res/values-pt/strings-saved-sites.xml @@ -20,8 +20,10 @@ Partilhar favoritos Usa os mesmos favoritos em todos os teus dispositivos. Deixa desativado para manter separados os favoritos em dispositivos móveis e computadores. - Sincronização em pausa\nLimite de favoritos excedido. Elimina alguns para retomar a sincronização.\n\nGerir marcadores - A sincronização de marcadores está em pausa\nExcedeste o limite de sincronização de marcadores. Experimenta eliminar alguns marcadores. Não é possível fazer uma cópia de segurança dos marcadores até este problema estar resolvido. + Sincronização em pausa\nAtingiste o número máximo de marcadores. Elimina alguns para retomar a sincronização.\n\nGerir marcadores + A sincronização de marcadores está em pausa\nAtingiste o número máximo de marcadores. Elimina alguns marcadores para retomar a sincronização. + Sincronização em pausa\nAlguns marcadores estão formatados incorretamente ou são demasiado longos e não foram sincronizados.\n\nGerir marcadores + A sincronização de marcadores está em pausa\nAlguns marcadores estão formatados incorretamente ou são demasiado longos e não foram sincronizados. Não é possível sincronizar o teu marcador para %1$s porque um dos campos excede o limite de carateres. Não é possível sincronizar os teus marcadores para %1$s e mais %2$d sites porque alguns dos campos excedem o limite de carateres. diff --git a/saved-sites/saved-sites-impl/src/main/res/values-ro/strings-saved-sites.xml b/saved-sites/saved-sites-impl/src/main/res/values-ro/strings-saved-sites.xml index 7c4146522bf5..4ffe4f649064 100644 --- a/saved-sites/saved-sites-impl/src/main/res/values-ro/strings-saved-sites.xml +++ b/saved-sites/saved-sites-impl/src/main/res/values-ro/strings-saved-sites.xml @@ -20,8 +20,10 @@ Distribuie favoritele Utilizează aceleași favorite pe toate dispozitivele. Lasă oprit pentru a păstra favoritele pentru mobil și cele pentru desktop separate. - Sincronizare întreruptă\nS-a depășit limita de marcaje. Șterge câteva pentru a relua sincronizarea.\n\nGestionează marcajele - Sincronizarea marcajelor este întreruptă\nAi depășit limita de sincronizare a marcajelor. Încearcă să ștergi câteva dintre marcaje. Până la rezolvarea acestei probleme, nu se va efectua backupul pentru marcajele tale. + Sincronizare întreruptă\nAi atins numărul maxim de marcaje. Șterge câteva pentru a relua sincronizarea.\n\nGestionează marcajele + Sincronizarea marcajelor este întreruptă\nAi atins numărul maxim de marcaje. Șterge câteva marcaje pentru a relua sincronizarea. + Sincronizarea este întreruptă\nUnele marcaje sunt formatate incorect sau sunt prea lungi și nu au fost sincronizate.\n\nGestionează marcajele + Sincronizarea marcajelor este întreruptă\nUnele marcaje sunt formatate incorect sau sunt prea lungi și nu au fost sincronizate. Marcajul tău pentru %1$s nu se poate sincroniza, deoarece unul dintre câmpuri depășește limita de caractere. Marcajele tale pentru %1$s și încă %2$d alte site-uri nu se pot sincroniza, deoarece unele dintre câmpuri depășesc limita de caractere. diff --git a/saved-sites/saved-sites-impl/src/main/res/values-ru/strings-saved-sites.xml b/saved-sites/saved-sites-impl/src/main/res/values-ru/strings-saved-sites.xml index a86af9080d96..5e8ea38522ce 100644 --- a/saved-sites/saved-sites-impl/src/main/res/values-ru/strings-saved-sites.xml +++ b/saved-sites/saved-sites-impl/src/main/res/values-ru/strings-saved-sites.xml @@ -20,8 +20,10 @@ Синхронизировать избранное Поддерживать одно и то же избранное на всех устройствах. Не выбирайте этот вариант, если хотите использовать разное избранное в мобильной и настольной версиях браузера. - Синхронизация приостановлена\nПревышен лимит по количеству закладок. Удалите некоторые из них, чтобы возобновить синхронизацию.\n\nУправление закладками - Синхронизация закладок приостановлена\nВы превысили лимит синхронизации закладок. Попробуйте удалить некоторые из них. Пока вы не устраните эту проблему, резервная копия закладок создаваться не будет. + Синхронизация на паузе\nДостигнуто максимальное число закладок. Чтобы возобновить синхронизацию, удалите некоторые из них.\n\nУправление закладками + Синхронизация закладок на паузе\nДостигнуто максимальное число закладок. Чтобы возобновить синхронизацию, удалите некоторые из них. + Синхронизация на паузе\nНекоторые закладки не синхронизируются из-за неверного формата или превышения ограничений по длине.\n\nУправление закладками + Синхронизация закладок на паузе\nНекоторые закладки не синхронизируются из-за неверного формата или превышения ограничений по длине. Синхронизировать вашу закладку на сайт %1$s не удалось, так как содержимое одного из полей превышает ограничение по числу символов. Синхронизировать ваши закладки на %1$s и еще %2$d сайта не удалось, так как содержимое некоторых полей превышает ограничение по числу символов. diff --git a/saved-sites/saved-sites-impl/src/main/res/values-sk/strings-saved-sites.xml b/saved-sites/saved-sites-impl/src/main/res/values-sk/strings-saved-sites.xml index e2ea75418745..3b80afec4a1b 100644 --- a/saved-sites/saved-sites-impl/src/main/res/values-sk/strings-saved-sites.xml +++ b/saved-sites/saved-sites-impl/src/main/res/values-sk/strings-saved-sites.xml @@ -20,8 +20,10 @@ Zdieľať obľúbené položky Používajte rovnaké obľúbené položky na všetkých zariadeniach. Toto vynechajte, ak chcete aby obľúbené položky z mobilných zariadení a počítačov zostali oddelené. - Synchronizácia bola pozastavená\nBol dosiahnutý limit pre počet záložiek. Ak chcete obnoviť synchronizáciu, odstráňte niektoré záložky.\n\nSpráva záložiek - Synchronizácia záložiek bola pozastavená\nBol dosiahnutý limit pre počet záložiek. Skúste niektoré záložky odstrániť. Kým to nevyriešite, vaše záložky sa nebudú zálohovať. + Synchronizácia bola pozastavená\nDosiahli ste maximálny počet záložiek. Ak chcete obnoviť synchronizáciu, odstráňte niektoré záložky.\n\nSpráva záložiek + Synchronizácia záložiek bola pozastavená\nDosiahli ste maximálny počet záložiek. Ak chcete obnoviť synchronizáciu, odstráňte niektoré záložky. + \nNiektoré záložky sú nesprávne naformátované alebo sú príliš dlhé, a neboli synchronizované.\n\nSpráva záložiek + \nNiektoré záložky sú nesprávne naformátované alebo sú príliš dlhé, a neboli synchronizované. Vaše záložky pre %1$s nemožno synchronizovať, pretože niektoré z polí prekračujú limit znakov. Vaše záložky pre %1$s a pre %2$d ďalších lokalít nemožno synchronizovať, pretože niektoré z polí prekračujú limit znakov. diff --git a/saved-sites/saved-sites-impl/src/main/res/values-sl/strings-saved-sites.xml b/saved-sites/saved-sites-impl/src/main/res/values-sl/strings-saved-sites.xml index 7fc411ea087f..3cbaa0938ab1 100644 --- a/saved-sites/saved-sites-impl/src/main/res/values-sl/strings-saved-sites.xml +++ b/saved-sites/saved-sites-impl/src/main/res/values-sl/strings-saved-sites.xml @@ -20,8 +20,10 @@ Delite priljubljene Uporabite iste priljubljene v vseh napravah. Če želite ločiti priljubljene strani za mobilne naprave in namizne računalnike, pustite izklopljeno. - Sinhronizacija je začasno zaustavljena\nOmejitev zaznamkov je presežena. Izbrišite jih nekaj, če želite nadaljevati sinhronizacijo.\n\nUpravljanje zaznamkov - Sinhronizacija zaznamkov je začasno zaustavljena\nPresegli ste omejitev za sinhronizacijo zaznamkov. Poskusite izbrisati nekaj zaznamkov. Dokler ta težava ne bo odpravljena, zaznamki ne bodo varnostno kopirani. + Sinhronizacija je začasno zaustavljena\nDosegli ste največje število zaznamkov. Izbrišite jih nekaj, če želite nadaljevati sinhronizacijo.\n\nUpravljanje zaznamkov + Sinhronizacija zaznamkov je začasno zaustavljena\nDosegli ste največje število zaznamkov. Izbrišite nekaj zaznamkov, da nadaljujete sinhronizacijo. + Sinhronizacija je začasno zaustavljena\nNekateri zaznamki so napačno oblikovani ali predolgi in niso bili sinhronizirani.\n\nUpravljanje zaznamkov + Sinhronizacija zaznamkov je začasno zaustavljena\nNekateri zaznamki so napačno oblikovani ali predolgi in niso bili sinhronizirani. Vašega zaznamka za %1$s ni mogoče sinhronizirati, ker eno od polj presega omejitev števila znakov. Vaših zaznamkov za %1$s in za %2$d drugi spletni mesti ni mogoče sinhronizirati, ker nekatera njihova polja presegajo omejitev števila znakov. diff --git a/saved-sites/saved-sites-impl/src/main/res/values-sv/strings-saved-sites.xml b/saved-sites/saved-sites-impl/src/main/res/values-sv/strings-saved-sites.xml index 642eb90bc087..698b6d3fe37c 100644 --- a/saved-sites/saved-sites-impl/src/main/res/values-sv/strings-saved-sites.xml +++ b/saved-sites/saved-sites-impl/src/main/res/values-sv/strings-saved-sites.xml @@ -20,8 +20,10 @@ Dela favoriter Använd samma favoriter på alla enheter. Låt bli att använda funktionen om du vill skilja på favoriter för mobil och dator. - Synkronisering pausad\nGränsen för antalet bokmärken har överskridits. Ta bort några för att återuppta synkronisering.\n\nHantera bokmärken - Synkronisering av bokmärken pausad\nDu har överskridit gränsen för antalet lösenords om kan synkroniseras. Ta bort några bokmärken. Dina bokmärken kommer inte att säkerhetskopieras förrän detta har åtgärdats. + Synkroniseringen har pausats\nDu har nått det maximala antalet bokmärken. Ta bort några för att återuppta synkroniseringen.\n\nHantera bokmärken + Bokmärkessynkroniseringen har pausats\nDu har nått det maximala antalet bokmärken. Ta bort några bokmärken för att återuppta synkroniseringen. + Synkroniseringen har pausats\nVissa bokmärken är felaktigt formaterade eller för långa och har inte synkroniserats.\n\nHantera bokmärken + Bokmärkessynkroniseringen har pausats\nVissa bokmärken är felaktigt formaterade eller för långa och har inte synkroniserats. Ditt bokmärke för %1$s kan inte synkroniseras eftersom ett av dess fält överskrider teckengränsen. Dina bokmärken för %1$s och %2$d andra webbplatser kan inte synkroniseras eftersom vissa av deras fält överskrider teckengränsen. diff --git a/saved-sites/saved-sites-impl/src/main/res/values-tr/strings-saved-sites.xml b/saved-sites/saved-sites-impl/src/main/res/values-tr/strings-saved-sites.xml index 951d8a3019d5..1bd535af4109 100644 --- a/saved-sites/saved-sites-impl/src/main/res/values-tr/strings-saved-sites.xml +++ b/saved-sites/saved-sites-impl/src/main/res/values-tr/strings-saved-sites.xml @@ -20,8 +20,10 @@ Favorileri Paylaş Tüm cihazlarda aynı favorileri kullanın. Mobil ve masaüstü favorilerini ayrı tutmak için kapalı bırakın. - Senkronizasyon Duraklatıldı\nYer imi sınırı aşıldı. Senkronizasyona devam etmek için bazılarını silin.\n\nYer İmlerini Yönet - Yer İmi Senkronizasyonu Duraklatıldı\nYer imi senkronizasyon sınırını aştınız. Bazı yer işaretlerini silmeyi deneyin. Bu sorun çözülene kadar yer imleriniz yedeklenmeyecektir. + Senkronizasyon Duraklatıldı\nMaksimum yer işareti sayısına ulaştınız. Senkronizasyona devam etmek için lütfen bazılarını silin.\n\nYer İşaretlerini Yönet + Yer İşareti Senkronizasyonu Duraklatıldı\nMaksimum yer işareti sayısına ulaştınız. Senkronizasyona devam etmek için lütfen bazı yer işaretlerini silin. + Senkronizasyonu Duraklatıldı\nBazı yer işaretleri yanlış veya çok uzun biçimlendirilmiş ve senkronize edilmedi.\n\nYer İşaretlerini Yönet + \nBazı yer işaretleri yanlış biçimlendirilmiş veya çok uzun olduğu için senkronize edilmedi. %1$s için yer işaretiniz, alanlarından biri karakter sınırını aştığı için senkronize edilemiyor. %1$s ve %2$d diğer site için yer işaretleriniz, bazı alanları karakter sınırını aştığı için senkronize edilemiyor. diff --git a/saved-sites/saved-sites-impl/src/main/res/values/strings-saved-sites.xml b/saved-sites/saved-sites-impl/src/main/res/values/strings-saved-sites.xml index 6b7afd1f878b..9b87419489af 100644 --- a/saved-sites/saved-sites-impl/src/main/res/values/strings-saved-sites.xml +++ b/saved-sites/saved-sites-impl/src/main/res/values/strings-saved-sites.xml @@ -20,8 +20,10 @@ Share Favorites Use the same favorites on all devices. Leave off to keep mobile and desktop favorites separate. - Sync Paused\nBookmark limit exceeded. Delete some to resume syncing.\n\nManage Bookmarks - Bookmarks Sync is Paused\nYou have exceeded the bookmarks sync limit. Try deleting some bookmarks. Until this is resolved your bookmarks will not be backed up. + Sync Paused\nYou\'ve reached the maximum number of bookmarks. Please delete some to resume sync.\n\nManage Bookmarks + Bookmark Sync is Paused\nYou\'ve reached the maximum number of bookmarks. Please delete some bookmarks to resume sync. + Sync Paused\nSome bookmarks are formatted incorrectly or too long and were not synced.\n\nManage Bookmarks + Bookmark Sync is Paused\nSome bookmarks are formatted incorrectly or too long and were not synced. Your bookmark for %1$s can’t sync because one of its fields exceeds the character limit.\n\n Your bookmarks for %1$s and %2$d other sites can’t sync because some of their fields exceed the character limit.\n\n diff --git a/saved-sites/saved-sites-impl/src/test/java/com/duckduckgo/savedsites/impl/sync/AppSavedSitesSyncFeatureListenerTest.kt b/saved-sites/saved-sites-impl/src/test/java/com/duckduckgo/savedsites/impl/sync/AppSavedSitesSyncFeatureListenerTest.kt index c9f08b804735..9b9e048dea0d 100644 --- a/saved-sites/saved-sites-impl/src/test/java/com/duckduckgo/savedsites/impl/sync/AppSavedSitesSyncFeatureListenerTest.kt +++ b/saved-sites/saved-sites-impl/src/test/java/com/duckduckgo/savedsites/impl/sync/AppSavedSitesSyncFeatureListenerTest.kt @@ -24,7 +24,8 @@ import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.FileUtilities import com.duckduckgo.navigation.api.GlobalActivityStarter import com.duckduckgo.sync.api.SyncActivityWithEmptyParams -import com.duckduckgo.sync.api.engine.FeatureSyncError +import com.duckduckgo.sync.api.engine.FeatureSyncError.COLLECTION_LIMIT_REACHED +import com.duckduckgo.sync.api.engine.FeatureSyncError.INVALID_REQUEST import com.duckduckgo.sync.api.engine.SyncChangesResponse import com.duckduckgo.sync.api.engine.SyncableType.BOOKMARKS import org.junit.Assert.* @@ -67,6 +68,7 @@ class AppSavedSitesSyncFeatureListenerTest { testee.onSuccess(validChanges) assertFalse(savedSitesSyncStore.isSyncPaused) + assertTrue(savedSitesSyncStore.syncPausedReason.isEmpty()) } @Test @@ -83,18 +85,30 @@ class AppSavedSitesSyncFeatureListenerTest { fun whenSyncPausedAndOnErrorThenSyncPaused() { savedSitesSyncStore.isSyncPaused = true - testee.onError(FeatureSyncError.COLLECTION_LIMIT_REACHED) + testee.onError(COLLECTION_LIMIT_REACHED) assertTrue(savedSitesSyncStore.isSyncPaused) + assertEquals(COLLECTION_LIMIT_REACHED.name, savedSitesSyncStore.syncPausedReason) + } + + @Test + fun whenSyncPausedAndNewErrorThenSyncPausedAndReasonUpdated() { + savedSitesSyncStore.isSyncPaused = true + + testee.onError(INVALID_REQUEST) + + assertTrue(savedSitesSyncStore.isSyncPaused) + assertEquals(INVALID_REQUEST.name, savedSitesSyncStore.syncPausedReason) } @Test fun whenSyncActiveAndOnErrorThenSyncPaused() { savedSitesSyncStore.isSyncPaused = false - testee.onError(FeatureSyncError.COLLECTION_LIMIT_REACHED) + testee.onError(COLLECTION_LIMIT_REACHED) assertTrue(savedSitesSyncStore.isSyncPaused) + assertEquals(COLLECTION_LIMIT_REACHED.name, savedSitesSyncStore.syncPausedReason) } @Test diff --git a/saved-sites/saved-sites-impl/src/test/java/com/duckduckgo/savedsites/impl/sync/SavedSiteRateLimitViewModelTest.kt b/saved-sites/saved-sites-impl/src/test/java/com/duckduckgo/savedsites/impl/sync/SavedSiteSyncPausedViewModelTest.kt similarity index 57% rename from saved-sites/saved-sites-impl/src/test/java/com/duckduckgo/savedsites/impl/sync/SavedSiteRateLimitViewModelTest.kt rename to saved-sites/saved-sites-impl/src/test/java/com/duckduckgo/savedsites/impl/sync/SavedSiteSyncPausedViewModelTest.kt index 34f20667ee43..62ebaae76dee 100644 --- a/saved-sites/saved-sites-impl/src/test/java/com/duckduckgo/savedsites/impl/sync/SavedSiteRateLimitViewModelTest.kt +++ b/saved-sites/saved-sites-impl/src/test/java/com/duckduckgo/savedsites/impl/sync/SavedSiteSyncPausedViewModelTest.kt @@ -20,7 +20,9 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import app.cash.turbine.test import com.duckduckgo.common.test.CoroutineTestRule -import com.duckduckgo.savedsites.impl.sync.SavedSiteRateLimitViewModel.Command.NavigateToBookmarks +import com.duckduckgo.saved.sites.impl.R +import com.duckduckgo.savedsites.impl.sync.SavedSiteSyncPausedViewModel.Command.NavigateToBookmarks +import com.duckduckgo.sync.api.engine.FeatureSyncError import kotlinx.coroutines.test.runTest import org.junit.Assert.* import org.junit.Rule @@ -28,23 +30,41 @@ import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) -class SavedSiteRateLimitViewModelTest { +class SavedSiteSyncPausedViewModelTest { @get:Rule var coroutineRule = CoroutineTestRule() private val realContext = InstrumentationRegistry.getInstrumentation().targetContext val savedSitesSyncStore = RealSavedSitesSyncStore(realContext, coroutineRule.testScope, coroutineRule.testDispatcherProvider) - val testee = SavedSiteRateLimitViewModel( + val testee = SavedSiteSyncPausedViewModel( savedSitesSyncStore, coroutineRule.testDispatcherProvider, ) @Test - fun whenSyncPausedThenWarningVisible() = runTest { - savedSitesSyncStore.isSyncPaused = true + fun whenSyncNotPausedThenShowNoWarningMessage() = runTest { + givenNoError() + testee.viewState().test { + assertNull(awaitItem().message) + cancelAndConsumeRemainingEvents() + } + } + + @Test + fun whenSyncPausedBecauseOfCollectionLimitReachedThenShowWarningMessage() = runTest { + givenError(FeatureSyncError.COLLECTION_LIMIT_REACHED) + testee.viewState().test { + assertEquals(R.string.saved_site_limit_warning, awaitItem().message) + cancelAndConsumeRemainingEvents() + } + } + + @Test + fun whenSyncPausedBecauseOfInvalidRequestThenShowWarningMessage() = runTest { + givenError(FeatureSyncError.INVALID_REQUEST) testee.viewState().test { - assertTrue(awaitItem().warningVisible) + assertEquals(R.string.saved_site_invalid_warning, awaitItem().message) cancelAndConsumeRemainingEvents() } } @@ -57,4 +77,13 @@ class SavedSiteRateLimitViewModelTest { cancelAndConsumeRemainingEvents() } } + + private fun givenNoError() { + savedSitesSyncStore.isSyncPaused = false + } + + private fun givenError(collectionLimitReached: FeatureSyncError) { + savedSitesSyncStore.isSyncPaused = true + savedSitesSyncStore.syncPausedReason = collectionLimitReached.name + } } diff --git a/sync/sync-api/src/main/java/com/duckduckgo/sync/api/engine/Models.kt b/sync/sync-api/src/main/java/com/duckduckgo/sync/api/engine/Models.kt index 17b45c32b1ae..ca3ea6686672 100644 --- a/sync/sync-api/src/main/java/com/duckduckgo/sync/api/engine/Models.kt +++ b/sync/sync-api/src/main/java/com/duckduckgo/sync/api/engine/Models.kt @@ -67,6 +67,7 @@ data class SyncErrorResponse( enum class FeatureSyncError { COLLECTION_LIMIT_REACHED, + INVALID_REQUEST, } enum class SyncableType(val field: String) { diff --git a/sync/sync-impl/build.gradle b/sync/sync-impl/build.gradle index 77f38a8318a1..529cfa28d9ba 100644 --- a/sync/sync-impl/build.gradle +++ b/sync/sync-impl/build.gradle @@ -81,6 +81,7 @@ dependencies { testImplementation Testing.junit4 testImplementation AndroidX.core testImplementation AndroidX.test.ext.junit + testImplementation AndroidX.test.rules testImplementation "androidx.test:runner:_" testImplementation Testing.robolectric testImplementation CashApp.turbine diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/di/SyncModule.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/di/SyncModule.kt index 673c79418e87..b80fe770614e 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/di/SyncModule.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/di/SyncModule.kt @@ -45,6 +45,8 @@ import com.duckduckgo.sync.store.SyncDatabase import com.duckduckgo.sync.store.SyncSharedPrefsProvider import com.duckduckgo.sync.store.SyncSharedPrefsStore import com.duckduckgo.sync.store.SyncStore +import com.duckduckgo.sync.store.SyncUnavailableSharedPrefsStore +import com.duckduckgo.sync.store.SyncUnavailableStore import com.journeyapps.barcodescanner.BarcodeEncoder import com.squareup.anvil.annotations.ContributesTo import dagger.Module @@ -144,4 +146,12 @@ object SyncStoreModule { ): FaviconsFetchingPrompt { return SyncFaviconsFetchingPrompt(faviconFetchingStore, syncAccountRepository) } + + @Provides + @SingleInstanceIn(AppScope::class) + fun provideSyncPausedStore( + sharedPrefsProvider: SharedPrefsProvider, + ): SyncUnavailableStore { + return SyncUnavailableSharedPrefsStore(sharedPrefsProvider) + } } diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/engine/RealSyncEngine.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/engine/RealSyncEngine.kt index 89cb233dbed9..17b29ca77391 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/engine/RealSyncEngine.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/engine/RealSyncEngine.kt @@ -20,6 +20,7 @@ import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.sync.api.engine.* import com.duckduckgo.sync.api.engine.FeatureSyncError.COLLECTION_LIMIT_REACHED +import com.duckduckgo.sync.api.engine.FeatureSyncError.INVALID_REQUEST import com.duckduckgo.sync.api.engine.SyncEngine.SyncTrigger import com.duckduckgo.sync.api.engine.SyncEngine.SyncTrigger.ACCOUNT_CREATION import com.duckduckgo.sync.api.engine.SyncEngine.SyncTrigger.ACCOUNT_LOGIN @@ -63,6 +64,7 @@ class RealSyncEngine @Inject constructor( private val syncOperationErrorRecorder: SyncOperationErrorRecorder, private val providerPlugins: PluginPoint, private val persisterPlugins: PluginPoint, + private val lifecyclePlugins: PluginPoint, ) : SyncEngine { override fun triggerSync(trigger: SyncTrigger) { @@ -261,6 +263,9 @@ class RealSyncEngine @Inject constructor( persisterPlugins.getPlugins().map { it.onSyncEnabled() } + lifecyclePlugins.getPlugins().forEach { + it.onSyncEnabled() + } } override fun onSyncDisabled() { @@ -268,12 +273,16 @@ class RealSyncEngine @Inject constructor( persisterPlugins.getPlugins().map { it.onSyncDisabled() } + lifecyclePlugins.getPlugins().forEach { + it.onSyncDisabled() + } } private fun Error.featureError(): FeatureSyncError? { return when (code) { API_CODE.COUNT_LIMIT.code -> COLLECTION_LIMIT_REACHED API_CODE.CONTENT_TOO_LARGE.code -> COLLECTION_LIMIT_REACHED + API_CODE.VALIDATION_ERROR.code -> INVALID_REQUEST else -> null } } diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/engine/SyncEngineLifecycle.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/engine/SyncEngineLifecycle.kt new file mode 100644 index 000000000000..e574c00b98d3 --- /dev/null +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/engine/SyncEngineLifecycle.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.sync.impl.engine + +import com.duckduckgo.anvil.annotations.ContributesPluginPoint +import com.duckduckgo.di.scopes.AppScope + +@ContributesPluginPoint(AppScope::class) +interface SyncEngineLifecycle { + fun onSyncEnabled() + fun onSyncDisabled() +} diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/engine/SyncInvalidTokenInterceptor.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/engine/SyncInvalidTokenInterceptor.kt new file mode 100644 index 000000000000..9f42c24622cd --- /dev/null +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/engine/SyncInvalidTokenInterceptor.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.sync.impl.engine + +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import com.duckduckgo.app.global.api.ApiInterceptorPlugin +import com.duckduckgo.common.utils.notification.checkPermissionAndNotify +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.sync.impl.API_CODE +import com.duckduckgo.sync.impl.SyncService +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject +import okhttp3.Interceptor +import okhttp3.Interceptor.Chain +import okhttp3.Response +import timber.log.Timber + +@ContributesMultibinding( + scope = AppScope::class, + boundType = ApiInterceptorPlugin::class, +) +class SyncInvalidTokenInterceptor @Inject constructor( + private val context: Context, + private val notificationManager: NotificationManagerCompat, + private val syncNotificationBuilder: SyncNotificationBuilder, +) : ApiInterceptorPlugin, Interceptor { + + override fun intercept(chain: Chain): Response { + val isSyncEndpoint = chain.request().url.toString().contains(SyncService.SYNC_PROD_ENVIRONMENT_URL) || + chain.request().url.toString().contains(SyncService.SYNC_DEV_ENVIRONMENT_URL) + + if (!isSyncEndpoint) { + return chain.proceed(chain.request()) + } + + val response = chain.proceed(chain.request()) + + val method = chain.request().method + if (response.code == API_CODE.INVALID_LOGIN_CREDENTIALS.code && (method == "PATCH" || method == "GET")) { + Timber.d("Sync-Engine: User logged out, invalid token detected.") + notificationManager.checkPermissionAndNotify( + context, + SYNC_USER_LOGGED_OUT_NOTIFICATION_ID, + syncNotificationBuilder.buildSyncSignedOutNotification(context), + ) + } + return response + } + + override fun getInterceptor() = this + + companion object { + internal const val SYNC_USER_LOGGED_OUT_NOTIFICATION_ID = 8451 + } +} diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/engine/SyncNotificationBuilder.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/engine/SyncNotificationBuilder.kt index a9463bf357c1..828fe8ee4e1c 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/engine/SyncNotificationBuilder.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/engine/SyncNotificationBuilder.kt @@ -32,6 +32,8 @@ import javax.inject.Inject interface SyncNotificationBuilder { fun buildSyncPausedNotification(context: Context, addNavigationIntent: Boolean = true): Notification + fun buildSyncErrorNotification(context: Context): Notification + fun buildSyncSignedOutNotification(context: Context): Notification } @ContributesBinding(AppScope::class) @@ -51,6 +53,31 @@ class AppCredentialsSyncNotificationBuilder @Inject constructor( return notificationBuilder.build() } + override fun buildSyncErrorNotification( + context: Context, + ): Notification { + val notificationBuilder = NotificationCompat.Builder(context, SYNC_NOTIFICATION_CHANNEL_ID) + .setSmallIcon(com.duckduckgo.mobile.android.R.drawable.notification_logo) + .setStyle(NotificationCompat.DecoratedCustomViewStyle()) + .setCustomContentView(RemoteViews(context.packageName, R.layout.notification_sync_error)) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setCategory(NotificationCompat.CATEGORY_STATUS) + return notificationBuilder.build() + } + + override fun buildSyncSignedOutNotification( + context: Context, + ): Notification { + val notificationBuilder = NotificationCompat.Builder(context, SYNC_NOTIFICATION_CHANNEL_ID) + .setSmallIcon(com.duckduckgo.mobile.android.R.drawable.notification_logo) + .setStyle(NotificationCompat.DecoratedCustomViewStyle()) + .setCustomContentView(RemoteViews(context.packageName, R.layout.notification_sync_signed_out)) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setCategory(NotificationCompat.CATEGORY_STATUS) + notificationBuilder.setContentIntent(getPendingIntent(context)) + return notificationBuilder.build() + } + private fun getPendingIntent(context: Context): PendingIntent? = TaskStackBuilder.create(context).run { addNextIntentWithParentStack( globalGlobalActivityStarter.startIntent(context, SyncActivityWithEmptyParams)!!, diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/engine/SyncServerUnavailableInterceptor.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/engine/SyncServerUnavailableInterceptor.kt new file mode 100644 index 000000000000..144806dce8aa --- /dev/null +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/engine/SyncServerUnavailableInterceptor.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.sync.impl.engine + +import com.duckduckgo.app.global.api.ApiInterceptorPlugin +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.sync.impl.API_CODE +import com.duckduckgo.sync.impl.SyncService +import com.duckduckgo.sync.impl.error.SyncUnavailableRepository +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject +import okhttp3.Interceptor +import okhttp3.Interceptor.Chain +import okhttp3.Response + +@ContributesMultibinding( + scope = AppScope::class, + boundType = ApiInterceptorPlugin::class, +) +class SyncServerUnavailableInterceptor @Inject constructor( + private val syncUnavailableRepository: SyncUnavailableRepository, +) : ApiInterceptorPlugin, Interceptor { + + override fun intercept(chain: Chain): Response { + val isSyncEndpoint = chain.request().url.toString().contains(SyncService.SYNC_PROD_ENVIRONMENT_URL) || + chain.request().url.toString().contains(SyncService.SYNC_DEV_ENVIRONMENT_URL) + + if (!isSyncEndpoint) { + return chain.proceed(chain.request()) + } + + val response = chain.proceed(chain.request()) + val method = chain.request().method + if (method == "PATCH" || method == "GET") { + when (response.code) { + API_CODE.TOO_MANY_REQUESTS_1.code, + API_CODE.TOO_MANY_REQUESTS_2.code, + -> { + syncUnavailableRepository.onServerUnavailable() + } + } + } + + if (response.isSuccessful) { + syncUnavailableRepository.onServerAvailable() + } + + return response + } + + override fun getInterceptor() = this +} diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/error/SyncUnavailableRepository.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/error/SyncUnavailableRepository.kt new file mode 100644 index 000000000000..282f80c7f94e --- /dev/null +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/error/SyncUnavailableRepository.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.sync.impl.error + +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy.KEEP +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.duckduckgo.anvil.annotations.ContributesWorker +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.notification.checkPermissionAndNotify +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.sync.impl.engine.SyncEngineLifecycle +import com.duckduckgo.sync.impl.engine.SyncNotificationBuilder +import com.duckduckgo.sync.impl.error.SchedulableErrorNotificationWorker.Companion.SYNC_ERROR_NOTIFICATION_TAG +import com.duckduckgo.sync.store.SyncUnavailableStore +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import java.time.Instant +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import kotlinx.coroutines.withContext +import timber.log.Timber + +interface SyncUnavailableRepository { + fun onServerAvailable() + fun onServerUnavailable() + fun isSyncUnavailable(): Boolean + fun triggerNotification() +} + +@Suppress("SameParameterValue") +@ContributesBinding( + scope = AppScope::class, + boundType = SyncUnavailableRepository::class, +) +@ContributesMultibinding( + scope = AppScope::class, + boundType = SyncEngineLifecycle::class, +) +@SingleInstanceIn(AppScope::class) +class RealSyncUnavailableRepository @Inject constructor( + private val context: Context, + private val syncUnavailableStore: SyncUnavailableStore, + private val notificationManager: NotificationManagerCompat, + private val syncNotificationBuilder: SyncNotificationBuilder, + private val workManager: WorkManager, +) : SyncUnavailableRepository, SyncEngineLifecycle { + + override fun isSyncUnavailable(): Boolean { + return syncUnavailableStore.isSyncUnavailable + } + + override fun onServerAvailable() { + if (syncUnavailableStore.isSyncUnavailable) { + Timber.d("Sync-Engine: Sync is back online - clearing data and canceling notif") + syncUnavailableStore.clearError() + cancelNotification() + } + } + + override fun onServerUnavailable() { + if (!syncUnavailableStore.isSyncUnavailable) { + syncUnavailableStore.syncUnavailableSince = getUtcIsoLocalDate() + } + syncUnavailableStore.isSyncUnavailable = true + syncUnavailableStore.syncErrorCount = syncUnavailableStore.syncErrorCount + 1 + + Timber.d( + "Sync-Engine: server unavailable count: ${syncUnavailableStore.syncErrorCount} " + + "pausedAt: ${syncUnavailableStore.syncUnavailableSince} lastNotifiedAt: ${syncUnavailableStore.userNotifiedAt}", + ) + if (syncUnavailableStore.syncErrorCount >= ERROR_THRESHOLD_NOTIFICATION_COUNT) { + Timber.d("Sync-Engine: Sync error count reached threshold") + triggerNotification() + } else { + scheduleNotification( + OneTimeWorkRequest.Builder(SchedulableErrorNotificationWorker::class.java), + SYNC_ERROR_NOTIFICATION_DELAY, + TimeUnit.HOURS, + SYNC_ERROR_NOTIFICATION_TAG, + ) + } + } + + override fun triggerNotification() { + val today = LocalDateTime.now().toLocalDate() + val lastNotification = syncUnavailableStore.userNotifiedAt.takeUnless { it.isEmpty() }?.let { + LocalDateTime.parse(it, DateTimeFormatter.ISO_LOCAL_DATE_TIME).toLocalDate() + } ?: "" + val userNotifiedToday = today == lastNotification + Timber.d("Sync-Engine: was user notified today? $userNotifiedToday") + if (!userNotifiedToday) { + Timber.d("Sync-Engine: notifying user about sync error") + notificationManager.checkPermissionAndNotify( + context, + SYNC_ERROR_NOTIFICATION_ID, + syncNotificationBuilder.buildSyncErrorNotification(context), + ) + syncUnavailableStore.userNotifiedAt = getUtcIsoLocalDate() + } + } + + private fun cancelNotification() { + notificationManager.cancel(SYNC_ERROR_NOTIFICATION_ID) + workManager.cancelAllWorkByTag(SYNC_ERROR_NOTIFICATION_TAG) + } + + private fun getUtcIsoLocalDate(): String { + return Instant.now().atOffset(java.time.ZoneOffset.UTC).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + } + + private fun scheduleNotification( + builder: OneTimeWorkRequest.Builder, + duration: Long, + unit: TimeUnit, + tag: String, + ) { + val request = builder + .addTag(tag) + .setInitialDelay(duration, unit) + .build() + workManager.enqueueUniqueWork(tag, KEEP, request) + } + + override fun onSyncEnabled() { + // no-op + } + + override fun onSyncDisabled() { + Timber.d("Sync-Engine: Sync disabled, clearing unavailable store data") + syncUnavailableStore.clearAll() + cancelNotification() + } + + companion object { + internal const val SYNC_ERROR_NOTIFICATION_ID = 7451 + internal const val ERROR_THRESHOLD_NOTIFICATION_COUNT = 10 + private const val SYNC_ERROR_NOTIFICATION_DELAY = 12L + } +} + +@ContributesWorker(AppScope::class) +class SchedulableErrorNotificationWorker( + val context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params) { + + @Inject + lateinit var syncPausedRepository: SyncUnavailableRepository + + @Inject + lateinit var dispatcherProvider: DispatcherProvider + + override suspend fun doWork(): Result { + withContext(dispatcherProvider.io()) { + syncPausedRepository.triggerNotification() + } + return Result.success() + } + + companion object { + const val SYNC_ERROR_NOTIFICATION_TAG = "com.duckduckgo.sync.notification.error.schedule" + } +} diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncErrorMessagePlugin.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncErrorMessagePlugin.kt new file mode 100644 index 000000000000..5a61e92fdbe8 --- /dev/null +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncErrorMessagePlugin.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.sync.impl.ui + +import android.content.Context +import android.view.View +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.sync.api.SyncMessagePlugin +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject + +@ContributesMultibinding(scope = ActivityScope::class) +class SyncErrorMessagePlugin @Inject constructor() : SyncMessagePlugin { + override fun getView(context: Context): View { + return SyncErrorView(context) + } +} diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncErrorView.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncErrorView.kt new file mode 100644 index 000000000000..e9dd707f803e --- /dev/null +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncErrorView.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.sync.impl.ui + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.widget.FrameLayout +import androidx.core.view.isVisible +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewTreeLifecycleOwner +import androidx.lifecycle.findViewTreeViewModelStoreOwner +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.common.ui.viewbinding.viewBinding +import com.duckduckgo.common.utils.ViewViewModelFactory +import com.duckduckgo.di.scopes.ViewScope +import com.duckduckgo.sync.impl.databinding.ViewSyncErrorWarningBinding +import com.duckduckgo.sync.impl.ui.SyncErrorViewModel.ViewState +import dagger.android.support.AndroidSupportInjection +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@InjectWith(ViewScope::class) +class SyncErrorView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : FrameLayout(context, attrs, defStyle) { + + @Inject + lateinit var viewModelFactory: ViewViewModelFactory + + private var coroutineScope: CoroutineScope? = null + + private val binding: ViewSyncErrorWarningBinding by viewBinding() + + private val viewModel: SyncErrorViewModel by lazy { + ViewModelProvider(findViewTreeViewModelStoreOwner()!!, viewModelFactory)[SyncErrorViewModel::class.java] + } + + override fun onAttachedToWindow() { + AndroidSupportInjection.inject(this) + super.onAttachedToWindow() + + ViewTreeLifecycleOwner.get(this)?.lifecycle?.addObserver(viewModel) + + @SuppressLint("NoHardcodedCoroutineDispatcher") + coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + viewModel.viewState() + .onEach { render(it) } + .launchIn(coroutineScope!!) + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + ViewTreeLifecycleOwner.get(this)?.lifecycle?.removeObserver(viewModel) + coroutineScope?.cancel() + coroutineScope = null + } + + private fun render(viewState: ViewState) { + this.isVisible = viewState.message != null + val message = viewState.message ?: return + binding.syncFailedWarning.setText(context.getString(message)) + } +} diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncErrorViewModel.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncErrorViewModel.kt new file mode 100644 index 000000000000..b79c6022d2f4 --- /dev/null +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncErrorViewModel.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2023 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.sync.impl.ui + +import android.annotation.SuppressLint +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.ViewScope +import com.duckduckgo.sync.api.SyncState +import com.duckduckgo.sync.api.SyncStateMonitor +import com.duckduckgo.sync.impl.R +import com.duckduckgo.sync.impl.error.SyncUnavailableRepository +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map + +@SuppressLint("NoLifecycleObserver") // does not subscribe to app lifecycle +@ContributesViewModel(ViewScope::class) +class SyncErrorViewModel @Inject constructor( + private val syncErrorRepository: SyncUnavailableRepository, + private val syncStateMonitor: SyncStateMonitor, + private val dispatcherProvider: DispatcherProvider, +) : ViewModel(), DefaultLifecycleObserver { + + data class ViewState( + val message: Int? = null, + ) + + private val mutableViewState = MutableStateFlow(ViewState()) + + fun viewState(): Flow = mutableViewState + + override fun onResume(owner: LifecycleOwner) { + super.onResume(owner) + syncStateMonitor.syncState().map { state -> + mutableViewState.emit(mutableViewState.value.copy(message = getMessage(state))) + }.flowOn(dispatcherProvider.io()).launchIn(viewModelScope) + } + + private fun getMessage(state: SyncState): Int? { + if (state == SyncState.OFF) return null + + if (syncErrorRepository.isSyncUnavailable()) { + return R.string.sync_error_warning + } + return null + } +} diff --git a/sync/sync-impl/src/main/res/layout/notification_sync_error.xml b/sync/sync-impl/src/main/res/layout/notification_sync_error.xml new file mode 100644 index 000000000000..4007fb79441d --- /dev/null +++ b/sync/sync-impl/src/main/res/layout/notification_sync_error.xml @@ -0,0 +1,34 @@ + + + + + + + \ No newline at end of file diff --git a/sync/sync-impl/src/main/res/layout/notification_sync_signed_out.xml b/sync/sync-impl/src/main/res/layout/notification_sync_signed_out.xml new file mode 100644 index 000000000000..5e23785e2a6b --- /dev/null +++ b/sync/sync-impl/src/main/res/layout/notification_sync_signed_out.xml @@ -0,0 +1,34 @@ + + + + + + + \ No newline at end of file diff --git a/autofill/autofill-impl/src/main/res/layout/view_credentials_rate_limit_warning.xml b/sync/sync-impl/src/main/res/layout/view_sync_error_warning.xml similarity index 95% rename from autofill/autofill-impl/src/main/res/layout/view_credentials_rate_limit_warning.xml rename to sync/sync-impl/src/main/res/layout/view_sync_error_warning.xml index 914240cc69f5..ecd06e31c4ab 100644 --- a/autofill/autofill-impl/src/main/res/layout/view_credentials_rate_limit_warning.xml +++ b/sync/sync-impl/src/main/res/layout/view_sync_error_warning.xml @@ -18,7 +18,7 @@ Синхронизирането и архивирането е спряно\nСъжаляваме, но функцията за синхронизиране и архивиране в момента не е достъпна. Моля, опитайте отново по-късно. Синхронизирането и архивирането е спряно\nСъжаляваме, но функцията за синхронизиране и архивиране вече не се предлага в тази версия на приложението. Моля, актуализирайте DuckDuckGo до най-новата версия, за да продължите. Синхронизирането и архивирането е спряно\nСъжаляваме, но функцията за синхронизиране и архивиране в момента не е достъпна. Моля, опитайте отново по-късно. + Грешка при синхронизирането\nSync & Backup временно не е налична. + Грешка при синхронизирането\nSync & Backup временно не е налична. + Синхронизирането е на пауза. \nСинхронизирането е на пауза. Ако искате да продължите със синхронизирането на това устройство, свържете се отново с помощта на друго устройство или със своя код за възстановяване. Синхронизиране и архивиране diff --git a/sync/sync-impl/src/main/res/values-cs/strings-sync.xml b/sync/sync-impl/src/main/res/values-cs/strings-sync.xml index 61b00bbb327a..f5b1d0bceb22 100644 --- a/sync/sync-impl/src/main/res/values-cs/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-cs/strings-sync.xml @@ -25,6 +25,9 @@ Funkce synchronizace a zálohování je pozastavená\nOmlouváme se, ale funkce synchronizace a zálohování teď není dostupná. Zkus to znovu později. Funkce synchronizace a zálohování je pozastavená\nOmlouváme se, ale funkce synchronizace a zálohování není v téhle verzi aplikace dostupná. Pro pokračování aktualizuj DuckDuckGo na nejnovější verzi. Funkce synchronizace a zálohování je pozastavená\nOmlouváme se, ale funkce synchronizace a zálohování teď není dostupná. Zkus to znovu později. + Chyba synchronizace\nFunkce Sync & Backup je dočasně nedostupná. + Chyba synchronizace\nFunkce Sync & Backup je dočasně nedostupná. + Synchronizace je pozastavená\nSynchronizaci jsme pozastavili. Pokud chceš tohle zařízení dál synchronizovat, znovu se připoj pomocí jiného zařízení nebo kódu pro obnovení. Synchronizace a zálohování diff --git a/sync/sync-impl/src/main/res/values-da/strings-sync.xml b/sync/sync-impl/src/main/res/values-da/strings-sync.xml index b65f3d31a12a..2e3cb562bb6b 100644 --- a/sync/sync-impl/src/main/res/values-da/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-da/strings-sync.xml @@ -25,6 +25,9 @@ Synkronisering og sikkerhedskopiering sat på pause\nBeklager, men synkronisering og sikkerhedskopiering er ikke tilgængelige i øjeblikket. Prøv igen senere. Synkronisering og sikkerhedskopiering sat på pause\nBeklager, men synkronisering og sikkerhedskopiering er ikke længere tilgængelige i denne app-version. Opdater DuckDuckGo til den nyeste version for at fortsætte. Synkronisering og sikkerhedskopiering sat på pause\nSynkronisering og sikkerhedskopiering er desværre ikke tilgængelige i øjeblikket. Prøv igen senere. + Synkroniseringsfejl\nSync & Backup er midlertidigt utilgængelig. + Synkroniseringsfejl\nSync & Backup er midlertidigt utilgængelig. + Synkronisering sat på pause\nSynkroniseringen er blevet sat på pause. Hvis du vil fortsætte med at synkronisere denne enhed, skal du oprette forbindelse igen ved hjælp af en anden enhed eller din gendannelseskode. Synkronisering og sikkerhedskopiering diff --git a/sync/sync-impl/src/main/res/values-de/strings-sync.xml b/sync/sync-impl/src/main/res/values-de/strings-sync.xml index d3850d0c8215..9785ed00a6ef 100644 --- a/sync/sync-impl/src/main/res/values-de/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-de/strings-sync.xml @@ -25,6 +25,9 @@ Synchronisieren und sichern angehalten\nLeider ist Synchronisieren und sichern derzeit nicht verfügbar. Bitte versuche es zu einem späteren Zeitpunkt erneut. Synchronisieren und Sichern pausiert\nLeider ist Synchronisieren und Sichern in dieser App-Version nicht mehr verfügbar. Bitte aktualisiere DuckDuckGo auf die neueste Version, um fortzufahren. Synchronisieren und Sichern pausiert\nLeider ist Synchronisieren und Sichern derzeit nicht verfügbar. Bitte versuche es zu einem späteren Zeitpunkt erneut. + Synchronisierungsfehler\nSync & Backup ist vorübergehend nicht verfügbar. + Synchronisierungsfehler\nSync & Backup ist vorübergehend nicht verfügbar. + Synchronisierung wurde angehalten\nDie Synchronisierung wurde angehalten. Wenn du die Synchronisierung dieses Geräts fortsetzen möchtest, stelle die Verbindung mit einem anderen Gerät oder deinem Wiederherstellungscode wieder her. Synchronisieren und sichern diff --git a/sync/sync-impl/src/main/res/values-el/strings-sync.xml b/sync/sync-impl/src/main/res/values-el/strings-sync.xml index 50e2b8f9eff3..adade4f9e109 100644 --- a/sync/sync-impl/src/main/res/values-el/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-el/strings-sync.xml @@ -25,6 +25,9 @@ Ο Συγχρονισμός και η δημιουργία αντιγράφων ασφάλειας τέθηκαν σε παύση\nΔυστυχώς, η λειτουργία Συγχρονισμός και δημιουργία αντιγράφων ασφάλειας δεν είναι διαθέσιμη αυτήν τη στιγμή. Ξαναδοκιμάστε αργότερα. Ο Συγχρονισμός και η δημιουργία αντιγράφων ασφάλειας τέθηκαν σε παύση\nΔυστυχώς,η λειτουργία Συγχρονισμός και δημιουργία αντιγράφων ασφάλειας δεν είναι διαθέσιμη σε αυτήν την έκδοση της εφαρμογής. Ενημερώστε το DuckDuckGo στην πιο πρόσφατη έκδοση για να συνεχίσετε. Ο Συγχρονισμός και η δημιουργία αντιγράφων ασφάλειας τέθηκαν σε παύση\nΔυστυχώς, η λειτουργία Συγχρονισμός και δημιουργία αντιγράφων ασφάλειας δεν είναι διαθέσιμη αυτήν τη στιγμή. Ξαναδοκιμάστε αργότερα. + Σφάλμα συγχρονισμού\nΤο Sync & Backup είναι προσωρινά μη διαθέσιμο. + Σφάλμα συγχρονισμού\nΤο Sync & Backup είναι προσωρινά μη διαθέσιμο. + Συγχρονισμός σε παύση\nΟ συγχρονισμός έχει τεθεί σε παύση. Αν θέλετε να συνεχίσετε με τον συγχρονισμό της συσκευής αυτής, συνδεθείτε ξανά χρησιμοποιώντας άλλη συσκευή ή τον κωδικό ανάκτησής σας. Συγχρονισμός και δημιουργία αντιγράφων ασφαλείας diff --git a/sync/sync-impl/src/main/res/values-es/strings-sync.xml b/sync/sync-impl/src/main/res/values-es/strings-sync.xml index 0ae8a146884d..e6ea1b046259 100644 --- a/sync/sync-impl/src/main/res/values-es/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-es/strings-sync.xml @@ -25,6 +25,9 @@ Sincronización y copia de seguridad en pausa\nLo sentimos, pero la sincronización y copia de seguridad no están disponibles en este momento. Inténtalo de nuevo más tarde. Sincronización y copia de seguridad en pausa\nLo sentimos, pero la sincronización y la copia de seguridad ya no están disponibles en esta versión de la aplicación. Actualiza DuckDuckGo a la última versión para continuar. Sincronización y copia de seguridad en pausa\nLo sentimos, pero la sincronización y copia de seguridad no están disponibles en este momento. Inténtalo de nuevo más tarde. + Error de sincronización\nSync & Backup no está disponible temporalmente. + Error de sincronización\nSync & Backup no está disponible temporalmente. + La sincronización está en pausa\nLa sincronización se ha pausado. Si quieres seguir sincronizando este dispositivo, vuelve a conectarte con otro dispositivo o con tu código de recuperación. Sincronización y copia de seguridad diff --git a/sync/sync-impl/src/main/res/values-et/strings-sync.xml b/sync/sync-impl/src/main/res/values-et/strings-sync.xml index 497edc76fcd0..f1690b0dff94 100644 --- a/sync/sync-impl/src/main/res/values-et/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-et/strings-sync.xml @@ -25,6 +25,9 @@ Sünkroonimine ja varundamine peatatud\nKahjuks pole sünkroonimine ja varundamine praegu saadaval. Proovi hiljem uuesti. Sünkroonimine ja varundamine peatatud\nVabandust, kuid sünkroonimine ja varundamine pole selles rakenduse versioonis enam saadaval. Jätkamiseks värskenda DuckDuckGo uusimale versioonile. Sünkroonimine ja varundus on peatatud\nVabandust, sünkroonimine ja varundus pole praegu saadaval. Proovi hiljem uuesti. + Sünkroonimisviga\nSync & Backup pole ajutiselt saadaval. + Sünkroonimisviga\nSync & Backup pole ajutiselt saadaval. + Sünkroonimine on peatatud\nSünkroonimine on peatatud. Kui soovid selle seadme sünkroonimist jätkata, loo uuesti ühendus, kasutades mõnda teist seadet või taastamiskoodi. Sünkroonimine ja varundamine diff --git a/sync/sync-impl/src/main/res/values-fi/strings-sync.xml b/sync/sync-impl/src/main/res/values-fi/strings-sync.xml index 39453c77ffef..1c1b3b74e2a0 100644 --- a/sync/sync-impl/src/main/res/values-fi/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-fi/strings-sync.xml @@ -25,6 +25,9 @@ Synkronointi ja varmuuskopiointi keskeytetty\nSynkronointi ja varmuuskopiointi ei valitettavasti ole tällä hetkellä käytettävissä. Yritä myöhemmin uudelleen. Synkronointi ja varmuuskopiointi keskeytetty\nSynkronointi ja varmuuskopiointi ei valitettavasti ole enää käytettävissä tässä sovellusversiossa. Päivitä DuckDuckGo uusimpaan versioon jatkaaksesi. Synkronointi ja varmuuskopiointi keskeytetty\nSynkronointi ja varmuuskopiointi ei valitettavasti ole tällä hetkellä käytettävissä. Yritä myöhemmin uudelleen. + Synkronointivirhe\nSync & Backup on tilapäisesti poissa käytöstä. + Synkronointivirhe\nSync & Backup on tilapäisesti poissa käytöstä. + Synkronointi on keskeytetty\nSynkronointi on keskeytetty. Jos haluat jatkaa tämän laitteen synkronointia, muodosta yhteys uudelleen toisella laitteella tai palautuskoodilla. Synkronoi ja varmuuskopioi diff --git a/sync/sync-impl/src/main/res/values-fr/strings-sync.xml b/sync/sync-impl/src/main/res/values-fr/strings-sync.xml index 34cfa5b6e5ab..6bae69162106 100644 --- a/sync/sync-impl/src/main/res/values-fr/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-fr/strings-sync.xml @@ -25,6 +25,9 @@ Synchronisation et sauvegarde est suspendu\nDésolé, mais Synchronisation et sauvegarde n’est pas disponible pour le moment. Veuillez réessayer plus tard. Synchronisation et sauvegarde est suspendu\nDésolé, mais Synchronisation et sauvegarde n’est plus disponible dans cette version de l\'application. Veuillez mettre à jour DuckDuckGo avec la dernière version pour continuer. Synchronisation et sauvegarde est suspendu\nDésolé, mais Synchronisation et sauvegarde n’est pas disponible pour le moment. Veuillez réessayer plus tard. + Erreur de synchronisation\nSync & Backup est temporairement indisponible. + Erreur de synchronisation\nSync & Backup est temporairement indisponible. + La synchronisation est suspendue\nLa synchronisation a été suspendue. Si vous souhaitez continuer à synchroniser cet appareil, reconnectez-vous à l’aide d’un autre appareil ou de votre code de récupération. Synchronisation et sauvegarde diff --git a/sync/sync-impl/src/main/res/values-hr/strings-sync.xml b/sync/sync-impl/src/main/res/values-hr/strings-sync.xml index d47c168f6cf2..5af5a1c54f9e 100644 --- a/sync/sync-impl/src/main/res/values-hr/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-hr/strings-sync.xml @@ -25,6 +25,9 @@ Sinkronizacija i sigurnosno kopiranje pauzirani\nŽao nam je, ali sinkronizacija i sigurnosno kopiranje trenutačno nisu dostupni. Pokušaj ponovno nešto kasnije. Sinkronizacija i sigurnosno kopiranje pauzirani\nŽao nam je, ali sinkronizacija i sigurnosno kopiranje više nisu dostupni u ovoj verziji aplikacije. Za nastavak ažuriraj DuckDuckGo na najnoviju verziju. Sinkronizacija i sigurnosno kopiranje su pauzirani\nNažalost, sinkronizacija i sigurnosno kopiranje trenutačno nisu dostupni. Pokušaj ponovno nešto kasnije. + Pogreška u sinkronizaciji\nZnačajka Sync & Backup privremeno je nedostupna. + Pogreška u sinkronizaciji\nZnačajka Sync & Backup privremeno je nedostupna. + Sinkronizacija je pauzirana\nSinkronizacija je pauzirana. Ako želiš nastaviti sinkronizirati ovaj uređaj, ponovno se poveži pomoću drugog uređaja ili šifre za oporavak. Sinkronizacija i sigurnosno kopiranje diff --git a/sync/sync-impl/src/main/res/values-hu/strings-sync.xml b/sync/sync-impl/src/main/res/values-hu/strings-sync.xml index 068b501babd3..13650481c761 100644 --- a/sync/sync-impl/src/main/res/values-hu/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-hu/strings-sync.xml @@ -25,6 +25,9 @@ A szinkronizálás és a biztonságai másolatkészítés szünetel\nA szinkronizálás és biztonsági másolatkészítés jelenleg sajnos nem érhető el. Próbálkozz újra később. A szinkronizálás és a biztonsági másolatkészítés szünetel\nA szinkronizálás és biztonsági másolatkészítés az alkalmazás ezen verziójában sajnos már nem érhető el. A folytatáshoz frissíts a DuckDuckGo a legújabb verziójára. A szinkronizálás és a biztonságai másolatkészítés szünetel\nA szinkronizálás és biztonsági másolatkészítés jelenleg sajnos nem érhető el. Próbálkozz újra később. + Szinkronizálási hiba\nA szinkronizálás és biztonsági mentés átmenetileg nem érhető el. + Szinkronizálási hiba\nA Sync & Backup funkció átmenetileg nem érhető el. + Szinkronizálás szüneteltetve\nA szinkronizálás szüneteltetve lett. Ha folytatni szeretnéd az eszköz szinkronizálását, csatlakozz újra egy másik eszközzel vagy a helyreállítási kóddal. Szinkronizálás és biztonsági mentés diff --git a/sync/sync-impl/src/main/res/values-it/strings-sync.xml b/sync/sync-impl/src/main/res/values-it/strings-sync.xml index 181ef0195912..44a02e3fd88e 100644 --- a/sync/sync-impl/src/main/res/values-it/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-it/strings-sync.xml @@ -25,6 +25,9 @@ Sincronizzazione e backup sospeso\nSiamo spiacenti, ma Sincronizzazione e backup non è attualmente disponibile. Riprova più tardi. Sincronizzazione e backup sospeso\nSpiacenti, Sincronizzazione e backup non è più disponibile in questa versione dell\'app. Per continuare, aggiorna DuckDuckGo all\'ultima versione. Sincronizzazione e backup in pausa\nSiamo spiacenti, ma Sincronizzazione e backup non è attualmente disponibile. Riprova più tardi. + Errore di sincronizzazione\nSync & Backup non è attualmente disponibile. + Errore di sincronizzazione\nSync & Backup non è attualmente disponibile. + Sincronizzazione in pausa\nLa sincronizzazione è stata sospesa. Se vuoi continuare a sincronizzare questo dispositivo, riconnettiti utilizzando un altro dispositivo o il tuo codice di ripristino. Sincronizzazione e backup diff --git a/sync/sync-impl/src/main/res/values-lt/strings-sync.xml b/sync/sync-impl/src/main/res/values-lt/strings-sync.xml index 33038e7f0c7d..f537bde8b041 100644 --- a/sync/sync-impl/src/main/res/values-lt/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-lt/strings-sync.xml @@ -25,6 +25,9 @@ Sinchronizavimas ir atsarginės kopijos kūrimas pristabdyti\nAtsiprašome, bet sinchronizavimas ir atsarginės kopijos kūrimas šiuo metu nepasiekiami. Pabandykite dar kartą vėliau. Sinchronizavimas ir atsarginės kopijos kūrimas pristabdyti\nAtsiprašome, bet sinchronizavimas ir atsarginės kopijos kūrimas šioje programos versijoje nebepasiekiami. Norėdami tęsti, atnaujinkite „DuckDuckGo“ į naujausią versiją. Sinchronizavimas ir atsarginės kopijos kūrimas pristabdyti\nAtsiprašome, bet sinchronizavimas ir atsarginės kopijos kūrimas šiuo metu nepasiekiami. Pabandykite dar kartą vėliau. + Sinchronizavimo klaida\n„Sync & Backup“ laikinai nepasiekiamas. + Sinchronizavimo klaida\n„Sync & Backup“ laikinai nepasiekiamas. + Sinchronizavimas pristabdymas\nSinchronizavimas buvo pristabdytas. Jei norite toliau sinchronizuoti šį įrenginį, iš naujo prisijunkite naudodami kitą įrenginį arba atkūrimo kodą. Sinchronizuoti ir kurti atsarginę kopiją diff --git a/sync/sync-impl/src/main/res/values-lv/strings-sync.xml b/sync/sync-impl/src/main/res/values-lv/strings-sync.xml index 60db9fd36609..d3e77f07a533 100644 --- a/sync/sync-impl/src/main/res/values-lv/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-lv/strings-sync.xml @@ -25,6 +25,9 @@ Sinhronizācija un dublēšana pātraukta\nDiemžēl sinhronizācija un dublēšana pašlaik nav pieejama. Lūdzu, mēģini vēlreiz vēlāk. Sinhronizācija un dublēšana pātraukta\nDiemžēl sinhronizācija un dublēšana šajā lietotnes versijā vairs nav pieejama. Lai turpinātu, lūdzu, atjaunini DuckDuckGo uz jaunāko versiju. Sinhronizācija un dublēšana pātraukta\nDiemžēl sinhronizācija un dublēšana pašlaik nav pieejama. Lūdzu, mēģini vēlreiz vēlāk. + Sinhronizācijas kļūda\nSync & Backup uz laiku nav pieejams. + Sinhronizācijas kļūda\nSync & Backup uz laiku nav pieejams. + \nSinhronizācija ir pārtraukta. Ja vēlies turpināt sinhronizēt šo ierīci, izveido savienojumu no jauna, izmantojot citu ierīci vai savu atgūšanas kodu. Sinhronizācija un dublēšana diff --git a/sync/sync-impl/src/main/res/values-nb/strings-sync.xml b/sync/sync-impl/src/main/res/values-nb/strings-sync.xml index 4e24850e153a..71579d2f5275 100644 --- a/sync/sync-impl/src/main/res/values-nb/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-nb/strings-sync.xml @@ -25,6 +25,9 @@ Synkronisering og sikkerhetskopiering er satt på pause\nBeklager, men synkronisering og sikkerhetskopiering er ikke tilgjengelig for øyeblikket. Prøv igjen senere. Synkronisering og sikkerhetskopiering er satt på pause\nBeklager, men synkronisering og sikkerhetskopiering er ikke lenger tilgjengelig i denne appversjonen. Oppdater DuckDuckGo til den nyeste versjonen for å fortsette. Synkronisering og sikkerhetskopiering er satt på pause\nBeklager, men synkronisering og sikkerhetskopiering er ikke tilgjengelig for øyeblikket. Prøv igjen senere. + Synkroniseringsfeil\nSync & Backup er midlertidig utilgjengelig. + Synkroniseringsfeil\nSync & Backup er midlertidig utilgjengelig. + Synkronisering er satt på pause\nSynkronisering er satt på pause. Hvis du vil fortsette å synkronisere denne enheten, må du koble til på nytt ved hjelp av en annen enhet eller gjenopprettingskoden. Synkronisering og sikkerhetskopiering diff --git a/sync/sync-impl/src/main/res/values-nl/strings-sync.xml b/sync/sync-impl/src/main/res/values-nl/strings-sync.xml index 08231d2a53ab..0202f28aa626 100644 --- a/sync/sync-impl/src/main/res/values-nl/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-nl/strings-sync.xml @@ -25,6 +25,9 @@ \'Synchronisatie en back-up\' is onderbroken\n\'Synchronisatie en back-up\' is momenteel niet beschikbaar. Probeer het later opnieuw. \'Synchronisatie en back-up\' is onderbroken\n\'Synchronisatie en back-up\' is helaas niet langer beschikbaar in deze app-versie. Werk DuckDuckGo bij naar de nieuwste versie om door te gaan. \'Synchronisatie en back-up\' is onderbroken\n\'Synchronisatie en back-up\' is momenteel niet beschikbaar. Probeer het later opnieuw. + Synchronisatiefout\n\'Synchronisatie en back-up\' is tijdelijk niet beschikbaar. + Synchronisatiefout\n\'Sync & Backup\' is tijdelijk niet beschikbaar. + Synchronisatie is onderbroken\nSynchronisatie is onderbroken. Als je dit apparaat wilt blijven synchroniseren, maak dan opnieuw verbinding met een ander apparaat of met je herstelcode. Synchronisatie en back-up diff --git a/sync/sync-impl/src/main/res/values-pl/strings-sync.xml b/sync/sync-impl/src/main/res/values-pl/strings-sync.xml index d4adb950efbd..6bb87fb0d13d 100644 --- a/sync/sync-impl/src/main/res/values-pl/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-pl/strings-sync.xml @@ -25,6 +25,9 @@ Synchronizacja i kopia zapasowa wstrzymana\nNiestety synchronizacja i kopia zapasowa jest obecnie niedostępna. Spróbuj ponownie później. Synchronizacja i kopia zapasowa wstrzymana\nNiestety synchronizacja i kopia zapasowa nie jest już dostępna w tej wersji aplikacji. Aby kontynuować, zaktualizuj DuckDuckGo do najnowszej wersji. Synchronizacja i kopia zapasowa wstrzymana\nNiestety synchronizacja i kopia zapasowa jest obecnie niedostępna. Spróbuj ponownie później. + Błąd synchronizacji\nFunkcja Sync & Backup jest tymczasowo niedostępna. + Błąd synchronizacji\nFunkcja Sync & Backup jest tymczasowo niedostępna. + Synchronizacja jest wstrzymana\nSynchronizacja została wstrzymana. Jeśli chcesz kontynuować synchronizowanie tego urządzenia, ponownie nawiąż połączenie za pomocą innego urządzenia lub użyj kodu odzyskiwania. Synchronizacja i kopia zapasowa diff --git a/sync/sync-impl/src/main/res/values-pt/strings-sync.xml b/sync/sync-impl/src/main/res/values-pt/strings-sync.xml index 1dd56665aa44..57f567cdf87b 100644 --- a/sync/sync-impl/src/main/res/values-pt/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-pt/strings-sync.xml @@ -25,6 +25,9 @@ Sincronização e cópia de segurança em pausa\nLamentamos, mas a sincronização e a cópia de segurança não estão disponíveis de momento. Tenta novamente mais tarde. Sincronização & Cópia de segurança em pausa\nLamentamos, mas a sincronização e a cópia de segurança já não estão disponíveis nesta versão da aplicação. Atualiza o DuckDuckGo para a versão mais recente para continuar. Sincronização e cópia de segurança em pausa\nLamentamos, mas a sincronização e a cópia de segurança não estão disponíveis de momento. Tenta novamente mais tarde. + Erro de sincronização\nO Sync & Backup está temporariamente indisponível. + Erro de sincronização\nO Sync & Backup está temporariamente indisponível. + A sincronização está em pausa\nA sincronização foi colocada em pausa. Se quiseres continuar a sincronizar este dispositivo, inicia sessão novamente noutro dispositivo ou com o teu código de recuperação. Sincronização e cópia de segurança diff --git a/sync/sync-impl/src/main/res/values-ro/strings-sync.xml b/sync/sync-impl/src/main/res/values-ro/strings-sync.xml index 436b8b4cfc2c..2018e8c265e5 100644 --- a/sync/sync-impl/src/main/res/values-ro/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-ro/strings-sync.xml @@ -25,6 +25,9 @@ Sincronizare și backup întreruptă temporar\nNe pare rău, dar Sincronizare și backup este momentan indisponibilă. Încearcă din nou mai târziu. Sincronizare și backup întreruptă temporar\nNe pare rău, dar Sincronizare și backup nu mai este disponibilă în această versiune a aplicației. Actualizează DuckDuckGo la cea mai recentă versiune pentru a continua. Sincronizare și backup întrerupt temporar\nNe pare rău, dar Sincronizare și backup este momentan indisponibil. Încearcă din nou mai târziu. + Eroare de sincronizare\nSync & Backup sunt temporar indisponibile. + Eroare de sincronizare\nSync & Backup este indisponibil temporar. + Sincronizarea este întreruptă\nSincronizarea a fost întreruptă. Dacă dorești să continui sincronizarea acestui dispozitiv, reconectează-te folosind un alt dispozitiv sau codul de recuperare. Sincronizare și copiere de rezervă diff --git a/sync/sync-impl/src/main/res/values-ru/strings-sync.xml b/sync/sync-impl/src/main/res/values-ru/strings-sync.xml index 7c8286036206..b513d7368c19 100644 --- a/sync/sync-impl/src/main/res/values-ru/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-ru/strings-sync.xml @@ -25,6 +25,9 @@ Синхронизация и резервное копирование приостановлены\nК сожалению, функция «Синхронизация и резервное копирование» сейчас недоступна. Повторите попытку позже. Синхронизация и резервное копирование приостановлены\nК сожалению, функция «Синхронизация и резервное копирование» больше не работает в этой версии приложения. Чтобы продолжить, обновите DuckDuckGo до последней версии. Синхронизация и резервное копирование приостановлены\nК сожалению, функция «Синхронизация и резервное копирование» сейчас недоступна. Повторите попытку позже. + Ошибка синхронизации\nФункция «Синхронизация и резервное копирование» временно недоступна. + Ошибка синхронизации\nФункция Sync & Backup («Синхронизация и резервное копирование») временно недоступна. + Синхронизация на паузе\nСинхронизация приостановлена. Чтобы возобновить ее, выполните повторную привязку, используя другое устройство или код восстановления. Синхронизация и резервное копирование diff --git a/sync/sync-impl/src/main/res/values-sk/strings-sync.xml b/sync/sync-impl/src/main/res/values-sk/strings-sync.xml index 229f6fca08f5..d7ca95705144 100644 --- a/sync/sync-impl/src/main/res/values-sk/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-sk/strings-sync.xml @@ -25,6 +25,9 @@ Synchronizácia a zálohovanie boli pozastavené\nJe nám to ľúto, ale Synchronizácia a zálohovanie sú momentálne nedostupné. Prosím, skúste to neskôr znova. Synchronizácia a zálohovanie sú pozastavené\nĽutujeme, ale Synchronizácia a zálohovanie už nie sú v tejto verzii aplikácie k dispozícii. Ak chcete pokračovať, aktualizujte DuckDuckGo na najnovšiu verziu. Synchronizácia a zálohovanie boli pozastavené\nJe nám to ľúto, ale Synchronizácia a zálohovanie sú momentálne nedostupné. Prosím, skúste to neskôr znova. + Chyba synchronizácie\nSynchronizácia a zálohovanie je dočasne nedostupné. + \nSlužba Sync & Backup je dočasne nedostupná. + Synchronizácia je pozastavená\nSynchronizácia bola pozastavená. Ak chcete pokračovať v synchronizácii tohto zariadenia, znova sa pripojte pomocou iného zariadenia alebo prostredníctvom kódu na obnovenie. Synchronizácia a zálohovanie diff --git a/sync/sync-impl/src/main/res/values-sl/strings-sync.xml b/sync/sync-impl/src/main/res/values-sl/strings-sync.xml index 655a1ee01c6e..64194b91579c 100644 --- a/sync/sync-impl/src/main/res/values-sl/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-sl/strings-sync.xml @@ -25,6 +25,9 @@ Sinhronizacija in varnostno kopiranje sta začasno zaustavljena\nSinhronizacija in varnostno kopiranje žal trenutno nista na voljo. Poskusite znova pozneje. Sinhronizacija in varnostno kopiranje sta začasno zaustavljena\nSinhronizacija in varnostno kopiranje žal nista več na voljo v tej različici aplikacije. Če želite nadaljevati, DuckDuckGo posodobite na najnovejšo različico. Sinhronizacija in varnostno kopiranje sta začasno zaustavljena\nSinhronizacija in varnostno kopiranje žal trenutno nista na voljo. Poskusite znova pozneje. + Napaka pri sinhronizaciji\nSinhronizacija in varnostno kopiranje začasno nista na voljo. + Napaka pri sinhronizaciji\nSinhronizacija in varnostno kopiranje začasno nista na voljo. + Sinhronizacija je začasno zaustavljena\nSinhronizacija je bila začasno zaustavljena. Če želite še naprej sinhronizirati to napravo, znova vzpostavite povezavo z uporabo druge naprave ali obnovitvene kode. Sinhronizacija in varnostno kopiranje diff --git a/sync/sync-impl/src/main/res/values-sv/strings-sync.xml b/sync/sync-impl/src/main/res/values-sv/strings-sync.xml index 4e1626d84fa1..7584f0ca4332 100644 --- a/sync/sync-impl/src/main/res/values-sv/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-sv/strings-sync.xml @@ -25,6 +25,9 @@ Synkronisering och säkerhetskopiering har pausats\nSynkronisering och säkerhetskopiering är dessvärre inte tillgängliga just nu. Försök igen senare. Synkronisering och säkerhetskopiering pausad\nTyvärr är synkronisering och säkerhetskopiering inte längre tillgänglig i denna appversion. Uppdatera DuckDuckGo till den senaste versionen för att fortsätta. Synkronisering och säkerhetskopiering pausad\nTyvärr är synkronisering och säkerhetskopiering inte tillgänglig för närvarande. Försök igen senare. + Synkroniseringsfel\nSync & Backup är inte tillgängligt för tillfället. + Synkroniseringsfel\nSync & Backup är inte tillgängligt för tillfället. + Synkroniseringen har pausats\nSynkroniseringen har pausats. Om du vill fortsätta att synkronisera den här enheten ansluter du igen från en annan enhet eller med din återställningskod. Synkronisering och säkerhetskopiering diff --git a/sync/sync-impl/src/main/res/values-tr/strings-sync.xml b/sync/sync-impl/src/main/res/values-tr/strings-sync.xml index 35715842bdc2..d8d863238cab 100644 --- a/sync/sync-impl/src/main/res/values-tr/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values-tr/strings-sync.xml @@ -25,6 +25,9 @@ Senkronizasyon ve Yedekleme Duraklatıldı\nÜzgünüz, Senkronizasyon ve Yedekleme şu anda kullanılamıyor. Lütfen daha sonra tekrar deneyin. Senkronizasyon ve Yedekleme Duraklatıldı\nÜzgünüz, ancak Senkronizasyon ve Yedekleme artık bu uygulama sürümünde kullanılamıyor. Devam etmek için lütfen DuckDuckGo\'yu en son sürüme güncelleyin. Senkronizasyon ve Yedekleme Duraklatıldı\nÜzgünüz, Senkronizasyon ve Yedekleme şu anda kullanılamıyor. Lütfen daha sonra tekrar deneyin. + Senkronizasyon Hatası\nSync & Backup geçici olarak kullanılamıyor. + Senkronizasyon Hatası\nSync & Backup geçici olarak kullanılamıyor. + Senkronizasyonu Duraklatıldı\nSenkronizasyon duraklatıldı. Bu cihazın senkronizasyonuna devam etmek istiyorsanız, başka bir cihaz veya kurtarma kodunuzu kullanarak yeniden bağlanın. Senkronizasyon ve Yedekleme diff --git a/sync/sync-impl/src/main/res/values/strings-sync.xml b/sync/sync-impl/src/main/res/values/strings-sync.xml index cbc88883efb3..acab54d8afcb 100644 --- a/sync/sync-impl/src/main/res/values/strings-sync.xml +++ b/sync/sync-impl/src/main/res/values/strings-sync.xml @@ -25,6 +25,9 @@ Sync & Backup Paused\nSorry, but Sync & Backup is currently unavailable. Please try again later. Sync & Backup Paused\nSorry, but Sync & Backup is no longer available in this app version. Please update DuckDuckGo to the latest version to continue. Sync & Backup Paused\nSorry, but Sync & Backup is currently unavailable. Please try again later. + Sync Error\nSync & Backup is temporarily unavailable. + Sync Error\nSync & Backup is temporarily unavailable. + Sync is Paused\nSync has been paused. If you want to continue syncing this device, reconnect using another device or your recovery code. Sync & Backup diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/engine/FakeNotificationBuilder.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/engine/FakeNotificationBuilder.kt new file mode 100644 index 000000000000..338a8f60f956 --- /dev/null +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/engine/FakeNotificationBuilder.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.sync.impl.engine + +import android.app.Notification +import android.content.Context + +class FakeNotificationBuilder() : SyncNotificationBuilder { + override fun buildSyncPausedNotification( + context: Context, + addNavigationIntent: Boolean, + ): Notification { + return Notification() + } + + override fun buildSyncErrorNotification(context: Context): Notification { + return Notification() + } + + override fun buildSyncSignedOutNotification(context: Context): Notification { + return Notification() + } +} diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/engine/SyncEngineTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/engine/SyncEngineTest.kt index 41b6187dbc31..c2062913072d 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/engine/SyncEngineTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/engine/SyncEngineTest.kt @@ -29,6 +29,7 @@ import com.duckduckgo.sync.api.engine.SyncEngine.SyncTrigger.DATA_CHANGE import com.duckduckgo.sync.api.engine.SyncEngine.SyncTrigger.FEATURE_READ import com.duckduckgo.sync.api.engine.SyncableType.BOOKMARKS import com.duckduckgo.sync.impl.API_CODE +import com.duckduckgo.sync.impl.API_CODE.TOO_MANY_REQUESTS_1 import com.duckduckgo.sync.impl.Result import com.duckduckgo.sync.impl.Result.Success import com.duckduckgo.sync.impl.engine.SyncOperation.DISCARD @@ -62,6 +63,7 @@ internal class SyncEngineTest { private val syncOperationErrorRecorder: SyncOperationErrorRecorder = mock() private val providerPlugins: PluginPoint = mock() private val persisterPlugins: PluginPoint = mock() + private val lifecyclePlugins: PluginPoint = mock() private lateinit var syncEngine: RealSyncEngine @Before @@ -75,6 +77,7 @@ internal class SyncEngineTest { syncOperationErrorRecorder, providerPlugins, persisterPlugins, + lifecyclePlugins, ) whenever(syncStore.isSignedIn()).thenReturn(true) whenever(syncStore.syncingDataEnabled).thenReturn(true) @@ -582,7 +585,7 @@ internal class SyncEngineTest { private fun givenPatchError() { whenever(syncApiClient.patch(any())).thenReturn( - Result.Error(400, "patch failed"), + Result.Error(TOO_MANY_REQUESTS_1.code, "patch failed"), ) } diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/engine/SyncInvalidTokenInterceptorTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/engine/SyncInvalidTokenInterceptorTest.kt new file mode 100644 index 000000000000..dbe1272d19d3 --- /dev/null +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/engine/SyncInvalidTokenInterceptorTest.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.sync.impl.engine + +import androidx.core.app.NotificationManagerCompat +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import com.duckduckgo.common.test.api.FakeChain +import com.duckduckgo.sync.impl.API_CODE.INVALID_LOGIN_CREDENTIALS +import com.duckduckgo.sync.impl.SyncService.Companion.SYNC_PROD_ENVIRONMENT_URL +import com.duckduckgo.sync.impl.engine.SyncInvalidTokenInterceptor.Companion.SYNC_USER_LOGGED_OUT_NOTIFICATION_ID +import okhttp3.Interceptor.Chain +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SyncInvalidTokenInterceptorTest { + + @JvmField @Rule + val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(android.Manifest.permission.POST_NOTIFICATIONS) + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val notificationManager = NotificationManagerCompat.from(context) + private val syncNotificationBuilder = FakeNotificationBuilder() + private val invalidTokenInterceptor = SyncInvalidTokenInterceptor( + context, + notificationManager, + syncNotificationBuilder, + ) + + @Test + fun whenInterceptingSyncResponseInvalidTokenWhenGETThenNotifyUser() { + val chain = givenGetRequest(SYNC_PROD_ENVIRONMENT_URL, INVALID_LOGIN_CREDENTIALS.code) + + invalidTokenInterceptor.intercept(chain) + + notificationManager.activeNotifications + .find { it.id == SYNC_USER_LOGGED_OUT_NOTIFICATION_ID } ?: fail("Notification not found") + } + + @Test + fun whenInterceptingSyncResponseInvalidTokenWhenPATCHThenNotifyUser() { + val chain = givenPatchRequest(SYNC_PROD_ENVIRONMENT_URL, INVALID_LOGIN_CREDENTIALS.code) + + invalidTokenInterceptor.intercept(chain) + + notificationManager.activeNotifications + .find { it.id == SYNC_USER_LOGGED_OUT_NOTIFICATION_ID } ?: fail("Notification not found") + } + + @Test + fun whenInterceptingOtherResponseCodeThenDoNotNotifyUser() { + val chain = givenGetRequest(SYNC_PROD_ENVIRONMENT_URL, 400) + + invalidTokenInterceptor.intercept(chain) + + assertNull(notificationManager.activeNotifications.find { it.id == SYNC_USER_LOGGED_OUT_NOTIFICATION_ID }) + } + + @Test + fun whenInterceptingNonSyncGetRequestThenDoNotNotifyUser() { + val chain = givenGetRequest("https://www.example.com", INVALID_LOGIN_CREDENTIALS.code) + + invalidTokenInterceptor.intercept(chain) + + assertNull(notificationManager.activeNotifications.find { it.id == SYNC_USER_LOGGED_OUT_NOTIFICATION_ID }) + } + + @Test + fun whenInterceptingNonSyncPatchRequestThenDoNotNotifyUser() { + val chain = givenPatchRequest("https://www.example.com", INVALID_LOGIN_CREDENTIALS.code) + + invalidTokenInterceptor.intercept(chain) + + assertNull(notificationManager.activeNotifications.find { it.id == SYNC_USER_LOGGED_OUT_NOTIFICATION_ID }) + } + + private fun givenGetRequest( + url: String, + expectedResponseCode: Int? = null, + ): Chain { + return object : FakeChain(url, expectedResponseCode) { + override fun request() = Request.Builder().url(url).method("GET", null).build() + } + } + + private fun givenPatchRequest( + url: String, + expectedResponseCode: Int? = null, + ): Chain { + return object : FakeChain(url, expectedResponseCode) { + override fun request() = Request.Builder().url(url).method("PATCH", "".toRequestBody()).build() + } + } +} diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/engine/SyncServerUnavailableInterceptorTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/engine/SyncServerUnavailableInterceptorTest.kt new file mode 100644 index 000000000000..9cb05c9348bd --- /dev/null +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/engine/SyncServerUnavailableInterceptorTest.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.sync.impl.engine + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.common.test.api.FakeChain +import com.duckduckgo.sync.impl.API_CODE.TOO_MANY_REQUESTS_1 +import com.duckduckgo.sync.impl.SyncService +import com.duckduckgo.sync.impl.error.SyncUnavailableRepository +import okhttp3.Interceptor.Chain +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify + +@RunWith(AndroidJUnit4::class) +class SyncServerUnavailableInterceptorTest { + + private val syncUnavailableRepository = mock() + + private val serverUnavailableInterceptor = SyncServerUnavailableInterceptor(syncUnavailableRepository) + + @Test + fun whenInterceptingSyncGetResponseTooManyRequestsThenServerUnavailable() { + val chain = givenGetRequest(SyncService.SYNC_PROD_ENVIRONMENT_URL, TOO_MANY_REQUESTS_1.code) + + serverUnavailableInterceptor.intercept(chain) + + verify(syncUnavailableRepository).onServerUnavailable() + } + + @Test + fun whenInterceptingSyncResponseSuccessfulThenServerAvailable() { + val chain = givenGetRequest(SyncService.SYNC_PROD_ENVIRONMENT_URL) + + serverUnavailableInterceptor.intercept(chain) + + verify(syncUnavailableRepository).onServerAvailable() + } + + @Test + fun whenInterceptingSyncPatchResponseTooManyRequestsThenServerUnavailable() { + val chain = givenPatchRequest(SyncService.SYNC_PROD_ENVIRONMENT_URL, TOO_MANY_REQUESTS_1.code) + + serverUnavailableInterceptor.intercept(chain) + + verify(syncUnavailableRepository).onServerUnavailable() + } + + @Test + fun whenInterceptingSyncPatchResponseSuccessfulThenServerAvailable() { + val chain = givenPatchRequest(SyncService.SYNC_PROD_ENVIRONMENT_URL) + + serverUnavailableInterceptor.intercept(chain) + + verify(syncUnavailableRepository).onServerAvailable() + } + + private fun givenGetRequest( + url: String, + expectedResponseCode: Int? = null, + ): Chain { + return object : FakeChain(url, expectedResponseCode) { + override fun request() = Request.Builder().url(url).method("GET", null).build() + } + } + + private fun givenPatchRequest( + url: String, + expectedResponseCode: Int? = null, + ): Chain { + return object : FakeChain(url, expectedResponseCode) { + override fun request() = Request.Builder().url(url).method("PATCH", "".toRequestBody()).build() + } + } +} diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/error/RealSyncUnavailableRepositoryTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/error/RealSyncUnavailableRepositoryTest.kt new file mode 100644 index 000000000000..85c60da697b9 --- /dev/null +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/error/RealSyncUnavailableRepositoryTest.kt @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.sync.impl.error + +import android.content.Context +import android.util.Log +import androidx.core.app.NotificationManagerCompat +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import androidx.work.Configuration +import androidx.work.ListenableWorker +import androidx.work.WorkInfo.State +import androidx.work.WorkManager +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import androidx.work.impl.utils.SynchronousExecutor +import androidx.work.testing.WorkManagerTestInitHelper +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.sync.impl.TestSharedPrefsProvider +import com.duckduckgo.sync.impl.engine.FakeNotificationBuilder +import com.duckduckgo.sync.impl.error.RealSyncUnavailableRepository.Companion.SYNC_ERROR_NOTIFICATION_ID +import com.duckduckgo.sync.impl.error.SchedulableErrorNotificationWorker.Companion.SYNC_ERROR_NOTIFICATION_TAG +import com.duckduckgo.sync.store.SyncUnavailableSharedPrefsStore +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.format.DateTimeFormatter +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RealSyncUnavailableRepositoryTest { + + @JvmField @Rule + val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(android.Manifest.permission.POST_NOTIFICATIONS) + + @get:Rule + var coroutineRule = CoroutineTestRule() + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val notificationManager = NotificationManagerCompat.from(context) + private val syncNotificationBuilder = FakeNotificationBuilder() + private val syncUnavailableStore = SyncUnavailableSharedPrefsStore( + sharedPrefsProv = TestSharedPrefsProvider(context), + ) + private lateinit var workManager: WorkManager + private lateinit var testee: RealSyncUnavailableRepository + + @Before + fun setup() { + initializeWorkManager() + workManager = WorkManager.getInstance(context) + testee = RealSyncUnavailableRepository( + context, + syncUnavailableStore, + notificationManager, + syncNotificationBuilder, + workManager, + ) + } + + @After + fun tearDown() { + workManager.cancelAllWork() + } + + @Test + fun whenServerBecomesAvailableThenSyncAvailable() { + syncUnavailableStore.isSyncUnavailable = true + testee.onServerAvailable() + assertFalse(syncUnavailableStore.isSyncUnavailable) + assertEquals("", syncUnavailableStore.syncUnavailableSince) + } + + @Test + fun whenServerUnavailableThenSyncUnavailable() { + testee.onServerUnavailable() + assertTrue(syncUnavailableStore.isSyncUnavailable) + } + + @Test + fun whenServerUnavailableThenUpdateTimestampOnce() { + testee.onServerUnavailable() + val unavailableSince = syncUnavailableStore.syncUnavailableSince + assertTrue(unavailableSince.isNotEmpty()) + testee.onServerUnavailable() + assertEquals(unavailableSince, syncUnavailableStore.syncUnavailableSince) + } + + @Test + fun whenServerUnavailableThenUpdateCounter() { + testee.onServerUnavailable() + assertTrue(syncUnavailableStore.isSyncUnavailable) + assertEquals(1, syncUnavailableStore.syncErrorCount) + testee.onServerUnavailable() + testee.onServerUnavailable() + testee.onServerUnavailable() + assertEquals(4, syncUnavailableStore.syncErrorCount) + } + + @Test + fun whenServerUnavailableThenScheduleNotification() { + testee.onServerUnavailable() + assertTrue(syncUnavailableStore.isSyncUnavailable) + val syncErrorNotification = workManager.getWorkInfosByTag(SYNC_ERROR_NOTIFICATION_TAG).get() + assertEquals(1, syncErrorNotification.size) + } + + @Test + fun whenErrorCounterReachesThresholdThenTriggerNotification() { + syncUnavailableStore.syncErrorCount = RealSyncUnavailableRepository.ERROR_THRESHOLD_NOTIFICATION_COUNT + testee.onServerUnavailable() + notificationManager.activeNotifications + .find { it.id == SYNC_ERROR_NOTIFICATION_ID } ?: fail("Notification not found") + } + + @Test + fun whenUserNotifiedTodayThenDoNotTriggerNotification() { + syncUnavailableStore.userNotifiedAt = OffsetDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + testee.triggerNotification() + assertNull(notificationManager.activeNotifications.find { it.id == SYNC_ERROR_NOTIFICATION_ID }) + } + + @Test + fun whenUserNotifiedYesterdayThenTriggerNotificationAndUpdateNotificationTimestamp() { + syncUnavailableStore.userNotifiedAt = OffsetDateTime.now().minusDays(1).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + testee.triggerNotification() + notificationManager.activeNotifications + .find { it.id == SYNC_ERROR_NOTIFICATION_ID } ?: fail("Notification not found") + + val today = LocalDateTime.now().toLocalDate() + val lastNotification = LocalDateTime.parse(syncUnavailableStore.userNotifiedAt, DateTimeFormatter.ISO_LOCAL_DATE_TIME).toLocalDate() + assertEquals(today, lastNotification) + } + + @Test + fun whenServerAvailableThenClearAnyScheduledWorkerNotification() { + testee.onServerUnavailable() + val syncErrorNotification = workManager.getWorkInfosByTag(SYNC_ERROR_NOTIFICATION_TAG).get() + assertTrue(syncErrorNotification.first().state == State.ENQUEUED) + testee.onServerAvailable() + val syncErrorNotificationAfterSuccess = workManager.getWorkInfosByTag(SYNC_ERROR_NOTIFICATION_TAG).get() + assertTrue(syncErrorNotificationAfterSuccess.first().state == State.CANCELLED) + } + + @Test + fun whenServerAvailableThenClearNotification() { + syncUnavailableStore.syncErrorCount = RealSyncUnavailableRepository.ERROR_THRESHOLD_NOTIFICATION_COUNT + testee.onServerUnavailable() + notificationManager.activeNotifications + .find { it.id == SYNC_ERROR_NOTIFICATION_ID } ?: fail("Notification not found") + testee.onServerAvailable() + assertNull(notificationManager.activeNotifications.find { it.id == SYNC_ERROR_NOTIFICATION_ID }) + } + + private fun initializeWorkManager() { + val config = Configuration.Builder() + .setMinimumLoggingLevel(Log.DEBUG) + .setExecutor(SynchronousExecutor()) + .setWorkerFactory(testWorkerFactory()) + .build() + + WorkManagerTestInitHelper.initializeTestWorkManager(context, config) + } + + private fun testWorkerFactory(): WorkerFactory { + return object : WorkerFactory() { + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters, + ): ListenableWorker { + return SchedulableErrorNotificationWorker(appContext, workerParameters).also { + it.syncPausedRepository = testee + } + } + } + } +} diff --git a/sync/sync-store/src/main/java/com/duckduckgo/sync/store/SyncUnavailableStore.kt b/sync/sync-store/src/main/java/com/duckduckgo/sync/store/SyncUnavailableStore.kt new file mode 100644 index 000000000000..32f3f7457b8d --- /dev/null +++ b/sync/sync-store/src/main/java/com/duckduckgo/sync/store/SyncUnavailableStore.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.sync.store + +import android.content.SharedPreferences +import androidx.core.content.edit + +interface SyncUnavailableStore { + var isSyncUnavailable: Boolean + var syncUnavailableSince: String + var syncErrorCount: Int + var userNotifiedAt: String + fun clearError() + fun clearAll() +} + +class SyncUnavailableSharedPrefsStore +constructor( + private val sharedPrefsProv: SharedPrefsProvider, +) : SyncUnavailableStore { + + private val encryptedPreferences: SharedPreferences? by lazy { encryptedPreferences() } + + @Synchronized + private fun encryptedPreferences(): SharedPreferences { + return sharedPrefsProv.getSharedPrefs(FILENAME) + } + + override var isSyncUnavailable: Boolean + get() = encryptedPreferences?.getBoolean(KEY_SYNC_UNAVAILABLE, false) ?: false + set(value) { + encryptedPreferences?.edit(commit = true) { + putBoolean(KEY_SYNC_UNAVAILABLE, value) + } + } + + override var syncUnavailableSince: String + get() = encryptedPreferences?.getString(KEY_SYNC_UNAVAILABLE_SINCE, "") ?: "" + set(value) { + encryptedPreferences?.edit(commit = true) { + putString(KEY_SYNC_UNAVAILABLE_SINCE, value) + } + } + + override var syncErrorCount: Int + get() = encryptedPreferences?.getInt(KEY_SYNC_ERROR_COUNT, 0) ?: 0 + set(value) { + encryptedPreferences?.edit(commit = true) { + putInt(KEY_SYNC_ERROR_COUNT, value) + } + } + + override var userNotifiedAt: String + get() = encryptedPreferences?.getString(KEY_SYNC_LAST_NOTIFICATION_AT, "") ?: "" + set(value) { + encryptedPreferences?.edit(commit = true) { + putString(KEY_SYNC_LAST_NOTIFICATION_AT, value) + } + } + + override fun clearError() { + isSyncUnavailable = false + syncErrorCount = 0 + syncUnavailableSince = "" + } + + override fun clearAll() { + encryptedPreferences?.edit(commit = true) { clear() } + } + + companion object { + private const val FILENAME = "com.duckduckgo.sync.unavailable.store.v1" + private const val KEY_SYNC_UNAVAILABLE = "KEY_SYNC_UNAVAILABLE" + private const val KEY_SYNC_UNAVAILABLE_SINCE = "KEY_SYNC_UNAVAILABLE_SINCE" + private const val KEY_SYNC_ERROR_COUNT = "KEY_SYNC_ERROR_COUNT" + private const val KEY_SYNC_LAST_NOTIFICATION_AT = "KEY_SYNC_LAST_NOTIFICATION_AT" + } +} diff --git a/sync/sync-store/src/test/java/com/duckduckgo/sync/store/SyncUnavailableSharedPrefsStoreTest.kt b/sync/sync-store/src/test/java/com/duckduckgo/sync/store/SyncUnavailableSharedPrefsStoreTest.kt new file mode 100644 index 000000000000..ade0a55c4615 --- /dev/null +++ b/sync/sync-store/src/test/java/com/duckduckgo/sync/store/SyncUnavailableSharedPrefsStoreTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.sync.store + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import java.time.OffsetDateTime +import java.time.format.DateTimeFormatter +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SyncUnavailableSharedPrefsStoreTest { + + private val context = InstrumentationRegistry.getInstrumentation().context + private val store = SyncUnavailableSharedPrefsStore(TestSharedPrefsProvider(context)) + + @Test + fun whenIsSyncUnavailableIsSetThenItIsStored() { + store.isSyncUnavailable = true + assertTrue(store.isSyncUnavailable) + } + + @Test + fun whenClearErrorIsCalledThenErrorIsClearedExceptNotifiedAt() { + val timestamp = OffsetDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + store.isSyncUnavailable = true + store.syncErrorCount = 100 + store.syncUnavailableSince = timestamp + store.userNotifiedAt = timestamp + + store.clearError() + + assertFalse(store.isSyncUnavailable) + assertEquals(0, store.syncErrorCount) + assertEquals("", store.syncUnavailableSince) + assertEquals(timestamp, store.userNotifiedAt) + } + + @Test + fun whenClearAllThenStoreEmpty() { + val timestamp = OffsetDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + store.isSyncUnavailable = true + store.syncErrorCount = 100 + store.syncUnavailableSince = timestamp + store.userNotifiedAt = timestamp + + store.clearAll() + + assertFalse(store.isSyncUnavailable) + assertEquals(0, store.syncErrorCount) + assertEquals("", store.syncUnavailableSince) + assertEquals("", store.userNotifiedAt) + } +} From ca3c07d32eceb4d20261725d496173451c7a683d Mon Sep 17 00:00:00 2001 From: Dax the Deployer Date: Mon, 27 May 2024 09:16:02 -0400 Subject: [PATCH 17/17] Updated release notes and version number for new release - 5.202.0 --- app/version/release-notes | 7 +------ app/version/version.properties | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/app/version/release-notes b/app/version/release-notes index 2ed085d1c0fa..a43a0bfbca62 100644 --- a/app/version/release-notes +++ b/app/version/release-notes @@ -1,6 +1 @@ -We improved back button behavior in Custom Tabs. -As usual, we also included some additional bug fixes and improvements. - -For Privacy Pro subscribers: -New VPN feature! If you experienced issues using Android System apps with the VPN connected, you can now disable protection for these apps in VPN Settings > Manage Apps. -Visit https://duckduckgo.com/pro for more information. Privacy Pro is currently available to U.S. residents only. \ No newline at end of file +Bug fixes and other improvements \ No newline at end of file diff --git a/app/version/version.properties b/app/version/version.properties index afa661070dd3..ea0a9ebd32f4 100644 --- a/app/version/version.properties +++ b/app/version/version.properties @@ -1 +1 @@ -VERSION=5.201.1 \ No newline at end of file +VERSION=5.202.0 \ No newline at end of file