diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f8481c3 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{kt,kts}] +ktlint_code_style = android_studio +indent_size = 4 +ij_kotlin_name_count_to_use_star_import = 999 +ij_kotlin_name_count_to_use_star_import_for_members = 999 + +[*.{yml,yaml}] +indent_size = 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..227ba7c --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/app/build/ +/build +/captures +.externalNativeBuild +.cxx +local.properties +/.idea/ +/build-logic/structure/build/ +/core-datastore/build/ +/app/release/ +/app/alpha/ diff --git a/0001-readme_and_add_LeOS_repos.patch b/0001-readme_and_add_LeOS_repos.patch new file mode 100644 index 0000000..bed79a0 --- /dev/null +++ b/0001-readme_and_add_LeOS_repos.patch @@ -0,0 +1,130 @@ +Subject: [PATCH] readme and add LeOS repos +--- +Index: README.md +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/README.md b/README.md +--- a/README.md (revision 606b549c3b7cc2ed522e768543f0428cf91534f4) ++++ b/README.md (revision c17032f99e142d8795ef140a61b160508fc385d9) +@@ -2,72 +2,22 @@ + + Droid-ify + +-# Droid-ify ++# LeOS-Droid + + ### A quick material F-Droid client. + +-[![Github Stars](https://img.shields.io/github/stars/Iamlooker/Droid-ify?color=%2364f573&style=for-the-badge)](https://github.com/Iamlooker/Droid-ify/stargazers) +-[![Github License](https://img.shields.io/github/license/Iamlooker/Droid-ify?color=%2364f573&style=for-the-badge)](https://github.com/Iamlooker/Droid-ify/blob/master/COPYING) +-[![Github Downloads](https://img.shields.io/github/downloads/Iamlooker/Droid-ify/total.svg?color=%23f5ad64&style=for-the-badge)](https://github.com/Iamlooker/Droid-ify/releases/) +-[![Github Latest](https://img.shields.io/github/v/release/Iamlooker/Droid-ify?display_name=tag&color=%23f5ad64&style=for-the-badge)](https://github.com/Iamlooker/Droid-ify/releases/latest) +-[![FDroid Latest](https://img.shields.io/f-droid/v/com.looker.droidify?color=%23f5ad64&style=for-the-badge)](https://f-droid.org/packages/com.looker.droidify) +- +
+ +-## Features ++## :book: Features + +-* Material & Clean design ++* Material F-Droid style ++* No cards or inappropriate animations + * Fast repository syncing +-* Smooth user experience +-* Feature-rich +- +-## Screenshots ++* Standard Android components and minimal dependencies ++* share option + +- + +-## Building and Installing +-1. **Install Android Studio**: +- - Download and install [Android Studio](https://developer.android.com/studio) on your computer if you haven't already. ++## :scroll: License + +-2. **Clone the Repository**: +- - Open Android Studio and select "Project from Version Control." +- - Paste the link to this repository to clone it to your local machine. ++Licensed GPLv3+. \ + +-3. **Build the APK**: +- - In Android Studio, navigate to `Build > APK`. +- - Select "Create New Keystore" and enter the required information, including a password. +- - Wait for the build process to finish. +- +-## TODO +- +-- [ ] Add support for `index-v2` +-- [ ] Add detekt code-analysis +-- [ ] Add GitHub Repo feature +- +-## Contribution +- +-- Pick any issue you would like to resolve +-- Fork the project +-- Open a Pull Request +-- Your PR will undergo review +- +-## Translations +-[![Translation status](https://hosted.weblate.org/widgets/droidify/-/horizontal-auto.svg)](https://hosted.weblate.org/engage/droidify/?utm_source=widget) +- +-## License +- +-``` +-Droid-ify +- +-Copyright (C) 2023 LooKeR +-This program is free software: you can redistribute it and/or modify +-it under the terms of the GNU General Public License as published by +-the Free Software Foundation, either version 3 of the License, or +-(at your option) any later version. +-This program is distributed in the hope that it will be useful, +-but WITHOUT ANY WARRANTY; without even the implied warranty of +-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-GNU General Public License for more details. +-You should have received a copy of the GNU General Public License +-along with this program. If not, see . +-``` +Index: core/data/src/main/java/com/looker/core/data/fdroid/repository/RepoRepository.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/data/src/main/java/com/looker/core/data/fdroid/repository/RepoRepository.kt b/core/data/src/main/java/com/looker/core/data/fdroid/repository/RepoRepository.kt +--- a/core/data/src/main/java/com/looker/core/data/fdroid/repository/RepoRepository.kt (revision 606b549c3b7cc2ed522e768543f0428cf91534f4) ++++ b/core/data/src/main/java/com/looker/core/data/fdroid/repository/RepoRepository.kt (revision c17032f99e142d8795ef140a61b160508fc385d9) +@@ -1,19 +1,19 @@ +-package com.leos.core.data.fdroid.repository ++package com.looker.core.data.fdroid.repository + +-import android.content.Context +-import com.leos.core.model.newer.Repo ++import com.looker.core.domain.newer.Repo + import kotlinx.coroutines.flow.Flow + + interface RepoRepository { + +- fun getRepos(): Flow> ++ suspend fun getRepo(id: Long): Repo ++ ++ fun getRepos(): Flow> + +- suspend fun updateRepo(repo: Repo): Boolean ++ suspend fun updateRepo(repo: Repo) + +- suspend fun enableRepository(repo: Repo, enable: Boolean) ++ suspend fun enableRepository(repo: Repo, enable: Boolean) + +- suspend fun sync(context: Context, repo: Repo, allowUnstable: Boolean): Boolean ++ suspend fun sync(repo: Repo): Boolean + +- suspend fun syncAll(context: Context, allowUnstable: Boolean): Boolean +- +-} +\ No newline at end of file ++ suspend fun syncAll(): Boolean ++} diff --git a/0002-name_change.patch b/0002-name_change.patch new file mode 100644 index 0000000..a7d6499 --- /dev/null +++ b/0002-name_change.patch @@ -0,0 +1,5986 @@ +Subject: [PATCH] name change +--- +Index: app/build.gradle.kts +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/build.gradle.kts b/app/build.gradle.kts +--- a/app/build.gradle.kts (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/app/build.gradle.kts (revision 0458da45767012c818054339c035c122795f8f19) +@@ -76,12 +76,12 @@ + buildTypes { + getByName("debug") { + applicationIdSuffix = ".debug" +- resValue("string", "application_name", "Droid-ify-Debug") ++ resValue("string", "application_name", "LeOS-Droid-Debug") + } + getByName("release") { + isMinifyEnabled = true + isShrinkResources = true +- resValue("string", "application_name", "Droid-ify") ++ resValue("string", "application_name", "LeOS-Droid") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard.pro" +@@ -90,7 +90,7 @@ + create("alpha") { + initWith(getByName("debug")) + applicationIdSuffix = ".alpha" +- resValue("string", "application_name", "Droid-ify Alpha") ++ resValue("string", "application_name", "LeOS-Droid Alpha") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard.pro" +@@ -102,7 +102,7 @@ + buildConfigField( + type = "String", + name = "VERSION_NAME", +- value = "\"v${DefaultConfig.versionName}\"" ++ value = "\"v1.6.4\"" + ) + } + } +Index: app/src/main/AndroidManifest.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml +--- a/app/src/main/AndroidManifest.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/app/src/main/AndroidManifest.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -26,9 +26,9 @@ + UTF-8 +=================================================================== +diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml +new file mode 100644 +--- /dev/null (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/app/src/main/res/drawable/ic_launcher_background.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -0,0 +1,78 @@ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ +Index: app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -1,6 +1,5 @@ + + +- +- +- ++ ++ + +\ No newline at end of file +Index: app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -1,6 +1,5 @@ + + +- +- +- ++ ++ + +\ No newline at end of file +Index: core/common/src/main/res/values-ar/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-ar/strings.xml b/core/common/src/main/res/values-ar/strings.xml +--- a/core/common/src/main/res/values-ar/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-ar/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -216,7 +216,7 @@ + افرض التنظيف + ينظِّف الملفَّات المتكرِّرة + مكِّن المستودع +- أعد تشغيل Droid-ify لرؤية التغييرات ++ أعد تشغيل LeOS-Droid لرؤية التغييرات + يثبّت + في انتظار بدء التثبيت… + حدِّث التطبيقات تلقائيًّا +Index: core/common/src/main/res/values-az/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-az/strings.xml b/core/common/src/main/res/values-az/strings.xml +--- a/core/common/src/main/res/values-az/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-az/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -148,7 +148,7 @@ + İmzasız. Tətbiq siyahısını yoxlamaq mümkün olmadı. İmzasız depolardan proqramları endirərkən diqqətli olun. + Repozitoriya əlçatmazdır + %s tələb edir +- Dəyişiklikləri görmək üçün Droid-ify-ı yenidən başladın ++ Dəyişiklikləri görmək üçün LeOS-Droid-ı yenidən başladın + Səssiz Quraşdırma + Səssiz quraşdırmalar üçün kök icazəsinə icazə verin + Yadda saxla +Index: core/common/src/main/res/values-be/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-be/strings.xml b/core/common/src/main/res/values-be/strings.xml +--- a/core/common/src/main/res/values-be/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-be/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -80,7 +80,7 @@ + Ўсталяванне + Ваша платформа %1$s не падтрымліваецца. Падтрымліваюцца платформы: %2$s. + Рэпазітар недасяжны +- Перазапусціце Droid-ify, каб убачыць змены ++ Перазапусціце LeOS-Droid, каб убачыць змены + Адсутныя функцыі. + Няма даступных прыкладанняў + Пароль адсутнічае +Index: core/common/src/main/res/values-bg/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-bg/strings.xml b/core/common/src/main/res/values-bg/strings.xml +--- a/core/common/src/main/res/values-bg/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-bg/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -202,7 +202,7 @@ + Автоматично актуализиране на приложения + Инсталиране + Опитайте се да инсталирате актуализации автоматично +- Рестартирайте Droid-ify, за да видите промените ++ Рестартирайте LeOS-Droid, за да видите промените + Изчакване за стартиране на инсталацията… + Любими + Активирайте хранилището +Index: core/common/src/main/res/values-bn/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-bn/strings.xml b/core/common/src/main/res/values-bn/strings.xml +--- a/core/common/src/main/res/values-bn/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-bn/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -130,7 +130,7 @@ + সম্প্রতি আপডেট করা হয়েছে + ভান্ডার + এই সংগ্রহস্থল এখনও ব্যবহার করা হয় নি. এটিতে থাকা অ্যাপ্লিকেশনগুলি দেখতে এটি চালু করুন। +- পরিবর্তনগুলি দেখতে Droid-ify পুনরায় চালু করুন ++ পরিবর্তনগুলি দেখতে LeOS-Droid পুনরায় চালু করুন + পুরানো সংস্করণ দেখান + +%d আরো + প্রিয় +Index: core/common/src/main/res/values-ca/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-ca/strings.xml b/core/common/src/main/res/values-ca/strings.xml +--- a/core/common/src/main/res/values-ca/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-ca/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -207,6 +207,6 @@ + Neteja els fitxers redundants + Habiliteu el repositori + S\'està esperant per iniciar la instal·lació… +- Reinicieu Droid-ify per veure els canvis ++ Reinicieu LeOS-Droid per veure els canvis + Instal·lació + +\ No newline at end of file +Index: core/common/src/main/res/values-cs/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-cs/strings.xml b/core/common/src/main/res/values-cs/strings.xml +--- a/core/common/src/main/res/values-cs/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-cs/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -207,7 +207,7 @@ + Vynutit vyčištění + Repozitář nedostupný + Povolit repozitář +- Pro zobrazení změn restartujte Droid-ify ++ Pro zobrazení změn restartujte LeOS-Droid + Čekání na spuštění instalace… + Instalace + Automatická aktualizace aplikací +Index: core/common/src/main/res/values-de/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-de/strings.xml b/core/common/src/main/res/values-de/strings.xml +--- a/core/common/src/main/res/values-de/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-de/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -205,7 +205,7 @@ + Repository aktivieren + Entfernt doppelte Dateien + Installation +- Starten Sie Droid-ify neu, um die Änderungen zu sehen ++ Starten Sie LeOS-Droid neu, um die Änderungen zu sehen + Warten auf den Beginn der Installation … + Apps automatisch aktualisieren + Versuche, Updates automatisch zu installieren +Index: core/common/src/main/res/values-el/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-el/strings.xml b/core/common/src/main/res/values-el/strings.xml +--- a/core/common/src/main/res/values-el/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-el/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -204,7 +204,7 @@ + Αναγκαστική εκκαθάριση + Καθαρίζει τα περιττά αρχεία + Ενεργοποιήστε το αποθετήριο +- Επανεκκινήστε το Droid-ify για να δείτε αλλαγές ++ Επανεκκινήστε το LeOS-Droid για να δείτε αλλαγές + Εγκατάσταση + Αναμονή για έναρξη εγκατάστασης… + Αυτόματη ενημέρωση εφαρμογών +Index: core/common/src/main/res/values-eo/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-eo/strings.xml b/core/common/src/main/res/values-eo/strings.xml +--- a/core/common/src/main/res/values-eo/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-eo/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -70,7 +70,7 @@ + Instalante + Via %1$s platformo ne estas subtenata. Subtenataj platformoj: %2$s. + Deponejo neatingebla +- Rekomenci Droid-ify por vidi ŝanĝojn ++ Rekomenci LeOS-Droid por vidi ŝanĝojn + Uzantnomo mankas + Konektanta… + Ĝisdatigi +Index: core/common/src/main/res/values-es/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-es/strings.xml b/core/common/src/main/res/values-es/strings.xml +--- a/core/common/src/main/res/values-es/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-es/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -208,7 +208,7 @@ + Limpiar archivos redundantes + Habilitar el repositorio + Instalando +- Reinicia Droid-ify para ver los cambios ++ Reinicia LeOS-Droid para ver los cambios + Esperando para iniciar la instalación… + Actualización automática de aplicaciones + Intentar instalar actualizaciones automáticamente +Index: core/common/src/main/res/values-fa/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-fa/strings.xml b/core/common/src/main/res/values-fa/strings.xml +--- a/core/common/src/main/res/values-fa/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-fa/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -201,7 +201,7 @@ + از تم رنگی material you استفاده کنید + مخزن را فعال کنید + پاکسازی اجباری +- برای مشاهده تغییرات، Droid-ify را مجددا راه اندازی کنید ++ برای مشاهده تغییرات، LeOS-Droid را مجددا راه اندازی کنید + موارد دلخواه + مخزن قابل دسترسی نیست + به‌روز رسانی خودکار برنامه‌ها +Index: core/common/src/main/res/values-fi/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-fi/strings.xml b/core/common/src/main/res/values-fi/strings.xml +--- a/core/common/src/main/res/values-fi/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-fi/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -205,7 +205,7 @@ + Puhdistaa tarpeettomat tiedostot + Ohjelmavarasto ei ole tavoitettavissa + Asentaa +- Käynnistä Droid-ify uudelleen nähdäksesi muutokset ++ Käynnistä LeOS-Droid uudelleen nähdäksesi muutokset + Odotetaan asennuksen aloittamista… + Päivitä sovelluksia automaattisesti + Yritä asentaa päivitykset automaattisesti +Index: core/common/src/main/res/values-fr/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-fr/strings.xml b/core/common/src/main/res/values-fr/strings.xml +--- a/core/common/src/main/res/values-fr/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-fr/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -208,7 +208,7 @@ + Nettoyer les fichiers redondants + Activer le dépôt + Installation +- Redémarrez Droid-ify pour voir les changements ++ Redémarrez LeOS-Droid pour voir les changements + En attente du démarrage de l\'installation… + Mise à jour automatique des applis + Essayez d\'installer les mises à jour automatiquement +Index: core/common/src/main/res/values-gl/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-gl/strings.xml b/core/common/src/main/res/values-gl/strings.xml +--- a/core/common/src/main/res/values-gl/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-gl/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -205,7 +205,7 @@ + Forzala limpeza + Activalo repositorio + Instalando +- Reinicia Droid-ify para velos cambios ++ Reinicia LeOS-Droid para velos cambios + Agardando para iniciala instalación… + Actualizacións automática das aplicacións + Tenta instalar actualizacións automaticamente +Index: core/common/src/main/res/values-hi/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-hi/strings.xml b/core/common/src/main/res/values-hi/strings.xml +--- a/core/common/src/main/res/values-hi/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-hi/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -207,7 +207,7 @@ + ऐप्स को ऑटो अपडेट करें + अपडेटस को स्वचालित रूप से इंस्टॉल करने का प्रयास करें + रिपॉजिटरी को सक्षम करें +- बदलाव देखने के लिए Droid-ify को रीस्टार्ट करें ++ बदलाव देखने के लिए LeOS-Droid को रीस्टार्ट करें + इंस्टॉल कर रहा है + गैर-मुक्त घटक हैं + सर्वर नया पैकेट प्रदान करने में विफल रहा। +Index: core/common/src/main/res/values-hu/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-hu/strings.xml b/core/common/src/main/res/values-hu/strings.xml +--- a/core/common/src/main/res/values-hu/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-hu/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -199,7 +199,7 @@ + Nincs internet-hozzáférés + Próbálja automatikusan telepíteni a frissítéseket + Telepítés… +- Indítsa újra a Droid-ify-t a változások megtekintéséhez ++ Indítsa újra a LeOS-Droid-t a változások megtekintéséhez + Várakozás a telepítés megkezdésére … + Kedvencek + Eltakarítja a redundáns fájlokat +Index: core/common/src/main/res/values-in/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-in/strings.xml b/core/common/src/main/res/values-in/strings.xml +--- a/core/common/src/main/res/values-in/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-in/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -205,7 +205,7 @@ + Menunggu untuk memulai pemasangan… + Perbarui aplikasi secara otomatis + Cobalah untuk menginstal pembaruan secara otomatis +- Ulang Droid-ify untuk melihat perubahan ++ Ulang LeOS-Droid untuk melihat perubahan + Memiliki komponen tidak terbuka + Server gagal menyediakan paket baru. + Tidak dapat terhubung ke server +Index: core/common/src/main/res/values-it/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-it/strings.xml b/core/common/src/main/res/values-it/strings.xml +--- a/core/common/src/main/res/values-it/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-it/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -210,7 +210,7 @@ + Abilita il repository + Installazione + In attesa di avviare l\'installazione… +- Riavvia Droid-ify per vedere le modifiche ++ Riavvia LeOS-Droid per vedere le modifiche + Aggiornamento automatico delle app + Prova a installare gli aggiornamenti automaticamente + Ha componenti non liberi +Index: core/common/src/main/res/values-ja/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-ja/strings.xml b/core/common/src/main/res/values-ja/strings.xml +--- a/core/common/src/main/res/values-ja/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-ja/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -205,7 +205,7 @@ + 強制的にクリーンアップ + 冗長ファイルをクリーンアップする + Material You カラーテーマを使用する +- Droid-ify を再起動して変更を確認する ++ LeOS-Droid を再起動して変更を確認する + 不自由なコンポーネントを含む + ホーム画面のスワイプ + コンテンツには安全ではないものが含まれています +Index: core/common/src/main/res/values-kn/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-kn/strings.xml b/core/common/src/main/res/values-kn/strings.xml +--- a/core/common/src/main/res/values-kn/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-kn/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -208,5 +208,5 @@ + ವಸ್ತು ನೀವು + ವಸ್ತು ನೀವು ಬಣ್ಣದ ಥೀಮ್ ಬಳಸಿ + ರೆಪೊಸಿಟರಿಯನ್ನು ತಲುಪಲಾಗುವುದಿಲ್ಲ +- ಬದಲಾವಣೆಗಳನ್ನು ನೋಡಲು Droid-ify ಅನ್ನು ಮರುಪ್ರಾರಂಭಿಸಿ ++ ಬದಲಾವಣೆಗಳನ್ನು ನೋಡಲು LeOS-Droid ಅನ್ನು ಮರುಪ್ರಾರಂಭಿಸಿ + +\ No newline at end of file +Index: core/common/src/main/res/values-ko/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-ko/strings.xml b/core/common/src/main/res/values-ko/strings.xml +--- a/core/common/src/main/res/values-ko/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-ko/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -202,7 +202,7 @@ + 비밀번호 + 권한 + +%d개 더 +- Droid-ify를 다시 시작하여 변경 사항 확인 ++ LeOS-Droid를 다시 시작하여 변경 사항 확인 + 자동 설치 + 설정 + 안전하지 않은 알고리즘을 사용하여 서명됨 +Index: core/common/src/main/res/values-lt/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-lt/strings.xml b/core/common/src/main/res/values-lt/strings.xml +--- a/core/common/src/main/res/values-lt/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-lt/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -203,7 +203,7 @@ + Automatinis programų atnaujinimas + Pabandykite automatiškai įdiegti naujinimus + Diegimas +- Iš naujo paleiskite Droid-ify, kad pamatytumėte pakeitimus ++ Iš naujo paleiskite LeOS-Droid, kad pamatytumėte pakeitimus + Laukiama, kol bus pradėtas diegimas… + Mėgstamiausi + Saugykla nepasiekiama +Index: core/common/src/main/res/values-lv/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-lv/strings.xml b/core/common/src/main/res/values-lv/strings.xml +--- a/core/common/src/main/res/values-lv/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-lv/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -203,7 +203,7 @@ + Automātiski atjaunināt lietotnes + Mēģiniet automātiski instalēt atjauninājumus + Instalēšana +- Restartējiet Droid-ify, lai redzētu izmaiņas ++ Restartējiet LeOS-Droid, lai redzētu izmaiņas + Gaida instalēšanas sākšanu… + Izlase + Iespējot repozitoriju +Index: core/common/src/main/res/values-ml/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-ml/strings.xml b/core/common/src/main/res/values-ml/strings.xml +--- a/core/common/src/main/res/values-ml/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-ml/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -100,7 +100,7 @@ + പാസ്‌വേഡ് കാണുന്നില്ല + അനുമതികൾ + %s ആവശ്യമാണ് +- മാറ്റങ്ങൾ കാണുന്നതിന് Droid-ify പുനരാരംഭിക്കുക ++ മാറ്റങ്ങൾ കാണുന്നതിന് LeOS-Droid പുനരാരംഭിക്കുക + സോക്സ് പ്രോക്സി + അടുക്കൽ ക്രമം + സിസ്റ്റം +Index: core/common/src/main/res/values-nb-rNO/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-nb-rNO/strings.xml b/core/common/src/main/res/values-nb-rNO/strings.xml +--- a/core/common/src/main/res/values-nb-rNO/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-nb-rNO/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -208,7 +208,7 @@ + Installer nye versjoner av programmer automatisk + Venter på å starte installasjon … + Installerer +- Start Droid-ify på ny for å ta i bruk endringene ++ Start LeOS-Droid på ny for å ta i bruk endringene + Har ufrie komponenter + Hjemmeskjermsdragning + Inneholder sensurerbart innhold +Index: core/common/src/main/res/values-nl/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-nl/strings.xml b/core/common/src/main/res/values-nl/strings.xml +--- a/core/common/src/main/res/values-nl/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-nl/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -199,7 +199,7 @@ + Laat de topbalk uitbreiden en instorten + Apps automatisch bijwerken + Installeren +- Start Droid-ify opnieuw om de wijzigingen te zien ++ Start LeOS-Droid opnieuw om de wijzigingen te zien + Favorieten + Materiaal jij + Gebruik materiaal jij kleurthema +Index: core/common/src/main/res/values-nn/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-nn/strings.xml b/core/common/src/main/res/values-nn/strings.xml +--- a/core/common/src/main/res/values-nn/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-nn/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -204,7 +204,7 @@ + Slå på samlinga + Nytt «Material You»-letar + Material You +- Byrje om Droid-ify for å sjå brigde ++ Byrje om LeOS-Droid for å sjå brigde + Legg inn + Ventar på å leggja inn … + Oppdater appane sjølvverkande +Index: core/common/src/main/res/values-or/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-or/strings.xml b/core/common/src/main/res/values-or/strings.xml +--- a/core/common/src/main/res/values-or/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-or/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -207,7 +207,7 @@ + ଅଟୋ ଅପଡେଟ୍ ଆପ୍ସ + ସ୍ୱୟଂଚାଳିତ ଭାବରେ ଅଦ୍ୟତନଗୁଡିକ ସଂସ୍ଥାପନ କରିବାକୁ ଚେଷ୍ଟା କରନ୍ତୁ + ସଂସ୍ଥାପନ କରୁଅଛି +- ପରିବର୍ତ୍ତନଗୁଡିକ ଦେଖିବାକୁ Droid-ify ପୁନ Rest ଆରମ୍ଭ କରନ୍ତୁ ++ ପରିବର୍ତ୍ତନଗୁଡିକ ଦେଖିବାକୁ LeOS-Droid ପୁନ Rest ଆରମ୍ଭ କରନ୍ତୁ + ସ୍ଥାପନ ଆରମ୍ଭ କରିବାକୁ ଅପେକ୍ଷା କରିଛି… + ଅନାବଶ୍ୟକ ଉପାଦାନଗୁଡ଼ିକ ଅଛି + ସର୍ଭର ନୂତନ ପ୍ୟାକେଟ ପ୍ରଦାନ କରିବାରେ ବିଫଳ ହୋଇଛି । +Index: core/common/src/main/res/values-pa/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-pa/strings.xml b/core/common/src/main/res/values-pa/strings.xml +--- a/core/common/src/main/res/values-pa/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-pa/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -205,7 +205,7 @@ + ਬੇਲੋੜੀਆਂ ਫਾਈਲਾਂ ਨੂੰ ਸਾਫ਼ ਕਰਦਾ ਹੈ + ਐਪਸ ਨੂੰ ਆਟੋ ਅੱਪਡੇਟ ਕਰੋ + ਇੰਸਟਾਲ ਕਰ ਰਿਹਾ ਹੈ +- ਤਬਦੀਲੀਆਂ ਦੇਖਣ ਲਈ Droid-ify ਨੂੰ ਰੀਸਟਾਰਟ ਕਰੋ ++ ਤਬਦੀਲੀਆਂ ਦੇਖਣ ਲਈ LeOS-Droid ਨੂੰ ਰੀਸਟਾਰਟ ਕਰੋ + ਰਿਪੋਜ਼ਟਰੀ ਨੂੰ ਸਮਰੱਥ ਬਣਾਓ + ਅੱਪਡੇਟਾਂ ਨੂੰ ਸਵੈਚਲਿਤ ਤੌਰ \'ਤੇ ਇੰਸਟਾਲ ਕਰਨ ਦੀ ਕੋਸ਼ਿਸ਼ ਕਰੋ + ਇੰਸਟਾਲੇਸ਼ਨ ਸ਼ੁਰੂ ਕਰਨ ਦੀ ਉਡੀਕ ਕੀਤੀ ਜਾ ਰਹੀ ਹੈ… +Index: core/common/src/main/res/values-pl/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-pl/strings.xml b/core/common/src/main/res/values-pl/strings.xml +--- a/core/common/src/main/res/values-pl/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-pl/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -207,7 +207,7 @@ + Silnik motywu Monet + Włącz repozytorium + Instalowanie +- Uruchom Droid-ify ponownie, aby zastosować zmiany ++ Uruchom LeOS-Droid ponownie, aby zastosować zmiany + Oczekiwanie na rozpoczęcie instalacji… + Ulubione + Wymuś oczyszczenie +Index: core/common/src/main/res/values-pt-rBR/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-pt-rBR/strings.xml b/core/common/src/main/res/values-pt-rBR/strings.xml +--- a/core/common/src/main/res/values-pt-rBR/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-pt-rBR/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -211,7 +211,7 @@ + Repositório inacessível + Ativar o repositório + Instalando +- Reinicie o Droid-ify para ver as alterações ++ Reinicie o LeOS-Droid para ver as alterações + Aguardando para iniciar a instalação… + Atualizar aplicativos automaticamente + Tente instalar atualizações automaticamente +Index: core/common/src/main/res/values-pt/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-pt/strings.xml b/core/common/src/main/res/values-pt/strings.xml +--- a/core/common/src/main/res/values-pt/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-pt/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -209,7 +209,7 @@ + Ativar repositório + À espera para começar a instalação… + A instalar +- Reiniciar Droid-ify para ver as alterações ++ Reiniciar LeOS-Droid para ver as alterações + Atualizações automáticas + Tentar atualizar aplicações automaticamente + Tem componentes não livres +Index: core/common/src/main/res/values-ro/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-ro/strings.xml b/core/common/src/main/res/values-ro/strings.xml +--- a/core/common/src/main/res/values-ro/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-ro/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -210,6 +210,6 @@ + Actualizare automată a aplicațiilor + Se așteaptă începerea instalării… + Încercați să instalați actualizările automat +- Reporniți Droid-ify pentru a vedea modificările ++ Reporniți LeOS-Droid pentru a vedea modificările + Instalarea + +\ No newline at end of file +Index: core/common/src/main/res/values-ru/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-ru/strings.xml b/core/common/src/main/res/values-ru/strings.xml +--- a/core/common/src/main/res/values-ru/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-ru/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -211,7 +211,7 @@ + Произвести очистку + Удаление лишних файлов + Включить репозиторий +- Перезапустите Droid-ify для применения изменений ++ Перезапустите LeOS-Droid для применения изменений + Установка + Ожидание начала установки… + Автообновление приложений +Index: core/common/src/main/res/values-ryu/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-ryu/strings.xml b/core/common/src/main/res/values-ryu/strings.xml +--- a/core/common/src/main/res/values-ryu/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-ryu/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -208,7 +208,7 @@ + ちゅーしちちーがクリーンアップさびーん + じょうはべるファイルクリーンアップさびーん + Material Youぬカラーテーマしーようさびーん +- Droid-ifyさいきぬーんちへんかんかくにんすん ++ LeOS-Droidさいきぬーんちへんかんかくにんすん + ふじゆーるなコンポーネントくくまびーん + ホームやしがみんぬスワイプ + コンテンツんかえーあんさんやあらんむんがくくまっとーいびーん +Index: core/common/src/main/res/values-sl/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-sl/strings.xml b/core/common/src/main/res/values-sl/strings.xml +--- a/core/common/src/main/res/values-sl/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-sl/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -211,7 +211,7 @@ + Aktiviraj skladišče + Odstrani podvojene datoteke + Nameščanje +- Znova zaženite Droid-ify, da vidite spremembe ++ Znova zaženite LeOS-Droid, da vidite spremembe + Čakanje na začetek namestitve … + Samodejno posodobite aplikacije + Poskusite samodejno namestiti posodobitve +Index: core/common/src/main/res/values-sr/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-sr/strings.xml b/core/common/src/main/res/values-sr/strings.xml +--- a/core/common/src/main/res/values-sr/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-sr/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -72,7 +72,7 @@ + Инсталирање + Ваша платформа %1$s није подржана. Подржане платформе: %2$s. + Репозиторијум је недоступан +- Рестартујте Droid-ify да бисте видели промене ++ Рестартујте LeOS-Droid да бисте видели промене + Недостаје корисничко име + Повезивање… + Ажурирање +Index: core/common/src/main/res/values-sv/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-sv/strings.xml b/core/common/src/main/res/values-sv/strings.xml +--- a/core/common/src/main/res/values-sv/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-sv/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -205,7 +205,7 @@ + Tvinga städning + Aktivera arkivet + Installerar +- Starta om Droid-ify för att se ändringarna ++ Starta om LeOS-Droid för att se ändringarna + Väntar på att starta installationen… + Uppdatera appar automatiskt + Försök att installera uppdateringar automatiskt +Index: core/common/src/main/res/values-tl/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-tl/strings.xml b/core/common/src/main/res/values-tl/strings.xml +--- a/core/common/src/main/res/values-tl/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-tl/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -144,7 +144,7 @@ + Bilang ng mga aplikasyon + Nagtataguyod ng mga serbisyo na hindi libre sa network + Uri ng proxy +- I-restart ang Droid-ify para makita ang mga pagbabago ++ I-restart ang LeOS-Droid para makita ang mga pagbabago + Magpakita ng higit pa + Nilagdaan gamit ang hindi ligtas na algorithm + Pag-uuri ng pagkakasunud-sunod +Index: core/common/src/main/res/values-tr/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-tr/strings.xml b/core/common/src/main/res/values-tr/strings.xml +--- a/core/common/src/main/res/values-tr/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-tr/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -206,7 +206,7 @@ + Gereksiz dosyaları temizler + Depoyu etkinleştir + Kuruluyor +- Değişiklikleri görmek için Droid-ify\'ı yeniden başlatın ++ Değişiklikleri görmek için LeOS-Droid\'ı yeniden başlatın + Kurulumun başlatılması bekleniyor… + Uygulamaları otomatik güncelle + Güncellemeleri otomatik olarak kurmaya çalış +Index: core/common/src/main/res/values-uk/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-uk/strings.xml b/core/common/src/main/res/values-uk/strings.xml +--- a/core/common/src/main/res/values-uk/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-uk/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -212,7 +212,7 @@ + Автооновлення застосунков + Намагатися встановити оновлення автоматично + Встановлення +- Перезапустіть Droid-ify, щоб побачити зміни ++ Перезапустіть LeOS-Droid, щоб побачити зміни + Увімкніть репозиторій + Очікування початку встановлення… + Має невільні компоненти +Index: core/common/src/main/res/values-vi/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-vi/strings.xml b/core/common/src/main/res/values-vi/strings.xml +--- a/core/common/src/main/res/values-vi/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-vi/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -205,7 +205,7 @@ + Dọn dẹp các tệp dư thừa + Kho lưu trữ không thể truy cập + Kích hoạt kho lưu trữ +- Khởi động lại Droid-ify để xem thay đổi ++ Khởi động lại LeOS-Droid để xem thay đổi + Vuốt màn hình chính + Chứa nội dung không an toàn cho công việc + Có các thành phần không tự do +Index: core/common/src/main/res/values-zh-rCN/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-zh-rCN/strings.xml b/core/common/src/main/res/values-zh-rCN/strings.xml +--- a/core/common/src/main/res/values-zh-rCN/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-zh-rCN/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -205,7 +205,7 @@ + 启用此存储库 + Material You 设计 + 等待开始安装… +- 重新启动 Droid-ify 使更改生效 ++ 重新启动 LeOS-Droid 使更改生效 + 包含非自由组件 + 服务器未能提供新数据包。 + 无法连接服务器 +Index: core/common/src/main/res/values-zh-rTW/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values-zh-rTW/strings.xml b/core/common/src/main/res/values-zh-rTW/strings.xml +--- a/core/common/src/main/res/values-zh-rTW/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values-zh-rTW/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -204,7 +204,7 @@ + 自動更新程式 + 嘗試自動安裝更新 + 安裝 +- 重新啟動 Droid-ify 查看更變 ++ 重新啟動 LeOS-Droid 查看更變 + 等待開始安裝… + 具有非自由元件 + 無法連線至伺服器 +Index: core/common/src/main/res/values/strings.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/res/values/strings.xml b/core/common/src/main/res/values/strings.xml +--- a/core/common/src/main/res/values/strings.xml (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/core/common/src/main/res/values/strings.xml (revision 0458da45767012c818054339c035c122795f8f19) +@@ -171,7 +171,7 @@ + Unsigned. Could not verify the application list. Be careful downloading applications from unsigned repositories. + Repository unreachable + Requires %s +- Restart Droid-ify to see changes ++ Restart LeOS-Droid to see changes + Silent Install + Allow root permission for silent installs + Save +Index: metadata/en-US/short_description.txt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/metadata/en-US/short_description.txt b/metadata/en-US/short_description.txt +--- a/metadata/en-US/short_description.txt (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/metadata/en-US/short_description.txt (revision 0458da45767012c818054339c035c122795f8f19) +@@ -1,1 +1,1 @@ +-Material-ify with Droid-ify. +\ No newline at end of file ++Material-ify with LeOS-Droid. +Index: settings.gradle.kts +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/settings.gradle.kts b/settings.gradle.kts +--- a/settings.gradle.kts (revision c17032f99e142d8795ef140a61b160508fc385d9) ++++ b/settings.gradle.kts (revision 0458da45767012c818054339c035c122795f8f19) +@@ -15,7 +15,7 @@ + } + } + +-rootProject.name = "Droid-ify" ++rootProject.name = "LeOS-Droid" + include( + ":app", + ":core:common", +diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png +index cbc409571658e8c4c887051b768469afd255f7f6..db2ed4c70a962646c834da89547d49bae5ded3d8 +GIT binary patch +literal 128798 +zc%007_al|>|G&NWUXd+ZlH_orGAo+4V}!~$Mj7E8o3e5$6glA)mA$uPWSodJ4l<5$ +zkgVf~b8wvVxsTr8|Ka;XI;U>;b>GkN7|-i^VtdV;n?r(wj*gDo(&Ew$IyweA+8;I+ +z;IF^oKMv^VV(2U{ncNC@U4O%z@4WOgSvmHcgfrtip{8#)R0Q58%Dp?+S16RTa6`q> +zs>b8;Q^j+io?I~Xd(J1iAPM=Rv=*Ris7u`<6K8tc4@Ww5>tv(e%@y(v3s9;}5%T5PIC#Nc8Ptd`Pp5eYc0UxYw({1j{iw?(Pn*gCY>WDm(} +z>z-!aN+qHXm)DXry{vP``8e4`cSMLpU5Qub7zwV)n+m +zs6-eHQ#u?7q3B2js_K3UP7q;ZX)M3*>}0I9kHD$*Nru)pO +zrWWEjJE{m*i#Rphec*r&Q%JVRcsY#7S2A!3uEp6QdKIuaQ6>cX+@iTN=wi3>tP>2g +z=C*8619N+IH=I5`xU1eek>yTr7DG6BRTnJcjVxfSgR;3vzi)v;o=4dPKf{S?ABL6S +z7|>n_14JTXP0?DxT5+gcJ?tAr9G(O3MQJ>9)_A3100~aQT|#Fekd}MT$xS%0`_l|3 +zHx2<2Wk<2CLK-q!j-KHW`x-MPEl +z1JU{>1WEMmdsN3$+0JS$H-Zarq(}lso_DYy40oQBX=gj&0$PtF!xA3`@6- +zJs)5F0o;-&!Qk9zZ0v+^WJvYWX-qhLQUz7xrS|1Iw?P5KW)cW>59BiZym{wVk@!78PC_aGY@wz!f?h* +zl-WNMtkJ@T_I$Vei#Z@v_ +zja?I24LN+>t^~*7Y;!B={R!@9C-;8#C?gqG&oXt6`iouuVrg98E=}#tM?UxH8lPm= +zj#*xN>LMxfRGp^<+U{!TI^TfWCr($XvxT?GbP0(`T@KP<-vaX`o{XEY@75Q*avRI) +z4I5~2hNB&{D-0!!OuF@dhjGuc_-7{4wXyo0syhnP8lH}j$&KGx-drTtun1H{i`>hP +z{CmREGQM3x7Ev=B+`6IjkuPU11UQ90 +z_%jFHmlS9}Arjv1GE0%mJuFtQmY-zxhA_M219{Pm{gjvI{ZRNyBurqq`#H2Lojw{h +zC;29knHS>>?#iGO6`%-|NM4vb`pxqxYQ$m~-_T$)ggwFHSU)caMvvUJb2m#>6o5o3p92u?clz?&rV}AmNgCUlRS!tIjo2ua6c?6@Vd; +z^^`J2?6Z81or0yvFi#V#*0kp*C+*C5m(Lf3;@CVc#zUpW@<1#lGhi|A+15hgj>ety +zgfOzptSGmpU9iyb=RoOa_q|nfQt$g>d>gdmvq+;pT%(WEB9hoWY5nDIVao=@bB=bSR;giTfcGSET`VBQyMN55Fkq*^FS)IsMgWflGXn +z8g6cNYCvKNJa)N<>5XS3pYfZ6q@Vj`t7kcD8r(c>O;AJYAOsbCPKpd=Q!Ag}@*MnR +zwQr~{Ez+yL+{&>0xAG2W2y%$}0N+xtsF7+b`}eRXpK+8?QxqI@TaWvt+c)OiSB^J7 +zex3UI!e4p?=4IwYv;(=SzDf=E`#=uMz~LIB4X^C2`5RpU_(g!dUT^Ep-1?=V_K^p&){{W*M)5pT#@ +zelI04Rwn)g$jR%ITPz{cdOmNNH0a-OP{`s}?`t?Z)?RllNc9?efd8Vem}?LsUSflM +za_VNS_Wf^mx#xn?$dL@*SMp*V>S}A;x*dgt$}e~>av6jQdY_qi=*vmcH7*g>E{$+u +z51EMx*bF&o=!lxu2Q;x|Bnw}a&@ZMLu&K1wNU&!Ly|S`3&njud%VvY +zw~Yb~-%oYk>GFTc=$R;azO}Yk*u&!Cg=XIHsU3I6(a~<_@{}!^%G>Vq{UcwLf*wmC +zS-GyT!$apM_+|ZW`r8FmJ)jT2#wd}!ERS-d7b(ws@`_UlO_7S%0!zlpZgKU`jkCIS +zmBnlSZg3~;-b<6#W_DQgcJJ@m&qW0&-nnz!U}{nU`feyJBjZHyW*+A6qhsy4q%j +zj8^tL_}p0-)>RE$UR@$=apWH+KX88N%2?T2#FGg=FaEr);Nt!5q4yuelarE6TRP5H +zr_MozK@-)?S8Ss>>MqpqQdn1_Epm-3vgWXo-j$=TjHeC--A6ineDh0Cg%!~a_HQ0> +zwl6fKG@N<+PAARcBDdUbM9Pm56YDQIDYx2_1N%bmslwR$ki?6d(Jl5nr7FRdDRNui +zv=(OW4shcUEAd)-p^1BG@J(@s#?OVp~B1#)VaIw{CCj(Vej +zjJMa~v*MYxr@n3({raWDAFl;9`%bXBl~IuR3JFT~S%xI5Now}DJX%3hK#a +zAF>y`UVl@*ae@I6^!Jaj`DnrLMAM{o=d5WbYDq+6f?DbuIN!3k{To}0Kk!x4ChzRp +zH+b;(xEIX!es3g8$Gb2^afM{%7?WfSn|so=A|Nba-IC1VSh{HVCfT?`G)2mOAfl# +z{NeXR16^v~DxMuIVtQ@l+Kpfj*CBK`WsH +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 +zE09YQ1?Ivkum!tq8tQGd)XZj61-uSg +z0qI8i0gZd}T6Ouv_1PFhYyN7V-uZLJM0c{0FTV)1i#lJ~l__ba7EyWfcQH8tSU#fzs(t`7B`XsYTnIUM&K+N{BRKWz5~;Hv?a +z2uD6^+6}<~D1mwgQvJaKLoX_1<{Yl84U}ScGv@z5^cUZuZo4nzJZ{|%c6a+I7gO3( +zlZf_}B=amwt#A2VCm%LW%`X^B2=b2PZ|_LK8iOLTgyO)0iz}l`#PI6{_rx7vXBU2W +zAn$eGtx|_(^p7PKUH4s?i^V0ZaFrQ +z)}^DNs5ord%$DZg;@znY0Lc9DhMY&!!p8u-dWR%PBQ3Hk1`ErA)B@xSb%Gye!+Q>r +zPtsFH6~I|!HZ&gYM7U})BKcb+@Tnc#A%r&HN!eLL17Znzh_ +z_C~EdKXqS^G)^a$_WRk@1Z~wU7SQ&xdOuxa_)t()Z +zr`)4%yTovLWQZM3Y_KNjv$;*pQIoo;R_@ZCSB)Hz +zs(vbbtz9`=vZ9Q8Vagq~+)0Y=RtxuZrRRuXch6v$-ldz~a~{B_GAsc8UEIWmC}5Fr^)qY0CSNBq&ReDTqXZ3>k#-&f&=eJf5OeDItMXk +zX|u)B#XyDSZeAsQeBK3-?g_U9udSG(CS{>(;R=wPsH+4lLd&{S1^PjB%;-Qa^j?&B +z^dHw={W>~P+0Q1Xax6HwS2~fc3!A3R; +zwHp}=phVjecJ|@l^EWrZlrHV(OIKE3N14 +z)}SspQ9={Yk62qtlA=u(=r*B#WWl7vvw1bao4m61ljQBK^WdWc)7RYg;is(ErN4%2 +zyA2d;r8R{8?t1d+Gfes+F|Otv%A%7ugoRnFwU;qc6|YMYljO_1{kEorr(tcpP!-N$ +zwM-m9=(8@afwlTbH#xPJt3A?w74jFi_HVyiuhk*6qY%m3*Q7r)q04O<$miFHj`PTX +zEJ(oM)WM7}#T5gsz(ZYrCno?xaZ?W50E;YysIC$7b|W27nuL&ffK!hE^Mm|+fHh^H +zR9SV(?IUoU2r44#nyd6E+h9Teat*x5eDV9lI~+3^MAgf0q!>9n>?yk1*K*YjyeJ9` +zHToFxAKPGjgv;ZG4>Kpz?pGMh`RLL?yPWk1_j3D6oJYSGj_bz585yabSutR95g2|Y +zf^xa6=5b3Gc;$3HBtm76XDexQ`(Yhy!MOsgv!+bixD4AYN4|^i0Z$%qlZh{;tqIDA +zLyOgF_+Pto@+{`8=i$rlj&lqv7_iwauF4SCQ=eeQI( +z#-5Rba|e&f;|%%*<*W|b3S1k8Uo6M^6HDu0jR42kWRv~hbCVIJ)&-{`vZb0dr)85+ +z`9IQ{kmdbxuN2=`sflvzzhN9^l;CZ=sVI}!A|R<1h!`^w^d1=v1&@AT&PP^Utke+W +z46*g*?=$T!qjSgW1iC|*YVHXZWr5Dz9yJ^JPfk6&n4u2o{PdLmUH5hNI-Li^(`nP^ +z)2>T(CB~Wtq)Ig6(lL*NP&aCQe*xlvq2f^ +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 +zB_UKHr0o+; +zI+)3MO-%rkgOz?zesq@GF4L4GjrMYOX-m`e+SD9?SF|)VEJ2-6jbyu`qYu%pK>d{g +zA`WO~;FHgTV^Kq_JH|P7;AzWtp@Ze%H?(~|QeHNkO|;mEJo*ykL{LX#0jC)8I%fBw +zAHR`cTf%7Hs2sd|e{HrG*M}VPO|6?Qh`h3_3OIb`0=^gw7Nngl6 +zeW~Y>h0(=bjyTlyE=X+u=Oq;fg5EO9NM^CpF_j;s()G+GRp-6+e{X2%jROKDNIn6` +z8HmjWAh~E;6$xdENa!81aXcU~$^1jSD0DEUg4FtI)8FS9wU_1v{ITE>ORXqm~pC&%OD +zsyV=XFar?5K^1f>$Hrj&8n9^jbILG&>HGQV`(FH)!bLLeSYy@{C@j)5|MamQU8zMU +z^OhbR|BD>Gg^sYiK=PQzM4eOnPx3KE=IqLd5(;ReGDDgoZvh;R&HW<;F_DW*Ro#Dv +z!z##R$<3kPc!sYa&R?PaC+3x?c<;$wN2Xkn2cSENfzigF?`ZTr +zH+geSt7mj0E;jdoUrYn|kHn91D8j>eX$JVjD_RQ1kHg9g&)cQ#FdSP+I{<6`z3>^@ +z#^v$6&PP6iR`4WRSqqY1*@L@jz%xl0{c$i!;}s{0%}L!gpCN!T;s92W1iTQY96NCE +zL@QoLF8x{=b5{_<%;HzIDAj{Np)(s{)QXW}6(sBAL_|4g#@q`u5%%(-RD>rkhLbXvT-Z{~sYZi{Zl2c5`7O7reaW +zSzwMy-)&R|U`DT*Dcdh-JV*myTCQzKrM0lOB>)H5sjLbzT;h1J`u=;|BEz!syJr!h +zMDKRErTB9C@WrJAxl0aA;hIU`+=70j*w!^JTpE-5Wl(eXN>-k!g^J;;V<-EGIi +z*FHo`_5$Vo;3%LC(v6g~YNTwPqxlk}HK48<$>ZJdM8G!MtwCb`V!-SayPc2bJ{L#s6*S!h0CR>$bU;Yd!P{%rp`rSir+5&su(O4 +z`rtgP`Acwu6G}YEIcEh4~97S{kPk?IGiZEDG8ik +zZO;lD2rA95CFi)~PBBni%5>gZi^ZFI|0`tD;g*mHZA=4_qlo0QBrV~$3n!H +zXC2MYr4G=-sBy#E5&3+V@pD4Z^0)XEsE7iR}|Od5qklDr%^ +zZ6ViV?0xy)rc!gspNtE}Q38sS-)nGnPjUlyKwO5kLh+5MZninY6=1a=v$UO*k%RAm +z(WdnQt&JVQw6f+TxvXV&(abqO4{#%aMwT0vOW-6b+)f0n-j(7nKKL +zM*;ve4lsf2faCC?oycH^gV#_@?D)DR8lBs$l~xHr62L*;PT=e2xUtAp)F;_)8_>7R +zC!a5V`oe2UoG_VCdJNj*#3q$}X7TL$#(?|6Cf|PR0%Yc_z}N9G-zgM}UII1lFbJ}l +zb*??g`e7zfpi2-SEJygj2w-xmG~iPQa0X&;A0$Q6)DO2XPXj7zME(+oSSo|{*$JivwP +zNJyBuO?1hU7Il0BZxP>D?Gm})HNu#|06s(GsRY{rI}&Y_3kdo~N~r>pN1Oy!-q%8l +z_Wgov^7rQ*3VYz*?9@apJh)W_$-MWUeZe^!LC0i!1=a?|p)CDJJS9|54zq%Gv3TP9 +zuD1mmVm}P_e;D@w=G(UoVE!puT{lv=k7;}0U{M>dO!|2s`D~VUgv95FgVOhVLYc@< +zam9ccD1e%#Mw5<%O+zc7hG<611^DuvCR-aQsn1{-n*@F@0AL?g?tV`OoI5xV_ep8n +zGV|=U8AS@``s73Ak)hS(FePtK%R8s6?DPxocL>ot|8-Kwk5-uNl0W30-+@i%MY8eS +z`z2rm*q@TdL&JchsovoOLz+`)B=4)U!)dk#aGd@}CFKhu#&*)w{q5+$M_*>b-7x~? +zQW7RZ9ubty)y#F4Ko>f#gFb5|V&Fg6N^Bpz?BX;|kAE80uEJ)=-R56obxH2Kwt+84 +zrJ?@`*o^KUgJZi0XyV4*NDYGL5cQ+&A24=2~}&H_5wfWJg*v1&<&2(^~Jw +z7ob1j0q;FmcGu*tKvtw!ZTdMl3Q?2wn2kjiQ +zP_JZ2i0ofqZJGQR1o;>7Y#z_qa(P+iKg5u6+6Cuvjr|hu=mrP!Uqv);tUF4L%V&6= +z_v)mTC~fr00icx+ZHCZ6o0|jgNxi$KkB46-+(wTpQU#wbeyP-HZ+pbmtEt+*-n8t# +zozh|tTK63ecL#Nz^HF5k#H7&Acv#x)+*~8lHlG3HcvOgt!}?-?`VJieZfxXzv}uY` +zsz(AKbpM}LrFCBw1|>B+etekkLphv5;Gea(IS%>g0mRh14hV4~Z6HqFz;`AADWEAN +z|9t?wXfZmGgI7KfB0qF5%1TjCrJ>#VGg(&k0+fe-t$UjcTP|aYo$`;(_PR7*`YhDn +z9-W^wxTt*>!o=k5=~S^8Q>{znC}HWCvQJ&EII= +zs7ixcW;zRyFims +z$tJVR+&E;j^4=6YSSFHzgC;e+j!Pu!VbqJp{~b*MbjTEb<&HeST`QQSJ7+P_MoJ&y +zx7JJZN00tSfxeyNTJ$Zm?bR%eY~)R)cNLwpM*Au>ncLDaZF{ms6he7+R^6JcuEmNo +z)@O!;p1yv~F#OtBFmtw30EKO1kG{A;(@;LNP0`JAq{eYS(5y7jd!i(ogTrDErg~8* +z+#y27f**g~XHF1pqsTfq9$$XE13m|68D`Wt%~L%pl$ipk71BoI)+*W#;uNi>iRkDL +zv=Aa`Mhkekx@TO+vwYewg8!#Z!54af!dfkd%x}>;9?rM%&)0EKB5)S?+iyD!rwUZBdcLOz=Lc6 +z1_gd~jh4)ap|gNMXzCsfPW0kL(j(Jp`X}2Mki%L%8t*VrNdWa6!9!Y`>DKR@TTa7H +zORG@n~`bo1>_HMpy$T=ID?Y`-V!bJiB +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 +z2TdzG>p@X!K&lYzFZyf3=?iE7YYs!D +zw*MoEXRmwR^UCttmC233RWb+~4jvc1jD;5)FMVb9&%Q-%di6r>*^bt08PB%UiJYO! +zNWElrQ-JTQ$z`$iK5F~INpXhrA{UL;-RU~GDA90U^(zh9iICCx9j;{SnY?fnDn$#Y +ztMwtw>x0?bCs-r+pzpI1AV%MQp@$^_JjU?5C+Vtz6|l#9|H|j!QT2 +z>G+G@tvR0c=P%G9UjgsT$Mhvf%M%2U4+3GA%$?HaWT}anK&uAAhIz3?P?^80Ntda~ +zd;htSx_nPPzr51@p3c67!D2l7)&c_luW7J&izHxejQ$DG%in!K)fTUzfDoJlXFu9d +zwSc9QfnMbjFBsAMs7q1SA2+h1Hq=L!+Uy>##&4KBxRvb&pV0#V>z&W5EX7}h_dXa9 +z59qiHLq#V`|2?9I0cqc*AC*Q76MahS69m%;&}Rkc!YdJD(|Z25h$z_s$sk#BxvFb7R^X(44c^(2zYPo7NMzv#)VA?!ipLoRaPpK@S%C`cXgkNafj7Up1)Tq1<1& +zuKYr2($N>&LLyMhSrSzXx +zKUfY#5M6ne4wt398E|C`4y7sZsYDaF)(;2VHi=DvK40#3^>$XZTqMvpOr5K +z0}0B>3$8y&6z@p4t*KH=%`=>xAF&vMu-miJ#lizwpmkgtmab$I@2@=tB-N?xWYf6d%^Sk+j#>hc1AeZ|FAv>e-W^PEgn!sldiCXfA>+En+) +z%ZSrN*W*92S%JP_5BdxMs~UK|B*M`b16`bdn_hD!lcjJ-M`w>e;Z|`jl9>up>-?4{ +zbs<1^sESYW=YYDB94ARumq|FfNs^Ve;*Z`S`PF{DJH^v5hp?xY>>Wy(OE&dyf#c4J +zU@s|W9qv>HT#wk7-6xpMgg`Up4QB=`_8NeWv2+N}6{~B;4Cf+Av +zm2#W!uJ&OukBzj?1ao#1@Genze+4XHp%17@YWB|Qv6eRZ-HEbwo&RoD)^hC*_iaYY +z23{4_e@Up<>M*`l_ZUDMkkK(|#9i!*bat2X+%W)#Lsu5F=>H{2tjomy=feNZuUGKB +zq|b?7t($9z=@1~aD_+!k$vCJPY=04hfLhGLC|{coAHr|fA5NdPhiJ3u0Lm4L1;Vq& +z9?*V+8@|J>6^rf){LhXGd~^l42lPV-GraozT)T%@!-8}I-lu#6)m}_+> +zf8OD{v6hnu=5s)km7IcVXlUm2eZ4qh^?NL9vgl{P*UcRy`uh_4)W(UoMk#!c`NWH< +z0g?F~L)>$eCyG78dXE$yeYvoFW}MFOH#u)u9$IW;`7uGYX&yd +z`nq+hEWQ+=FG{@p5wCuDPrj*hy`Jm8u- +ziOhMj5*vS!+lvWrs?Qki19-VPUk*=_IJoiztpZhHpJ2j@6VKHF?~qFP?SXKJ1>6{Z +z0(Rqd8}k6slS&VEa4~g8TdV+1DM$67#uG2ffj>IggjC@7Sn0Dd@_L%{gn8C+OcIeH +zCoD5y<)053{3eh^YJS#Xi=x>1Zs23Yd3ec+J)(Q{n7PnFvOvt)@wPH1X&VmRd{@*x +zMNg3QISwTWOaeJ*bAl#HU}%2?zw3H|oeX8c*>$X=fxJJP;|DAkZ+trGP7z!v4{JE| +zeVS$ybG|F$;M)NX2+s&^^kIwOWD$Cb{+-<@cvw?})Yh1~Q|IxG3q-&7;6yc{W3_lz +zDpw9110~7e%87RaHPgr#??-zfoJN5xp;=%d3}7n?wGfs$n|BZ#n1trXuoabpM85T2 +z)T_v;5LTnWf`7&-0Yq31j}yJYg^RYAT&2dN^SAL4Pliub7dG0LHDn$oPs1^=(SDyq +zt?mesMnbMoDPk>gF}M9`S$Mc;T81%RhsAykm_DY@&L1-4V^R7)m1M;P()i6-toiw( +zqcM$7u7r0Z@&xfKOh-~qHrcdrz@U6TD~Wff)**BOA+La|BP_{>E0h1n?d8 +z2M&BTrWC(tCGifHO=jK|hOG2#0;!D+^B2W2y+DffUAs3oT)vx)=``b*Dc3IP>2KjQ +zQJwIawY#P-^;QR0dd4HaiO~-X*%otJDzyB-Z^KJc1z0+%wHNHv+wpAk4ycw0-vkwreU +zF`}C!L;+zi3w~$!qxj1VPKruhs(y@_(B{{0iMioTXttu* +zU1IJ+sFxTQjDc#}7YD<*FPVa0dVk+73Z8{}uy@Ie&1^-#KEW9;oM_eQ&FsdUGa-+b +zzY`4N)l@*??kn>^{wRnecke;*1K34-T2l#J4ooI*!wNi$UKOF4w>nQB)*(vVL_&k) +z#eVvFxonMzy{A05jCA~0jp+rf{Hsn3Z0ZMw1iP6NtpC%eAz#w`ZQ*q57@a+-da%r| +z_nP$uhrhG=pOw5MFcUXvpZV8e@3XQa +zm%M^bbauV!_%T!-Hn#fN+d)t}jSz;?J*b2yD8@JqJq`;?EZd2moB}0<`y`4(vwe!n +zz)wkpw6@fVn`FtNK`#mSfiO`*ca`i#<3tMLjDOV#zUmPznjA!<)aM{}0c>;rK%K9; +z=?n|&fv^-Q+s*m%4GMTpIIFR%&Rk1zNI~*}LC1FAA(%1fnfJNr548Ap9{%jqI#6~- +z|4D(c2IQ|@`3df~f29rHG?@YpBTrB+ +zocAfiWR7~cyHvE0zwDU-_262Ep8c7RmA0F8yl-YIKPh{;dq(c$Th=j?=;58MNbLF$ +z(ZnT@m{Gf%or;vw#7CPCj|Bo%hbVySh$pCk6I}~-P=OC$hqE0NaUe@<^-%81mrtDtg +z&Q;+_iXx#ycL_3d{dxo5%CLFd^T>sDtZ$CYr6VTIgtu;1gOe9j!>QJ6>Lv10Vwu&A +zMJAoSUAU{lpBmzdE;O?XZOQ{Rmb)JxN6q{2No2@oz8WzT#uFa4VT3hYgcJ>+H%L_K +zDvtnkQWQFul6LvDg5PUPDN0E78aQ1<|Ymg$AKx4_dx +z%9k3l6+Z=u6eQmOoQI)iCnyR8d;)U|Ck7-{`q?cY=y(zlYn}!2tW_W@ZD@>jgPS?& +z*WWX+^g}_S=~@p&KcVCHk~Hz<7~wod?kq7ANe{O6Mp0Z*xmM7)Dq*VY2Z5SFp=kRqG~g@ +zy3YIDh|D0-u^JC#*r8bv68}vQVp584!^i6=owI{Eqk^cY|9P^JIPDC2NJ@$1f +zMFvH%w$l`+JPGkzvofdD#^)9K0^Y;C$UFa*>e|d5%ig7noRVy0eO8Qo-*4ghev)wH +z`ec=BeV&DFmWXQ8tufDoaWGIAzA(H^JQ~p{ARmh6%kTF*d-_>_yYOd?xOzW+LWw}_ +zLh*a=AW{az%$>djSZa_kj*vb=COiYUBQG{&Q{5CRx^SSWQ{B1PDKn5~AV-8uN0E}m +znt@t$+VY2*L)^mHuPoGIjvdFJrML^^52(^(ntUDp84u9Pr8#(dm<*aJ3H{e!KMM1E^FTL4Czeiy%_esdehA_ +zJ_wJOuUsf+oM|yVw6=MX=CbcHFzGD)N{r@uSZ+yzLxHJ^hXdzG3;sHXND@ZyEKjnC +zzuDx{NZeidIUYuciFx1?E_$gUR^;zRlR(d{;a+?p-c;Ks=z4*#MDvlnSipxKLcj)u +z0W@?_F9V9gC}}YUiD+ncYJX~#db84SAE+7%Ky63a&3hFWYyyBYkQE1W6y#mWZs7zB +z7-~`l205#kn{c!g&jRnaCLci80B2P%3Ehz(G7`>uwarTa6y63~HHkEY*C +z$wo~Bz^QADoJMO4j^w~VnlyEFX +zGl;sU=il{bEBg4V@CV9tg}x}EENF!w)lM8+4(>pL7eq1!DjV>qfymfeP*xWF*+bs( +zZuTBXm}+%Z-M?TXnt*eZEj83Iu|4>KfxXc_IUPsAGye1E6RbOH06mQZ1jItvD$w>w +z*=W67e{3$N^Ro#$7tIq_zB+cMa%pQ1xoA8cB~?Pa~uX7^HSag!u`s&fD_IWLa^$@p%UG52LojoHwf>?i0jI-kn82qJZxR +zMWHD@WJ<~Yx3?~Qo$ultZnX(TNem?F(DX^QJfBc)ouD9bt;Zc)OC+6?l^L^)D +zUX`8TJzDuw^0bCMi&ie9g!O)pDH~}D +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 +z+=VkAycbPfBsM1;>?H6sduGL1BazXmJDC)W +zE~bT!OQ8po@{C0MaxYkv=pY@Hpc^My7!~speEk~_WOW~4_UL8vRaeb((c;%Bauf|| +z=y$C9s}!fTKsoPQa_d2`h}8;G1p4!!;ArTM%g0LbD^`GTrIyM`UII>Pf3L5s{z9Q~8k +zoq2DhZjjk_uY)AXFYpxrMMW +zC!=15-kS?xW8^n;+IN<vH_!i- +z=!i-la5(~*{9eD3Lk@4TSE6r}*oYb53D35?NUK=cGna7me0H@tjk<(HZR6C6Ba4`i +zIf`NhDAfRk7t9F)_(exW-UKrS_S@ToTt?~uYv*><>Mw9Aa@y|O7JiJ>rWx=}8~`n^ +zUk348NikzL&Z-Dk)sfIP(;T3rGeG8SDZx7qlF=FQTOKWa7r*oNP=D&e1Zx!jwKmMc +zE(IZLnMythQ6P}HbHV&6bKwn$%0=&tmkXz)*%gntg-d0jZJ3HM*1uFrWjs^v=E{fI +zB>+yq${&;knBN`&m%B3snmx2^oyQBT3RkuHFX85! +z_eB>jPY$hiwXQgEx-aNF{)JD~EyK>N0zMcD^gyQ45}k1X +zOqhw>1Z6<-w{QmT1c{y~{F0FXoei@R&Ps2v)NK@mrbh&yV&XuKjH&YLHo4C*P!(q` +zI*#@G$b%j++hon4@7h{|f3*ehR2)dL$>iBzWilpyP+$T2j^c-$VG*7%ux}Ol4~y|E +zK=IPEH#>n~)hXbuE3P8?L~UOV@ebG63#TvgCau@>S-$7%m6gVwl+#puuXRUdf7VwF +z_y-2dy34cH?=4n#%y=qXr<`uw(P+GDR`S@<&B*Ke)Y_KL=4;KOfKp@f@T14g~KV0b@6bPgZSM&eFN8j +zoPQ5G<{E8Eqx{DdP$z&>fqSQ~ARd2sjxQqN=rTQ4zH7*FoTzM1&0gF)@T>e~!-wRM8WrL2c^chdqZByIqMwI? +zkF_RyAi3HcpJ(A2W<8ive2{90S?NcR&%qU&*!M5BShBD?YaLqJoXbwpqk1YsZ_MCU +z9z^u+yT*++pV}9s$$Dv&y~rH|Ht2eR4{vUD;0$r+ELvRk<5}D1np?gG*$`@iOcF$`W1#~7N^xL7w3RJ<+h14uo2{71fWt+ +znn=VX7n~^Z82I`RCB_wiUyK^E8)fTcPi*9aqI7!hFIu%QSl(}Yc(`a4JpsMq0VuB9 +zN;~(RfD{cU!X6Q9s&y{K<1Z6`nYRGGS7hrc%a6+cCaBbVm16wM-ud7B-^B7$q?DCl +z54Qg-6Dnp_;GYNk%6#iYk;$X(8$mZE*Qu7yunrXwHT}LP*;p85F5t; +zB$sw5l&}szLxoNl@n`<53I3tmX3o?|9P#47@m4_qC!!oMAoA*({&a|QWy<9J#x+)4 +zI?T`wh2NGA5WvUKw6L!S0V42$cC+WVWs +z!E46*sgAB3Gvlnsam4Os(s7-(x7bn!)F +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 +z{ +zP|zQjX2DStka)xH#6uUkr`s=gqiYd&x!6T0%56MWZv7RY3UC1}i!v-hEu%%rC{d!QmjV%T*NJhR8^Fe$Vt|Mc +zP?5;O`+Jc!qbxT9iUVoDf&8&%6!h#$tpT5WjE!Sf%Occc$;_4ASr +z5A8%#_Z(ROrMtgzAd$76+iEbrgxvm_H;;VQcwypFo?1P5@yAZk@)u8T*e;CSVUFU0 +z>EnVl1Xl75(MqjxmDY<@YWO`z>8&K?#D8=JZD5Klm|M`PwtM0~#WW6nI +zK||~Js6F7N%%Ln`H#_vN4r?YO-a|;dK2D5av?UC{4vJEaAZ6W&E|%PSWjycF3qTfL +zRc8wKtnXTp>J8~=b}IlNWz@J~OM6{-mUIOXmBJ*y!m|XK+Sz*>E)E2l6R+Cggn?Hz +zA9!IE+0J0IJq#e$Zw7&59|w>R`=W(&Z-9SRG`v|<)s3+fHqOlBaUO7<_RJS(#mN$c`!Eyd4*wj`I(PgmeWsM+!vIq-&+!-S>?vZ2Yf=sY@p!*-mxj^}0(L+WrpA*9yzRVpiSb +zDR`5=Ti1)D-ijsKw((HmN8*v(eaGFkm0yQ}JlpEqp~y}hM!pf2KOkipf=hnN*b#Cw +z9kq?{APToUF4l=j=I4!mcA+qh&M!lWwuH^{%yd^X$6e_uE#M1AUvK7vb-Z;d-cGc0 +zXHZft0kzhlW-sogX<$m43joWhKOtO#>_PmWoYx{@7VCK4i|@OvVYaU9k}1q`7ndNC +zMUJ}NJl+Xy);0&Ayak+|jUq~!hIM1x>^_e|D7)r}SE#@BH%|l~v5hw;lNM~M5S35M +zlqh~FJc!EawwZ3Kix3`c&n(afKzxBc;>F8!Vr=az8F#sbbVP$=lrB(qEsR8R281t! +zBIX!(LH9&{f^qlbJLgYjZ`rlt_K4w@qW!1K9b3&5 +zWk!OtKQe7zIIJ(Gp6NMa#d>X|d2OoHhf-hj>_M%5p>w_KzPT`!Zs|Gg}xU^MKO>ljurId0uQ5DGa_z=IQ_vnnV;X| +z7MLuUeP+Z>_jwJlsWD~JoU(?HKvwj{`!OH&D4kE(eTB$tXN31s_{#v4iFFI1%sq^D +zx~7_&nJ7f}Q9u6L+Y{fl?ybcQ6JD%v~>si@FpeT=%4ahY9|3{B6uKvCgpCaYX) +zEp@K}(>u-uO22#A1-rQ65)0T_MUu0VqRvACs&FC-ahBbBlu^Z=`vC;~Oc}KW+?n$W +zYK_tSvqO*uti8S_rs?9anG2Q2RM@)c$vvxD232d~qnF95qy_ngQmlM$;+<%o?`u54 +zsjvJ%c=0fMjwu_@DUwqdI8!`7xt65J>9ijw&tZBY10_x;*nD)vy| +zLxRlZ4Dz8*q@#GaMyE6BSR1@p>JKM^F5}3EHf#nv5D(e%S4UfO!z4u^on0RhZQyWR +z@T4WcqSQ%qVZl&5?_SD#OIQO3GgwNLQwHn=g}TpH@E{YPk3PNVEn*G3sY;46_{gu_ +zeumUlgen5>6>Gxqg}u41VqIU3MpyQhtVU-=b6UKkkBuAL}ho +z2QL9YcN+k79Q4Qar!u|*td}V~0^J~(QKONM7%Su~a?v*Xs_OorJ~C5p=XweTL=?N} +z*yL}Njg~ApYu1$eT^G#6fX~P#?Iqgz0!S +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 +zwk(SVE3|~zN;8hdE8|h +z^msC%68FIid9@53k)K+ZT_DYk9eC4Pv^bk1RqM~XYGsy#8;$_L7QnWkQdjoetW4X? +zA|OJrlY|5~g*K0ivh$r>repxHPN4teiA$*CIo0yrV&NuFVo3Zzxr&-RoZ!izy#z}8sT +zR$f1HKu?5zbEbi~FB|jsKbxV}D-iAB!l}_@lcwgpp7>lLo%DUXZ=P1V(636j`c69X +zIXAhONGe=lieHx}|4TXO_c9f#fEHmn>=y0b9>U7;67mM;{J)PK;-;WV<+^N(>{e*V +zrm)V0ng=6=Rydz1oWH_g-H6r%(n0jlXbe-hd*8h?TK^q|(8%7$z#FNpy9c+NTp +zYnH?T0I^lF77KNbLQFpKdN$GUe$zEB?@r;q=0{c2O@WoKWyL8ix24&`9w&L=FEL=_Q6K4X7= +z)&xw#QcEw$wngu=!ty{XjW$y6VxeQ>n9?Y6k@7S6?$^eB?x&;s)(iFRb~Tw=r>#<2 +z9F7shhw)cX)VkH7R^CxJ#;S_{dMR>np$S&7vljN&wt68X)R$Ow>Cx8X6DAig-8Xk< +zJeVqn{u-Sbg}W+(lVZ9!7FQtv)|k8GFveN-s4@n?;2p5^5qc5a1nAKS)~h_#S@$s$OrNPLqIl-050ZoINuHU^&sAd_o7T*$dh$t@Gi}b! +zTHwX8cz$MlRkeCtY~_Gr4v*@md_5_HCN+PuE19{XH=#{X^_UL)WEGTM96tp&A}n5= +zFv+N_yS2(*m)1uG*{KsFsuVVBSJHtQu!Vs_BnlbctuRf1pV-(0;wutBC)Nc=JgUTp +zQXg$XD^yYQ?msBMd`vL+%;&;!0io}0A3t^NMV(j{Fu8oN;Y`9v-UK7u?a-DNWzrS!sEY4I;Knc}Eo8gO30^8VKM3<`oGE9?xvq&Z-*d6^ +zQC9?CyWo(n94y|gh9t;x!;^%iFRe(N=H&T-i;&)t@(}UbKvPU;l}22?jpa`#^gvdZ +zi9lEy$3wyk-3y+h+u3C`zFqEpwjq^>kBix%3z;Izd&6>ai{iBPW6aw$*n5nosRSZn +zp$JT*{9AMema2f~C4=L}z;_@&yQa|qu5Y`JgEt+uhPj*o3$I`oc-($3M%*cInpE|z +zao3`OC9>`pOe^kJzQ6u%NRun}v+CwF)U7=(tY*FL>qJwPsm&)q(8o<3?PH#sT|WG6dSp41yt8bvVdb$D`n*o?@>S~?VO*Ur!+iT8dxnCf +zPJ#nIMmI%<*)8K*+3A|PKVCT01OYxZA(BmEk%1e8TOfa+RfSopcDH1v3v$qXLsOcx +zZ(c6KOE)&cI3IUzUW+JvCBt=ezRJRcL7h>59$NVZ%Q7!4PtuyQ3c&hC1pbW*4V;W< +zj|2-u>i=7m1+Mj~{@*4z9weATM0XPTcEF`n)ofEzp}+-1L+OiM0l&8u7HxiDN6gKp +z^9ZT6W#m?lh28e8`e!HM4|pc_xICpx@o$+03*}(0oDd)msTJg!ir4zg_BDm9k5h3? +zkmU#)><}^j@|~DuiK3{U7#FL(7<31_0ReX8-%_JHlOuiwbZao +z^Up#BN*d4@b)#tNBrUTF6dJ+bF=B*z9R%V#tZEeI$%tfqF!JIfi{#qMXc0nJH5r~$ +zO@V3sa%o*}eg76J)JV$tjYd;bjZg((XMaL%$9o8Q%xFe@`ACm2$G5kR{?{Y4to`R+ +z?^1{Q$1k}*Hlx&z0jL2bB&5+=$lNSl9JCWVi1~xDmq^#;$X#yD^H2 +zw_OkxFffI=l&y<{V?R|ofNP{dy2*{uJ(WLLj!L3#Cf>0z3CTOZgsiMw4u9)8wux@? +zn&fsxo*83yaLh%1zVfSg*kO}?`|w{kq-c0{lx#h5u>0w=W6exh%}$-V3w&~DM9n|F +zN$224cs!=LOMVv)?sddgGcSxN>;iJY0<%hnJ28UFTp4wLzy*DF(B;}(Tlo_awV|vP +z-Kn4gb_D +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 +zL)X!E&x}+65y}cPK1t>7Q=DwzvO +z9?g${;=IHSFD8D1Il?ZrGq~nv^6~HL_+S6_#qP$#I+wNo%-k!{8IdIIpZ`w1Agrs% +zOyaEO2#!=vezP2o)=W3mn0*27Ol^@PU0WA?_optCTy0RYiqM{aMK<^`1IQKl5)iZ% +zHXPJVW&zU%oM9d?ZUER-@aavhu3| +z%I(NeKEXJzD4AP!YF2$2e~)i3db)r1{Orew(p}6eC`M7XeDuBo#2yfu5*noG)pC6)|l4612GUEya +znjJ^R;M#)LNq}p32^^xAk~bxe0JplCV_sqBv9l8$vUT$_;2JKw)|SBKdJ)voj=NDS +zNa6mkPfIUYysbKRc3{4EUQRK*54ZcDmP=!?ElgZYVDcgMX;@WR=i4#FtJWFj=vBkZ +zA2huePO2kp{^~x;6<0dn>1Xh;tm7H+kkPdpZ4*aQM5*ED>)r+Kzk|Y|`>7S>c6PG> +zxJLo+5nWFUAON=(3cL@deXGGh&=RbL;^lv(fMX%q_|)x +zb#||DaNPIeN?me$#EV$kiFF0x^CXiuJ~(zl;5)Z_-TSs+C2wOb~VsD{N8U20q;B5T+o=^9|_1 +za~z^fGXRE|W+WJ){uAN}rnMVhj@rqw0ALCDy)F`X+t!vyooa;ku>i`Zu^b+qMa}{Y +z4kgsuTFC!b4F2H8Z{nDEYSb%_^P*<^=j}EZq^plsKAY`EY4FjUkx^p`m0w@0kQwBl +zuTr%QbVp6Zidk%*_`5OV$0w8c%pX}KRJ$%+Nr2Zm7%zl>x&P*M=7$n*T5~0ca5Hi1v_cd(cZGWr+cp`GIu#QB{&rB4(g2%qS}qG>}{LaQ+{9UxgO;C?_d$sHSP@H +zIe~z>Yr=E}qf7)2`T_uDF?yWRAs3k@yDzsbxd$9a5i7C29sM$VQ%o`D_Oao^t9lQY +zAH^AnFGv_)?%K_g>b+Qf;aB6i3q4gHx5}k97>B>)lzGju9DBd5e26_^Pwe$(Xv_4r +zoK}T|2rShOx=gw$$?$%EpLFl}#@8VwXG??2HdpsX&UygAGDQ{7Bm+Tr`X061Zh`^0 +zP>wT+fJ1l8^iTn^SvU_Ew2pCo2gmn6F)ttK}yNoLT_RP+7cGq}>5>au2ci&X^ +zhkc0xI*iS}G`XW9ng`U2L?=<-zqIclNx^bqMqMwLNbXk@h#Wn; +zH0K`j=7;o|^S@$mZ2rYF|Gd=b|6X*@4s_11z!T0K!c`+cS&%|jxj?TVnYRP4OHPI5 +z$5nlM5hm)aiTNU)G;49O?BJ24JvYm~`V00yy>|GELVE}I%-m){>sn=roQ=R6spDDZ +zH-0a(4HNV{ynr-8dLC5$>V0&rf_L&}0O50VH6 +z&nbw;rhmC+@>TS2e4u~WUx$KEN7vF@&IpJ+_{`H3nGu#S)L=z9fSB0g +z2aouG6BwNY{6qJU)Ge=Fm2aZXT*!_4I}Q!yp(mSeIG+s3P^Otj!G%Y*oU0zOVs(`4 +z7qGhZdk8%NK(`H3_|F$fP!>bJv~H#p8~v*eRMONmm0|Qb~Mr=r~pOR0R#tA7Wg~B +zO`5<6G(qj7Sv`N^+ubfXIf?&V|W$qO^7p$L-d6*5b2cX4KQ( +zpUyN)n2c;%N;)pq_-Mr1$-&72J{Qv$G1a1r4ZP!VBwk*SDfN4L_HPT<#C`aUHvFvU +zkrpVee!_ksrH`sT|Mo=t4~Z)Afw{@tdxqV)6~;4w-TRKi@fafEf@!6`$PgyW*Y| +zH&yiY6fymA!?79!vKnDIi^Y5O?nBnj&$R7BDga{T5OWX0+PMc>%s~R8U`cjRSmNCL?}~^KB4QE0b7>$+UgDfydktAs)kV279vbiP +zj_Jf^riD{pj)2X}0V+uS5##a`CxQsY;4?s^Z#op2&LSb#;<~);1J(W0s~|o|4gAe# +z^-JsMA4}L%LG}yF5)}LIMQec$yp0I=>QYzxwjeU?bKZ$dIooG1)h)Tu57!CGjf%Wa +zBsxA&8^q`RcUC;)N7H-cEt?-RDDsbTRDA;wi~-DNzlr;6`afVsgaxS|GXR(QQ`Apw +zWJ3Zy_JQ_rs2D|ZG~raUlbUj!CjNJz5}FR2w4T`ULOJ8ap(DI?&Ceo;&5^}JTMVDm +zU$l6A=u*24tDE8BF!XW1*!yEwyznQR6Zq49T+y#QF&X1*PhC}dp?&e-cH;ALfCm7v +z$Z}Cej=q_3;<8=Ay#h0$Nji#7UlaD*9Qh8D() +zlAD<^h|Gn7#dfZfqO6t35PrOA@>bINeXd1Qjxl4KW +zGHQ!Veggdbd-z3*we73ew*mC_AB(U;&8fw^HlR?`Lz4}!kh;z(y +z7%#{5O29by!TxVYv!Ci^-zI$oVsI#U1B6)m7vOx6fou^e2K5j`$`K%sa9BLRaSht> +z_4d;9vO5N2_dg$MvMxU6K6{3ZybqA{g{|Vx +zwBZ?nhLRh49bvz^cJ>xm`q+=woiao?Km)U@WA{bE+!)jV27qjnV5k9PBgy>eZb&&s +z(~uPOq>Jh?V~o(zcO9ycijzZLSr2+!bzUo0R%f%7MMb1Mi`jDevJTtk5^Z(FMZCn#I)G~XoX#T5WNS`UIeelxS4(b +zQO2nuFRcyI>l<92{P)eR0sTNawX*!`ZH-2O^VIOB;G=H#_%v +zYc=@vUJ=fz0mk!O(J8kR7f3p8-Q$;cc#NXZuHMP{d5``(D$yYBRfu|Cv?@t1x3gIU +zESM!s00O%;W*-u8XC9a}pi+UOl{;ged)933a=F~{ix*$6zp)n)Y?1oXj=$M`Wu$ca +zWbp@baj%Lq?cEP$>cpqBg|K1yJ&KYQ2MsQLm16!r;jOw` +z4a7@x1>!wGJYoU%$WGH0&I3Lri1$uqzyI;umYpP9 +zwxqIUOJysgBCVDvBqkNwY?YlEOQ?i;Qj}^)MIj+eS;mqj)M&AkWk$#{ma&Xk%<{Xg +z>HYnE{&{*Z?)$#3bI$9$Ugw<7>qA^9&HVB`t+2pveN;{~&DzVP%gK)6lv@G +z`n*#rOj#hf=p`)p3^98WG1EmrCjk7@he^qA^DVHuY^8 +zTMt;44L|L7vVKTdDXFp<3aFMouW^~W<7oCTg&4K$SkVbJofHnM!zuamVi>)7((ac+GJIN&|64=`~Bs=qpzxVqW4? +z?Wgq)Rrfm-OG*`m6_91A-Bkyqt)w9`=OnPsceLreR-+9HIqD<*inA}Z3r?krt{7jq +zxSYpt4H?#+5pDqH4qr$v&VuY0=rRDLN~^@^fj}VT|Mxs0K(N%DFYE>Xh5q0NDlKDkj?Y}L@ +zm}jjqc%S8ASN+D1J<@+h7q>psp9$M0&KsEk{xBsl5Qigcvb(K +zT@MSTJeE)>$Jq@f^viKWQdP`hhVg99iqThwppfBm>vzd}ie4Tm$6VOVX{5VGBN?&( +zUWGP_Uq~0TXm`ksmjAow-4aRi=JfHg%%bM%H}|T4P)#0t40pHnL0rDD7OIXnRFkts +za9zO2dgO^vJnWSuaf^-AuSRJ;oepfCsvzW@Sav!3#N$R&T|jk_{TdeXRI=t>A+c(5 +z`I^W3y1hh<-PQM>I$bMT^EH#t?u#_IO8dS#BIK{zy?ZrNEOs)ae*;^vz98U_PI-7I +zpdK_a5B!NvF5xSaF)8{9GQ4IG-Q_?T)I$~O1NQrWfqmy{ppU8|kxa_E!n4I<(`ARSM?0>k8-$&S_a2tDmd@)< +zjQ13B8@A%tBY32+w(5;ovD&?Qm@yTrAn_Nv<3BbLiA8t~@;oBT!Z~7W7#eFAt?oFZ +ztQmOx-Z8q6+hdQ|@T!5rnr5eChvw62`RO>T1|0w;Ts5F}>h~QAsp*s4NADZo(oXP5 +zF!pH=SQ8QGGGti}?B<(+`6VVm2EU69==EBmoc?O8|97bjkP +z&s1!%x3&K(Z*$%%** +zBKAl_t!Q^P&@Mt01LW}SdtpIx2jnF8irYEpBK#op?b{EW#II^SEY}05QyNQWmEd&y(a59dun5nB`fo$%X0j> +z=iZop3nmd1OLgR?$_sKvF5{^7$(?+D45=9%tPHUxJ8}h?ml-yxO%`NviqJy%OYGfT9D!Kl&^ +z_9rt=h?nG@E!UjZ^$LA>iaPN1B{LNDUxF959UJ=RIc??nYW&d|!?5rGY+<$$o^Yi` +zzG90Gs>q=#8fk}vyG4Y3cTEkjbDE1lfSHTH?hB4Kfhp5dgxsN^!q +zJ0`;kqeB`6x6+b-V1Q>=Ce+6dcNDq@5ibs(H6PcMS?(Qf8vu-4?*#~U%jg&Wd+qS2# +z4{mYYyS~Ew)6YR!vOh_W^Fu@N_|DTFtL(-`Sf~sF-%DIf_F#)Ai;?RPTp#N%xj*Tb*2KS2&MCh*tQdQ>G|C4jsFLGs +z=tQVN5ILmP;L`@fJY_kg)(>ptB{p*t>LLv=3JV~q03C+#evwuPY1GT1`A+7CAz0=Q +z%pW|Yv{&hnko9^w#MlLMeuE1Vqf3`Kb&Rk +zgsd&Pl1Y>ECOEO!#B1wH>6LQr{*|#}2v(2P!W4K!O`(!umkiuQi4}K>>tQs)-QaUs +z7n!65QuFv@trnzW6n)oVt(tTOj(ZgZR>3}$oRK*4=INpPwdyL(Le};gS6B3#?snl` +zBlKrt$PiQ6gxpsQoV=IVU4)k_@&hazmT})u(E>kMN}SB&&yu$;R~)$UNn(gx6 +zqwXZruGBshiyiyYpTUy(ZMP>uEA1nH?5LlFW!QFKynM3YqCTO5@NqVn&st>ga&5et +z3sURIh1Ytzq_B+wV_)J4`)b7Xb-HxGNLv8YFkc=u;d(Er?>+kb5}50&CFjBUnXFvM)Qi7%w@lY$kyn=xliHBJqBT +ztb<4-|5&o34s~gkNoFh$1-Z-(oDtL{0sq@}n5iA{?Cwj$$Uj@%Pg-3)+iWy;CUEfh +zs-zr}`iNSUNaLv@G!SG|u!mS|FIIKB0lQ^<+-L+VGycm3IlWiNI_&yzQ~`1@)|Riq +zVa?#p)I+li-alpSF7kED3J{c%r;0&X+eg{#U)r7@*(lTy@acocsGXnl`)hrTK-U+F +zv-uxUO;|p?sI|keCz%8ra^RkUJ3(>+&R&YC7(x@7{`~?;c*#;1hu*xks)ncKB9L4u^eVKdU=Z&XV^YDaC{9}(>MBTzBj<4U5eM~5}#2s)Zeh#me +zV0xbY`(+IE6zBV#YcV&Z!&L_zC(Nl-C_r+~=6rE5+H`K@Ijt=|Z{Al2sn&~h2@M0_ +z7c1b)XT7r`f7a&&a$bI;RwUmVpXDa~o#?y|-xSGx3XCaU_T-#}q`Vj%$biLBmJln` +z$!lMP@i-&H?>JwOxg6s8&3vomMR8JRfZXe!h?I*_Ikphj +zIhYO13+b0(FG$l7aDGBvjQh{@Bm#*y&F~9&^f1r8ybZ^#bdhxhNWX)S^)o#G#ybAF +zg*?Zde^fJ6D^w8!hm9`s-z5Fu +zE7ynfB}jWgeAIU{m-Gl}ADlYypSuSKjrG;OxgZpg*gnqWe0f3dtq0Uv6y1m +zi;N>RI0#)ay?`3((s@vMt&_`786@7|x@Y7+zZEQM@RlP*w0WiBzoxEDF5ksGQalf@DB7O^I3cVr$70ow(&zJ!KIh-4G_mo4mv^p{gaUD$YY88n +z_NXVwK{|2Iva<`8y7f|8$F7BU+JokrQVT6T&)SG5-D(}Y1gkFuY?CMMQi%A1$>ZO+ +zo@zM%!;cNvQ6SB92N_TiSS?`jx#59W6qG|;Bx=7kJ0m~Mo<0-8o)l`3Kg|4GeVQ21 +zDAJ13JtL2}1V+6Xz6zE*<$~C-;^K6k0_4vSLkBaDBCrZ7Ps7zWRbcCsgatCG-Jw-fO>tb3VLbf+Va%xZ&b`$l!l3 +z`jIo`IQ*E4jKW?+iXZeIkkPBkI6>it%i>*Nxw}+yj^^@je2{kkdSr#IlrHi{f=nL0 +z_U6pCCz2CS=DoRZRWBLzRIPqni=S)sEPt)L?e)SP_VT%SqF+F&0JiI9)01qDb?6UA +zUF2sXh93h&;PgIJ-4L&;1irWgdA!${-%cL6N9|{<-%Y97b65yrb5a9>=F$%AltWHS +zELk8s!##p>yUu;fn>I-Q|Gl#+f0}v%nR%SZ`Ai&nHJuK;xeW6NR$k+A9-J#|5Cg@q +zG3ZgT+QOth8-bB@1{5A4!LkJuO8ceIRHO#>k-fCmvsumk?#=gQrw9u<`|lF{PCgL0uNBaoaru>I +z-V{KLB475j81{dfQ;9PT2|Q0ZDbR(_LZfrEbkvhU_DC*3!q>Gb1Mb$JpqtF)kM$CL +z`}2P~J4*%!WR73+ +z4ek`kwPNFL;yT= +zfeL^&1X3T&Uwsv;$0+%+je(k!fuOr$u}7cWQaOhlzPdD$wyOx2y0>e`^S%aEbH%Ud +z`hBxbJzJ$x%F!sD4)YwY6z%7+shh>2;X|{I!xN3)b?+#xO)Jf0C%xY~T7XchnqWT< +zCU^z937||MGJ#WZRd)<=1qB`f<^JHDi +zCpEoi!#<5pdh~oc$N30ETF*l+_Ad+)NdP;* +z1~%U()IbA>=)_0jHM&u17i{<_TjY>=N4^G<0u(CckZ>u2_`%Bo@tX;mzb+jx3654Q +z&}XF?Z5uybNb~)wuy8hH^Cmr&AUA!H=Sz8|h5}07_l+(t66lPRV6#g3Jle6{3m+>d +zO@sO6PblPM(e!bHKiQplgW|ZD&u6c=e;31N?1X-X4GSlB+nwTE;k@C^fho{efmF){ +zto1uAv;*pzjDo{3neJFmAcu)b+d5P_c6UTuc!{N|JE}bu?Q?0a-T%T%@#B>E_^pPx +zm(~O{-Vz&2zHv4Gae; +zu_%5%5h#8f)x)!o0F^IDq&0YBfXjhG(ky@4aX!jgIfS@IJob!Wv#iKFBxExb4Za9lUmvgM)aIy~w7=TA{T;q%{+S%b{qIF;MFSS!lE<4^Am}gW2LL4A< +z_3Xsi;qTuRWAf|SX+JN-85&{)iBy(E^YN4C>@eo2mfnv&9*#n7{ls?xGf(NSb3{LG +z7}f&;sgBH(c|zp~#O@Fv_P9wvt*fWOl4dy-WJw`b)W{xkRVB#El0q4_*NZZ$iTiCo +z1^nK6-DoB*jIZIuS^wO)m=uX^^k0c#H$5jN2e=1Q6%YEKw1VV=-1 +zu+Z*UI&#nag6i#(4Z?o+M+5=CH`HSl13`NV7Nd517V{leneEz5g7wJ2(%>5iI1{X4tYbx3zBOfA%>6XhH3_USQz}9fG>qDwCj}Y +z-oX9NSrqA9?#{i=d2*-ZN}Apr1F^o(x-vKNE=aAFNTSc>)QWLE&(6NLR}E^a6e4$i +zgRO|!Z}WfVvM9|GMZX+xeD@Dfd|2$&QFPX2Wi}DT^h~vi(3rgFRzm56)8&CeAU@!H +zp9HoNQ)r7h%N|?={P{O;NcaP902%EI-2Yl`JTd8P$8lY$&eQ=XB}<8wsR*M#5-I$# +z&mYz+RuL0ldR}@r`a$=4pnJ^vK~pu6h(Pl>a=DC0UYX;}^(ceY9=;3tv?mYUZ|LuN +zs4-gxNTdgKR!C`95f=Aw;S0C5yRPyex{JU3TIC^WOYm%E+A5Y`f +z!<06QTS|H=b!vU;eb8NYTb{fKs_w%$}$!S;QQ&fQXJbx|W*TchUgml!@7 +zBiP!f#l`Hu988(Lv>fx|?>s(`C0QQ)U0}p4&Fa&!N&Mdu_9sj6V2}KrBA)N0Ys7Se +zy8XlDOj*B5LTgG_t)=#%vQs@L(qs30dNWjl?&A#rOiyFj->pL6U?N}x08y$qCyfT! +zx2Xeja4Kj3a6?l$1TFM0x*;TL$RUP+n!}R_zvr+jy*#lAX9H9I1s)%qrzqxzySbyd +zC~g}5_Y(|puv)@(EIR!wcBYNPus180s`J|!9+hz-SNE#U!}*yTUro@h1Nzk2@E4y1 +zCVOVQuh(<*B~;85uN_}^@7b~Wlw%H9w1F;ibnUMM?to?7yWl$KbXW>fLH9>vKFZo3p6wR&kc=a7Uw_WVwZ-S5_w?6$Uz#T-ef~V>&HHut#KXK>O-4&6x*A=ONz-K~-k$ohVZq^L +z%oTv!e)MuGPZtc&^%Zlwi-@7pN6Gm6WkP^o4jmS17y>?gNXYv9J4EDw5x)Vl)tQgN +z2ey3O4kgy$q945wL>{qE42y?Y;SX3vgus~ZlrL6*gBxKxNb%E{(El={cmYY +z_(^V*sYA;xhp$a?`%QC>g>O13m!bK^X!ocAdyo07{d&86&EjU2&1eh3Ep4>qfpctK +zma++K={=?b2&fnbnym>}3}I`IVw127i;zjSs`sG$b%13Muk0g!io +zuwXZ=o|^{`nG<;v(Ekr7<#F})>~ARU=<*Kqwgu3A5EYJR8F3RO@e6JrwRuUu;k +zSv#zcPN)wc>^teC`0&MwHSSm72s?ki<(d{Mt=F|a*-&jJMZ-0qZN=Hkc2#)3*~D;q +zfH5yp$?c_&Gwl_L=XUj$=4Tvt9Yb+R6XH@2V3h?<*sNK=<(2E>r +z`@d;*IA#T78nVXLZ)sgRr`%dip0E>nBA5bV~v)y +z@#e$E?4HxnA?U4dIW@Oq?HBBYhg# +zGO96abxwq4xYw#j_7xPoX&F9Yd^6(erjExeiI$%_fY`yu!up;*w~~T6U-F(+EYFzI +zgA*cgs06NWhq6vdK0j%fE@CAPSeQtXR`*T*9DO;&!I>|oY86oII%u_cu9%w#zQmn( +zj-tmMD+^zz&649oagT8nScV^>N%PnF<>VD=h8u-NFR!hCBjK|unihR1=cB6G>Ck?0 +z=X(#$ +zY&H)3hB<#@V5B{k`Vw0aYIZGDV~UiW<#tttX*msN2CR5884w+mM13*HDrxx8MEHy- +zV1t_7q^fVdr84X6uQ$cKRDCcy)4rLhrR=LE6t-Nn)KlOv9#QMJI2b-&dbFRgx^_*gy0>cTNO@!o(bnoE<-O$SkxgboGm7#!OII%Hs*#1TB4mI})>i*)smoaAnJ#-ylf25N2c$4=4J1c-q +zrXe*>f{#he1mD9I<3d3aGfkRUtSK-}h8R+bSp(!7??eF=ESI~^ +zjlgVShR~s4F#XpctHN;XS*i@nB?q%iP77nX?(6ls*xr`H(g0VvH#EgzTHFLlS>VCR +zJPV68QLa)=S)U2Hp7Vj-g+!HYlRHFz46tu#5Yn2f@zh6r#l|&LZHf!FVXR6&y7-+VgVJM;&uxQLhecvCy1KsZ`o)^4bhT9b`41SrL` +zM-`wza9)h{iz&((Tj0kiYYZQtRNd1weSKr$s8HD}7jL^);)e{Dvby6M+Yb6XP&Tj8 +zy=S~xVHJNYES>ztwt>5G<(1{UXZBe1l@+zqMwe=qz8Pl+f2kG;R}J(R;RJTI?e{$S +zCx6STkqMrkJV`p19AmPCIq`LaC>es|ag8DcNIM83S6##l +z@KSGG#D(nI$kW(ZAg`xk!48({#xFswCdiYcDZDZ_%bBiq^>gFPwqMIkbzRl8Z)E3t +z^7M;w4O9 +zZm232SlVk!eQx>#>JfOr;}KQ;62pW(rA=$VtyHGoZ2%N%+W%!kOG`n3Yy3=c#m&N< +z1&b6X#qU+Gb%QmcJj}nh`Y4MMUsbp5xf^S_9783pY2?((SO#@YKBf1Ok9;emo;}U7 +zHha8)+hfk(W@RR;kA9LcfpWjuJLH<%R=X$b$%&+v_oKAt+Q)m*^odtr9KE~}`$N9D +zeG6E(g6mNZkfuj93^4JDVV$UMIgSK2fWd1Cmy}RILid1YqyV`G@)T_w0oj!2QK5z& +zz^2x2L*iv59CBoYtS{>#usnh)xJ&~CAc+@gz$8#XqcjKKz+MSM?Lg&h=&A)OPcm{f02?F5h`?iZEYW+F+rvqLd3D&+8iTa;&o3MnIw_~@c0l<---*em +za#9NZq_TBd+p97)e1g`j)XSvnaMSkIkZGh{F+}#S3#E!$@4Zr;QRt*|9$igx`o5%X +z16Qtn)2L#hIRfgTZnx{tRgGq!xTW~S{Z67Fw{Dq9Kklf?8Dv(u4&1BLQVJ%1IWQ9< +z;25;ep$rQTjKPW6ONk&SVugpr*+sr=rhhbW!x1>xr$7eG9hwkrAE5&cXf4zr=YrHK +z{4*ddVo3ld3>X?p4v~aac3AxaGQSmyY*Tpb#0vcW#Jn!J)CBHpNjzFIQ`5DZR{uN3 +zYOaVQf}SvF>B!(;zn*d_!aqBRGCzC%hxz#d*=@){hHRotelo&MM2%Taz4O +zje8VGs#%a89i6Ls{skSWJd$ZldK0S1@GjKSqe$3O>QWJQ+jJ3XeZ4FFS`Ci|ua;xH#PKq%M@6DpbJ3{1D+78;z7Cq)qI7yTMX-wBWXCg~}soDcfJcdqytk_67=ZtbN)3 +zBmVI8IqaIAgtQXGhEFd+j}=0mc!(F{itBJ~g&JN0D=Gi~Vb&n-g0Qm1Vxvd{@VraU +zLoV76_~`_GA0t0eBx3GHW;S?lHf +zCE0pMWQb;1iq>D&b*j)-no0@X6gjtMCd*8?({8!##b +zOT61_hKwqf=G{!sXswg?c}}v{pBXHEcLP-ygc%DAFgX02WBn;j!D!D^^c|hRc&8*$ +zIH4qlDaFLW;X@W!V*o=K(0CAKyh9_LQm +zpYXqOlN)m9IX}r<7ddqtXn2|*tYByU8_|x#NBldn1;<@5oVo9d6Sxl2gln+qC*~K| +z&m|AhJ2MSTzY}V|hOaez@O5wh;#C)em;Pr$0uiaRDbLkOnT6fG +zSE9GS<#x=$-`mBH(85~pdK!+BrKvI9j#sJz@f9B^I`q +zcd!@;VPV+*t{X3L!xzCZVSX%ZD_Qv%Y-uDOh^jxd=8p4sQn---gMC#--7~SSC*Ztd +zuSp2OxH0p$lg8as(+$j_7Psv>LMf>9Cc53O+&*UyN=uD$a9ur4AeB+`RJsn|&SqaT +zp?D4$vCPg09QZPDZrksy3q@&!*6IIruNZd~%7@p*bC_oI%W?sxn`BLl=Q}Unv@8}@ +z`LgVeg7`#(e4uWKcMgV2=wkAQUJc=}F^lkW_kFS0rPoNfo*N>lsrbxVQ!Li60I5z8 +zL)O4PBC&%h#L5IYP^psx;bUnhI|LG|o=|WNh87?bbcWob-neU*k2qd@4xLq~@U|(z +z2c1LyGka5{c<+}}bOU~~7i&qV30aYZxG#nMr;Xg6swdK$&_+757q8D0%nM)_u%Lic2dKDePr9VYAM-E +z_6;Qm4%XEG7QWw!s|G>^_Pe-#eryXCHI#?dDIg!g`DOJSUZtSWQJAy8oYPFwB3F$& +z`Zfw+JuG~5yLTv=={>p~>ynYyW&68cSEb-;aO1>>SHlYD3#Zy9PFj>2z7@Rp!})Ia +zWy5urcHY#gn(pymA>j;;oCy8xy=C@3wd`A1+NoK!p=%YTF%6M3&lFNVtX?&?Dp3(X +zTv%HCJIy9DTX!=#V6Cvaf&{njLyMbHu-vOXM##b!i~DS#Os#p7(1uVp15zZxn@oUx +z64ypRH8MlFTZ{lT$6D?7#}1fh_&Gg0EYx5Q08r`w($r=kSda4sDa#>O*t(Dh`_s6O +z+_9dWL_oh{JW!d65pB +zT-uRujWl?4;>YWBa^V|-ua1{R}9JW)}^T}$sF!>U^X$XscQ +z%_P(ga)~hKXop0{j*j(*Qi +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 +zf5%zseR-`sxc^6Z2?zTOxQjx=>b*L6XP;h&S +zsrlfj0x+H-31??63|OdU!+;2-@?r+wDe1WV^C#Bk53fp$U+B1((X5l~B$caj&^)$G +z-3Q%)(^+*~c{OwB*V8iPF1r;@?8ju95%5e^CsVR(BkwIIaDEUZ$2E92?*0ONyF(&x +zEJ7SsM|h2e9_9&(dKpj&z<0V)_ZH80+}9@L^5-1m%|UfpYl_D@zeXJY9Z6^+K3O*5 +zkcnR$;B31gS%D5y3s!%_Gb&M1mIY6BgVBq<=30&8Vr`q^L +zQ7j+Uv_q{xnggEw_)4-UXK%)S?c`i3R-Sh}c3aW>2(h~eYzaI9GdNepd!1JRq#ArY +z8mT~8f_cyl)lB4_M6KXl$)do)n}74)LLe60c^^a{{D6dzaL3HWw}SmRzoJHQ$lY9C +zb?k*ox&=KwUKH>Y$Bss$5u*B57Iv>H3u~jws(H@QjKc37@{bN7b4BU{M+;9ui3r$( +zAy21qOWp%NQYy%%a#1T^1qR=~8T`F^!ysPH)<$+p{`T`tw*}vwDd`K_(j@03 +zyUooY(#dAqndjQ16OFfpw&WfbSG{39jsNyt{rYQ^+v??a9=qys?QPu(-+W2+ +z-=!|SjggY(QFx+d>*;F|bXnHz)%|_y#kg+b;7YSUs%z!sE~GY333=R8bk;kT?~H;n +zva6iWx_5pQ6UZJPVAWFf<}#_WtZz(bPp*_UX}%aoa&SjAI*^IVid*9?>Ws^)YINl)xB56wHv^KS*t)|qEzbw=bXnJ$XcvCYB^bp +z9@A$?H<^Q9$#U35IFfogwPfEs@2{m|R$r4FaHNtla1Va$11dV@>LA*OEyz7OR)dMd +z^%AOy9^6CPB*Re}iTiAJ^ll|5&(RGv0xyog*~HBM#Zmd#J3nGb<{EVbV{dTRyWrs8 +z8U~v%&D_J%%401)(cGRKzhi3;q`zkiDkHCU2w0at +zKCw;04B7p24f1@@1Eq>Wtbk^6O*m%8>nKifMnL!y&u4pl^Awp8j04+b$&6*xxgmwSjLAr~4<-`S~knG=x^$Xcq*kt1RWB5^;T#H@@%4$0? +z0d)#B0=(lMcu4U3z%@9oxmVaXfE^owQl|f4ieg&W{HS77EBhN$iQ($bHJ&_$)t#ex +z;;T4{wAHu3nvb-Jf7{w|z((S%OigSyF;yG&TcH?z4_u`QemmWk^Lm2mnirjnY9zFy +zeAr9ISya8neCmJ$%YuSSjPyi}f=|K+VBKc<<>0};!^}POYrR|@7Z213j?BEs4z%Tr +z?-Aq{b|-Q<1(<8N^i>~rJh+22*hWF@SFjyVs1B1>ee=cWV7MB;F^5qtmioD<&eFPK +zNn}1?O|0+ELbn2QB-;N9(*J1<5|28CRbuG?{PJPXfZJVU4`^@~)zoQGz)(Kydhp*o +zU}izCvZEq5``#Sy7BCj;KzON_fZe*iNtiE2~_ +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 +z2njO#Fv6g=Qhk`6&lACKlJ +zGOSv|-8t`?d+rLOakaUrEEUS8pppr3J*y9QEQ!=Ym4o39X(L)Pa)@~&%H`4QcB9zV|@-e$lwn|`u)wB<9V1xRjPez* +zV5IL{__Sec6nr*sZ!LBjydkW8UvX0*2P&w>_)JE>4NmT)L1`kSzQQ1>OdFj;eP>FhpZVo1Z4c>I>O@$|}-;v)~MA_xk@9A*sSerKHF&Y8O3 +zx^R8N?~(T2nq64(rnqrk9*>bLZj3#37OJMz{sq^Iz9BH>=J +zNW6AS`w=^ZchX2NJsjTWv?*32_uZmMW5)p7fy&OVflO8gDSmaQ$1B(@YQ{gRRgs3a +zeY?y=gl8-{O^{Tvtx0j-%1t(RnZG6~W=Gnw8)Q7S5sCxA>t}|z=l%fWtCwMB#)o2= +z&4@|rBwoU?Ce^<2YN}pq4Cd}Kw!_QHENFM#eokJkO_U0-&?cyJb!+`B8@(ve`DW=b>T)tFXDPYP%sFVXa<#nM&vsg^3STTc45 +zq@ND#qK&eHb+Mz@F!^~>5w;#%Rj}=TW?%^@5yP>Avk&{R6rl(fr%6QUieY1Os37Ay +z0Q&#DdL&0*Jes3Qw`$$kjcv+Vs4`7wKSipKrCWy}0=L2*YO+Prm*f!&vqTw4ycEVQ +zv<+lz0rt;Oi5EP*GG=hBNj4pFNFSE?1;BwsYNWwpB6_k809*w!(hv4oPfQa?9f*_} +z;^hG!nVdqqUM9I?b|3eF0R?<`nE-XiZ=T^|NPps`ok@9o4_b<8#CTn;^QXH|)0~x> +zrb&i6}5%{Ht$L}vc9$Aa{n +z^Z;aELDiAkQZrbG?2!P%xVdIKd7PYc@VKQxR`!K;NhG|R0FXLiEtYsKRYaJ3FNj4Z +zC7g%O_+x+r;V54QSOPWLVQ@7(aiT_XA{(hc>gTzI0JE@RVF%a^xR#W&o2bhCli64U +z_5;*Uw6xRowC!$eqGdAoQEF1su8?>z4e2U}Oa`+^x_(6lGJqan0_6Od98cfi#~bvJ +z^>m06!_Y-h$P`%G-jD`Qn;JC0Nxn`_7t#-_Dv=1VqPru}v$NYcU+xpkxmxqT0KE#p +z+X(v_R@i8mM6_c;lO&C#f$^_945W)O3HHkvZtjI=R@|pQ>GGp%?bxH8g54pHAbfaA +z{<-Igf*8{G%_Q|Hn_1m?7pkt!zzx{$eFT0WknbREVgT5xTR~>jX4tV0{SZeu5t~#E +zW)bv1M8d{HUb56q+ua!a9t1kXnTkm`6Y!V7N5PwaFifmF)9!-R0SYx +z^9m_sO30Gp=%?ff$>a?@?i0@9Ypwckpi3j_5(U90%y_d|d) +z6QC9XF%*GHxHITz)*e`7c#6f$6T6ij&f3>RTC`%^-%Hga*!G%}xT~<5At%<#s9*D> +zSBjsyZtrwN)mGB33$aH$A80|2ciTvjx703DO^WdK@Y*>BIX?iBL$>4bx0h@oE#5Gg(Rc@^nIlxQxlX8_U}Z&9pJpM;sG*#r9n^yoM>b78z*GP?+oE(SRxsIrdi?6x%T{j +zp}e@pCQ4M#QTH6yJr%@mWj6o>Dq+9aEXA0BWG}L~q+$p`sH?g`p0|v+)|x+};6bMQtBrS{XtPHELkhfsenL&@1-UUcA(Kp +ztvZ1YKVG=`mprHqs2SiHeOtT#zbZcNW)$&`4w6kRy-X +z(1g{p9glVRbb4CfnL2J81olN?w&)^T^oMSn6a^l3$`nv!(-g$s04E1t*wRq5 +z^k96^L*nDlUfwh~nGe`tEo3^XOzX7OUrm>5eS^8d)+Me%oV+_EU2U~4x&3jT`FY~O +zzI*-${}|1&%|%%{aT`3Du#TT(=_&vl!()Ki +z-8%>I1{-Wt=16zP*19TU#!lgEl5=*5bH==D{~k8p964uz%bDO>`uwr3T3m?rsM)JZs!%wkV^diAsNHX&;<5*=C?S*J97Z#BLGa?M+i| +zc8sCXyaOcR9kDD1zH-%DoJvO2t{`Q>j$!v}(x9=@_#Tbid)mMy3xpEav3ECFu_5&g +zS6=Bks{2JtOxw|{j30jeit}HMs(9Tf4&^1}T#Z<8z$o`U_-OR9_7}4kJ&0>;#-5iC +zR)3z}p2=h;5|swAGXg)@t{D1HrOHWXk^UXQSqjwUA5k6#M6uysHz7NJ0fhL>aeuyN{Q3KMB- +z?=l50Kp(Kt>)d_jBGl6?<~`z`GGinF(QTu%yF(g04=gy#^exO&{@6c1;=6PI?(ZCB +z6}r&+i!8>&BASeeiww$bj*B{DT@sU!G4N9I@^b#N_65oA54`0?GQA;s5f(3M1op$(Qx7YllGEm%71ASRide{a +zq{rkiIQoY&BqUI|+<-voEE+|f7};I1B{3=R1Tl#i`!BR^IikDfqNnrw9{aw0wj{8< +ztL?6KXWYNLUYBnu@!aB`1rXff&2H+|n@`pRTx~4dTU)r;PH}hKV<)bNn$v9Woz^%` +zJayV5;xa?#(qaHWKAfbXDWM<;z{fZ3T@Lipwhc5{=$ian8uNDz<*qG@7735Am?La7Q;xRhssf3W17}G7`doqXLlbq8r-n) +zR||Lqp6^q{hFc(q@(5jp-M59(1PPZOTY+_og8%JAL>KfnL)j5W^(`#MUR%{e_RB+U +zxHjxKbZJ6>Z~wKOK|i^4oaP494D9eh6=|jH;ExvY8-R{%$i=d^5Ux!pJt2|M;pRJe +ze6=jhOkO?=4iVp?X)MkN;UIi_}c{3GlIKy=%TR4?eG +zrorof+y!*RDDn!C@zHofi5S>qwxEtZ{42NH5qOF{Gn^tF2dTsJTp;%3Re1U#yCu$0 +zNJK$0zRZw358Itx7r3Pm&cBra;jw)L)JvpJ>b1wTjUc{&39kz=8gu9!>~Y@lk(;FN1xGsj0>RHs6&;Xcb9a9o<>-&1`eAeIWEbRXCq;zS++=ot0}9j>qpMpFKe`rQBqE>^RLlI@v0 +zseNIPqu5AT7vm9Q$5H|0-Q%BadUoUd$FqCV9hL^2cBO_rj4ts!b#9SN{T-{SCIZ!p8iAq!lkEcuGk-x&#ipv+OMt5hzN?F5!dg}-mHTPH6;Xo;kX#RjC`V +zhHV@(ct@!d>B`giWF!YIAw1N&c-Pg4rhu>q$nXWZ&V|)UFE8+^|FtFTAoG@3(lq7R +zjJqpn5@#}(sv{qUEPYf*?M2_buwZE`0rD$I`-%N*gCD!SGv=SEhC~f-6`+5!r5O%c +zA|!kUTL4JMRSl$CEy4)`9v+?HfQy{_98FM@k+HS95>Q9oz{nh8-WPA2VhoRXQ{e!Ic-*g)Ai9-X$_u|w9j;hI+2$B0Mh!s7T +zv0j%02oc~*8O?|P9mBT_e~}smRWGsTE)E8;Yi9a)Uc#osHkl+=h$B4c5<&n;i-E7y +zYAW-vw*m)~^x#U#7uhid*b~S#&yc$uh|()mQNbuT%d8ANvl_l4HS4Wsfz((vnr2R(_3H#sB!z0+)q&sM!VP& +zGL_|f9h*T!#*0)2k}(R2mk`|pJ{Gw#0D=ps4^DznTWQ{_5MyNyFI&);=+>9uv~%z` +zMd{PWPCM@if_^!0K2No9-jwEL+7R(i=Z80_Zkyc~CU0TqouSk-|HCnUzwm1(?oU^T +zWyiT?{$TfW^O2GB?!CLaIJ76=;FD%$+trJG!NOtep$*bZT{Oxo2(5jp5GLTxoFJXx +zfL8?R*jGm;aM1#UiDVE5EQkRF*9ySz7dy={tnLB@T#}5ik)AKcphDCFKwY?%9k;;` +zEiNcUdbpqhum!4~OSSZvs>hazl7wc}J81e?Py;82Ck#6g|X +zfOhxr6u>qX5g+gb4ZxE-gMFLorpz_Ju-hxn9=l0gF#CW9+n;yrukm??;w$dWx!g$v0f^K&*-7dE>*gfWix#O%$J_+D*gzsM+Hh)69?RU+Y)RqNjYgYHC52Q%ip71G0 +zmnSWw6#0t;4<(NbVNkx{?3-^`EcWPY0g4FEDYim!jwsTQk0IZz?kv6+===ylGLms% +zPV|`P7LyptaPYj8^$4^sw&9#2kibmRRdyoY_2@7CERwrVon&a-&3LoPLeYRHti``( +zk6aGv|EAo$kQ^@x4Muj%1{uV#|Nrze^kZO(nl|tlColtF@pF}yuBRABo$56&fMQg@ +zzO$#g#f_dvz0R6;?Vq!}J#}#aGv`|Io)KyLaxdwwt^48Ki-wEN#(iC1R-e$D>r?i4 +zbUM-M;Di??#e4o;bH-)ZMA2ZmL-nJD6N}X==PYk)*4J`x{L~(=X=$UTy>(>9GQHS+ +z+-F`;!y>_MM@ZOpliyY2>A>#%+p~>}%^VY9gUpg6tv7JyVq+Z}Fb?vO$Xn+w#Q{WV +z^f7?MxGek3+%rWpZyFFXOk;3m--3(L`rnXWmQ7!9KJ6W)%Fe874d8P8(g_ +zkE1kLU||I?QI0^JROMi(v5fQ`&}=1b6?7pYttci%1oG4@GF?K{h%tj1@b{Pe>0}Q3 +zRSqx1EKS(?Dx#`#q3>}nwe{{OX{Jab_~07|$j-I3`^2BucaEH5wQS>FM|P1?5!T=! +z9khS9{{!unmZQ_7xEB^LOt5YL-7i!pK3_W6;b}`11v_3#KfH3c%;G!;R#*Z2^Z31x +z_tC<0wDYd8R6(^QOY9l6D7di7ww^$;o@exOD +z%xXEObh<90&W(!TR9&U!Jvw*p_4SBb>yGVZN1GBfs={~HJNW!`$?|&Nw6~F=%XWM^ +zA}?@6Pr?HGMND_mVuALs{?uqIYI1is);_F2l;i7f1b;erI|3-E@(P#1Qo-gednL2X +z5ki7x{`#2TkdQTGH)Uu4iL%u!a<*R7c(h6K_^s>&lvFYq$xYK<#pmes?O_)mvkc!^ +z>VU;Lh*Nba*6bt74sWF@4A&E4F{G8dRhrJ&dA+q4>}ryv0YWGtgT!%C6%PJLMZ`x4 +z>sASiiD(T$%?pDM3X4QJrC-~zo6?1HDp$}rBcpG?X~(niit-VC*9giD^5c*E*`l(Mo%_Wy#bqS-owAq-K3~e9QUq3+R~HwB`d-SAqA- +zr28XGKB4sWG;Bg@_Cnb~8#W>bY&|emrWmOp`0@=BMvpJhyCo^K^K+ulH;^g8L`~Hy +z)k#n6ePg>funBXN%gy6)Y;KJl+lXp*y1@@SVKiyU(~D4%<`2z=yXg5R`Vx%anJu2| +zSp=zKqP(IBIUDZJRv#m}DD^6y;0U4p9=&)nA$$0wyoBWM3bU$z( +z7?N@xSu#i$5*lPb8NxEwDTjAg5vx!|?RIiLopnGuS?at!fd4^x^0QfUx0(Ws8|3jP +z&9YmI2ScRy|E*J`>s&4kXtMiv8GXL;FkB{Z>?Iopd-&X$E~m_HWWme&>CR`%-vQ0uRKenK;vM#^GlAkU=$M6?Uh^t-eGr#V=C**djLt{W`ZO +zGvb5i{uboB+w8DHH<@3iD5-K}Vu*fV!Xh4yvSb&T5}Vz_twMFF!t4wSrfBY}Kxa%Y +zBZNc;FcMX_q}+;mntfMNrPe}uQiG~I#QF3^>ATq7GG68QO?7dkRnJzv^)}B4+Qj_q-5#GD +zr#^Go)dX?mV0gO9fTJGVLC6zJlvN%wKpa!_E0o?c!i$Fw*~MaL&qo(&i{JdQp{fFJ +zwr7ygVU5FSPh!hw6aNsV5w}YTzP1lGUDw%}+bVBW%x#KoQ2{fg}hkJwNZzOA; +zJ_v|TcDvWPwdpVuAKE)u^+4qm6vI!w7R49+jOS~t>5e1AxJ#G%OQlamtvUc_-p1if +z#zRYOC%6KY`WY6zWa(voOW34isVMD;&-5h@c)qSb-u;*ZIksuO7nqmKm*B0!P;wL$SFDbI3Cfr|+!W5>|=#RyVTT>(_k@1tgl +za{x1F1sOAz#t~O+QVJDOCPo894C)jj%as6r(pGlATso;t{Z>?&+20bK+`*3NNV(@I +z%l7^z{+G~(q0ndQJ> +zMRe+Fraa!_Jzf7#?8MiOMA#KJ0s{)e2Ry)*{pc$3SmMgxA$lv^EeLd44g$%_KDQsUJBXm~^T#M*Byve;cRNd6;! +z8st65=7C~lD5Vr>tg_r7_Af2py;9v!b$_leJvLr(^y9Rt+jsA&?wQ(mM#0mbQf)IV +zur5~$gGU98VIa5!PN&DUw#{_5|Ga79#TTLxEx8kB^mnwSWflLZbZxZjP~6>a%Z$aE +zE5rPEL+wx}F1RmN1LemVcT}NU$uK3(`<|&D!5;0T0r6(f!a!=m6a&Xh`yxQF$lTmPgBaO +zaC1IERIi*;>0`-_K=q~148d%Jc9PfLs-_Q;U5`4YNnd~aWLupRW#-yPeX#+2^Zf6^J@R*zM93- +zqV2PKbaniWF#y`Q4uds@1whsC-WB4iC7pEK9rT7^LScVo{4G{oSAcprTxW5gE$EX3 +z5O)QCOTR3adg#|;*Sw1r%n{iVE-*Yw*(Q^5#MHw4$ +zv`~nP@hLeM=&1p76u0bsl}ZOPRI +zOPfwHiC5oeO#8L_P6{T^pLDJHzqPO^{7%ma`XD)fn(>R~MOv%={ogJ8(bxaX>&L|> +zr}P|mo5wGjH1GTPkp4?Jh9$%yUGWZ_RD6$)VV!@)9CKyJnpI>OP1{HaXc95nmf;vC +z6=%M(qoXaIS{IXT26JwGkD;=a +zyu2-TS=kjSmTLpW^XhpsgXU!Cr)_!qeccZG)#FQgo)o{_ +zd8BLVd#`L$0`JyrFZ|$sF8^HN8{zmcr=IA?s+Oto=f`|3#_Ir@2)7NxPTw>=qA_~O +zpM}iemsDd1@^G|xmC~P(ncGYf8Kr!GdN|PA8NlBIo5Y0A!porFX$^CNy-DUOk_yP2 +z4l>sT#I$E`!Y(yVtK-s|aH`NAb%HmZTX0eur~ZtH+^NovA=r|X!jCiEMu!9@-5K;& +ztn}jNUrX!5aJ~4!B%Bsj(cW>@NTlt%~>$>3+H+|!y`$O=7Z&U2W +ze5ZiHEpa=0%6X+5BP4ONzhW@97FLvIl_XZKWLxgyAX{H}5su_WIFCautp+>+e4h@U3))zd@K40}?-|WbJX6>cid41v& +zJs(jHu#owLq=>e#hScMb(<<}$@yhOyR2CLlR`2{nVQsV0VLW;cufQpycO^$jOC)K0wFEofSR8e&A|zrP +zWEKPvLuV6DHo;la=`4<?zWK-~VeT#~=I`zMC#>Jr +zJ>}yQ^a1hV`lR%*1N-)Wp2Q^7rWmFA6`3A2T6u7S^zc&mijdG@{QYcTug65s?$4W! +z{>@yCY&&*!LF0b&#@xZzYxRYB3Py4H2I2<1Vnxl5`GMSx0F||TW~{i5p71En^k)j& +z0nD|XnB6crVUfok9vS)M-423~`^mWIGd*wl#Qoy_u_=wL<7V?|uMF+RCULqsBf~sa +z&UKb^v&~lWxO$^FMkD1wdt0mkb&N%g|Q?%Z?Txs41@9+F|+BM-?m +zuaSqOn~%vQQ{<9x#ZV8=J}>qEmL~^7tm8GFNyxuU$tah{U2kD|Jvc8&dtHRXPvcf+wI=Z$ElusI=uC@ +z>5t9(eq48QNJ$qZ_+XJD+wRTzwVj*ko|@vl=H7gvbDzCGW*lZbpu#F|s962$bAY0o +z(dLZJ%W+T+#4+2I;^-FAOo8ey#hMmlS@;k&_n1qgXyFcN+k11C_r&LQ@;cfcWw}@ +z<6JnR&hxwXf`Q>wc?mNiI&g3&erWawef8L70*>X@11qWL?F)zcK%i|Ix$O-{Dx7pl +zIBSab9rPmtkr)ZNAUqW1)BK#OEOYMYnj1w9J^6#5U!(2~3ru|9p~4jUi%os}x4lo; +z*+JCgb4dCenpUnKvAj%cAIuGwAN=L`r%|erd67$gbQ1r#+g@?DJmSE)lZhitSfZpa +z$I$FH`qQ3&Za=wZshJegSRP(G5zu6rIkODeCu(TxL2u(O88#r0z;qM{t)*-AA%=M= +z_3epyhW#!t7s;AW_-slkdt$6>oh%OvRtz{a=*;5o{2Yn@-I0OMeu=##GE5d0bz~jX +z1h|(!@*Z_1m9pINgO@LejA8y3H3Eg!<*zCcb+x)$#=FN8K_VAoJd?9t6`9cMNl*VSHKRFGrM>IL_y-%r&4A??{s$#3QN +z6WqJy-In|J_qy_97W#@$?Dp*u`SsKf|E6xhx$lz3 +zx9Ls%v20hIjK&EKYWyIz4%sSuMwZ$*g$=i&~$oP?VzMrRGBp>7RlMjUX`R@^&8{N +zDm)aze>(LK3ZKD?-*y=tdKBdUXew0Rg$Mxg$rZD4# +zlJcDH7g5c@)VQam(me~6(R|a6TYe)ZZr?GcX~_{s9}Y9vb$74pwYY^&CWoJ^8mHvW +zg_V-6Et%F0kkoQ(3Au;P*qvP5aY3>CZ``=y?BJJvc)JZB6JQMcuK^(O>SVIt-ibkE +zP8P5MwnRnf4|Qa`LKf6d`zgUQe9rl;ho+2~Hm$tsovw;Ql017 +zY5D#d@fmh|m#U_Y6*61X+9lmud2hx(@%{C@gcBqDuI^hyGOah_G(eFT#CUTQ@*-m6 +z2C{(8P8C|l9E99KUdU^HLoyoWlX9QOnd-vLslynj`r^GFXP>p$=@|2e+m3b2xwi1$ +zFj;+Tg+e~j+`sI{8mZuR=(X+SK^ +zld~oH&imJzm-jG_4{Sc%{${g~Kj==VkEP+Tk?<@W_mPS<;<#+6xv(bERrvcrCk~2a +z`20w4==YDJVQfcpnAt1Dt-i>q|NLTSZ^aMGes7?J6?fcMhL71+lm6awIXhy}ttz(L +zv`{T8d(fqO{BxUHa(sk0CGeYbJY_WJ1cgjI8*=J5I8hBi=&aGWMLNdt8V7h>(I-VN +z9Q_HPV8=%kg2nIxGM-(p=}3{j(8!CcwdIc0H_V%xgxymP_68@l%kD?){MkIGPghfH +z_w^b2++WY`Kw)Z`P(I@^NmJ~@leb468Ftz}W>m<0>Drb@^tKG{#G`8-V{-YFq +zcTS!z?bh=!vkiN=!Z03kBPLJ?I-RJJB-6??#l3tj4goQnaqbEb*l49rG&s7bW{%F| +zD*KzGy4z1xa#8!+9)vYQtft})kvRKp-mav_?1MO*}_(CZMGL2spi#@yL&Dp&V_G5 +z*Mq3l7fZid+|YHGS=$t1oGy2g-zf52BWZNhUXHS-^?tcPn)VIGe2E>OH5G+cm%X^?TXg)QiMlFJx)xHFFR@p^*Tn(O7%nv*_*W{+ +z`A*V1?O2hO%Ks|+x;^yW^JVJY#?o`GgS0w_eJafoys4K& +zdzE~^GzM|>Wi{Sy^?|CWa$dSsl$u1HEPHM~ZH&_^F~!vsCK3!gWOnbjFXKQWktgk{7z8&1XRMU7lGw0z`L>5h+zBg<>`HSZpW_LaxK9LMZ`UHmsw+@RHsH@+%J +zV1GH|>imvs*A}qvHu|=C+fQhX)Jn}KE{?5N?dllzOSzD9`?MU^`JoSzqHj~N`sBh(|-|yiya_J^!vaT|S{+ns^ekW=0?DQ)4Jo1k3 +zV7S@sP%ou&1}kl+{pVe5F!wX9#IWCX+IO_@V6W^_0(-;k!CgGjX{y1^G+3ypaBwXg +zrPy3FdBHo`X9?0zD9&=3O;m6&=K|S$QJm>L+9Ji{)pg!@59)maOTr7GO4+9#Le`ds +zxEOg~z$^rxF*1J +zZ1~LW4aV;erD>)e%OA}N$+BuSdRUhnmv_wb`!R&e1{`P0Rb^Y2-J9C)QZzB*+v8(< +zuIYz0E!juOFLjH8dB=*oi-M>sZCJO&qf(m{Og(0fN62}()SH%C=7^I~b{#J?^m$h$ +z4vKU!woeNV35i^04C@JgC(0Lp7x{nY{BDcn_@B_9DphlUO9$6XQrlj18F``&ZJp~ +zx)-Me}UhbY7MAd2A-7Eg|>5ns#0>cYD($ +z&YnKmuU4%I?wohQ)+LvMIX~eoex=0bL9U14>hKzcM^K+Dhqx6Ps2A07OQCjRneM=A +z?UGX1k#gUL+=_*w67{u?fWenDLfebt+yA_HW%F|FJ$fYbyY2O?sqQNyjra0eOd{^~VXK{YW{>%IZ +z<4n2ei{3Kv(`vzP)h9Ezo3gAt0!|owj{zIEt4h4caiqT +zIc&?twqy={zuC7@)v-}&Eo-b^6zDL=@6hnHeB-WdyVTN};<@_kW6Pt@oQe{>T(

u(P4u=>ybyMo&tu9HiTQk&29^TQXt@!T_MQ(8ol +z)hFcICZe)ppuv*54(xozMgnm6Mz3iFPtU{dGo(+{=a=BM>!*K!bm?okzh+kF8V0X9 +z6sHSI#gcc^jzvQT`eP!;^UN=Ps&j>Yg^h};f2*m{MIjnF9Os!O)L4Ig$nMB*U}b0P +zLr(YjvF;C+>g`R+yQh7%A23sLpSRK*lI@wfe&xS;`lSZ$VOPz`wc1e^bM*R|U0++5 +z*avay%WXKTaq8J(TovL!mcp5Yh%Bh%wsEnsgZFBy3W>9N1p6yM*v2Pp<5Rce=AO+3 +zYR5cNkF>3+ZTuc>_p{yqo~6&yEgBys_-tq5yu&L)CvCB2ff0#!_gbHN`FYyl_Q0yRc^@L^|@S=l=6=(N`KOY@|5@%A|`S{ +zX&H{*vQF!J3`2z!5F9tEpqPytDQTHIpBqZ9id(Dm`38II3CEb^-dn(9eDzitbskGNU>Rj@h2|PEtaqd;y +zL^TJ&apR^+|Adc7`d@DVsyGQ}k+DHj87u4>x)$rSNpr+f`{DG}zt7cjIEPEWTD4*W +z8BPFza@KUIPBA%FYA)Vxd_Sd#xwE;TH8$^6TGl~>+9Pk`;A5fQqqMYSO+@{Xvq|zC +z@wGQc$x8lkYvQm`cJr5)UTFNK#fRII(_PrD$UFXQzA)@>4}EiSZkxe-Ug;ydGu1tE +z(!s&!k;~}5G3-)*G&sNF@3u^Rj>Pq?PNPSTBv+a6?;HH~m{SG8Joi>lg-w*Ko^$Zq +zJ)Ff&Hc=iB=p#q!6Jj-+*-O<^27!HDq}tG}D)p=g##W2enyIUw#z0)w1#*sLko*6l +z>QrqioP{kVgEk%Ly67So4jTQ6Y|VcbpiR00o0MFh9`D8`L$%nSQCEGhq1v!*#1D|> +z(u(2t!*cEi+mCs0^f7yaf3E3;RpB|qUn<+Q$o2PL%GLzd^Y(SvJbzQo&-$;Xs$ku} +ztj=~mao9OVp1<+Hr02cPim69L0)y+l(79_3eV>__CEpx0zp)Q`?`ohICzS}d#6&QR +z%>36Fc&mz)q$M;D?>&hIw#khuGYn0gqM+h*+ny~7R_&JVth#H}dPQIp6)5L?(TM{A +zjYE1WHh|(D;isO(8w_b%Q|il=VgOO~MY}kQ>ujRp>pAIhqdC2JOFnv$EdDJVow%%7 +zKrT-}pCB;ot;EDiZcKt4R-r`LRViK0k{1dpP7)7o684cW*($ +z!(NwVL>&ioBmXyDO=g$K?+|F?sa3Jz>X1pMIJ`3Pv2heRB-Oe)YV2DShFV>T$p~j{ +zqzCZEk7#;Va%3EPIQ6BwbXpMS$j_rWal37z+TlNc8O@1o4};9cEyY%o_#(ugG^aM2N>jYIKjioa2mICU*9uL##i9DN +zXO~tknoh6j^pxALn`+2%o8WgVnubFq$y&GE`Qb+;?_1yBaEkBRhDckw_#Qy4@C9mQMha+D7A&t)X4`jx6a@9k&zq1=dK{}l-6XWeA +zBhbwZg(3+DbCI*q#oZkZEibgrHf@2E=Hs=ZixyayBtaw=025kw-iY1U1%0AAY64mI +zfF>UKl_ox+aE?+S9yf*FsagvGSpxxi5&|;I>cc>C@!B>Sx1H#hXyPGFfhL6nueHK? +zTFX5kI2`&aiOgi;Wr7hY3aw@j6XyOv-IN+CFI)67WjDOmXhwTSc`qsY0#D$SJ|?eR +z>l!4_lOHwfZF-Hrp-xQ=uIxQ>bMQj|!krH^IleQ@rGew}20X`D%?>psIlc5P+!m+g +z9=kHy%D4Xg3&Sj4-iyMc4hTzZN#oRq$OL$*l5RI?UH~|JfzxxuaO+QI?9s?&OoF_; +zyH0T|r#rBbqaJ15@y%z}B`c?J#$;LT2%N&%Yjj*6Y@P*lK>DKwf9HpE%wTYH*J0xS +zO4<|}$NB_OPi>+)8aV2=r>$CR35?^k5hx9Lp1n+dsRhct*xJJ;R1;2R&!A%<<-sl}0xZa>P2@(fRCcL6?43?uF*-y0d+I30wK6|~nMzH~w|5E1?vMHX+I#ay +zZ~NZpniD*#Lc6y`ErXy`&O0Qrci&cet-!JSx8K>Z}>XxjRa!{Elvq&aDs +z11|$Oqo(e@ZfOd%F6j^E=%H5V(?)Y7cdd3%Q#kLjmKhl0I4%?-C?oS7WRg*2x#w{s +z8F;Aj5@ri6p5iaHLQXgeyZe7;&z((sr*>-eK8O3<}bGDe!K&x@Zx +zu_<=Jm*WImmuhaA$1Nl@akmpy(z)F!Xr}U>H1eZk4y^h{_h0Hy`k59FtxF=> +zFH7k~hi)jjjSV(YP6LxTharuX#B2IR7(N2Xc98Z@n}%XZt;n|^2`(TBKR1F&oEkaR +zRp^$P4k@7n4LGPh@$~eaE7JQ^1&}xL>xWK|-`tRoMnX8a0p^^Lpj#dh*0Jo*dEqto +zq1x+;`wp^}nn@rQ7iNkK}gc;iYfd +z^Cp#l#TV-Ljd(ZW>0`IroLdf6`2%a0zxJmq&qNdt=PO3O#^zArYS?hTSW;!rXOzkeRJkPUsGGooyEJJ +zF8?-IbZCru?usSuy=H959*$VL~mC=J$R_@m(gGe +z?q6AS-`wFXb5U~vt(HF>$ +z`DeK`GD>j$oqr5z1BCvF7zoNh46Fcx3p_Bb8ybKw5M5;wt9TCf;`Mv@P8T~i{EZ|q +zFbS_k2Xa~zs1NvV0uNrgZ#aMBy)oQnCSk9g>p^8Pw7_Ih+2wbI{15XB&pP#`Uxutx +z=Q?%O)P8x;^WpTDpv=obO>j2!aNV9QxySnk$IQ}e%xs!q6)-+#xIKH*p0cSk$b%Nf +z7g}Tw4!_Os;x|vS7CW&~ayb$EuZNCjIPbf0Zvz6Js(2{8PBuy)nfFfMJdB>Vb`k6y +zewTCpf43*85jm^hnj*t5nIs_p4FTeRrI&l}OBrHd8Hj%^LnxvhNksKP7G1xEgU7`* +zQ9&4j|4DdppPg_FXlOkF#&#IJ&CYq@W=e&=Q~(nQkVF#zQhh*>U4^}G_?j{hWQFjR +z5AYDW+V34?#_CN;tAmvA;3U}Eqr!nG7}{|k%?NAmDf!)ZRlIhaw!3FN<2)zKkHAn+ +zP4Bf=nL=${)SceKbd7#7E62^?cB3{f%Jtu(7MrjD<1%wriq|0WvY!_juHAIc6vsB8 +zOlQfdb2AkEgVuF%YgRrCWmPh3hj8Q-?21tqJBsA-6G^DuAwn>{Rf~c3&lJ!;#sBX% +z4a)PSWb8K%CaPaJJ_5HPeL96>Cacp~H-+D_b!YXUR4d1{Y+0GR>?Ua250czP5%Ou^ +zN<;ghgI|%*Q~VTX#aSq6gbj{0!UF}gg|xS%vu@+1Y13o95Ys>fN^F*dP+i3f)4&72 +zA&HlHxa$hwGwoNu3jsd8zgp;lztsw_w(JD!-C7!aDYk(6Pq*;<6UN>WvZqBSy}VhY +z<-E~2b{*Y^9+`l>r#fmoc?Q24Lt;II8!z5h#XN$pM`*zKGwnr5C=@O=?H9Xky0^kr_6OP&@Ei(Ssx}9^mFn8(wVze(%dB^z)b7k- +zp&~-w(CFVb*w?9AlIk~$5GtGg*ZN)~9C2QYS)Ka?PX_=RfL#;Z1%&U^VI+hJT@h}6 +zw{m!yEBCTJM9A6ukWddEK6NlrA@mDjH;QTBAS{23eXh-fZ@c=u0m1?Y`#s&su~UKi +z!TSVWj05DCJ2GebHd-9$vN%IVZhsLG@2M2tkXY@3bH<2HiYeB=m;bAJXsv{LyF8*u +zvwBJKoa`En^?BD8xrR$K+i?BWA##4CZ&_m|(i_`@jVi{nGH#6pPWZ6K+zF>9(gF!r +z97)(>+U}*^BJ2r%{`5l7MA>&D42}y|dm*TTHnoshMAKWgneZ8;9j6{Sx#RAg +zUH?7&GJWKv_4V6I!DUu*_w#9kEi=EceBLO{i(?(oi(eS{%8i*g!h=!H^drW{|73tmDmB(QBJ +zNDzO7FB`^-F2dI5WS=$fRC{fEr}w@jSxkEqG{dr)Kzrrk3vX286$vw7!9FbXwFx75 +z)jor19h6$aL%Hf8HD#iT%^pblA)@?&B$URrWq}XX<7RO#wlT^I@Vl49^6`IxVX*Zy +zJDw(&X50`0K3R$713#Q5|4>nm4L^7yL=F>R7$Bi7c&CtDQf-d6t649wC8!l%C@`(2 +zkSaB~ZtFchW{h2R-w;arJufHDS${K}&n4{Oi#Oa}#~+D7akBU(y3FW1Zy-IkpZG-W +zq}G#!8Vo8yymWuAyCAy!Z&6O$^e5bhx7<_Sra4-49RXsy`7lb3f2o2dkKet#lFwgU +zm(2uryEIYOKs(7RV1fk?mWA_DICoF*_4uR7IeBlufdmXq!c_Blw!m6?T{5&Y_l@%kQvV@qdQPTQrtnMc*b^gXo5 +z!xHP#WR@9z>)4)O1*1zt3F}Ba=C!|<58Tk-S{(ANnR;Rg>bh&Ob +zMzGtHRPU56^PY6z(pFkKa8Fiv4w$snht1&RQz=D)wepJ`2}eu<<{~;IlXhV~FQxi5 +z;he4{WHwh-hZznn%8ABoU+5hH=A%w34N9Nj6|hU0EBSaCf+HJ!GoJQwX|n-TmVyXH +zB_cwB1NM1C+|rX6kGwmbeK{rKvs32 +ztMR4_nU?tA34;95UhWaLHi)OWD6n?`6tXzzLw?0Mj-+``KKOZb5jKjH*FjhtAIw%e +zY`^{d>q#RG5vF^Dh^6WHqML}67U&Z&hPpB& +z0|K#>X;T3M3%}U3Asm<@1r9q9(=gZ(X4b;Nh?81=tg`@qY-S;WX)}Rk$gd~$PNJ^c +zT6_)m47}-`pLN@%l2PybSJnUAH*cr$R+_CYW3D&S7D^t+glqyHR-BOYuXmrKuWe!T0f)ppQ>- +zbyD0OD%~C<1<{w}2>P=CY@=UzGaG8vu?MGKTMRZt(Z1l+65UD_(vsYxoc`tC|4Dgp +zq&O(-!iCTF4_#+0k9dAr&M3mX>I9MVccHgPfRM-Z_}H_BQG9}*1)LLr2rU-|R(@^= +zR;j@e;ED_TrMAGcm%F_2wQ{XagrLctvntNeYZto0d2MRtJ!{lnTN{hVNNK +z(^upqBQBr4g9uHOBzt*7TnFI)*kWuH)`1{~3Vy)AS@SGa1swv{Frk~|cLIp6pn{EB +z;+!8uT}R*L4&N=&9yv2_Q@w_g!lq@i*8l7)+vu&^*Kb}JspDc@Mx#U$_9M9A-{|Nw +z09oZ%q-*@iHY*VtaaSac&|(DcCZ;M2DTe+tjpxfJRz%C^8j63-y~uVxJt;D^bN{eU +z0f^GlP7VcPE&E*X>(qH5YNQ+5d4+4MvbvDq>(BJZWnn +zSWe%h&tOs-pc~Wfm_mN8JIQ4R{RG5Ew0T)7A~ac&Y=uC2Y*HpFHA4rjkwh2)Oon|x +z3{Xj=B0x_{nJ^I`?OnA#2o%!3g`gJF2dX{w81*6XPRHi1^q!5D!R|4c?X+6`a8jd; +z^WC}+y$TU_9KA*AB+57LDf2qL?w$qS{w!BPyn!KtwK&zWCi%>T%}|jnvtjImUwd|I +zxPRlTTji5~WyZtkG0u;e`#(l7s6J0treWL+Z>F5&fvkwF$cd&EcT(U?*}5L2f!6hc +z+zt?{aj+X(a+m!GYs@kiRG#=jG4o2ss^o}K^0({~4lj@L% +zh|nlWGU^EeGW&AU0K%pqE`u=9HGuR052DdJaN>k34yzVqNlz?*RKma&3qb;h(m|=C +zKg)iZI)KPw)W{m;n3986Nz*f9i#2zn>wv=W@k7kB!9&y5TXyWnuutfUkN8l +z#EA<6KdBu)5BKkKi{YqR4b2v)JwN~0NWaoA-1~_@?c?{VME`MxBN0lRSYfCsu|PJ3 +z_s}SY`=bN52sTv^y}hQ@K8nV;oZ~W!)?Ek!4h!JyM#~pmccul!n}^g^fp+s&RykBo +z1F+dip4+Ss_)S81&j(1Oh6dn6UByjFtm`R2h|9Q3oy#Ej~ +zS3;S%%N*&kR+*UA0v)tR5~H#IqgOV3JRAYR6KVtX1u%qwb%Ue{6PqwVQAs8XPKWuI;=pc?fdbPD2?B{KJ^oarFep%{1q +zjEAl6Fmnv!;Q^9>oy4YdRK1={D-#Rpn~23NP?a4rcY`tn;wwc6Im+{MHoV|s;ADDy +z@WS87CYm+j1fPdfVmnUV#7{TLp +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 +zphf=0LkJ8yn>C{@AXQi-}VW%iSY6uGd3Y +zL^T(?kakjiHxv!zOJ|)dyH**h$o)ne5riK1XZxS=&|)4^+VXoAK@H{Phn5YBZgOTw +za^3RHgLOKNUiVZTerzT_Xz*Dk4o=g6*UvwmiZ;jp->L=R`woHC170aLynQ(ZIb6H!hnZ}9gj|Nd~|C^ +z?g!0{NfFrF!PEZim-d5sm1>rALGLXxdr)2x3vwR@V&zJvUdfeIgqe9iHjxy4&+NU6 +zH<#nv#TNhgT8$5?1^iWRP*}2Y0nyD6C&~2<+G8hA0I=2*`QUH9Xhtv<->va7@WS@R +z%Q;x!w16Du4Wr?p;PO7)&2R@sGg@UuiUc?e +zTEju`1A-GTY4JJFi+$jf_0U>Fqva^jp +zRTqx4RXqG>AF>%gUzeMXtaVF{Sbz23NPJUS83mK0$)OI7)uWdh28JmFA1(4d@-?OO +z9819d<&pO#c4E&|V887}y&bvZ8hYp&ZR`47t}mz~OS$bq(*aCVwdeRrAOF5+rNPz% +zO@5PR#Wa2LUbGVVXZsqF%Yp}kKm%Su%g=ys5}rNEs2!PSkAwlK+kxV +zLCw%7K=l-?n=iU)`^TrPOuLiJ2GMY#7=9a^z&a=4<|#J?h#OOrU)S*7P#Dk)yUEZG +zO_4oWlH^kcQ1HZRU8E0of;?ck(oV_<+%N$O5-0VX=s+0#W4Xt7<-(>fFBLfjdk@8U +z8$4_o8|?Ztbo!=cknF;fl7w3W8K-nvrCmYo7(EUujkA1>OE8!pd-~7H90-r-S_r%S +zrIKw;XkhoFmN!vH=a;8HUQZWXqn&=BB(6ID)6{tQwwS+PLguem%i851gUuv$ofK5$ +zc6$0AcA0wFp>_eThYHg?9!?QTgTe|wT-^+DBsqR+EK`o0_64N=yEpV+dws>7H*4*f +zht*1lVIfO_MU_p9Py)khALP9M>FFojXD>{bZfY&6i?jh%xCCNkzcO42i?BwL;SveJ +zRuD|9b--%86BjW5*Rh{=DU@8aQTWjvY>;hTy{7P*Z{j`Ea|7x3O^?_2R$_eh@3i8E +za*D?(>PvIWqfSk-NvJxZ&Jc$tl!fMMd)7= +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 +z^RWVR13G) +zzk}qw)H|vKLZSm3`iY{e0nlq#_}|mJ)I~nr^0<$1J{0jR)SxPJde5>{Bo{?F{wh>R +zFLKr^>dT)M`SInpnFyIMHXJU?vZf!Q+j*u-LjR;9#%+-U1VHj7c>E4weVP&So(4}D +z!$WOx@fem<*r$YMXxj#Lk!0lUPf)*Bw*6bngYlu;s>1h_&q0fGMM59OAh++H^<3-6 +z8hZ`Vd~*>jJbfJ~UUFLIJ-<*C6d30lmm`DQwqN5#Ywb4dZx-1 +z*5{M6GUNZ7PyZtKJ1!wEXCsc-iS%G$w)9V@%T15jKNn`I4X3E<{4Wx1p|r2Xns|$> +z)=a4Lht*FaK!ZYLKEDlT&s&TlRkTuoA4GmayHEHGqEGVbqw9~>YsbaDHQwryNvo$% +zUE5@tbOjNh5^$hIwMx;#HdJAw9e?J%=t_kHT+EHatoIbhALmQ#N2{ +zfT=Rk+W-R6CCY09F>8&-@3}Ng=i=l$_$fuLodze|`)w#=zXdyHb*f4S`?mYYDih!N +zLB~?y0MB*W{sk84m7QpKSbRE#aB2d4N-4W{1jw1|#WdX$fdBT!n#Q(xLk=xao{|w1 +zA?Yt(6>Zfz&?hob9Fe{3t8a7jw+5U-x2StuQ2&P*l6kJ?DB}V_V^S8aU1Obf0_o9v +z%m8}X0?lh>OsVXUB?LXdovI*x8skjwtHD>}b(d_4F$GjcC_IT0U7iYbv;m~tuz`Fj +zG=a$0cbAR)u{g){nLQO+aF=oTm8%%6gimkhpB?DS8a+ZM3_LEX&?7WrE&A^_g{+-A +z%#D^LC)%#o7%Wo$!^mUcmUcV9N1@XK{1*;6P6vY6xiEn$ko_H7*#b>zh$N3FL*~e4 +zq=y8{$V?OI#yYUu`Sp?p +zkc&68Xy*C@nj(gndf@MKotO1{DRnt5VzFgkD;c%=^&b~0w_*2}QYWh%8JZ_3R`t%# +zwqE7PVNg=b63y5#@E~gfLq<~(U6x37pDoHn4R7dPlqYbQd>HK_KNauo<=%nYoY?xD +zXaXugN&i{8H5Jf!AZPscLz8`#0$ZKg2e-S|{y-%@Fu$}?!7gp4upmzq{daxP^TLg= +zr;?EeMaY(+5i$S1XCnW5ve8M_i6=T-To>}A_xMQ-kPN%NBR$qA6VY(%bH{nWJZ2@H +zGSf6jDT#}5Y}jo{A37PKwTuG1r(%Gbex{_w%&)Fz0(l)JzZ<=q-N4iTh&OM1(CAf` +zlZ3g;z4=78P$qmMPUoLef+C!U10X~;V!*Y%-)74pH%qGRkG<&#dougU7XorQXs)x{ +zhH8aWH@vcSk>@W~tJMriN-r1UNiQP{Ko;)p1-fp!11|LwsP346Bu6)HP;3Q=!=&2x +z&8h4X4^{JmroFez6Vl}(?{g!1C|}jA@#f3E>BSnPNV9-#tV8s#i93 +z3P*}zLFqb+x+E7AcdLGnGVxB&!}AYgw1#vEB#~kUh4SX$v6TS9ui?&ktRa3{{Dt>i +ziGuseZJ#w#CI7!Mya;8oVzoP!gNmLWy}JQI{qd%sqZ`oan1|Fb=Z!z>-nRb!Fx52q +z!N+@_{C&v=OwU-zdtuuPu%ZTZ(K6pR#q~)Yx&Cq=OdpffUK?>$>p7m`ZjCC?4=+EjW{-0&YhET*}4y7Pu +z+fQO=;m-#tawFm?`)0g?QZ6D&Vt(+COX)RRCVlo|@uZc!R>r@rBPi{NoJb4e1-H;} +znK{*sor;7~s&MQhMpaR;PE+d2wZq1Dt}bU7qe6D~w@%((5M`vUo!g_XqPN20Kinc- +z{9sPxpy6Y2#=hYbWIOs6dJHpo_n9yMzm-HS9>gWd#C(0T#-En%ig>h5tMqvP@hWjP +zdvo!}$lGihCeD6rWS}IU$Lf~0EeSp;I^0{04{xKO7glW9nc9T#2gQ%Lt?LP<3*hxK +zD&q*y`xs~Lvxu-`)auw=n2su5~)%sv63y`7EBEByXJbv_={X&@^-?PoBR6R_O72M +z@U~8mJ+L$#U2``KlI{%d#d)%nMghjJ-GkD&X^Aw9Gjle +zv9dZ^HnXgg-I&Q7gHO7cE`-aq+(B!|x=Xy4p@BQW+=kt+s!MV|9 +zQz~yq-zGfL3xp?j`ruK_)kX3ax;>};^gl!?0v(* +zkjkB3gu^rwDOw +z@ghATTCQzuE=Ip5DAJGrX~)NmXL0A_`eOUy%;F+rBjb3nBC&U4bL3)VN$Pm(Er+;I +zjZ=){jh`CdGk#+HTzt{?RV+0wZaZ7!%r+p|j5^s*9>D|DE;B|+ivjQ$Y_vJv65n9M +z&*HdnUl!52ZSu&|QyK*#8ngCsMWV0Lvl4cgEBzmD{eN*fCNYVVTn*Ol8=Kg?*IgQ$$L;heP;$tncy +z=ga*iSv-zVD?p-Ey$!RwuBwnTWyhlR=LSBn`E#sxQ)c%4CH%zw_h$BWv-aP$+T-dg +zBv^)?MxK;Y^FJf&@fx@ofd$>;MP+M}v7WqHr~QqjB^fKZri~<2qJ)OQ?PlR2G+Hq{ +zCoKJxX@u(s>|Bwc$Ok;m?jf`CF1{g(b{weKpVu|xNU0~b3FLe1sGARMk1LPk*E}r0 +zU7lEeuROjy3vk@1^7Qg#;~e8O_D+@(U5_pa)_8{QP2ZL>XT5y+;7w(7m;MdC9}j45 +zQQm;7kafZVfl66R6=9f5b2s~PoZx5_lNC*I3sD@{6Z*yzyX|8#lesCAcx!MEXV=!G +zn!CkcKkoi7nfBQHwMvOtiBgGC3AzLmkSiBHX6`oc-(lq$;WYw^VtX)UM=~8i|(%a?P$LWmZW2QUfcE)~>-Npwm +zT7nmr0*|v*W$`glv<&MkJ8p(?v~k99y4urqXP%`e>@3*T5Tdf8f%-8*;HZ~zDG3BN +zRq&lTGe=(8I=>YXs#dAcvrx_XsgnB4fj%XVf6koTe@rv|Q{=vy^e)8EO3vNSX1?l$ +zg^t^}ye&Zf+|BCBTEb)7xe1%BlbMid~>{T7tjlW +zY(U97uxDmzIUSVRoI7-QGwgW2ryp_B>g2%R^tcimGdVtFIsDEAQ_QJZHR(snX-Pvm +z*XtZf_ZzcRpM5|l;r7%^+%EU5+eO61Qe-n_q@xQ6@B^+zxbGw`t?AHq7r~iqERsIn +zO7*(vXc+Q*rHaDpcIq66x&8UWH4hj0;~tL;{9 +zrWEckWr2BJ@V&U`e-9<%u8@16Dq26{hTq1j*1&^jYYZ@Iv>E4kdwiGz?;cO=JJRW} +zd4h2IblROz)j`5k3r{$#N#%6A0_8xszSwb*5dNGj?>j~i3(SQ8Y)|=_{GaHK1|qU)R?cKk-=SJ +z|32!H>i1N(OY7B?`SH`vDrW@yd~!Tfe<7jg_=={?GHKsJuN;37c%B@eo$zX#hG3I~n2vWBSsz5nA+V-ygd_rSDa(`zORQEEpYi%k^a@ +zcX=P(i-4dWR`XQ+vXb?eKL_& +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 +zt$J?-LfY5EdY(TNrFsN^3 +z>5yy>6uEO-HRmBE@@BmT$+dMVivq_jrml_X^935LD3wVj&9+pPKKpMavMtsU6JP8V +zt(jDsuyR^PqDix>^X6OO%{CQju7LahI4|0ar5+#Vk4dNSY_L5Z0L-$Ziy%)9uvZ`K +zme~ZV;)D9M4{R919MWK(QQga%<0#>r@!v$+^-0S8F5&e$1$}v4#&4h+C^ZHK%08Ii +zA<*59a^a~kyL&o(-&n+w_0Q2GErCD}PWU6woZFKiLI% +zN*lnhk#4oX@T0N0BZ^K!oCIohBGW2+ZsY37xjNZuUO`OEAEHGLQzI59U&Om7P|;Vg +zHZV~24fC(NAO%?{34g@(?p(Ejj-vl{VSDc66^X$lI5a~{Tl>=l<_mvDm%m2O)^03}C8Tn1FfQ3I +zNW@hRxeQ9Mz?fz=l7WU`JCXAgfuN4P!;MXN!fSvK0OhES%FT9{k}zn3y1s8;pb@dLyd_nk*y5)8h2W4+ +z!j`3o1UDVq7SXEA3%9(f>DCNVFIyAV21;+F$tU8WFKKzWq}nHfp-qsFo;Dq}1!>=s +zZs$G8ANm%tG*%Cdls_znKj^^;%W!;DLEZVF_W%1-2oe3zz1G)U58`rH`w%1G}`wn(==MPHT&uDorNNurUPVeEV +zk_&V}xZh=kyLOLcGIkdvkLN?n5&0YkaM58%mA0zqzW*IIyhYt--C?-N+|m4rH1qm`UfBGdi*CQ!Iy<=qWW|N1NrsT +zX(MyZJ0;}{q?W(s+r5WLI59O>nGkf!IB)67VnV(QjZnOG!olmW*1;dDs`)fqP*a?) +zJE2eus#ED#q2DSYW +zH`(R~+Ta{KzcCE53$O%j_D(m8mgj@SLZ!ZCt_Pn7&k}}h$J14XqK=b5+ +z&0{nBXJZP)Gxt2eC~?srE%~ojq?(IVvI@~0ZK(m&F3^61rB=A)w57M*bn)u1>xUEU=HlI@jYWj8Q6bv_ +z>KQDiJn0;~li59l&0>>kSVvl_I9D$;W+H_}tjp@9Jg&UT;fjdekX9gk6L$|_VuKtu +z65ke|YMvpP*fC~6sgtK8Xj{i?3wox3ZfDw+P*+eNOek2r$^FOGhcMzYGd`;5UavDB +z5qka=rR-O$GlXVI@+a%}d9mkgy`vCVXM`r9-Q3aTe3Y&N5tx#dwYx6kLhJvgwl>G+>$oh36nr`Odn1!;c_lO7P;T7!6ffiG=N$(G26+ +z+RE#KtiI)fHvC+bQ0$j%DO;5|nDw!Mjn`F~S>WgXlSv~5xstp6cBnb-NIq3{_e>~0doH~ +z7!qy`za=>~nOh&e1uGRm4){2JA}1Db?IbRET8XGCNTMhdB`rJv?wi}$r^sFgn&5c5ZfC2TkU4+q(CWGAToL4U+~Zg2 +zeLex&CuQF6%4+7P7c6R5f*V?(ENvR%z~RqoA{o7Yz+s{_{1p>_YHrn8}iK_njeBNCSwTXNvNBVEQ5!cN{RCN8nu1Jq`{>rJywmNx3Ro +zmesY#FWkCp*1zBLJvikpE281-(gf~pu52qLs56nm`wC~iz4%nmxUsDlb|8W{b)G*_ +zW@`|y3Kw|Nn`|<3oDx*L!y@C@=P5K0bCy5#_G((sy&`U7!NrgWcd`a{o;+nP={JOpgZCmE<;PJ=Jo0Y2x +z%umI~GSA{4e<7F4lMX~KQPzugUdRQnj(W2yL2xMDwfQsWn@w+3MlMTJ;vSt_dz&o2 +zU(hWjnQK$A2yu{l6|F%g*?=6d6Ch3C{fL6fhT&%BW4xqUQ&>>INJ%bfL`Av&?$1QB +zaSzbg_H57kD@E*mW%*X +z#i_^+FQ$jo0HMLQnF59;D<|~tWVp5iC7*NPZdQ!?zw_-+h@r~Lnf53B#bYy}&|Nj$ +zeY(+ANmdpLRXYexb=;Zbh?tD1+9fXtim9*NdnILib@OuWn%daQ0byI-YSZcbt$F~R +ze20!TMZQ=~2T3DP0Elgh5*5dIE~3;QJITAJ<38_cUpwWVk=v8W99f?wqvQ0RpRM6Y +zop3hTfkdZUfgn5^7jm*jKV#oNcOi@zIlQsuQCIhJV!usj;q4Sf^!BXS}7L?)yXkOQUvHV#{MHQpohNmOh`&u(7-M8QwU7zjq +zxpPse09s{)tQ;7##NV?SuLyfea!6FX{Q0#eKYwUR#l2P=M6>eK-!1x>Gt~N_boQkc +zf4q|~CpGD+JK>|bN136(m{Q))XLTxGL9PfCYtr}RVw0$|NtZ?27g3f6!VvSC`74_q +z0UF2kRzI6`vvjER)_Q63nNNiwkuQK>8M8-9F +z-&h)Dovj}+K0T|+n{1x#1?%^g;%{APGgMfb1uX|vX= +zckSd-nNF6IG^|7+MP^=g{hZmV4%7~$JAp!B_RWU|6UW7;$?s6*ZBP>t%s~@Ui2rG= +zEGmRG>;F~o9ixlP*_$5qU*=MTRX@93I|H|xhj-_v^Sm3ZvG5o(e|v5rHpEi6RiB7G +zlj;(zTx4subXq|o5^aWdBm+ZE-l7qyHZ)>aFAAad4?#JUT{E9LGC~5{g}--Mx`OUA#`6m$z3C1tZ0F8$q`{eF{chc}fw6ANY +zOWy6=oAI|ZV|n&@h>z@;)EQ2{?-4~hg7N!meIxKGtu +zw`Zl>o8m?D>Xe&!ku3Wa9OZ~8s$$NGklIhoK)dW9MBq&vz?)*>2BZLjq`3#`8(+Be +z+BfC88w@Mo;aT;Q@WVVvVT +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 +z68ID6A*?R;pYlwYl|*GLsLmNMy?;%&j4X~M +zmaXv61K-WtFkmqzg_>>wM*LnO8s%$u~en{&O0(NhXK--7X9Uc7L4oe*PDK2 +z`0Q`$x=la)x|I(Gz>oxIRPEUC2eUuEZ!tzZF@Jd54r)|hgh`>3nA4;n$v{>*DPwHX +zBuGF4h`}gHWNmrFmZ$OmH{Ug2^B@?B)*j+JqRfvnKKQ;s{p-s4GM5Yj(W15FQSJiR +z`dG!Pjolg?!@r5pf^q%K#QWmY-{h=Vdsop9?>t+wdUHlGNie}`n>+eG{pAnl%CCA% +z$&UR|6Z59YQ3oyDX;hYN%gK<4m=tc_74_e8m#@At|Nl?U&3XB;+Zum*_xll1eb^43 +zViyHuC2`FK3v7AlZ{wX@SjnQ5SotBI`s(%eu^aymhPjW&u$v#kwV@;fi*N15t*ZQA +zhZ3|F+`cO9XfJ5&4)qswS`*TXy3&15zp=dN4lDO9aCQGRlTJqkrejo#YKQWHUEj~P +z&79vBJoNX^XT7#ftp5(hF!u@;{MOaUK261>+*S+|2PFftXMi{a=YX_`Q>AYmttS8R +z>HY$T!6X1;>3?@^neV#9nEh=Boh@J;0~`SC$96t^8Po1)u>i$3SHg@3=70K1%AB@$ +zZLkl=E|z-+HHYPkZV%NMD+jm6=D%NDEnWVX?Krl*bF%H{J#FTqSm$_{Y?%w@9(cxQ +z4=mPco^{|j*8CgywC_0UtH&M)c_-$IK_ALRtKIwAtvh}8^!}4sOdhw>o>=nfqR*{8 +z>pRe;nCGqi4zC_`#9tRL-PT@!-&;;!de>QN8rM?nz4L&nmiYKvWA=XGs;;T7 +zy>`{R4Idpo_!(G20< +eoc$j_9K~$~1n<4yE1cc?ezY$_@niZkDg*#f^7_dD + +diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp +new file mode 100644 +index 0000000000000000000000000000000000000000..453a5c9cf486f7fafdc71376ce854651929048ef +GIT binary patch +literal 5524 +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 +zRa{5*nhI +z!+4I(Yk&CczHP>?TQ_1;0SjK3kAkVO&5W-jQR}k=yjgU?Yva19#K!J+U7IzyJt4QB +zlWPDXKYbCPA;9vBX=qSvRR}=(|7#--+%;mY70)nl(QA{x%`GB3z@GcN92xc$lwbTL +z*ccKZDrA~pxi!>$b*I1o>2-qxeq-<8IdZTLEPVN=?GN?lg%anI@(F;#ya-?fB47r^ +zmm*mX6_oheEnz3^Ip~004zpCVlgB!rdnaFu90*gT(?$PXr-^y5%uM+2yeX_sbu&Mc +z*!gQ7nm!^iSy%h9^E`3Ytpcj +z&>(9Plr%DcXGguhctgTx&ImYi=v14TZ#uaBvARh;AZ$Cg)++N)QOX-Pc~`t;u&1D) +z5|%{vVGnMcB|mf?*M&cFp4i{vn_u+yd;j98_ugym&J=yyhgCoRL-E*InPpM@&0B1_ +zThed}KoQo#Hh>t5afPK|xJkbCT+BmXZ1#b-!dA?i>{hfmGMy`*iy~N1X0wFzUv;u+ +zl%*h0R^ge%E=&PjhHGyVdgC$lU4CKg!m#& +z2xS$H1UJDr4gx?5P(lTP>?Wy_^u_1nzA<&g`qc&}^$Kg%2T7SQO?#y7^W`Pt?C4}U +zO7A2wZ0{oCroRZh?rLK5be*TS)w(uPJ*^Zky=}`oZZrPozYE+cv(uwt0w6)5bP3ZO +zqku6KkmXQAPH~qA2zWLtcVCG5MJ6q(5H(bSc7zR}4-k{$sC_oJr1oF!O^11ds?Ft)NH7m4;`rKvEfi^(I +zNI)NCRPR?rR2uA5Vh?6e-meZ4bWr)2JYCm<4&)g2VWR+SFzF}Dp6iv)03wO(!W8DP +zfUO&o3_8$;1OX5+7;r8<5W)z3kOL}u*}Zyp7v_*ZN0kfGL4rzxE36iZH(p@~fX1Z< +z3sq;x3Ftx#Am3@{vp}k;4!QI|m&9%L +zfl|%L?7nmqH!AmDu!OD+9mpU-0A!Lo+@g}1;X=>S15ZlOW&>4JIrNyXhys7npeL4sl~ +zl{MIYMf!WLw$BW9VYk6$*iV?XpaWg{ej8c+IhPtSAWA(-vV)Ilbe?7^*5MH_8bP=T +z`JERnyZ8-L1W{=Ug}Dv~5f`BY5R(kL&<23$=8#I-5E2By07Ct!5UQaS|An +zVC(vj=dSa8_YW6p*n7&Ug6N@XII1HDd%b^1;WS2pSFhH4IAis$>FMavyIUmA@ +zE(kC#Ns)}hs~Wz|$K3z6FWdh5I~5Q<1-mI +zda((NO`zKCw_$#0$}d+X!ykrr8Frwx*8BCu!Gr?=18AZ6#Y6!m43O)Q#T^Nw7itfD +z@5n~jA;Aa*5)R~U?E3F2x;~PqoglN(S_fT%HV#5pz+x!dstQWz15^S#^uflO$0T=s +zZG!UHQGzp_KX +z4hxJ_U?LCoS)Y>sxWmQYbce^Up0wwsnqvprZo;$!ZAfTJy#ECi>uXa(Ny`mGfXMU- +z=7GkqCrdZ{?TCZ}0wpkBfd-Ioc!d14GfaQaOHTg4gcEj>KIq~ry(ki#xUf8i?)}=yJ0X=|X +zgQr9s9_Z^>c9h@GCEq-`r|@N~5C8%>uo@&q5}-lj#{>NJ9pAKJYbBTc_`jXH-$EI- +zN!=Zjbq^kx2jUZ!C9=nFMF1K=0|=#t{Z+n$*-b1+wJz5K5@mH+HUp==nP6fbk +zd%0-_%0Xd%V9KhS6N9m87>E`TfqZJQ>64%^_ezBW*dQZoc&pW~l@Wm#XYBRt +zlT^Pug!G_`IKL^&2FUbKN9gdoI@3POyv~V5iiO0)0)EV@3m;_SMmSdojUU#CKl2&! +zZ+y_yc_JhHzY1?xXK;&EHN~|N2?Y +z#di%&gbp<1KW}eP`fi(cqIabv$D(ztuh~@e%byY2$B{XS20AcCtp0=W2HP;&-?%;TFi* +zTwbhRff;xStQJ7wwR?1(de4Esf3}J9Sp;1`!IA*yNPfN*eRvBZR*WEtFTb*#&i+md +zvez5)lnepT!6%ro2|@e@R?dC^pWaH?>G_&<-);WukAA74^q2W_>P(Ww&n6GF)Qa0%V?OQy +zD@ibUYOL*Bec3iMSu&YyJ}8yG-koVIfw7tZ14sRh|3}~8&xuSXJN*p#;-qgbo&qaM +zP?y;5tGycAGvXXh9~bjqVL6S@AXXI`4tl4_g}?Dl?Y6=Pg1|=`Scqx(0IV!Q2nT$F +z$2~vG6cFbb{8VcM@KUTUKw!5Io9g;5Bgw1ekq-U&mR*J#ECee|;K5NZ7e~I+^wvA8 +zx}Ip&7TgC`nqbc7SoZSY^wY-IT1z8?!fFHlI-m;tj$88BX~oTfBA&Tm@i%cZBv^5R +W)p!$;SapEHyRic0|NX~*r6K^E=59Oy + +diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png +deleted file mode 100644 +index cc2e82fe684a57153b9c8017c0cd8fdd5df3c54e..0000000000000000000000000000000000000000 +GIT binary patch +literal 0 +Hc$@&aUz9ENg{om_O{@%OD~qpwd!n8z0QdjldrN|A*X1ug_o+CynbiN!zP?wr$(CZQHhO +z+qUm&8*#Flna^Xt^WEJa4*!0>wyFNr|&z74RaF%Jl@HjC4Op^FKfDe$}z(a&20btWcl7$Wq(<|oxpP3UZGX!Ky4jjpCn^mE@ +z0?<4!5{-@A?!~;R0JDDbAUF$6bzw)4#fTAp`|iPkUoY!x;Rv0KyvFXoIJ?LwWsovXnSljZG?;-Q +zln|7#ub|AVFMC~fWd6-YJ%9I~Jn?82e&a3>)XVtm>F4vo!PQ5?`1~Z-LqsSmdIs=dK}rCSB*O;wB*FgY`!{6xC`lBU@_bo8Q7{Omn^^8Fmm$SF%7WNsq~ +zmt}w0d~E*2*+1*~^%3rW^hx`Go4G5_p69bC&3C?d_xX*L)1B+L!#{@`$)$mWQToMq +zJ!tt9-M78LjcDL<-@ +zetD;OX%L08H5Zto87{pKX2WdRL}clCdI~6cl72DH;s#4a3n4CibmBiZ?eqlM69VM>t~Ax7F$m}nPvIA +z7f|nAmJ$~~`nso`y57F!?cuN8#c;M*a?X*?vt{S}a^o8}SuI`YUmpGxHQX#xZLDck +zj8x-LtxKFi200-FGJH#?Gn3-lckS~3G0Jtzyazp~qr_g~z*JW+f7x|sdw%_It^vi~ +zkhyX7fP1;#376&P|HgmvVmKRD{Liwp!ixX-L;9IfC;4YxwHInyp>9uFjK$*)UC0RL +ze01;-0^OM9UpWyQ=riO(}YPYdPgNHxR&38iE9d{!v +z`CsT3OU_9RLe)^H7^%dOc-CPGW5_94!W&L6V%$It71RPM%$p$pFVgrpTCb)q{9ftFoYiD(19*qCvydZ3iBD5 +z8xdERSi|+P(0}{ +zfe{R0Oduzeo=`T@kC^lk=U`4i13hS0aEM4dT%!~k$V8N)=Nc%`jga{x*0FV)*B24xRdH3(X +zu$Pw20fUfI!YuS)2w@^=I_S9-qkHj~t%a{OWBuP$qxqd!@9dE--gX8$5CB91Fe5@z +zpwuAkBaILsGeiIxA__5n&-q^M+Bo-!2f6Z{Z}t`7B&7?}P{VMHgP){5h8v8Ou3e+m +zc=lkjy&BVeOxl^-1}`~?fKov~r9#?AS|J1okOAM4aZd7vo9W0Ex4!>RoDLv>z +z&4$>|a)Hh@OzsrNFPEbSucjzZq@8&>9h~8j5K-#s@tPSjC-T)nw|d-Yc(*&aA|ZG; +zqm&`k)=?9@wX=KyR~;`dT_x?maun?*UTy`tlzB#g2p|NME2Ps%YlOg?6M-*4yJF{4 +zGvUN@!)=%7dO<>hJ~OAt!sk>cUW@A{gFjSJ`(l?ibSaagw@Y_U(gsOnNDx>l0D-Gj +zkck?|esrQ*ICFFHE-V4hl~Nl_sSxrY(DNszNsIEeIPep+CxH$gf{vR59qrGuzZK-IzQ0rU7ontzVOJ|bN<_X<+q#VPmb@oO56CWBaWi$c@j;mRZpSAOKnjt>4S_A}$IYb_SV!N@VBXb+&WWk@esIcKog&A>Ia_u#iu@C^6Iv;=dyYR^_Z7nr<%OC2mJ^nc#xIyRGdc5+O-AfZB6N0S5EIn&17TF9%mXiiOLIUd4cx!17^s8No~HMQpO7omS@b&z7uy(-~5V~_p;R@?PQXx1;wx!6rn6@B+zs0 +zObk8Ph%$tzgPdu@9^`~T9>u>ZJ5x;;Uvp6Qlev(UWKf@r3$yat?f*}ojVi}`9uR1@ +z)ac>5z4ocSE9)`DMxsrM5LQM9d$&BYT)iSWd+NM@kG9jJ5CA6{f*7E;B1WKeJuLq( +zFoJzJ03GN;zrb{weCl91p}6w(JNc16=oQ~Syi=CTit5?1y6srKx*~h;$-N>x25_?? +zQ$Z(ROsyx{y!z=L^7^k#U+>6dwl@^2c>5EYiym;^&i()Xg?4>AcFouYiI0BSQK}+) +zOPj+G+61zIQH^2-_KALXw9AW6M0wej(3$4-3;Wd%#u|BLqS;+FdFmT`)bCP+WyoBY +zLeNrKtu2O2KYp@q{%(Hp-j|JEd}&twwxv8+SRP-kHJY9N^ix*1PkD4uS;&?RHXJB0oq<`HgZ(fgdHWN`ob&pWU)pX}jvvqIwBPFYAKH(8@wmDO_aV&? +z07XZ%tJQMMW_^zR_F2#Er`HU2>!-B!rsvB +z9r5aSf;r40db5}=zyd5X%tHj?9liKxd;Q`c1u6BTl#Z1}*arbXQ0O!j8ulwBKmZtr +zxcbdlUH1DgCN +zrh=d?X=t?_p)wGmfQuUl6d;1aq6BFqY$Zr2AkY^G_YAB9daxt}EW;sK=G~XfESEGC +znhwf!fWUGHyr=|LKmu_kL&C8MAp;Z;1R?a7e!D!YQK1iu3=5nE5R{6F+Dc7(I*xK) +zg_XLMMU)UV61EazLVyAg;-IS2?MK+L26Z8I(XE{dkQgFlIa(SG%W?@2fC8Z046k2O +zfsk-);z$CZKoY4&;TP%L0ZK!u9|%jZq!ib#Xe%{z+D=jk%+r8pL?w&d6n5Z%^MIjezqKG9pGGSP)!$IUN-9_RwEB +z(f{__YrUM8^Xse5Du0nbygWy;)M6PHg`k4A(yob)lN27!TcpS9KW@NOn)e^Go%0#f +z_Ol=1SYJcys9Yq3=X1@me+@2;zNDhKJK_x7El=;#P->c|5=8`{FaZgYP%=glh)N*g +zxWuIg0cudeEUd2|NT0v%&GD$Dy34m2r0BijtvrkpuT!{r^3T+lpC9gOxJ$com#fz9 +zmN^cYqC9y4r98RzUB;(RxV)%pgdx;W3)Cw7d_39yakIBjrUwat8kC_*$ZJLX)kV*U +zYa2&aAH{X9B~5~VpszvIZq9fjae@9~#@5O`;i9ANb;c?80anr!X*oB?UDBTT!dr{7 +zISfS>D3GQcapGj>p2x-Xr?fW@$~gf|s6ibXoPXlUw9}mJ9>HZPA?-(6pYah1;TWds +z{kVKdZ~nyDN1SQSI_WL~KUW12qM^ypzTBMOmY3IVX{Mc&siVz=$8KW +z3vs6e!(xYAnK$vo)>}8mdry8~Tr{i15S>Aw+F-Omj)eq31W>>bns~1%Ql$O3zxBUP +z=l^FmM(sh9Jm|$H)aL9@z~6m}ai|kc{o^NpI6FJ>nZ@acZkvox+_9JyeGnzkN@~YZ +zWFaN1xk2VuV+s>!;=DvT9<+z+KmOatvM+w~A5C(Dc3_Ca&$DX-4}+j;JZXG(vKk(~ +z`+}|T&U|IV9$li$X(y=iZP^*LvdhmHr$62D +ztgFxL--MBC{`gg(>IVun|6+2O~{Kk?yDpSoUWEfmFa +zh@EY*OBNC;dseqLhc9@|CoYk=+0N`--g?J99{J73`0n7f%Hi7iAv2-*jMq$YfzIa= +z_kY$4KJdVw-&if{qQDZgpfN_e16#1ERo^MG?bcuV#d`efi{JSO$G||eeXAx-4lfY~ +zGWn%tZo`t4%FIhOOUxy7U^pLVWaJ@4*5XfMCQWcp^w~bHIk9 +zUilP%J>|fGpI$rUQ&=HkHQy{O9GdyZi6738`iBS9x}V}nG)HSuLk0$LE7$(vv;A*A +zAfEbX#m4@O;r2tq6aM?w=at{=pB^Dmo7=VLsM5*WD8|Ok1yC}z_1OjY`tTbb^RW5< +z*}B>D0)7Gxv~_)3a-b?3 +zbEmJhLM_3U_s44p>3poG3ko`7qo^%xZP!8HRVRLuTVKejC-EEqPa>lK$$ya) +zGcz`Ym}=rJNi4{MLFP{xLIiy|sA(g~@dX(7fj_*jzYq&1^QY_#qyGuO1Hcyw01&wW +z+7ttTfw$1Yw(X20J%2E^ZQGtX;@Y-t+qP}nwr%g&wi;DQxsqC)em$(cvK&-9mn>}C +z&PIBIRkdT=*3sJDQ`@#}+qP}nw(Z>}9eXBKYN+ngz7MuY-Ml9^e` +z$(cU@iu^GvA8-J|HGuYSLNY)YaOS%P$PUB+cYXpJh??L4{s+t`J3!<>587HreBb=% +z;IVKo0Cu>gZJ|grsh+ozl*~}ZSyRzSERfoL93#4(KMZ#tfgb<{D3?ItjsO2?np@40 +zbd67V^G)zcL`?9T5C#H?<_H)iKwLzm1!%X08YY~v^?w~p%BY8|-283nCEM)J+^^s5 +zT76OTDxTk~1r3+N6||S`YWYP`QLUOP+oF=~vn +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 +zUy`5nEQ>{lhjkwU +zGgf9z7g_}jwMHHDUyJ9)<{2xjVFL+p3jg^>X0=Zl>kSWU82+M9DvXwi@hq^S=;TdF +zKb1fDvwTu69W+zApC#?(#8q>sqGoH^>@^%Pf39_>FXP+33107bNh@o0#gwnL##biW +zC8Z5)VrvB9+Jk3vP3FlgUN#~$pW`q0>zf~{pHe|N1B$ZcA+siX4<%%w-ybc +z84*7@&o);%P;|M&%+4*Qnn=R%F^Lu}*_4?Y1;4Ztp2==7G$FB0_*mRq{Ya(EYhzeq?e{cNFse(wBRU#M$t5#zaKKp_Z(DsoKVyzaS<=?VN& +z`QDRL@AB!if7`BOb8C0p?wIbTY&Fgo37evo&*W!+)_?qVum0~1pZ@aU>PJ@_63*l- +zFB-bR5sDjuyk0~hWD_({L%;;`&AQWm8*#vIMU|pWFNu}Pc2BJ>sAh3K&_rep8xT3Q +zw|Vz(ZqALa_xLq8wQ6A%l+}`Py7edp` +zic}`I8Br5fbg>6A5RotvtzwJ>NKJ`9?r*`ryrnvtR@Wsh@#z7js|AbR6*I_gUsYsJ +zphf|-6Ve>H&L_Jhru^NN(1uEiCWwfM!WyRer~mrj!qHS}S{Fz(TL~3lCW|tPR0$<4 +zsA0V926VsqgMiYeXa!?Ht3m>FQU$64%E$p)lz=93ecxGGyNV4YK=v3WROpz=u+_={ +zp-lmhV_bo{Y~1eQZQD&gT!pHDDzXTOG%!E<2ZxYxRq7ymOq8}-NXRekj#Ot*K@kCn +zXdr{q@GHg#)DRGLZHiWL2AyZD=hCWx3UWYXw8RKvaQMGvmaXLT9-T3WCZcxC?b6f6 +zROFgPWy~C2ig62#k?makOOqv7Y&xB`?HGOY_HBrqlUI1iOxkhF@KTHc&hy+qV}766 +z$zm)K(}~g=){zhu5-Mb8^|oCVUNDa${)65EPQ=R6xP_aCLU9Qen{Jw?88ezW0irI6 +zXYxcug&EW-n;n4)vIt~jXKqzBeDz>aoB|~Td*~|?KAqV*vB-*<{v+3S*7INWR=;?BV)zf4KLdKkK9tjY +zzXhyxX(C*XtOZ7SRQTYv{&jS99iu1-J(;8NuIWXXf^H}y=)%%VfBO5+i8vF{I?&^9 +z{pL*(8I%Y)b|uP(W*5oO9ZjGP8gHzdj74xfloGUK+n-Y)C_ME7IS2uQM0KWZbS%3;6eOaFhp4LMTz+D +zJ$Xg0Fq_{jFB=gd9raRMTmPc5IIZNYR`XSkjQ5DYLdkIVk&^je@w1f_P{)iwh!JfJ +z`LP)*|JaoIDBMNYf3GRNjG*WmpFBSL>l-8H`%_}!wxxwpKtwRFj9%`%LA(h?Lw%M9 +zUcRVZp3!H?M9{R9AP9&aYUsa&f0uqDe!cHBq7y~)eJ>qbokaZUeCzo9sSVb}@5^DlJ%#;;YM +z{}Pov)HGkIqZbokM?mq2(EWISZt`-q<%w>sHff*9(-FYV2;O-bOMhBN=YJ*hz1q9I +zcnfw2)b>apf1a@WPmGoEf7mg>cMmlqac2`3#3wrknziuYoHPwPDEJTlfy+cm07yqa +A7XSbN + +diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png +deleted file mode 100644 +index 23b969de27f5abd43b58097d47e29c89ad22d0c9..0000000000000000000000000000000000000000 +GIT binary patch +literal 0 +Hc$@Nk&Fg3;+OEMM6+kP&iCS3;+NxFTe{BO(<;Jwt;LX>H7cT9ud+1V8Py7 +zJVp0DNqNsoxc0wL(6)`_-OnFkx$HnFZ6pO69*suQe}zagX`A?0SsA-V|0e*1Z+N{% +zt8)Qf6bAqc-2k8s18Li|{$+3bEkwiwU=|)IQ;xH3j-<>~*=N7C#2O@2w+ +zTsGFWZQHo*&9;rx)ZT2{wr#6z&jgM9zKe7Qf5B&wwT-UKGS!o=nN%NFzoJFk+963g +z5r33z8*SURZS&3DkG5@VY}>XysaB|5sm_Y}DQ(+!ysobA{emw}S3i|WYTLFkw{5pG +zcgi2ok!^F2>|HxanPnq+U%Iw!o3^bw=Um&@c(!fZwr$(|07ka0h?#BMw%^-ang9UJ +z#y|pMEGa+@RKEe`BoPBzKxaw{v=)J>GsBcjXvkQ|UxNRig#Ust21dXD7*8A{3C~G( +z>hg2!rn8vuB4`7>G-&S+ORoy+<%_1Jy;^#XtF<~=p(df$XdV!W-_Vb@(`xF?-ZrJm#(I(^@}t7JRzwmhDHV +z^M|juUpVvOfyLdPGjBXtc>)>9;+5OBynC&De^H@)wut7p%$@prg?T^j`9^QXzl*i! +zpPSDU`7PzveT#*9PMTFqTM|03fdw)3fyzYpoAeId@Y~pKqrcAn@$cpN>Yq&M4GVMe +z!9@q9_IfV}%S;d;t~iXf+rrxq?dMr@9{x9n@_$NG?>6W7BQ~*|UmE!}*;r)!kZo14 +z%qEx^#(?u}!f~h%RCfO0iGKIzwhBfPn9Of=&Csf?hpqwZ0@(!kADC}T_^agO_~YB& +za7UH@r&YM#EAzY!MJ5Vt6c|V|p)DjFM??e#NCEOud*3#Rv9;uw^%@G1Qh}T +z(FQ~e!Ux(yBx^H&O8MHBX^-ua9=^K?q6PB-^OW&CwP0Q#0oA?Gzw@#FRK91B)%O+0g6C$5F%JOLT#fuRGzJ& +z=l5M1rY67q-@w&2rALy#Ov>i4M7}(~T9*EI)8lQVyh_6P_FE_6IA~sI5r`m!F8YB8 +zBtTo*=Qi`7XHj2L;D2Di!9)TZ32Xs> +z5F!rYgQAfSq94eI9}Z~k`QEX6%j)`8B1y0CgKv#hA3{hX^Fy~F^kqf7&m%tI0bXF~ +zwQ=0a&^lNDeQl`UzD7&};W(rTRU;puX|*ZvRVH8L>Oo_sbds$m_!5$ua+6*Obx59u +z$O?-=4GSmhY@PsCxDhTk9{(nYU|o?aDX7 +z0uWQEPsUF3eSZ9w@Ey0mADp}Vl*Zq7`!9QNP44#K%1ZfkdVp(7euT=g&P>ag8Ho_ +zrN(NrSLyI|x|CjSFkYxUI_(9!runkoBEAZK +zdkr*2e3?rS-(mnekOrU|q? +zd!q>{U;<4)I{)u28u?AuYN!3ww06LZe%R@$Ug3uGV}5`7|Bmg~Kov-~0(mQd8feZX +zhXhD*YQcRELNQ27%ys1HjDl^~)}kaCl;4QFUy~7O9byJs5T9`%;PeaKdsEYk3%#3_ +zcV*REK1mIm{i3LL^iTt;L7gZV7u6(;ltE_+5QHZP7znliE--;jH8@efSJ#Qhe*nh- +zwsDxc=pqCZ2-W&y%X^8;-xy6gC$D7SN58$QDD^Q7d^z9QnZ*VXa9|S9qT)#?3V+AJ +z5}z82#SNAqjt*?CSQLsukP#7$GEyrFI#Vk27iLlRfy&ASw?-T!?@NGL5F-{eP|XN! +zfD0_~sbGqSaE!y!f@4}6rV$Yl1x7qEjeJ=-c2Ne!Y2WJXT=YcK4r2lWlnQ|2SHd>S +zZT#&t_vL5PU<4Rynivp7>7q=4E)0qciHMdEC7{wIDkYLZ3P{9R0~R#TpLqQA8(_yU +zlCs)v4+|{)&Yy?4?qqr}81(n{00llg#^MT5pumub$Y(}{M<^iCSwbqrfp`#$^G3{R +zHg}qS@b0i^7?+!5mXNI)>o-q%{riHdrblL!L4vVE^-wNAC(h82R-vM(KND9gYzR78 +zqFh2sNTA>k640F$Pd5#mIq&L;&9-nu2m?_75Tsh=v9)2w^p9D|{<3-2nLffxRpShh +zr+^JUlX*up{I>jbHTF7MGgm>erpl+n)K|4P=0~b9Wr~o5-|p)(Jj;poudo5kl(6mw +zP67``>3Ly>^*3{$$>{&jBi+Xt8d5+c6*WHnh<9#Nt!RdlkzJ_rb!5%&Dp58>M2JUc +zcC=qr-hF|2V3r#~IO!qSz*2HQ{4wT!%j+KKFlM9!frUlnvlL*WwM3Nkfd-aeRVtYp +z1yPB%XUG2Ef6$ognxQ`cwS>1m^y{Hy@Pp7o9>CLowA%c2j==I=J&w7KEk#GPKwdV| +z+JhJg30gn|Bp@ovkt6q`(lf2c%W;$6`PCoCb`hXGUmz8KUv}D_jz5vy}{W% +zuUU^W`B4EGg6wV`Zk|0h~79+Z~Y@q}fI&uXX;EpkAS78d;G(#zfz!>q6i9{<6`+M|8ZG8z?Ee3WdX{&7)d`SRKEMMks6J`{Q{^t5V{|O5 +zpZTmLGnEfJZ{ZH#6`K$leBj#Q`u6L}uK^rHvjflwbQ|Yh`q0<>w!;N3e@zo@zjk2{ +zB`6M~vkZdNCv`xrbojvI!7dn0rhQnPo12>hvG1~nzdnR!lOoCGdA}6Z`f#PXY1{yy +C{3?b3 + +diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png +deleted file mode 100644 +index 7daeb42176897d418f02edea15e4bda3b253c5aa..0000000000000000000000000000000000000000 +GIT binary patch +literal 0 +Hc$@DRdTnm*7z&4@4*_rMrEaqZI(dHCI5ms&#A6Ku7 +zL3NRxR&gk3+lEQ_!|pDFh?oH8TA7Tj=9XWr;BY2DS7K+jwndVh=gX|D?&|6qW}amh +zGcz+YGcz+YGcz+Y(=s!|@H9hVexaPte{cYQ}ZCj_tp^j|Zwry*;yFIgZkD8TG!?wMU +zEImoFYi1hL7|${KsK;8{-u-3Uw!Qys+qP}nI_$B!tE;LL25#HNk<&Ew?t5>5Q`^?` +zDBY)~Ts4Rmz0xXHl$=jDB`qWWr91jGa7^a=QFP$3`!J^_CpP=bKh^LLCM +zA|L_`q-~qZwwrx8j%-_r3U`O4CO{f2JqZHPJY^&TcbE5uf!juk++$|^0YLCw%W@O& +z9|A~Fpp$#(E+r5@Kv@ZgNZ=vN<_3)L!qdn2ip(KGhXi&3SyWC3&b46C^e`_Dl{dgI +z4~NyYpfWK)oE&P-2-&G1*B#Q85D$QDf{H?^3dWX!;W=T$D`8?NL@UDSq3&w1qCd1Z +z!py7T`v<}5CQvID9f%KZ;}Sbj-&dH>7O)_T%1c4!c5p=d1Ve*1_ObFhs!NxcTqxr4 +zE5S31K_yK9#Q7n0Ay{@i80ZX34Asa8U2`Gp9BCT9F!e}lIjOg>K}Mv+mXoS!szKD6 +zrQ!2e!PifK|CfV6*`Hg$ysH^^Y=?4b1$tH}5jR$R&XZ<+~W&r+Nm-;6D8@ob%iw=cULigW5bznS1_?>DndnR_k?k1quQBOose +zjhBOM2a4ZZi`AQ&QY)yp+?azudN6|-ioU3SETSUQ8QXLTOw&TRU);o +zK@WaC3led6**Kak>icI`w37)!EW5GC(*i2r2@f7a3O1~f; +zUU3=tC>90SaR*?7lb}Bs2n*Q4lJ|Rs6)zvhQ#aVKyw&NQmGSxXWV~D!+J;)-8hWLB +zC3CC4=#=7Zm%i}iXHT%(Ry8-f=b9a--G0N}lkp{)<;{zJZTlOR#+VoJt45^{5f8#X +zlzAY0fCqW_P%6Qbm|_z)rZ1ie58NAmzZ^8*$hfoHjrnq^_`hOn +zeRWS;Rkec;ZI?0Hds657p>*!DaLsh!7Vm)t>}=wap+OgyeC;syJoPonO(%C(Bo^0; +zYWq`U)TsVUa29)Bc>dN6r9n&AKGSvW!b&4Iw`4KyWu$uA4$2y<()ax1TmK085b&Us +z&*xEvQofYW%bHQCb@raXNjGBduv2vjCwQrc^{d*A|8}u<-MKbG16m=#AcC|RJa_L* +z=d@@6eX})wq5oaL+#cIsLGA=$vYgMjS=61P%n +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 +z2fQp@^hw}^jV8uO&&0p0yybRA5}?4=T0TN! +z?%(j-|K17w{>FFhr~N)O3^uNXxB%2X`!hsx$Y8wNG16x&9A4UfK~g$bNhdQ`GbR&j +zNgJ+%Bq@9d=*Em}R@NH*>wXI@82hGa$cM$)yrESF3@r@}DUb$O>DEFDhH4*vSsALI +z%G(d|PqP1+d%XJJfoKFq%jZ2stQO0G!4;-KsK?yeK^tp<)IWo`$Cwyj*&uh`tA4kkN`6w#q7IpH~WWlGFx8U|C&ChW({His%|i-s>^)tlr?cr&^cJ= +zJnz`E^EK<)*bXuy#14n=00S^pU>eMNNVIT)-O_PTqyOS$>^z^Pzuh8Rvx4lt>gO-c +zIrp^nyd}01=~<7EEf>>jS;( +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 +z;EKy;`^AeE!qf;kYGneK21MVf$jN0f*W~#| +z-fLeoQ!rAd|LsAXPXthxO&A2oFapTg5p=;j{blKwGfSV(s)!Rte|Y(Kj&dXonH-+j +zovSef>8L)byts^2dV+WnNwT<=I~^dl_BP7w@hW%z?(Iw0xeQi^zj~{{&%DpRU(%-`9928JSutjgG4KML0I#Cp)y`?+Y$cjH +z_{1g8b@Mz2X!QHZW*IkupbIY49+Tu4<{dHr1Mk^)Xq!%(HD5J$-*(>pm&09;Sc{#< +za=*%sdn~%)@lU^(Nsz~5`Y-*2>s3*wkSR5gU|2FCAxjj6EYiw4tl~NIUYk<_ebYQ< +zezccPZ}7{>jgIPKZlz2f73H=JuJEO%2be_Cw->S%*YmSU4;ynhh6Es8B%NeuhpVVB-c6_*D@^>3-? +z7#08`;$Sy(ZYI6R9S_#*4ZBjhxNfCL!OV}Hfqqt`Ni@i&y&k`vzo +zrU0VIWYwF5^#B+Iu?`RfRL}s5?IQ?)j^f}ys)=(WY+0Un}+@n8bpnil-3uD +zxsk?xN*j6PWCYOwNB|;aV(&QRfB7BUniy{8DRVd+<6z9%AK}&wI1_2cez3hWR_285wEt}2pJeNk4+ouWa +z--Jb=!QSAW;=G7+4L}1`rIyu$0$~HgkQhVMP(z`J(d7}mzBpcKd8ua(WA@rsHjxDR +zrL@v!@ub2S3V?xtXrLkbgIs;V@ocYLz%g_;fJ&w~y@Kd_il&4LN&pEmMk5RZ7(h+M +zhp7Z3qo*s;+014}-*TIvGVe=P2?eqbCaaE0z`s6z*<*Fy7i^8mJ5U|nvKP@5&_fy6 +z9f+iIRP70lS2Ih=F)W}q={qwSlQ&T7kOUP*3B@OcK~RAR0u~{O%3n`&`9p#^j35E1 +zyJtoH1o_x@^_S2E5NaIyu|3 +z*7mNuh0aS@J(M4g1;!Y&4AcQArof2Y73g@9%4P1#@|)GqN3M@4tL1F<`}bfN3dRr= +z4b};QZ27H@V8u{fCnCWlp9H +zRvy@ZtB~cga}hiDZq|v2vY`7;vyjYT3Ed{y?}t`Ovo3VYZ+Ty#?;_21mi)j?ROI4+ +z46Qt1U?C2LVUP1&Zd%V`onVccE1L9VHmhu}P9aSRJt$Fpg{)c5@!qszCJa0?0GhDl +z9C$3xhW#ySbsM5Xu0&GUER^ytETMtY6)k%+Nzj8563X{E)2`7sJMs6bl>~l8WC*HZ +zG7g7yLObB{%^cPk^QYG4F+>9(L2u~ogQJ`0SajIlz`kRQ`&$5MUZNh<4u)yx=d$t0mYh|XZjqP@~vnRa<(RQ^DjRZf5o0UNW41`H#!l(DkyF= +z;_S^>mLxrMSilm>*SE?@Sf6K3DgrbRAQ}Kmn7|CCFvi{rj$4WXy@ObO@DDIv>5P5J +z(?u~0-FOEKgkT2hU^-^3xoLmU1{;W$?KuRM>(kl0==#k4?A_k1ZXIF`HeelM12$m| +zrZCQ6k{Lg+i|wEC1FI5%s9LMki!s)N^-&w*n29v*ffx!v#X)e3NH}97>^Egn3d~_l +z(5Iih&k=EPLFhtHbqZa}Ob38&&KZaa+X>?7|MjE_91hp!b}JL0}xCv{H2io}P?;-U1yH5yBl{ +zPy`jogEhke~+@y0J4NA=D6%aDqA=t~2u0^M@H+vpThghUm*1DbJ}q! +z3_JoG7#M*H4=zIG{YTk&tH-_zEvix!&R_!$UnXpBoyOzy>Vg09wbUnCvoaKmj%SEay1G +z=&NSee(4+sdhiyq!2k&iM35Zkcjdc1C3m+VQzjZnkh`Jf?%5>@%qyr+8$2Tj7@%CI +z#;BM*A3`t3%^$m!9d0DB8w`>_!UB*_dWGoTjGcGRbZ~+Oy6YbeR68_FSPv`{3{YKk +ztpgaMx>FV*qD$eQw`c(`(?K;)wYv0n-V}uXrQY%ff>v? +zWHY`TL=Au`RC|%R1Z$YD!ShdS{Dl~ju!8{;Xd~1QK1BTfoAm6KT6Pk12f8*>LPn;9 +zHgYm)A)tl=)i{?CfEu}ta_%(9b$VXVdFp+JQ~(`GFlYh+6?T}HpXU3+JNj+6G~P9v +zKTSXnDoD^mz!a3IdNeIvcLib=;*SsCzc_myK$X~GL4siuq@cru@KDkDw6~8IY}_qa +zIT~mx=s^Mq83OuHK>`o}QK%@Ok7`F7tXbG{CEWHNLke6r}}Ci +z?wP5utRXN@awO8$RriCCUOh~tCDT1I&R&Lctsq<$&HK9chT_c_?(i +z1k^_Uk4+dDW+a^W7z>et?0{%fh%9STK~$nbV}d36l%ue+xEha3@Zm5688ksX$bPs- +z6iA3ruVIs0}?>b1aZ-DYC{DEz(50Up!>HcJn!;r +zhaT5#`WQz&T-?fPo=hrGMju2E9ZM;`R;>C`f=MfB8QFswFH|*sudA(}sy>8ks8!H} +z_g}qM`{JbS=k8!mN&sE(bU{10IrMhol~9JfI+h+15c!v^*Rj4~+x~TU0`}oHg}rzqamib*+4EzCNx5!}At%hC +zgQU`>Z6*1!uAV?PJ_f@LnE0YedOrPQ;$J-$y|1Sz|5zjN5|$a|y)*(ak@79<-?TJ5 +z2{%9m11=cG%eedX`Qq!Q%v~k|L_}sYD)&lmXa+vQHJup4&SCdS@f$XOXm-GlOzHin +zzLFMV4m0SfUyf|xeOzJ}*5XD8FzA9PUb$Qee)7Ek*M`ytFi7+|q+OVh*CoiSKFz%6 +z?Hs7Z{}>zkLj*SB$M+rzzIdob-J{%t3GC9Las^`z^P?=U$7(zP0R~=>#A{QsKD9UR +zT1yj)2@#RqG%hmFqU%*{FTpEtfx#Cv(k#7zd{Y;KbTPqF*Ve{;7p(kYP<)A7;A=`{Uj?MFh1t;Ac!@U!X1ASGzL2Sl#+U +zJNx4`I8PJ6hGhwH=;&WRtD4hQ+amcnKHP#CcnzE?f?}-tOfS!Tf&XiFg}bf&nilpn +z_OHUdu%9k~f&*S;@vi4M{m1v~<|H8qE)o7{S2f_qci@x}_#4siMc(cGV!z4UPeFrx +zM~D8yakvxeX#YnxtIjNusE +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 +zhL*O1NSa$teB6x&3P%{r-* +zUf4!5B⁢hC)KP2~-lWi2^*d49lsSkz`IQ467ViBJgz!oztdMHm9+Ygd&hy!cj;v +z(pE#nC^Qth(3613kZ=J9Eo0?UrHm1j7!1Q^zzDF4zOysPWnI=KX@ev^78^vjhcrN& +z?m{x8>)tky?JB^c00ZRl{}}8HGO9B`4x$zw2-5Yai4R6(v#CtU2$KqnB^)wzoyMlu +zBOwrTGF_-9e&Nw$VIV5dnaN{-aiqd|0ulr<JYRwr= +zq6@PVuoV{Y2{JZ`Bpo(uFiI@|UDGQMY!e*` +z*hGPZg$A!uOjKnw37b&LKp9OB!7mNjxJn&5Qz|!(i=d?V&6|rZZZQd%v^exI3d1OE +zw9|4zL*sj>CVbMPewq}}i}e4!F>d_^brRmnAT80ad5%E;F?dR?+bQdSlLg3u{${cG +z+y4}cHkl@7Sghe|gVBr&8IeLlcnNeS-U0!wONrL)Pi}d#N4!G0RPo4AzQ{TU1B)x+8XMO6y +zOYB$U*%zbI;z=tTmN=`v2JtKupM?jp(!{W@xo=rzs;F4}rljV}CN+`_)7uOUk2WN< +zhGJldlK?p))uzX0ZVQ|?xx9aFb!qk*(|@olzOkX%qDpa=O^tB(!>{My8TsEs8umWp +za#7M?Q<0?M?Qdtuh?*-Y_LVK~+wUD?ei>9Mx?G`5qaug0EK0K|cga_NI_o>|?!}1( +z{qIVzSIcg=XYw24`XFz32p+r?AzStoI_guRa4n)k@l?ONR{wPRMise48QP-6ro^J+ +z#*c;(qfjeW;`Xg-C%2z*f7ZQgh39}bw6zJ1$yV%Z>mQT3V6x6cgh3%K^vDXPYQyI5 +z!eqjE@fVXv?}8PXTr-N3(9)fl4iCim&2WkMZa>-k%*VU+HBXXaO$V{J|PcXuyHYM0LiZ_gPt=Y05n?ss?68x4eYz91nqEF^1w0`(W&`NzBjs +z%<`g!Vn1ZbA=)Nvis< +z8^1`r@%J~4f3Is@h%n2xV1nfMG}?} +z0!AhQk-(gUFi6&;6)$J~7VSwT_^L~Lq_8QD)lUAM`1JHA#a+J$dtm{94NYd29=zhq +zb2Jq53uQFL-MMZEezzXUIgi$gmsUmc_<4=ZyDv7poJ40bK>`*q=r>g*62)F?cDm4x +zHvYt+f~;XuBQbMgb?J8(z9!!PckdVj*Kc|`7WIZjW(q|79#LNeti3mKLWL?y(@-BD +z-Qc};Y46G-W~k!QcvUEUlpr%vs&Wvh;8x^8GI-CehUvq2W3s-oe#fuskIUaykNoz# +z%3rT+mO=JK)E|n`Xtb}{yP8>sZ*dYQAQ)q8H0B;|S9nkCGOEuQKe6hkm#e8n86DxR +zZaXEdxz%mtuBJ73)t%-q=l*E^d6`zdSJ&*3?XcOlVz=*U>01siP~V2tQa}GuK)EPdGTsYGcb_kAmO8(1PU%|y%?ubBh)Dz^wzsg +z0TrMFx4K|I)z)IQy>`sxbOMrt`g$dOo<%~J`Hpbp#Ev^Pych)84UUtG5z6>o2oGL? +zrYysFZEnn9B|k%c>^RgvHV9#3O|tM~F;4+G0e8iu<*?5?NCBf(%z9KgfHH2qwLnS} +z_1FvNGxx@}q=0BZ-`=?ADJTp=XhRBH5kAzM31(M}*g}?PJbjs0^q;ffVUEfKpmFxn +zkIq=#mC~vqO`NsF{hi0|`r1lOdoKeHFwDMQd7 +z;whro<2CEPf2#-V^^Mt9p)!$&Dn*<^A;a)i*bI^bU^@C#R#MO1|IOq2R)z-XsHq>X +zKYquL{QhdZegf))(8_k;uq6}1i31sn@w5d7ZJudQ7(2MOWOrBc2TrAsD5IkTvl9P; +z>48Zo5s;Ep5{3zS(dSOAnNeCi4ZK0Z&kGxZWzEoD_wKUxGMRsQGKOc4vecy-Be~Wk#hYVLKdL!>dz0IK +zneR4!ZQ~Q*1jC2>WH9;n^5gWchU;NP0`oF=Y3qH;i+PmFIT|+f-S9{EU#|LTl?M*3 +zjK@217={e|nwt80cQ4nTgP%{qj0AESuV~hkjq$KG28^E8g$SR7H&%V%&xHwpzqsfo +z1D^}NHr&|D&5bp%E`fQO8a}g}L1q&_K$0nXge7%c`r6I!pE_ryItOkC+yOY?yD!*f +zwfpL&n?ajlIy05zOq>%L8)so;m#d(=#x2J`_K1gcDRG+sw*qD)-MRaugBCNBIbp_> +z@G`Bn$UKtiF8-z#%}3 +z2)Pj`M*X%?#yHc$pCA{OJofO-Y<=m7d)cCrPus6fE`jAeU}nY+sWwe@GayUR&ownu +zBlwPuG3~Uq$JtZX?9wmYHe{@MVf-rS$&4L}G=Ex@+zhmox$CXp=U)+xzB*~%!sqN+ +zXHVE5<(8G^-Izf?Ib0pSzx$udyfKKB=bi(#?%NamNP^w0i_ta2!Q)`Y(#W;gN##(V +zo14!WIxgRsQ;vXB@7iXf+gH2DYNB% +zw;O){i{|%Pp?R8LQZ7@^^N|QSM3lk5xpQ~$XUL0g{`1DKiUM;On3(XHo>9ML?%Yo#?_mo+c7<`t%TDrb_| +z|I1vut@HfcTAoX``@dY|RM(tp%Gqe+z*6S2mbvKW5BHRCpHn`>r05!2yisV66pi!< +Ij@}Rv0QiozrT_o{ + +diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +deleted file mode 100644 +index be7d161aa23d67dd361d9dcb2a37382c72f2ce71..0000000000000000000000000000000000000000 +GIT binary patch +literal 0 +Hc$@f)o4*m%W+(@#mD*X+2cTUXy7eq|#5;%?|$%!)X-zsb% +zc&S2IaQb!8{|Ugqy#Y!c{v`LL5CA7OT9Bdu06Nf1D9KaH3|_Mg7L%IZ-#^J!3vl|` +zStqn2x1yA_yaA+@6h#r*77Q39MN$;S-2p^Wbg_GFM)Zq#Ji#gjRgGiYwr$(S>a4Es%2?XA?R!_Y?K{?K_e@n*gf(p23`w${ +zh&0Mo-PJutbId)qZQHhO+x}%^ZQEAwSnYCFDguHe$!(iGgu4ZBBt;O(egQ$?;|Y;M +z9DtywXPGH+5HbaV%4Cq4P8z{zG*NK8o3rX7U1v9bQK +zwQ-TDBNGQGSOlR*s1U6B$G>XKWZHeu8B7~X!8jO-dqzovTn(*9ptC8mI=}}p0@ku{ +zR)9Ym!C*K(SVb_x$ZL^IKs*kEHPGJ>$tFl6&~?B_ScgY-3@SRrI%q!_%c~=#~Y215&DNnm2f!8+J}T9KSp@t{jitj?u7>g4)rrcV(QlBxZ}yP{@Yx +z8VqS-VV10@_S%Lv9qIZJ=|HJ28k!cK&s9a8DiGdOrct`&wfV{r7OQdJYPkOHxZ}Q9 +zE=L+8-T;p|65dzr@>(&yn1-hp=LI1zt_+Wr_ +zf`@uaD?xqm(@_Up8k1+iSJc4x-OtM_*Zjo5-FU2D(uzheySmpt_3wtFV_n~=X{qjm +z@x7Dcs#++9mEfEWlZVE$N8r(Zj7UkUHrhL!RNB&>TKTPE#6K&N{clvn|0>1lituM^ +z%Ac<5qI3f-X;4yv(kNxtP9pYzmgWw0Bhe*2wQ{PXmyZh7Xwo9D5Fwn|1lQgl7hDH( +z>muR5-;MF;qu}HIWN$5U$W(QK-Q;K#pq6{)=E9qv_Xzhxg9J^C#V;4GFG!zO9DscVPvHgnyyg7_i +z0lto!_{H*2|K-jCEmVS%4%DfkqJs?TJoMe|3woaYL*aD?M_89$*&cx)n2YLFP-rdG +z@wP65iL>EHYwlIp_bYvLZJC=Spd-MApeOI^z0)1QlKdzlxh!gFT;Q`duD#lqqE;QKYcqJ>MBiE{~jb|PjpS|JRJDrhkGAwfoxPSzZ^clhyJ&+Zll7&>6>XDDS +z*DEH@I{V^Z$$zfs{HwEuzBAIohng#f*Hnjh9zrV)MkRvJnLpp3;v19{re>A3@#;O4 +z71}Th{os*LE)JXA{;130Zxiv~hvCJ=F#nFANlLB%Y_Ui)rKGF_xtczrLtmddJ8H?B +zf>xEY0w(G>$~M+-h%Eqh!z1ys{Yv=YCBC{%u3!&Bqd)>z02RP1V-rKz#2{A0EZr9z +z?_%HG%VGJQtLX3YQ0en)SN5*+hZgsu>oj?I?&r)eO2Jv1*4D!anO91@?Sz;vdyl#Q +z_WJveiErx&NCM;+NG(t%D@nG7w8o6j?;{-({#A92934QhPRP`ILab{ix*wi>2z=%y +zZ_%C@ZYmQ=cyK)zGfUY_sczLJZgn7BPAZGVPrYAZyXC +z^K;fOCn(enSHfyXKZNynoN>C)+{Sv=FFK(u|?I$Z{&mJEj&GXd0q9tU6$Ix +zWw#++L``6O9c?`}j@5f*o6=r4vSv!&RgOB=RPXwMAWM@gAS(L$to;3bqFE>M +zJW6;WS_2Q){g-b5**E;VU +z%bkXio8rK6@cHA1H;t6%ORcB-A1Fxr0`Oc5GaDOcTDf6Mghs^FG+s{|*J1^Jom7f! +zfV3cMily~EpZu0K-uRzL{oTBwl0AH`(!DJGGK}e +z4}c3P04WR`5DNjJAVDIZ0C`AQ?YITxkzf`{giOWUJP`nhifpzkORf@15=g1AhM`dg +z6HZF-t%{I}FcqTcCp}R{7C%@De$dibhY>&|0WlLWi}HzPkOT58Sa*@nCy)U0De_C^ +zl2-r_8KkTVq$QLR8@NC05( +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 +z&)MV^a9>%AH7E%KgBjdP=`v|>+@NYZ#*2pR +zoF_@K#OOv}A8fUwQZsL!%L3g03;~CMwWLk~X`4QxLlP~o^%w&QA?>&jYem~oQmsH97k&QLKoHQ4O=@u6X3+<$Oi +zWz30t9)GYmZheL3Coy4ssK3E8Q+aTqE&87S{O7ab +zO`Yi2zV9AU((jrt8l$Kx34?SL8@dps-pT6nwk0m!f{iyt2?>u;9(#Cce(V9OoWfq!TxSnQUggD*` +z%3&Y}N&>Uzk|{tkW!uVqv62mB-}C#J#g@iMHiapOGJ+%n7jV(!Dp`<|Uk+|?CLx7X +zqiFkU(F=@Z)XIRxuI0_MY$Z(WN>xw{hk-0eTg=^-O+-ol+$nzk*Vo6kc<+o-@f(6E +zJl6weu{j&Ixq#JmJ^U(_64Xp2sRXAX+lLf@7+WaxvD4bV=QB1!hbR*W0)WMO0DpSM +zyG@p+3SO}*^(FwvfhZt`0Fp`AH@rxi@%iip|5LK +z5RoKeY^PwdRw}9a;U5rmanS+Srm<;>kG$L`wGv4HNFbdq6d`aB0w@sV-~C;TJrqkg +z-6!Y}qC=EPlIQ?jdkt_N%!ML&1xaE7+t;_`^CkwN=^(VCuPX^#8FLJ)^!xrjNQWRb +z7So}U>F97{1hA18aOaz>wQaQyUJ|{%3sp!*QSa|!%ZF^%asl3h1(`5?qKuOCY_Gs& +zzL-ACV=IY;6ef0Ez&Fus>$;P(Xn>?GvR(G|CvT2-Mu~)JBS^4Yn$={l%Ud}O_)Jk=noQF~584OSEhf=1ecyb?3BlGLs#W*B55^oTGDSPgit7sHQdyOkHe +zf@+E1;U_`1^TdQw%?wSpMhZFqDhJ`gxGIdcvMifo&yeux@mrHbgs~ASqI3`>z&E_M +z?!n7gHH)RL1ZgJ5xgD4c&9`MIe*9xdP5sB<5(nWLm@CKC>Tb;7Po6v>gHrMNn$pdL*dk!Ke65X7PI}aKC+NI +z>O(D?bVd+eS`E|XQw+vQdf+U$|7DNgcV_=f3fpg(Iw%4k5+)E1GEpB=@RFCTE4|(g +zDr!ME>?AYM%rhh?0Qtz}WDt1P0jIj%4<@UF@`Dwy;)K|JuX +z)f2b<(fCx@26MOH2#5IkNC+9?^?NIt#fx@gTP+76447Yw;Pqagd%fak{iH+BFAIqb +z#^!ya2-C)p){HX68W1VoI_#l=;Lq`yFae3op@^nSl +ztWsRCT&BV139jo0UJw8;j+P*5pc2x(bDDQz-w*e}sGJM}lj_51$dr)^Z)uDMAmB*R +z!4OoRh+mz5y!_zC+0@E_wfjAZT(CXYuCYlI{XC!sxN49g51$o=wFSwd$ +zrhtC*{wF_j`>yT}YjJW(j1)t{q4KIr;IfP0f^o%ge3+g`HV)cw;M2(l(>B(z?RS&w +z^$UQofZ_zz6C{bWiS!)uFlTWh4?EkE&#OWeX=7e*r$g>N6yA0hT)qShI9A^JbVSca +z6kPL<{~c)8dz$KV`#q78BAC`hP#i%EAYxE=A`eri2l) +zhoEkWQw!i|@jru6eB#Ky`S@OBBUR;4TbAPy?j% +zyicHo67=SgXEk!U*XMHm+l&3;@vX-ec=gZaxpUzB$3y}a_=3&hN+udeYYo1{SiF98%X6t6>##KNBZHnw3Gl!H3x$OW?{O!$^w}0dA$+pSAbuv^G8gU>2$Otr+Z(lIdf5!{&i!L1VSi`k_0E+2B +zGJtw^kjN`-qMBAw4U;C9$+IcjGVG(CDovtK?Yw*ZtzSKF%5%T&ed~DSh!g4Du^7T{ +zN6j)h^Q1qy?s1blr#)3tk5arYsPQ5R;`T)gJr*Yvyb +zvw!hj|Cxn7!5spt1O|Y(QGUCYj~^b)4=!KI9ej^Jzj9M;%jIkX +zotdPYk??i)CB@Tw4#`!2yFj`9n-4{v{`r<=$9Ec&kXIv%smDblg_T_83-g1Euird4 +zEw7s>muI2q*fpw|-(&dW)55f>(L6f2*C%Wrb4!m_N~xZ%FDo;P4+b{=`(FKp-yf=d +zZ*EJ<>0AZ@=h_r4jWD +z8+z%mPduGgXapJiwA5d+7jWz`tG@QrXKen@^}(0_(3lc*CMIK2!K4o*9rDeHOXJ+%z%rL+GFJsudpnC?w8bx?aPh-6hN@A%lYcf``0(K!S0EIUs`J`pD(l``L{ +zw>NFI#uUFZ(C79>#hPn71MwX(qa5jGgi|68`7idS(`;Ut91RV8lk3Si<-|3!KLq+q1oWr8NA$k@jV0bsI#3kK%Up#j|cr8H@GyshdYH4| +zP@^_`)7u!8Qc?x1+2>F(fLxF2?y2Inzy7NI>+doziM?cq4GwozoV&?eE*RV&XMabW +z`nn>v9mlWVcgnYY(0Sk)ch07JP1Z7~NRaEZ<>wFq!!HaeQG7PBkHJ%WyXXGyWOuVp +zy#0oli&yt6!MP*eSUFnnI@%BK+err2`s($sCqF|4$Tg75up=p=VPaP(-29zGL8))Y +zF)iEux=ycUws+nM@@y7A;Xu<jb_Gb`v|F18ljOh)6c3yTmYzahh-5uUE8PrslTw)i*PX4>QJJ&5`Af9}DyY{Su0*AvBc0t_B +zXxN9qW1e+T+j`PTd5x4^V!;B-!T4TLF+-2hIE@shywJyt}kx@Rdc +zk;@q-$%mgA>l^Oe_m7UVN?>3MK(;?3_HQ!Ef57tIxZ&@TW|G@z*I2vP(qxVnyN`|< +z5dSpr-qD`D_gdDo%10JUCL-ZL-s6&7pE!`J$HcAxB(@Ob+mDHzwaun-*VnyRi-fz3 +z?(VhrYZ{L_zL$*=2M>x6*aDyd%o`&f^?-9E@C-c_tT5D?6!MfvNQis46 +zgMrRtW5OS&wYPuc|DFGR#!>yoDtq*z{oyfthKDVR1bcyZTfA+Zqs;}K6I@!@nDD5_xp7>UYnugH8^%7menh$Q-o~hrc +zxD%o95g$B(F%5wMO-Q#az~NFL*gA57E&8j*)`G*$3<@X|S{oY+(`BM>HKpKuuvnN*&sMO~IH1v5C`{kw +I(DUjE0O~5+KmY&$ + +diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp +new file mode 100644 +index 0000000000000000000000000000000000000000..3d15e56f56293eab94d02579a53f7a6c2f5c6fa1 +GIT binary patch +literal 14464 +zc$@)%IDf}dNk&F^H~;`wMM6+kP&iC%H~;`IL%~oG3AT|WHLBfPkIg?}?-{~T4Q68e?;W-4G<3BXU)oES5+6m!hX&@#^3Y-njg>;yG` +z6%*z3VnUVFSQsL1Q>>8_AI*<)8JMyY92kh?oHX+oje72Y@PizKUE1Im+D}F9rc*BNn2F46u|KU;z)# +zTC!r>maS&2yMM92v24(2*&OHV^$F^eg +ztZJ0Q0gTW^8226Dk{m~JB*|I;^Z&=T7*!2qMrN(7xq5n|t<8|MZQEvKR=fMOZQHhO +z+qTckziit$ZQHha*l2fGRb)nZw{6?BZCl%7skNbNdfB#ZTif>j>3i5$VB5BB+qS*; +z&>mf_c;2?{kfd$CW2nzTed=^d6|3Ba$f=0Ar9TuVjQB=bKP|>eA9SDE`7*w!|W!%KYg?0EC|DFZr +z@H$Ro3<*deuvo%R@oij42GD56QEW#Xs&V7?fhgMXKCFa!^mbvP0jtn~^@v~=-iqth +zKBX1c5dnH=;~M^ie+C;cM{X7x=kON%G7SW31qRpg1vseyRak}?6hPs?Zg60Cx>Uw1 +z#n08XJEjvu;~@w@1kGqXBCt`54LF24Nb{P&M*+o!ok*z_@*~RQN=fH(CNpFSH&a}Y +zt8IlTH8Gk>rK+N)DH+9{zED9NF*s$LqK&KgH-3U^n1XvWki=6U5IOrsH^-X0*Tm(T +zkfE}j(a|0 +z5Z5|qtj?ee0wK6Zu}2i`*okeJhhLv(UnwQZ(1HKi_f>OgYrOPt@4nYs`D6J&?-e?) +z(EEkKFAPDUNFhrBgHkJfr7TB=zR`w}p=w=Kr-duNr#w$9kJzO+B4>%qFAGgUhcgh&D?%%hwDAQ-^}2zZbBv+$E1sQOLM)$QAgZ9)MX(Dwti{IdHJXRY0x8+;Hb +zBj_)eLN&JG1Blfn5M&nG;(_(d%-ZFrJ#k>>m!=JyY|GXrOA6S4==sJcm|h{zDxoF1s6n4-z#4k +z_``D-EZ^?6Dy#qAq^&3U^y8Uo*DMi7929m68bs;A0k@ +zcowHHv+6E@LJ4ZQdHIW754>TYRVAzh0U#%I%cBjxG9}M4vo9HkA4IrMGs|r%Op^sM+qK+6+ua5%4SV_@Y1k3%II&7n8iPMX?v2aJep0H|mvI+li&f|kGlTIs+vSeidd8~rIhdB+}&6IZPLXy4}d +zhgLr~tITg}Ly<75F?FAt2yR?4N$h$C(G+ +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 +zymuAM}aTF +z6PjyoR}rMGS4X>d(1dvrSQr{e9nk|f3Scl$>G(+At@)BYt2!9)@jC7#r3bip4YvVg +z=9m1;y&pOWwb@WB=V6RrhJv;c`_f!+Yub#4Sk=iZxZETfN*rCJHeTwY>^mwsw`@^K +z0*v8FXiS0W?ZMo~X)Tr@@M|aD7a}7zRl2>6{3Z5mYqafG~wC +zx=CmEVHoi71}-NgLV#y+EuyrqkLULt15lUdnz`S6CLU9wl}7>t3j;Nj;Brx*p{7-k +zA(_Hnjqm04d7T)*ApRAlgt#rS4@E#^rXFe7QOU~J3<1I0+QX!!H-U$N1a2^V$Ond^ +zKm!;`YrqQ>Jr;D1A8!`KYy!;VWkk{eDscn?D*qG)Uav1eL0uZ0L~B?TYcjY$ah)zm +z&IYTXHsm8CXhjDVZd9j5&>?krq9(k*vaoRR3<~K0+u;GiXtmy;V(zC=)`A2)qq!kEacn;B2fF>*l@Rct- +z!~Pltjl25t&#>T@Gi0)6uTX86Yd&tDgWm;J;q-XWLC=<3KEH^WuSY1Efx-#2rNVQL +z&Bz1M7cE|hiz*_cHA}h;0CJ`^AwgGR1HU9Nuv{LD0x=4;nlKn4s)Ty!_)_^0ybp=K +zKWrph1;}A1%rpQChY^I>dTjCh=F7j|%9yv#*9$HUMes3XU=-m7E6O6z#2~6DHljfF +zGOb?qsqg{G?J4Qc8AcGecn@M}080=D5c5xF#X=lf23R#;*KTMokf$M>?tWbjELj=~ +zK2`{z?kIf72OUNQr7!h`7a#Br$#b#%?RD!i1BJC%o(6{{4#N+s#y@;m10H4OG_oUA +z4~2*r-UT(i+U|E<7OaOxzd_(=v&+il1UEEA0HJrp)t?kCD%ged&N-%h>V+jsA4=&OMFkk&_Rao7;LGprQm?cct-{{=+e8Hri3j0RmfQgbY_eXqu95ys +zC{l}I3~KpwNSIN!0Rm8!f>S|#E1-gBKWE*ki{t)+e_7ngNfTex4(}Ld%U&w;d^i# +zA#(KwvA7&s@Q<>8ymsubuh?$JZ(|gDC#X7AcN`ut#a_73U*3Kkc=?%WuAhWL8Zi(= +z1Zk`HVw;ig@T}>Z|HXfoagTJ{v`VD08LDiCPJB_W^2#;+5NIq$V*P_G;(>3$myR2N +zfCXr-)+^XoPVySO_&xFFr;alqfnbaP3K4i9k%qR9f4t^<&uR1d?~6GC}L +z$Dn^m77>Cd#I0uNBsY|e(6iQ}V}Zt`+fWS7e#;G~AMWA-5Qwaz71z9JpW*7OtI-v4 +zOGuiRTS6WJpoJ!Re4)hwlp1CdQ-}#fOaW*YwpF?s7>9os5*`CoqIvC8VPGBt)XTAv +zUySrhmVx6CipqhI07lsJl)laXmOvU{r0KT1mf8oaVB64N@ezQ<5x*Qh^|N)!JML<# +zK>!>-AB>ZBuWB^5dq$uT8hT1OQM?;dAa+?UJM##QS3p!*HzlvSt=()^d&_ONeE@=< +zOTHKN{=HXycsp%N=|LVqk?chtsmTs0m_S2^@BrlInEu;iVJr|u^V+Wn6AKVv{BKjL +zUmer~FkW0TgiGWqwmNpru3M8R0t|eVb+_$k+qYlv*}hVxh_Cvpe&(4ND%?c~ENQRr +zuUghwHR)K39y&Y(1=<+mC=$zL&rQ=No(2$=>mFDRrTmHR@xKdMtcA7uN9AO6-%lX8 +zAgZEJJ)wWv@+UBb2}~gZggr1B)ccFS5`Ts&0EI?`);%yV2VlkdhC1hKAE8O;a$uSL +zRTp$+JOX3`@2}Ulw>Ju3FO0u)S+PES`Ru{6O71Q%`^fclK^$Z$e +zxzUM6uoxZ*Inh$pe +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 +zf@_hEr}YMX@$A1v6TLZGNG +zZ0Ihmu%D<5*6&8em1F<}nRO4ku?+=fNxR^PtN#7p8KdFMf29%rX2_7jZ)bR$I10Cz +z$n-&8^$kK#LBRrOYrV}+u_hQR{}}Ow+l*`{wE!8sx$ePr)bKDIo9@q8e{0>nH4Ez1 +zQ55y?YGK|ME+o$pbZ}Q|w?LajKWPjJQT2Px_kt^;#`@1wuJ1}L#{#&7&b6Px2|NP@ +zqHvqWun2K(g1^-}+lEV7i4s=nh2D>O-M#YCy1383JAoO0&Sb +zw9enTo0X`C;5x<<;B>=TJPHTKw8ffxX2;cgGzrMOk!T?MpMq7)IB8M?jh+QG_Dh(o +zye$pEr+@27`P`V`YO)`*yEqyEa(Dqc0hO@{FCYid@UNUQ^}vis7Y!ehlvtv^@S;A^ +z>lt(~69q+%h9pq1pa!}O+7?CQa$6LEckJZKvBdy_?lkjGzhbJ46%r+;!eX0TL~!u)xM~1%!UaHEF2xh1K^LtAwUh(`uMhS@8A;7 +zr?5VQ2SWB8c9uXKU=VLZCF2)>Vh@1$_9?6G{L9$8%Pj=;9tY?WFWPUo2XI_ndjP_= +zo}|ksY^pg4<+wB#_}9C;>R)ay@}IM#e8o0^0-k^cQXy2}2ofRdnY;S8DZOS!d*wFE +z&@PSR`oY2hJ+cwm+J`@AXb?Go$e?jvC4d|Xs1hQg%s5Q^$7}K%ITJ@18R@O)@3PazPZ)th>+;z=HS+AL6b<26tTQ +z(x<(lIBG2#Kmh|NpjLq@pg=ZZ19E6Q^(un1-2ITovj3f0z6PDNZu10o;%Vgbg0~5o +zJEnF*07mg7bV~jp9oAzJ6s$$-x*2_2n$oaz4XqwmUD$&t^~_bm+uZCFJ_gC~+pB{N +zR*a-D?k4n#z;`FYFmbmEkIF(WfATpxV=>~}HCqSGe#Yr)B~dR! +zlN#H|H??l#;{ZO6V+f>0Na6?_pb~l{4sBy2??cB^p*d(&(2TvJ;8A!J;6C*)mww<~ +zwp`d~;Q~sCh}s{w08oJhAS3Q9dcScXLl)%$9jqjT4gzpRhi2t1hY8hn)kg?2^;t-MEdLO{6 +z1;P6y_ugiz0c!vX_#ae&^au{NBMKm|to0ZX9DdSfa|XzuJ&Sq(wDExfrOappa;RVm +z?U8x;b>s+vuc|Z*HXsM^Z-`2SxO-;~Hk^V{ +zaP8epaw5OC5j#qfM?K-FP#0h+tUM7HfZ@TR)=9QzD)ox{(}d* +zrdfHbNf1W>Hn1>L{z(!PUIP1$au=l{&x +z$J>K3#ERk9ZoopS)+YTQUM%cIa*NmHb!+!QLC_1xtPIy#B13kGZDZhBbl^wmMI>Qc +z1cq=HVI(kc_8;cUW99ZR#;^yghu^?!(h?E;&}z-nNe((pmzgaAHNkFuTAY@K3svX|-kKUE7<03sUR4(}^G +z5*5^$wZIllzN^lH0Vs>=pBSdpY6uL;-o?{z)42Mwkq8_}w9L|02!)`BbIWOZvprpT +z!^eHOT2s?*NTq~eCs>+nmun8T3^u^$LID~AP*9MQPYHxkE~m@Nu9dfzB0 +z2=3$D-Mn$nOb9?D0v7@jDTJT_t6arW*C*iP=<8~*Lyk>KMdGze%&tBsTvqJ#hv{m0!QbM+BUUR<&G@n8D+`F2T# +zuAo^ooABDa*UbU7Kn*%Ud$b6icOPt-Ai=S5xem05M@~KH{$@!7bmzAnT +z%V^L?uY-)`oo;pAAS(ZW$Q-NIm;!?&7?2qjujO33x7YJl`!K%{wQL&_D8Yq*#0x3{ +zb`R;H|ANXNuPy$kk#D|vYUy-hN7XQ!NiD!~#I>+KGU~Bxs2S&p+!}5Ud2ky~ym2l8 +z;^Pwy8zE@q;59o*egakmfKzl<8~C%>QK +zersQc1c8EdL21aqt;K&ICf1k?xaBbf~n +zXM4&2Nxpx&6TMK|aJJLZC{8nfU94;|DyDD9-n~R=GrazyV(84E=7)}=nxC6M5ZyvB +zdI%JNfav=k8s?YWXT#MKo8S2W6^K1Mv4WWFG9#DTwGC=bxf&PIy+<)!YDz5t|Um0`7BhvTjiZiY7iRMjAr=R +z!s8;Tf(ts8TB}LD_*;jnCmAZb4vPe`T+NbTZ3Km{wDo#)Y6&Rle&xBr?;tFCForxG +z$d-(Mwt^z&MnL;C03kHOS-Hf=7&4uMMG6~ON4wx&#@yQarzEEr*tLy9un-?d{VGt# +zFVmE1CM*NGX}Ufk+Hx3L#~4VrWBb#dlHRAV7cLIe_2+=CFXc +zOd5o=yISD>#)`eR_zsaM#hjwBC#*lv@S!}BW4DW;u?Vf0Mg|IW4$zIWICyE9hQI6I +ztcx{Eg_g>e{f`H3>`-}YZBK^6SlB}Awxh9nVtcP+^S^0*!tuql{kkQ +z&^^FIJdfYst~H-&#C~kz17$bL-_zx%z+CaL1EReD1*FE +z|KJXbTM)*7qZ&j9i7<&5@p~_vYWbY(ov;6tFC8XEUDEoNo~?A0IdEM6VgAO?uY*%^ +z?o&-(!#B{0yImxXfQL&shA)2dgnEl``M-;e?dd23sD|}S;kXyrPkA&8!FzCe+scRG +z<2l4YHxZ`s8U{|+Hhe-d@SB^|UQ8lwhq-?C7Z;RhK%&(WAh=GM{vb;Vhww5UL3I@1 +z<2K&JcRms0-!>cnpQLu>iU<^i0~Z|W2l`1gI>;q?dxkU1n=y%35dmFA$l`6>+@H^#rST>FAIe=NjzEAOyoz6br=2vsTjzGs +z@|Qu2ez;MC+3KlMo8g!!eTa#>3qFe*=!6Zri;%}DT-Z$3ef>`EU)##*P$xkdh6;oY +zsI)fSEhgU2(FQi-KUS1=k{&REmyy4x(fW~%&OytsGk_^vv?gq#mBJ2%_bC?6Rs0;z +z<1$RpWrQ-i(OD!*KD(BApfXrzgjy6LVAu^IFH-g1?(p>p;s3AzM5l@H2(Mt`-fY>I +zg;zF5TrISLI)e+!af-lEue2vc_EQ{s17E>0^mmR;RkVNXS{~fd73iR4exNpdQu$XdALYmZ +z`|$#1pg`vlqS%e4XEi-PV|vq`y)fIXaBXOMX~zJ0h3U~vhqvP~-arU+AE3~TT`2vp +zdg6_W#Ws!0MWS&$R_-!-@LA57#<%eA2s%(4fiSjUZ6)3NUX!cO>S`8}0SMX_0?|ke +zh5Nw{y;t!mTt_!7(1oZXDb!*g?EhxuuceNsRIx_fXbMzX2tX!rm+?m*>RgRY_!lHV +zHxevt!upvs_%oz_QQ0xaYTWm&5-qnWe4-Q2Rs1^6;u=iQk%VgOMBM62~fK%TkQ%{N5wLB_im +zW$ +zrIvSB`U-8Z3{8&_ps+>x{ml(jd<56f4HI-Lp^Ve$%A_@4V#SyTv5v_Cjk6Z8lbBl}SYPF>=zn^vl +zAI1fohY7kGRZytKUPQmz`ddGxL>@MqdTaap-o{iNmg2vW1l>(Au?Aaj6}#Wf`cr?x +zl8@1hRD2sJ&<71VoDj$U0bl+CJ3Ys3zh3bzcz9;zwEq9H03U01On>>qUDJ;Syo_n6 +zP6vG4L_fZ?VYD3&@D@U#+X)$*MLjm)G=S=OAizUhMiMiyLDv(CIDGr?^nNs5%m{LE5I@4rQF!--FV+t{{MW#JMr^j{DqIP4*EDS)8=6M&Vu0^a^a +zfh4OaUI5^t2Pujl0BmLjFMEFg5NJJAMMGeohw7<1rQX((jjZKm2gVn-DCz-j!`-}c +zsp<9q+}3FgBuP>fQO^dclO!pM6{;^uQWPBocq#}fiij}Wgz?{F>tyI;Z*#OUWTc02 +zr4@?eP|&uG6!WLO-NPUvCZNYEDr*i~Y5qYJwz#XQuQmsYau6vxCAy@mbkE&i+|Twa +zk|gIBnO$XOW@ct)W@cu%rYm3huV7|oW@cu_t6`q$%#4uY{7%G)lhs$I&sUgchr$>m +zHY`IZ4ArJ1`>r=-wg=4Is!~swO2JCfYpi42lkB)VpP57o1~HxiZV?)K^PNRDk=wUx1tyzg$%cWcq~6iCej +zAq6sVcZat(C2QMF+s-?+>}%V$V%JhjAIxf+ko3eQ+eO7!V1HVnG=85 +zwi%M-|KIgQBuZ_JXR5KbZQHi>7hkq*+qS!3YwwOdbL`lvszzp2WoBfAHEi1qNwS_V +zA`+FErQWKwZQHhfdynm(wr$(CZJT3zn#IhBFRW49c1V)+deWhFw&^*}T!7dyf_;mj5Sc<)hLZ#f4O +z0mcBn!z4Q4;Lyc@S=xZ~JP6IZ2E<)1Kx@>1dVzR>dVnSZO9YC3kbcNQAcG)-pmdOE +z26!Xb&cMOB&bTF7TmVHNB79bqtqDWKHEK3uoLRsdbAxc3M0Y~bHPqXOdIt0-L%$cw +zF32mO5nw1FGoTlSjvtzJVdG-hstVh$!(Xq?w()RzF#)oF(;2;*CAAHsH$L7 +z(18d6>KzY6#MiVYKmY-Uh=G4Je;&#Y@4zQ7#rrSBuW!WalZ>|mq!(>;#ykY#NUn~! +zD_|rZ!*{&q#!C$Z@CLYY$S@Oq^$YLGZx5L18RGVcqX)73b!mt-?{17+cW!_;dmox@ +zcf;jFIXWN?@gZ)gmSKM^!+I1I`ujrgH{7%1Q$}9DTIku^+{|-Ndgj%gPjdQQ_C`zpERU}9Bh)(( +z9Kc$lUckl(u5{02kI1p>eaRk +zrN%M*wLc#m$Qw_?Cojk1C_G|dfVySEC*-b4g^Gf85Mlrr0#?8g@JS040B{O$N)(xzA|FFDn@Z3IB56rsdkHUI67qrD +zQgP7znbvt&k%|Zgor~c=`@_%XfJ51kB0lh95Cy;tNQvht_auX1DQHC +z`0Gya-NoQ^Z2$s9w2=!iEa16-Hw5S)f&-3YAx031L1Gk_ +zK`%V#M%TFTS)pg>+!`$wMhI@44Zm0hem)Y+8sI+=!kmbpRFDfG2fqYo0b~Gj04abh +zB`)W5efBmO|ILr!r1Gs}zm8@$UVE}i6zn~@2h=>-Jn4XjmQloh3s8GN9*`9j!LzXgl +zs4c82@H4=4O&~XTFE|IlqB)SD{Ey}3Erlw%;aer3@Web!C_hfJ_CW4PD3R~dN|K`+ +zEhx2CiWEgDqAdV;c|f+1!`$0(?z|#4l9Ab8Gu<1_?7TRLAp@WN0N~KP`O`k!Ji;yql_~uU +zamnFH!2tLEDT$DftM%9}LZ5?3@0xm&2RYr|Ozt_F8)nDYX;4G&GcQNPxGga$2ef9* +zO!q5{<4<`@z$XRoL`i;7c9s*B2HNcM}UQNxSNoeg#wK=v!A9kmhjB9+Cd=3WoCmOG*tsM;S@qa +z_#L?@+cMoOLuv)2ModYAVnWfN6aXcP0)WNSKrBctr!a*OxyVYeJ`AkfERQ@7OY6P& +zuh6%L1C9EME +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 +znPn4&G!0#CZfIZlRY93&98f|-3*aI0rrOG(V +z`}%{y4WAl3SCyRGqfUUgMx%|##xzjAC?;9h)*x>KNP&b6`AB0Z3GL8`er0To;{5OC +z1WwZJtShH1V~$K$F6uy%wWTm2C`@ZNuj$*>r*V&e-B-IW#_0}@071K`gwBTLRMqtW +zRNVr?68>+!P4X5Y4$4H6_`SUSaf-=jN*gBt8Onhipc5quPKFPr>6$@-c!>r$fJ>FJ +zI}en#nF(n@!Br?Pm*(&Cq5}(tHdzK+VA=X6o4??fl~r|B<>}uWNv<3z3^NtFPFab +zw`479h6Y3Hh?|9oZlOdxDXPQ7f&WJM0Gp*Md?QI{l;qQsX%ZRED^^nis7nG=twE^+ +zNQ=C3jxwPUn1eLfZhSscZ1#`P4i~M^Fs7-bvtPL#Oaeh*PJ@K10__S-<7DN_y1GsY +zzCQ!~+cG4Vubb7aa&j)`%oPBU!czB1`TYRl^R+gM@FSc0ws(o3!7fDL~aN +zAYp6o6G{RZ(xmeTi_5D~k=qN*sh_YGw=3V2 +zA%QwE>AlGilgS>;0X8WzP}*~r-1LSp&>2}Ll!!bg0WR1`(-Io?jP`ybbrPx)ey+GO +zJy|{QKnPyF|rL<$B=Kn;&6p#JIh2BWXw-G +zCqJUfr=T&=H1XHU1+AHRAR%d#qwA&^Vac?6=HgCy(3}i +z$r?~eBdYqw7zNN`r3Hk{$s14)lHY!1lEkM01%L_>(^$xgYKxNT#}Gc@-Pov2(c4r> +z)dD@m_y2B@w>$u%Nt6_&7yv+nCtPcy9l4?L4L97-SkhVYav=%OBD(ME#Mie42j7ilbun?amNcmip=@5SVBCQroR4UcjG(eJ0eCaQQ_5dF_vH%*X +zw{CCvytH?I5~YG41m{!a;Hb(=zyk5$VT;j-+xjerh(}S +zrVQh1+Nka64!`JY8HZ|h_~(=InGVWU@~!B%f?pKErglu~7Q1FKX +zI}~h1Q=w@%2+79DBWf>0!F&!tvUx~c#L%yQBMSJf>i74Dzq~4oEGM{0H*Ye)SQoCI +z4L=7@7z?_B&l|{>p3tC<#=jyX| +zZt0Bt=2tBP>~r83+rto4$yhT8_25?lxTpsN|VIGQG{-kBlb*cHeoM0JDa8S~EHN_@|SwvI7CQwk!O{3%98gp?qhOk25o5 +zt3ZZ?1rH30mZoA^OiA1WFaYA^rO(wVa8s#dMi>I15(3mRiuwShdg8VMX!2<*oPj72 +zG)xLMZdO`069A*wpaa6?IXV6?$qI@taY4C1SVhc0&Lt$Wj;KDt?gSQJ$99H5Ta@J> +zU!a$xfxsZ(sh5{V?TH3p9Gb*l-x~IVv+d3f&uf}fXaG0h{%$dF3813TeDFBc2P%LX +zjn@68_h0C4?fp>JPjikSkIIGIQ*!nw`Lk!cfPj$&iz7iF7Qqmj7)*+^$s*b_lrmur +zEK;vYo33qJ5D9(m{|x}f*cPZHuyS`dogPj!n!h=YSr&UX+6|j%VqoKssRG_&(&s5o +zcVF>^jjUJXXxkhTCYf_byj5+$cuYx}3iCZdwX6uCAq>$creU&K!LpuVL~H^_;rAb$W-#l8GL{t$ +z1A=v8Fv*JGvMTQp3Y{O)<%ccbJv;Q_W}|~mXOw;W`u8BYXi@s9K_$bV_5fb^O^1Yv +z?_Mr%2uMJSD*^y7nM*3E4KiiJ`hV9R`_}(YfZ=2?dW0$ig+;?TPEEOSODN-z(QN`- +z3&WvC$5m!c|Ev+u>x(R}7kq{Tq_TD#kc^!N@J*({KbzeyOD~l;nG*gA@<^7077qbW +zKsA~ct)}z9e+Gn)t^ya%LPjE@pQuyz#kDggtIvn*LP#!xBt?rETClyFKWEL$>jfBf +ztC0s)6E8sc06M_s@)aU)LH|J+mjqhb2scH?0S%~Yy6VJ%E2*l{l7W@wKw3l^?kBw1 +z_9c2f-(VjSR{2A>Yx1LU-DU$`jAaA+RV#GN~jXD<5fWwG)9 +zQ5A^Kg9x$c7?KkI*VZH16IRc+ToQq1f&d(W{qnueyKXTO8@D`}XH|_@%Q22jWl&2f +zB{WQ$LL1y{p1Rob#J0*#Ip8jo*1SByq<;X +zt33s%=6jsge&ZQW;jh2yu>Yy&tu3B*zx4B;=)e1fUI4eZ4NFNNeuBkfhUCP*#9%#AXtJ(r +zAJ%@!pTBcBCPJg#{GktRZK(xnBQAGI>7|L^bD#`FLGZhq +z73I@P(|b4Q_I1B5T=|E8_s>86=6c^h8*i52NbJGbKON%rd|i*QJtUF(+AD0^ZHVz+ +z8{-9<1QaoYT+8!L;k7#+n+kWGGYK6ht{J7?Bq^rlo%?oD7nh8kEIZHwM58o4>w>PE +zN^|LA!rqO?-Ru5*$~E?-3%Q&A(_yWt!~)`MJqQ3B(1qmAd!=piPxQe6`k0>}HuBJ7 +ziTX%G4BT8xDIPa+FXh`aH&Ffzw~9cRJI7L-60ev-`?7wc-T!|0eFeMMUF3^@wp+Ms +zTf|yxS#ke`u!kcGr}o_lTlX;#LWSGP56m&^RrnnfzBLY>T)hXyl +zLC~mpDFBm*V%;nCP~*Mrp^bKr_+or>NFF~#tzhR?23wXBc23sb$3+iy{dfqAogzd) +zACP#jv?um}H~$oXUI_`ug~7!lTmN~h`K&^LDBg@2&4uDKBm_ZfY`=@hh>qVjl%#qX +zr;mO!1x%%YBjLs%oZq$4dwqaWHYWyf +zAzEW%8W@@bUY7*J0#Xxx=vGHtbxKZxD2aO9Q566*w!$qfTfMO-Sdv7N0!xPfo&|00 +zkBQc2GKtp=aN3yRptL-^Dp{GmM|7WlHsBQ!6nONbLCAu|#802xvkb9nk`})}i~Oib +z@|j-s?k0cw(3J-p2)Mk0Sfe`G>dlU7?70wbXydNDiQ+?$fsA+OC5+KUDZo~O+Xlrv +zZ@%+h>Thx0lcssm(3^kvyGj%LC4JsYXedB{L|=f)g34qx9r8KLJoVy#|HVv43Df{Q +zfH~6l&b@h`y6<#wl7H_HTvr*whlC?EF~DRXV~GBm`k=nR-T+>8-pum`$A98Wqcw+5 +ztbXW24mbr&DH@^~Eoi#?YQ{H)Z`Xxeb(vtZKtarm +z$wv6~b7+IgR)odjw0r)l=RG}J?6Ei9Vw5cXkmaECVxVbIx#W8yx{@pV69(3)w-ewX6NAtI@jhb3=gS< +zG~!iM@V8F4Qu*ti`y1=-n#*@Xs%ddUhy@h8J$=6zvcA@}skmwM*zM}bDSM7R(uI@D +zL;}n_5YZy;p{vy&_WNx2eX?0o>?5?Vt;TH&|6R1zWRJJ}^xHnR|K3xsulR0pAOVfd +zj3N#afBR+qayv8k_rLaYFW(3XI;aLb`yG%zVvIqpg`P4HEQdOh!j^XpO2P6-0E@7! +zNcBPjIpdhJy3hGG$rKhQfe}5wGsgeHd#Qv@(0A?xEtT~T#ZKV%LryLH(~omCAxp%v +zBFsR=8{6FG3e=Zke=yqya2-ULuJZ!bN}h$rbw6P>_|K033%n@B9d0el`Tb)XI0&o7 +zCE>{@C;bPX5z{W<&Viv=9IX-Ir*;O}&`hfyvrd?ZKtucb0H+(c>$wZWKj@-Z7qU!% +zCA6x@o{6*L^-+)_wNYTc7}Ec=M>L{!WBYReh6BdL*{oOmgg1w#@b=c1(T1Z0fSi$6hV%dTNJ1t7ljF>!W7s&Iv$n2n9}zHy +z#C@^|AJAoCu6&=IS;+FE`0wU8@j$H0(s)EPJ=TYG0e?H!O(xD^%+7dkttIrlkLyB` +zK55KA8Mr^1TjA6Vvip8Gb^}QDXN!pH$4tm?yN`40j2}Az!V}8Q`wZ@i-x?y<{|{C% +zx)1i>5{DmzHOlgJNYkewT*#jR{|>NjGMRMFWgy*igoU;9J{>WHL?0g#{KIae2HY^} +zV8+uFx5l5td+lTTizjERj +zx4u4^j1kSgP4&wm{`Gz{pnQ$`t?bGO?YMJZG@W5uCTf~Gt(=c5wd*W +zGdGi4Gr8(?(oJ~YQY0rrXYd{0@%`TW)rjaEMLqMzJ7)|tNXDCPvhAu@eJ^9q+)M=L +zI_HIL!4h_C>T<5@Ol`egK(N)N{i>Pw9->77);ZC%vz-xn2C&}28!UA%sHj>b8Pg*(($iQsLf5GMf8Rl|hMgRcuo*7dB + +diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +deleted file mode 100644 +index 26357af1e557f992d58827ada472bb089488186b..0000000000000000000000000000000000000000 +GIT binary patch +literal 0 +Hc$@kDX}CxsG^@_jtpR*=l-CAXv5zI8<>Mq=&#Jqd0W*)<}{HNNuBHv +z{J&d1K(y@$ +zs*+!ESvqvQ`=5-ZI{`V8Bt?=7uq46S>Ms(9?VB0ztp5{$e{842KgmCvqBsrg%<~sT +z5r)PL&BNO^P8e%x6@UeRK%lGKRg{-96@c-~aCTSZslsJtX9dKB&*glIaWH7xNK(un_CB+^BM=c2&@W9VEp-K<5bP>lE|)p! +zUYCZUFe#lHa`>nl0~M8dTThE#lHT;H@;1}no7sIF6Z?OPB+L0%_v<&0VVRkknVFfH +znRl_0C0|4qA=^q+Gs +z?48Y6H+5{4ZQHK4ZQHhOTV>m>I`%m^4z|}`m~))9*Urru-D^MpAIrH9CzWPK**;@6 +z^Om;CO1s~>%e$3PwsFQy*|x2WX8I1MZFk<2v2Av#D3)Fpq5crlHmsrSucqC&tU}B7naZPbC3NimA){lJT-7oa +z>KkKppV(H3aTT_OB!R{Gxb~H}d@R)BVC~DhWg^t6F!qGrG3cv6F@!EZvN9|g8i9E8 +zUIE4t7-(lvPoNURY9m(8f~7N~Iv3$j|781^0qsq(X&=~Kjgk*p4z6Ks6|Ns2r#~A{ +zmm|XVYVhNOSVsy|!gHbDzDPN^y-?M4Jw=`-nepyz2S5K-=zmkZtq#vs;0_`pN0`9f +z)8IGnfn#$JY%3B-6~z6)IS9t5VX7JvL5#>SDRcrM@5&!UL?96$uxpv{G^{k^-ZET2 +zF>agyYv)9;?T+t%YxKP(-d>02D{ux8QGn}MU5LMZDE|0$sI_M&z`Z|3oF>FbAqkVW +z7w)reTXp+Q!zofdOpEHo{(4(=RMbjq4x%bH2q;ie3Tu2X80z)U8{ +zHa8>+Th)0MHHS3*~0?VI7lVa%e{7`M%X +zyW8pdkwqed5J-21q^?am^_tqP*Wkzp<6EDHwf2si@$PY&NMRtyi&J~zUFp +zIfjQJgkf8k5K)nkIHx8zYY}y8$(Zx_4bt^6QEIxWNu5Idt(4aFIRDCg^~C)64BS_) +zFU`se@!584645l|Pkbk2fB3LTW)BDuB|rqj5Jvz7vZvf>U3=o2E8%nQKEV;665lvD +z{DiQ*(-h?bkbg+eA&LJ%Yf#Hp%@@$Qa1TiR@>ch|oX_ +zmBfGXmGkq7XQ~go$C++FBLePhX_1-WIVSW{-tHSp2e{TNt2^vaTrPm5qiL-%KCiak +ze1((z`D0PF*{PTJrzP>-Aemc(*&e_aDA@}54(UWHq<^6_B%mWDVHbrc0HT6%yD8+^ +zyN5>K^XjbUp@vjgG^q#*N0h|fv*T}{j$_||TJ8eayrYMbBS}kAh7@E%N)TlpYzJz8 +ztKk3Iw%+VyU1rc`;T8KtW)mu&!ZfjtynEdS7>TBi!@9Tq?Bi1Vm|{2S(D?!t)U=$) +zel}3TI_N9OY@8eR>s%$gQ6o*?pWe&BM@;AP~ +zd^2v!{H(jdeXtV=9|WJdIk@S9k)i0>7F&&U2~+q$R-ngy=~mrja5O^1dkz&R2G5eQc~BqEoik+dMKh?NDP4X|AzZ|&#A +z^D*I86u{_FFun=ItkiW~x5tdDHN9)QNR@3$L=fgAv9&~wm5t$Cq+!ItYs9lU#5z;~ +zgMcI(3TK?oIOe-vx@gq*6j#2OHYs*#JJrQ{-|YU+7%t +zg3+n`#t6%ZutF3PMQ*UkCwcalJ?A$ExYid;QwB}(Rm~OhH&R-jogrdXU?fmL0-P3N +z+Sp0zU2r4=*f}hxU&KXXwB4y;UH2MKzU;{+Yo0eh9KU);d`5!?^!JN$2tj6C +zu^B_fm8)#CH(}P2MovUhoGUW1LjRfpfBncuqGRdg5u_2oCr!gKSSqIY&2Sfp~^J9G~_huPMzw=sKWZBdj0SPYeK +z6Avn@J2jS!i((}Y?>sRToCd(h80Wf>Crr=k-uJ$Dlw~ffCHb`RpPR=NZxCi@-a7n9 +z+nW=j$1R+P+WLQ#Z>pZUzGinsN*an +z{mcH4yjHQpUppeSH1HtcKl2*FzJig{Q7jkzwljmW?)a?#zh57q`7nj`!`@-+53gs?6ItQ9jx`}vJxn5{Wo(a`9Wb+y +z6;==cQ&fJ@SEP68R8l4Vo9F^DTEhh>Q#BPF4&-R|uA)Fe;X*%~sx4{z-i$EPAjaPx +z&l^A&XyA3QDS%-#mBiOrL8NpTnQq3+%v7Ek$3-yp{FQVl>0Y5zSkos1GsbEgoSMj# +znm}SI)BzjGCBP&`A&6e +zfv5;>oT?;(4JNuoK2QegA+a@GVTr)a%owZ6lSZCa(u0ux +z1A(3aty5G+|g!5lsa@{<0xI3|5Xu+(&&n$y*3UrNMwi<;*RJpJ);p(L}>@x#$tPIW;@J~ +zptf^5khbt(lG+M1v>((^BPc0IUYj2k6agw^*UMC7A&AM}+UB>cmk2 +zk3^bZ3M`9~WYII`EU+7FAT*dMRz9_ePD%`qPXaA>^p~p+rc-wgxpQtHeo#jSd+>uF +zT=5;!LMq&wfHDkd=ct6F>I7ZSFG)&eVQP|a3Z?*I=|+0A +zQsFNw82CG{epeH$Eu0!j3;ysQ4Au}v%bY2}u28BFq4HBJnZzyb2Qc=ke0d=j<%&+h +z%+)!NlB3>zyQ$tB*qNwF01ZRn$KeRk5sNy`^>vyuKmV&RQWl@thHZy~?M;UCpZ$s% +zrtncX_hP*H7kN6Kl0EM=9ZT1zpX4zDp7p@TfQF$V6{pp02BexHj1>NS`}~r@8mvm9 +zoTZ)xU0q-UVQmx +zP8PXQMkdJ^!mfu)`-d~=Lh_ItBqK41)6|Smu~G%mSdyhwCZGQsGj<+Ls(84RwQ|l%@6yf}%&VAmxX`{BVDr +zTX6&vM@Gpt90k286fLBXoDxd4eqJUF<5)S{#r;p!lQQwHv?bZ*f3Th%`kETl_oS0-$TA~FYKlZBfuT)6TbW}lRGCd?W2VDz7lfsyU +zIhPdP7d9K{7`E&vd;go~ZVnI`vZ}Alq!FmA;Pe%I`;z2jepdSk6(}cF<^`NjtR(hQ +zkS$>+O3t*^*uEmg3Y7MSj5}T%RAJ&z^kk(>8haF`G8t?@f27Rx?$Z8fS7y81h>EQw +z%i&)Wi(j?GaHN})2*0}Y50<;D#2f`{$&=Cz-9}e&+$si*y{;Pz1%$hj#X@3yiRbsQ +z`ku5ufMz;c(_oVc8`2W=?;Ax|Py?w7rI+#ARk2dt?mz$#LmwkF9r|4anK3Ir=NqSQ +zuAAp9wBeWzk;pQ_gplv8nY^NMc;VPU)aDtKhJe`-E(Vn$AeaN_5maKOdn1sc-;P0K +zoVTRVx?0=1zLR8cX|Ktt-0aTESXCBgE?L-;)aCsgDcABA?C#onn+v8IVFj_$(jBDT +z9%&xvXN@EVGR)m!%oQ9ESwFf32~fHqh58!)1|ooXtw9ro+ex%bH!YJ}kCo-FW`;_c +zmjU=7avK8rxgIfYTxbYqE`;)kLwyK{M*uqlFvLobk{Q_&ZqCFhG0gm`vCO{I5^1iX +z5s1AwD1_KZw5(M{oBVrSR5zA+NDzk?7JMo)?e~$%^w3=Iby7*AMWAeh(Vuq%v(O{M +z<~DU$=^}&FCerXQW+l!@!AAV9NOw|x&o|(2YS4$;JSTpn+X?U_;1=er~KwC$%h^%QKg@gpR1B$c#d#TNc43%_tQ? +z69UUlRAuXxNl4zJaFfBL4GwI@%2XA7CeuS_y4=sk4|Y_q6eAT7-N0D%QK*)l#@6gk +z59%=Ufmslt#e8{}Q&1$4-4( +zU4RiqWKJ$Yqs73EFfu~0(g|+KzJ7>v0!hYcD#`?eTR~>O#dYK9`UxNN@407Xwf^Y~ +z^TK{U%${G>Th5fURfB3e?=$xZ^u;N(-s>q~6SjI=$%<7kGfVq@B%MBQirY)Y0Qwpr +zxq(Uy00dTBl6|um_x%loFiaW|96>~ux^vUxPZzs-Q{Y7}!G65haUTuG{d +zu=>bN2L%xlGJApR2BPSFnWn3B97fkAF7aThSxR>zFS< +z#~p1<`irSYSln3Ft*fu!rUw|JS6Ia*Ny36EKP!rQ7;2QDR+Zw`QT>&LZ795=ePG|0 +zbmzz*Gb`kp0XNFat`x`5qKO)}IX%lvncgNa!~Es}-dOPJYh4d}TI03^L{&&nr8cRFRN +zoYO+H$s6hMs>~6yVy2Q~3jydr`6<*+5N*&i7Bx^oG@`nB&d%)0SubmOZLwZ$#M+_* +zs}*g%Y%tewVa@i1Tk!JD;|toTL^8>3nIwZ&oSZ +z4PSz%7dbS~aRJ5;EX9fXjG>K|%#Y!kmc~tifQevGzfgfTlz(-a>`F}3 +zgKg?Ka{Ji>w%%cvBx}gWx8nH0D0U)Ntb_nWQCZ9~O70VDsZY=}_&Hd#Z&3U}TDmWX +zBdc_>dx=ZXFe3m8;9zPdnG$WXGMB~8eykL44x)t)dzx!nR{)vQC;Sk^5p5GsiA&;~ +zNDzt2;(g~)A;MBHLmKrbvx%w38OOXuF56@ugQT5{V=^KaVInqF!0xMqnc +zv?ae~3K@}?7d{jqHb714l#&D@(AVVRD>5ma-#4rDg5D*D<*}<=dH8Z0K$LkmwI}xs +z>^7;H$&~U`7^hHmt8C2l8&|MBabyvkMG3aUwWImcYg83-b>!Wl651xSwf**bY(O&hl2QLtN_gM)vYUzuGM1 +zame9qJx3khGE^Gm;;j4~omCUZnSYRIXs-L`l3jUL6CeUmedWOK@X%9D3C|_S#QQ$< +zD`bES@MsD;1K0+D0=}nyS+vTlEvb26Wrnve9PbxDdD|mDi*0*MpKC?$vm}h|t}nbq +zE=Gq(A(sBN#h|sJ+M(iIA(HA;$TmcO$I9-^17aaGuN-=-h0V8)AIen+(^4(nUra(% +zmLH}kAFcNuFHg1aZSm!-10{RmWDk +z>+R8Px&1+dbFW%I?@FJ8x6UT-?28+j-Q5Hvm*YClx_rf4qKXNq1(zU&bqtgiGQ=+> +zBRNO{7-3`t7=m}-JhINey9X)*hya~XGt1ig$64)bZ2Q}%Kc`sjtWvb2f8-_doLomw +zZVI+_w$6_L8GsxFsp_GnHHD_F8+fp!;bi_lt%TDl4L23VX+1aHdz8~(H%T@hd+sFS +z{5vU9Y7xOEN^N}c*zH!!j(qE!?N!TEEZdm1PW7+K8*_ZGRB_W3juI6iajaOaSmrPD +zLieFG_(z#ka*#|YfFbM!5C00p_J4h0H3G +zF3*vroem9+$cJRvj-8ryZ55E_nn-@Im<{R^0E9pgMDS2(?#%+D-fDx$(chPglw~GO +z>?{Jd@aaHX{u+&#*?3%?N+@qFve9fdP}(e4s){@jxJe!A)LyC)qNb{q)BY{DgYj^3 +z&)*tD>8Ri=Re6tYH|v-JbJH+BmzQBJ*!Bz!4S^jJI#H>{+`7h(O}>8Xkw&m>HbzyZ +z_4HV}=#XbxqP(qTgU*rrqS?ce0G%x(6`B^mR48l_GeK&3y4BmbcGiT`*Ow!mT;89F +zydTwU=;%VQep@KFelX}Uf=+S9by1uAa0RKQf@97y0q;@yFB|=sYW!thNGkW~O9aVf +zc@9^NYzr8`&INV^paGx>puqF&wdc{jZ5d2XoL>V~y8Eq((zNxh#Qg>wKoX%^f0kMI +zRwJ`&I)g=k>1C98(Gsa#E-l3F$h;LqJI4t0R-kTz<5a~SrY&qFM3eRQmomrYrIG~_ +z30^_L6GhI-DQD&UeSQ7?K!okbC%(^WB6W->0bD!|IPYgcyJ^7g%U6YkI +z4*hj`p;?*AlBA%L+A)CvMn-@+IB^<44rkhDxR%hgkvPiZ*Hv7!Ek`rokBDdoM;%4a +zx%FElnrt*_gO0hWD0cKS-MB}G*LLQm&+F@P{Ee%xD2vZ(8i|uopOyEy)S1`eM8v!u +z>G_w9zR7AE!@6^BXyJ!TKt=1ShUFno>5*({O)0UH8Wm~(eRY%ukfFzfcLE>{bUA92 +z|EmT^F$H?EZKh7pv`8J}H7a_rCG>*k-Xfg{zJDmK!KH*8HP-)dSkr+QK3++5aZP`2oIK5auaO>ELxc;L9Co~Mc{3k#PmM>ZW* +zzzA7vD_WHdQsSN1$lUyE3Rpi04xYl+HJY#;^RCU!&Zljt6Jkn7ls~X|QzFn;*I=aN +z_f(_@RJ7k@l{Z%EbdbIc3$^f^addR+qzPLx`-a@f!AsAC%&b9}syfhF-U%wH-VxJ@ +z{oqzvhVrw=ve|Iu*;qN~U|s*+QgS|2{s66P`?%DX@L3>bWi}P#fWsCmJ(Pd+<-?mZ +z5R?dFhWy|T&;ZZ|(7_e1KoZ)zGuS@%I}>}~T6v5YD#X#!i(e!$lzTSvxcORXgD_fz +z)lb+vhi~K#20>=z3LPCC5GB;BW988&Qeq>~Fjx~*y+zwo2?%FzBeN!Jt#E;Aqk3PB +zU~wJ>P?LxoXhmotcm`?d2;g+M9U;=NDA)s2mLHo~H0|l%y3%8%eNv4j2<|;zON7ck +z=ZrpxJ26mOO>|o%97+}~8CK`hF|po^%g>JApY^9LjifhH;Hajk^1SHrTEJ(( +z-AK`pV5|mXcC0Xi_x9HmNE +zdjFC*27w;uXvLLyvp(Eq(m-dy8t8?N(Al)n3){2yU(wIOnsD*Uu<;)QdHCY=qi}Hz +zPJ;-7d{Z*D+sj3eZ7x+PL?Y(y-;-Er+WA%t&?=)+D5=F2av=R9I;!CUef{P)Fi=^I +z-uT^bHVhFr>1dn@2UcO14A&i1y#YomFiQY)sPxt4onA0Q0u14JTpG!+=63WI_SUN` +zDB0Fv)01}(y!X$nRSCMDue5#XLrD0h%b#f_-L}tQPRFzN3?0!OR6^V*b|@%_2&2n_ +zM5g-U2r!47AsAt%X``j-G_dkGU)2Q0d~eZ1uQ-8!mi4CKiEggXpgVD9f5L9q?k(uL +zb@H-uOdbJ4=o%dWP90_`Btxt7^&ukBfZFL0XM`*x+^*15R6`CZYJ>ebN;j9>nL9_RKbkXQGaQ)-U=$f(%s^UdX0*xC8r +z^_bclOmvZIAs3`ZEL7!8rKukTvSL1PXHMu;&)Due*VQ +zE|Uz>a2#848+nLYQ4ITDa25UUvzn}VV6?B~>paM37k;U5VB>U6i!wV1N +zxdD$g4MBhMrGAAB^u=|gUeo>>c)7&uCOnIjz^ +zZ0S-RIvq_|Zbd)&@_6*q9UqF}8iP~^16|3$C*$Osj>p +z+Kq@#j7CW&llI*H=n@-v&@Ko%VjH8qCl@{Tvw7wrKIxE+_FgzZ?9a)BjK~tpp&u+r +zi7Pb-77xxIZZy30$De1xp8rj#d^T3mwc4zKqX~>^-TC_|z1wti@oV1%wSnms^L!pb +zGpn~;qFVkh;;q$Z2&4Nf|CbA-CDHBY#vhFA*>$b@&tLlC3cvY+7y3YUL^sO-|Fh43 +z`G$o|<;tV`yJ`4flym>r!I4`{fepmjRz=`RKatvG$7E*tzwjRGL$rQq)(HIoBCV`8 +z#?j;@|NYdjq_jIO +z4e}_rYy{G`n@`M;i64>#{KgKRO-J|nssFP00j(!cQ!}Z$w$iZT#6ykm{JA*&WKjj{m$lN?)Ed^WJ@W=}E7PCpVwYcnwu|BDD=F;E}fi67UH) +zeD%7WoLDoE(p=C$Ph*xh%fcsg5?eDCY?RgEYmU;_e|h(fU%q>|C1Kw*{BU-S^7HuD +z7)|b+@dvNmF63RFvqs--M<&&AbuUrND{>gm+wUO8?VmFE;CPE{gp%*zAYI +z*QIw`c9|l9_(3ji0*n@|P03v8b_@YAmOucCptKUeO3moqlHD-2VlyvT<_hjaQn|1joA?dg?}u +z`9{zA`_g592r?L!;iym~P~41#Py>%Jff|U%#YYpVm_9t`ar&K;D#$2h5KM!13bZ$G +z#=@yBUx?>TBr7SI%FY`l0sOCi(2rK^{r5PFWD&wbY9ZpMVXjIhn|M(G5^-h%@W1}p +zofTtuQ}9##>L2sfB5#-9ySMtnEMxPZLQJ2J3v;f>2WfX?yy@LPH0RB~ +zYY|-dpa1#M|0y5a-Jy$04nd8pb~>#>>Vee7)vYHUb>qr@UocI88Gs3Z62KTQ9owIk +zKl4*}scy;87CcDN-Qx9=ay+D`uI3ie;*)M$ShZlQ`}oJN>fP$D>f7qdFyt3wX(7xq +z%(5@FNgZqvu4%@!OeS^#0YC*H!WUH0VO}g&nx%mgv18YiUC!_%OoyONIC?cp<~p8P +zYzUBpe}p{zIBW{c71OAYu%1NPvQ&Ed(Ocl8HX;OTLg>Uyq9P>! +zARmYg!)zdDrH;@4W(V&UySlr(g5?w*<*9zX6~o_xzjrxbBv+#z1|2oCq$LZz#Aq#q +zjoeX?N$8{=e7;oF7aBcXUBu#hSXC@m2g9lPIQcC|*!5^N5{8Y4F0*6yT~SWV=}}P# +zl!zq!z#WxTc<%MWQC?TLj}3yR;2BLq9UA8}wg+EFC6@;^TN}>?SKL)AeLq8uS +zC`GJ1sCA^5wn`h2el5=Vu}g-=E?Abp3&5+cuH*x=dlwSN4Ry#ReExe#ql9pwzsaK} +z@WcIg;byB$&ps9YaB7Un=pSG=EJtN>KkU86+Un6$Jw3i5S^E(-ZWN0R8?(AB$EHCp +zYjT|X7lzk1#nmy`c@Sg^=0nE`S-V}t%BOy`Pp^9B-~5eP$!KGtKCW6Zzf=5@PxRyx +zq3__B?88E-!!veB4qSgZ+E4qq#v9Su@Z*ge)Tcd-n0(B;2icaK)^zOICrm7ueCml= +zwi~v*z%>uHiD_*x)p#QbXSSky!prk!8A^x1*cL3D+9%BsIdH>eP89R+q)B6J7^QL| +zCLLN_$#}xOVBbWjSU~yy6ESa(?0(Moe%hn|?`!}Q{62LzF%QqMdj5GeW)Fvf0W752 +zk>W(`d!@g(?w+$d_Q#YSfC)`=Gm(y(cGqq+%&r4tPc0-YtU4*Puk!KAvlsYy^N)RV +zqY=#5QU=2HCS2wDSGCK5g +z>rY#h`BUJ}J7A$z6DmRI<3@J#mewemsj;~8Q=gjKK5^&9`rfv7dspGf7vmuoT$Uq> +zq7em-LITJny|kyvJf}F0ildpEWBEUSVb+Aq+IQu171^S$Oy4ccJAs9&sN^ip33|d +z#%3853$U7y +zSg3{*_T+7QEAgfhLavkU>OEEc7*Di=4KTj}=l$3rL&ri)&k}SZ2&yYpmnFu2vbofS +zNaSFzc+%|HeLDR9^=^2I)oE) +z6pFT8AX#@z_HqK&Zxfcj;HCfXJn!|qyH!|tJT`Ra*R?PZn%^0A%mc{Y8QKfO9pnA(EhgVnxLwz*+p*Qd +zy9>Q(CI_99zTCEB_DwC8CS3o%dRVh9>~JVN9j45U>@H;(8?62m;rdV*)NL{p;^6i( +z6U=!Qz#~Wo3rP@=D8?qJT09tR8pO7oj*Nrb#}@Y}vJ8d*BM9%@+l%>E?ZsXk6mSU! +z2|5XPIEXPuw#{%LL%1x!CeVKa0h|EC+|%Iq3CIE_Meed3OB73W8{sh4jJ*B#F;Ie- +z0}mj`5V#a(LdyN&7X-xb+mWM2u?7eYB<)4>x{iFsD>x_vzo0<#Vi(K(9`6iD0Ks{B +z4X_>m>ZU{GrG29ByZImkkRUxzclgsi1N?m3|J6=nn5Y)q)58B-M&gY9=;JD+R&v(_UUiGGJZX4Yi=1F$|pBUqyUIWu* +wI3(gu%(aYUG)Nxn_NTI0m2J$wI5Glau7M%P6u3*s7}s3J@a90N`4$}%0^bpuG5`Po + +diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp +new file mode 100644 +index 0000000000000000000000000000000000000000..44b3d4420b6a89ae90aefdc0f78f1acb8aa83976 +GIT binary patch +literal 23134 +zc$@$&K+?ZbNk&FiS^xl7MM6+kP&iCUS^xkqufb~&5w>k3Il}BtcIv<3^X5N}=>G)h +zXC@XP3B?SU{*+8V=NOEs*a6@wV1)t1n!%$y!i?-rE|A^HoRb8UvWWTuc}MK{^a+#N +zwxw)lBnyS4e-?refCkgM3eC2WBt>=jZ!%;45x&Vbk|Zbn3b3%S^aeuyVSV*~0`NC= +zbfBXH_(i@t=u*;o(=kA~9WX!OeK?{MsR$&J|0k=EheJASY88wpPEtWAZ>tm +zmYV8~O0R)=BLLU}0N9Xkog4r)#ZE`t8A$<{L40LnA%`9OQll_#+sF~sf7u%@-#a2E +zpdagKDGXV$@L3L6e(o)cH-M5L1%(8=h0z0Titq9;j`WZSkRiPpa7{&()L +z&1?eJEL%1Y8-mT%reUL~TIJaau#}l~zt-I?0~V^btu5P@d0o$gyE`O@#u0cTafW1) +zHcJc=(cRr0p69-{9ND&2TN!Jgcfa9IL`wf#1d+S@S&}5%Hf<}{ezt9+X1{j7UfBQt +zs%`(hXhF7Gx0aj}$3_S-72cBV-1*eCZJQ!Fl5~4Sl2THsR4V2wRWma)Gb}SR!|A{; +z*fVBk7-nY1qQ+w85+nrJb-9~aWDTX~kv_wK|Nr?&l3u_6KS@=}YTG@wZLaOJZQHhe +zx8~T^^{6#!R`+!G?6!LvRjDNZ-xu3$TgNs@n&-Oj`_apAMB1vhZQHi-(eA%s+f3Uw +zt13}eIx`&a8xQa6c)+&JkR<*8u835*s=KP~?&-0$vprt>#qwj@wr$&dZS20Y-kBYn +ztwCGW)#a+J%#5G~+e%#9S|rifAg?w?!8GT-Plz~j(Ix+J$$wn(AD8^cCI4~Be_Zk( +zm;A@2bdVwij1~bO3L5^%MG6uITG5YoBw(fRT`9bn!l4v8Q?OHzzw<~z0Eh+y31pB) +zGLS$NVK^`WLxF{;9Rw_-aR4I-ql!BG0B}*nGNy0^CvY8As8N9w1e&oA +zPeAp(4@9699T>!39K#EE4G$ynJ5?YB6JbQqigj3zA=oHjt-*(ms443i36*UCEGk`!7_TzDQsFnW}(19ZE;!S)Cqo|Jvq+nqP&!VIHD?l{F +zF@SwIfDXvti4>%eMjtj}0}@!o8a(hjL2yw)2|0}8GOpkOR#EsJNPxs3o<{d7t$;=W +zSMX1G3%M8zNWsBQJdL&NCxDM4s)1RoWmii$XvZK@kS}$dZRkNiHed*#fJQ|U5<H5VVa7W{AFE20j=ay<73IqgfBw<0nloZ(L!zK)&8=>N=3LTMDfg-Zx>cY&w=;d9> +zuEnCpqTZR+nYx{Y$JFc0{HYadma4M~CR$^XJ3%W{LrY97F|@?kVHA=FDF#hy7v5Jt +ze-2GV8W|~~1Q4xdx`+mfxQ5fXjN6!l_Yx8yu@z6F2qF;3HRr^cMH^liaP(N-u@gn7 +zrk*0CfsH0(d;5*;>vHC&t?6HDSClXWfWi#^4*!MaaWEgnCOiQb1ay+MeT%C<<)OBp +z^+@a2eI&i6?K)}^+Ubn5@i +zG5uFANu`hIl}b`7Kt`rj@Gy%DID@megDQf9?%0fHPzC`l*Sgkh)YJENSoe6$-jDa` +z+gN94t4q%ytQ$~-vK0n4T%Y)nyRQAJX4igIUD@=(0MNLHKgK6e8UsKZUO*)RI?3#v +zUR?bz{%y~*PGlx@ZNYI^M@$jPF{5)-cHR+Bc;ANq?r)y{4Nou3EUquC+-!b=`T<8- +zB(Wa{5JNHV^9bNCt%9Z1Y|`I)!Hd!7J^s|vvGCH{#tF0f22E>pY|y!Zbqpo|kq{lU +z1g(PvAZ0=%0YpVg`H(&jN&X_$7K}ZA85xqd~DcNeaZS~wR^7b(mj>7P?rTz0-|MNTXSXAum0X?pZMXh +zTDunxfWj60BHl&t^WRbk;ZaCv0G;IWZ+h>j0nxDW_dPw>%%5j=@H+`K;Aq}FH7X6NP?9(L`Xi{5)UIof$r>z_t# +z(7r`-8$d+#`jK-=fs`Q-AVNR_h(52i$g0X0W;H&epms2=_Y%*lpE#|#Z=2eiPL*;wp~35>tr@y4LSfz +z03GD91Ra1FV*OD<0|FVMg^a9vQ%!uul_ll=uz5f7p;h0fG&c7b*sr9LfSt{Hu!2+g +z1kND~f5na=eCx2Q`n(O#d+xnSw<}V-ind1w@kG*L-;GzxdbZkLmPr0K?t^*n&-Htxq_vVN1ihArK7& +z0=W1C+^%hq=)eKI3JYE)FY+_9wdZ|2a^5FyKjnY7Chxv>jYB=xD|#J!4JOb5NB|N{ +zpo2lnvaJiq76^mP+dBJo=Z#4{I!ebe@>6_3q#658)6Vc1X;X=&)__= +zP}N-g!Z0$*b8i5>$1{!FgAwp`g^l;9f|ItP7`PQ9kZ3_fE +zd>udd^KZw-KAc%j{V)H!`It6#9QvO-I>=A%?FZ&l{jhyE<&psRAJ*gR7=tQ+COp)T +z%wuFWS>1lbGaXNRBs%xlt`jzn-lFx7Lx(kiyeDfv{aAsafB@uefFS^ZcT*yf-wx>> +z#nh`YoBwO4!)M0~e#L|ZpBc0Aa~Cw%yU+vyU?YR0*pDnu<049xI9@;jM2O6J^|`=< +zH|#LKU^XNS*M*P@1QbA!5P%SYx0_~no$2!F=1pVmMmVsr89E^kdte7@efZnnFQ4*e +zcpKIsLG-Z>ijz<5*Kb|rhr~kyw0IP~cpa0_Js{vr8sD7EGaoLA9Q^(EWMCFKo66_fdP(4sR6?C8xHTm5@)Fraswk=Z#(Veypl} +z$-E_>KkK}&pHz0c6(s=!z(Ow`!4MYlHb&uPI|lF^@*qOwn)T259=v{=xdUw$B*+*x +zsX(DG8w4c%ncU#DjF>#>yJNDd7BL7kV=Ih99t|5&0+HmxFMG0cTibR9=1@*jcAEk| +z#e8=J6i}q_C_3;4?m`0?Xu(t1gu)Pmx3H+~Iz9e1|Gi`GV@;<;?6lFAD-^v3VCc?6 +z-8zjffE8E}R4S5igsxF?M3rJHwNKb4Z(eWv%UK5(RTqBWm#zI0sc*7Lia0C?gs=%m +z(2lEkr>_KwemsvH2xz$$z5Y_oz1QwAYp6{?0Q8#_&_K|a&kqrfG)b>b6E2NY8f6#~ +zQLIDwkViu+0uX!VD-Za%ccs#`1TgfDJ4Ji&?bls1hxA?MN1MAI*LBK>Wj$k`Z +z;T{fS1sXt$EZO<7HUIk$BPIqI!62m+&;W|Ec~TJAxH0U&v2Hhnrw9QML&qTxL?EF6 +z{0nv>OEr82f9s!7eyR@2q!b|FY|2%xk +zYU3?}6R-w_YGnK?bVCW^E&YT5Hdz7wpMm3%INSkesHGBN_wE3TR(na6s +zTXChWG0eaMh+`+7KvNdCJ$9ok+++h$1D5$k4^rm$r%k0Uz!m4^$B#9x%?>w{%Qj +zdcw(p%tSA%{eXTb`N!R8-|2nzm;2%6Yh0S@LJK`+@1dR@P=20&%$Zw&4nnZ +zp(Wov5(8n=P(f7d&-`;+r3ga+ +zRP6rbqW}AshYXJmO$hX<1R&!dAc9Jf*^8lfp%me$m>^I`?vMux@Q{W8x4LBES{KxQ +z)kA|+Isi+^H={jDa%O_@xLcCq+aha2S%t~>wR!4RY1hf3&;8O@EjoXv#=sF- +zx9{~;|M~Za46mUV3P_-#*NkGc?6rdeNy&u76-OWF+ng63h5#sG{*VW1SVjg>uwl@8 +zi;JE6e2~m8s2l{#1^`P_=LxrU-Q(fnjlS>PaR*9|dbnljsTW_N@lQd~^E0D7s;-p# +zNELT#=RhOT3>bz9-3NAg2^pXb^^}QN2~Xda_CNpA@1ifc?8dKOb#kZX8~^{b@y)P0 +zl#mJ(00lr26^>bzj1v{hmod|A7%I@fJXQ~RKw}h1bb?NFzRYJ-cGTiy-k<28{MCh` +zoGXFoU}@KR-Xr!s{(o@S@yN{%dpE%*^b?^%xG1%1nDP)bGY^iDVU&zWjoVwJCnM$p +zNPqEMgp6kcQYa?Eau{U%1bk#rr%NVMTsn4M(zuPZSCf5qWp6Ig}HC +z38a9)P%JpgAtWARK_%o32X|VjR~Usc*m^ap43`91DMiJKlfIjakM$r>!v&}RRB08ayxwUC7wj@Je?$w}lVS<9E1A!o@7_G362+{1ChTrJMLey|?4iQfT +z&=45(=!G7+;`9&=a=}cIQYZ)zFwll}NH7Wr5;52VyH2PH)~1m-Cg}m9=)lFnwOP`5 +z%hSOM!$3F`y3TZyXc!)tVHo41G}@|6fWbhtTbZ$$@hw`)i}lcLg_J@N5Fi>HM6*Jp +zAljlu#8NdT$W2SpXBOQUM7~}PTxKq8Zmd?h27=D7X);cjNI$5ubSGr4f*b{R3fk_n +zmuC+#xT&zDAQcn^0#(XvVI2k}h|#b?!hi+=O)okNxutMSp|=}Ky%}E(41L_R0`T^$;c+5yf)$z)mzMV5SkeA%-0WM7EEID2xUI +zda2S`%1u36dIsLvUK-=xWaM;y%~f~^QJD{9R5SpC)3^9Fk3pE(v!Owok1&u)qdmY( +zBV{^|H!v;;G8>bUWTH2=G-Aigc8yEKYD@3J!p_V?IuJPslh|%|l{~QAB7{UrTX<;^ +z)Bp(8V37|+t;ZsaFBQF%3&l_oBLmPdpoh#hQH^i7zIoqAt@3t|sJ7=Q1cs_VJXE?~ +zDV79Iz$izFk`yb2bt(`$H4i>=BMKHljh%^w@{ugT5W%3uu%N;C*kD3}2#5}5E}Fm3 +z|EA|YX;LXHzp9z12rCd+fZzp#ijK?%yrAeW4$!Fkx=60|Oiw0lTG)I!8>0*DgHh7l +z3B)U(e)=fN5p7maROOP}?f**%vmKJ4!@OYg|+GFZ_Mpu@5t +zxOY@pFj7t>3=-xDEc^+b+6hvH(E`zC?T4^(GVuT6;i9a!$M>BnN#|L^4q4|mHkiSJ +z05CVKHQ(;L7S1_5^Q6D%=**l1wKbc~!Gl(V%d=h266?C(Sp`s!hnc1lh(6->=O@rD!u2u+a{7;HEMTx +zUns$^oj8tW;i#{f1BR~ix +zG#D|2COnI+kN{%eyWQKiik%?0)(gK%OJJx%XZOt}b_SF|Ad{od={=UQ6|}!XZZEq! +z7~DPDK2GEV3?{m6*uJx9w?I`20RrEOL5LB96zmjU!n^ox5V-f0TMk}j5LpKhksIkl +zbx$6|ng-{X6F$P^Fvb^-fSPS?W(HzqK?l{6K$dqqQV%&@ieOsF4ve1=j=B^D#g}l@<)I)p +zB7mlSgTk}`5poFY#v$K6mh+y$s=6)+{02V2GmxVNDI{=p~a!koJeQPK1rOcg*Dx;_(HgfGm${SE=CuPda$mWCZ_4Ca06&_iSJpA|{ +zl`aTuz$@q-D@eh>Hf)3f&`G~^-ea@J%pco=4)WZgYH#bAeuCEMgec}7M0(T&roMX4 +zX0sV+2tnstu)|SPXBQ1{Nd1JQC4rj@-u&0iCoivg>fsMAUP}Nrp1@-Wjg>lh3KbAZ +zx);2!wsh}vXEYi_WO*HAkJMUR18Bf2X#?nCka`qUlOMvwfas1!F;JnXd_=Mj<3spD +z00hr|+vTG7XFU1%12hC$@G90pjFS)}aG(K#g|jm+`p1#ehI$?HC7k>|o-q~$e`LC`yfh%sTcY)|CbMyjMB@fgY=5HP!Z(f>bo0&@z*15Md@extv0YW&Aoe*Qg1BM;2L7+DCF@Mb+yEYV(pf?_dhctnNsGOuFjRk%h +z@UlD&!TAWbdEX2|bk)CmR1lgVIe{gNT+Orr=x|?jdU6)R8VGF0BZ!U+AcNxw0y>%1 +zlY7i{o8g{E@$5vzN)sW?2Bd(-`x6G3KC8P7{`If|HOhbz-t}c2#nN}_0h9w{kq*Z@ +z!+llzPOU=&ICu){#syL^u@w;z@RD~HlBbSr0?3SqqEoi0aS@4lVOUrGiMBb=Z)K5O +z+t34*Bhbjv+4zEFkm)cp6dr85yirRcN+7TuBjX|)F&sl31oQgB4PBe|dmW?*3NNKV +zX`EN5p*jC+(ArV^p|Q +zFpOqs0F~^FO+3423Nk{V(ostVI7vYYXi`H8MK_(mhTbC#AT%+^%Impa&}6?p`h`Nv +zebTf#>DeyfaNIx*Kw=-Z!x|BQjYG(TpjNprJdFc}3faZBG7y656`JlP4R~Ov{MP+qX4lI~asqyoWXq+rE9Soqu(P1=w +zVfd`$F^&Mh#t3>LM*|>+mr}t~z2B5ihX{t=Iv%(v2WL%Q+0R?}NP*#k&J8vD3lQcA +zEqhRuJR!UG4nmdP{Q&b<+0n9$M$9K8G=Gecc>n^PI0|Pp0C8-F2ZEf@ePE{;#0o7i +zftV^rafwv`$jAmpkU303fns1CL|{m!U{AG4W-k3bCqEeEl|8qzK)SkwNfsSR!(3qo +zv$w_fzHD=km7 +z>?}YX(*}YA)Gey7;I+^%fEf%l>xk^WDp<5kLjxqS1Fa(gXh%1KfUonmrE~uQb*vOA +z(a-9Z40BUi*a+CCZZCe+Z_|wIonv2~=c^%EPsT*fdd+?U@p1i@SN) +z8hc1CNC>LEKbxphDA+D_zd>6&Y3=#D>_5L-3Lb#OW^|1KBm`QJ0)d7+yP{fbL!{`c +zg69k8cDh&2H_I7M6dWsZ62Js#lK=#rt%`<4GoT{UiYlNGCSN~&swWow62(3oGI#gu +zzwnL)6aWO)jRL^J1}uRP)pzp6n#Q51k_0i#T0jUzC#^ZHyN_k$Y!nEP_x7-k2VekG +z*D4|H4#bZFY=|H=QCfvHF+t13%mdWI6ANHrUrA-VA#=Y)Ket|f(dGdNq_7U*5dhfO +zj52^yP3zvh{ksg%!|0dyT(_|5NAtAYjUo{0dfkGH9V6Qb%~41}Py^FDWryA_ +z+5GTEhhI;tLxCDwj3V^A-k<>(a_$k!!5z938Bljay=hS^3`l5HTZC4n?|2sM&AgYa +zx3BaRV=MucLhL8*clGne%j~|^_K!0j{U1+W*e?MEU|}ngBLE0t00C&Cj%gq5QAkwt +zC1uE%xde1tv8FBi(#tOTdf)tWhAd1}D?`Bo``77$TKpbppoSrt6qCBsVpeL0k;hHkz)=Am$u-DY4ry^vgE-5AIsST!Q +zUfH~gwgH@DkA +z-l0lICh-tdQ_v7FB^hODap9!yjKPevU}*P}NBg?RV~azLsU1cpHUX8sShWr4jYnoM +zhdi=3rLlL?ZPWn_^dJLq=wmS4=g2F3=dPp1*GmdiV>F-*nL?Rl{L|Ll{$uZ%zx9+{ +z*H>Or3DKkwQnVT0=Q!ijN-q5p$7$DG_AmdCvHk@dKM{x6>$_1v&ywBM)FLwi4A$p5(EavKd8OaO(lk4IxY$pd{N!| +ze{ex}^&;w-pa|fi5s(PM1_2>O%9e0#kfCL%!8>&n`7zRj* +z2%?1|0!uEeyNRo^8|ILL6(=>kl@*(E#w&Tp%Q@-Qobg6=9W{+{l%YXDD1^)o-(Yn3 +z`m*1)NQgr9wgHeAIKFCvnV${s9o;qW;3n(@5djwqTFQ2w0PG8L6)K4DXq9a2Q8GK#TBHrtDZ5UL%~WS1j_L1D&Z;qBL5q)RejC?csO(#?F-W +zv#eImp#ee?x4cRF)vlU8*tqj1vI&jdB1SH+mzyJ$S~EYTw$^KDt<(bp7CNzb_&b@= +zh#-YSFZXJMs}De+w7=tsGKiFf++yCrx*G?cN*{<2>N;+CzIVK`Xz0oJtd6KMzZ*Y^ +zuj7q|*L|tI>ao1;(fq1c-1H_x$U%b$A8lt0_HX;T%?`8)3Kf5HZ;c}aDL_Cr0m?lft5COE2?Xug}=X7&3A|Qm4$;_t*7sFj^x$L`-|AId_V4;CeU^0^i +zbNHl<<iq4Df;qh@y;=woKM7`!m?p@3K{U +z>ZSBtJ=9$lzm8HVB&P96{Pm(g|0(^=LReSlWJ}OM;!MsEnf`{!#pRY~iSEZo$sYVe +z(m#+F*nL~-p|@;$`E?p=bpV8rKJ5gCws1_Mtqf4Um{vo|1TRzn(Ko)FveZz(E@Fum@UuAg^`CS2P(3 +zMUw^pamX}iFt$~3$#fS25b*6YyWG4uo_@aQwOtcynRZS5_6nI%o*wHi7*UsmA(}EG +zE^1w8@4V|v?TF)RX5JbgsWhr5E;%F=1|i((y1sqA7y^O3L;d^9XKkDO*;oA?SV=zb +zgW13N()?A6vMPX3Xj{M9wsZ-1R;Omr^xkHtn`Y%#UI%}G@@TaaBw^g|-z3~N@+@B7yJMuG~XVt`O&ZY;16P;pf*8CcgC2H|+E`et-_ +z@ACV=K?sf64u5j~E;FgJ1Pvsqt+m#tFxux+YMV{K71i+c2Op@rKM9lTc81|CTq>E?gTg(;f;&0dv|%# +zDR=<(#(d89Crr!+s3F+(+e+UdMs6By!urLVsR>C0}l>B;BcBwIfLv$f&9gFV#}^kXn^=+ +zOviFiwLG*{LbW10$Ej&aZ}&4IU?Y0S15OWks@{`JSP(gX&~k5BWQ>J1o8uOq%nFA= +zm{zr|&VmEO@VVcqTm=>+NJ1!d841sk7En4Zr44vRMX6*eWKh&}-;Myl^=>RW)n>qf +zX}_#f`-c^w0nE!zb9enJAI!iWL>FF_CAbbQ?$P_`0qw6ikD3$!un;-q0ekq4uI2_pbZPpJTXl4dU2sG&vnCc*?bY-PuOe8RY +zMs;-iBs4(7RLqzj_8c7NpD4fb;u|)mEC8|Aq&AjPgI`4_NF5R&KP1@SuXU#-eP%Jb +z1OW4}2P_C^0AIKFS2!RpK2rF_IzPOa#Dm=G{Eo0Oh*4WBZ_MFS +z2Zm++OKWd7_fn7`mQ;2QX>8C01PUIoX@N+*-^d3b-h`WUlj4jp<94_c#LfWO(9>0Y +zU$mR=DVRXVghzZGKXYK{BQ&~UR6tu +zz_{>c^W(RE$ouF4(B}?{@9Ysd9XR9<3qZqERq_u!0N#Wbi(4elJ*(Y^MtgwYYKuNf +zDHuRl^Q^|+`F`=h*jQR#)V7RJK+C4=9olSn9$>#4tg*N=)&BH!Ruu~(DG@M@vKBS_ +zXaC|)F?CRE|BSXtM;IEQ^7N#x&1BOSfLdwU)LFtF%>}>VZP?0z2!%rc4G3iw1Qogi +z{CJ%5!|o}y0ujm{<#b=0$Grn$a_{-={{GKBRW88-f@{;NLu;SSAxNs7I1CVp1O?kO +zVaF=t201km2))8-xqZ8T?4R*>tMChZB-b_yx~-C!>v8pso_|ljYbgwZNUXl_j-~B! +zTZfyn`oMzq4DjA-f8L0Vz==jS4ca{RM+E{y3~_du_E~8Sdq9ZHdbpL{JHr=&4m%t; +zCvyN;*8ZKbaz}7L=*E(DhNKJH8AGz#2i8<(&P@z$5!Z;?(vB +zF1!z$4oDaw8%DX05P&g;s`h=Eb+!l`0A+vFZE5c~fX)A0|2;pZOYinhWLHA-GmiV` +zKmJgTI5dD~%Q9yD-VE1JuCmpuw$0VBBw0Kyow``RR#_noF&UAVzw521{=v$1zvlZ_ +zF#gKrM!S~&c}H%20N_h66F>jGe&krqQVIO+^)?0;zT<7! +zb)QIVqZ6b`R-V>T1Y^Q*HhfQi)#@tXfDoz%`)fY7^NtJpHUGSSyKn8Q+j*%~#riK_ +zBYf)Hwj!DaQP8UZ3C +z%}P%xJ?o>hX1{W(==M{GJhu2x{6~fzyFJJL;J`~8OAkEhKmPa){oQ2@fPmJP^{s4o +z?8rebqxSb9kN~4&Njo+`Fr+RZ``OKq^T+JJu+{YG?=m?Z(FzMd33KP$t~~UX;OTu% +zrsY11nH<|!&o?~&@bHP9AD6uD`fltUKd+P+?02YTcKPT3!{BX6&sBkxQ1_!a2ZsE> +z7rx_xw_#(^K%1~<1MKbsMaix)#F(;k@`8a1L`1+si)54Juy@K4>+5!{GT_)q**4L( +zoDlX+4dXbrYTfd0|FmF1vmi%o_;q!n-~lOHjtojy!4ObLnO$^)!0dh)`>MYv=Pl_3 +zbMFg!&$TUI^*Ph)b)7H)G|D?|)fc8MJ%7r{Cuhv=H7xE2e;(ALfz(S*`WKs&M?8O3cp(kop9 +z6G}sm3Wd_q94Mi{Qj(yuhd?^UxbRiY7rF{JUZS#I(+vj%6!J?}&Q&72$?QCvnw5&O +zpsP!)mj*6N+7p%RsS7lg;m*(9#hz{Ty>CZvKz%N{N4ju8dEjkmDS-nT2Csf8O;G!; +zdvC2ZX@oQswr=&M*s~Iq3pH-=tS|E1B$=- +zh?TUy@U32HM=`s +zc2&5B39LdPhA^zc)eB#aMO$0x@y)8e7d$Op4HIg<%9RT^cdw=T1c()$$mKrfa$MuY +zt=_xg44JDM64O#QoSL^78bG783{wwop?>kd9KEve#M?7{3_waKyR%kPGHE3O-i8L9zyAK`&);A9_x&?&zV@73S8&;CpcX`13?|9X +z`Nt<$Ev&oiT<|n;o!@oa>~10gy08aXNZ-4E<4&^Yd)SYB*`{arT39T#w!M(m^$;nL +zV41M2==~Xb)}d3-vo8Ig-}wJ;pR2zg?Y~@X^e?Zp@ejND2VBrd^r*=~NQ^QKMOiSs +z4I4p!pi1|>NCjdQlHdZhD~I6UDJ1wyJFkB};@!{qy7+4_fq@01#cIa2IXr>eG+Pj9 +zQp=rK-S3^Z`TSWn`%aSkliHrRTle$3F`vN_7LZ_xGbKJDvP5)*e$Vt@(e)Sjh6lxX +z^OGvGA>k{<)s$j}1Hv1DI6&f?-~+9@Dk-)evO0uhgvsoBVB^FD?}`1s0NnS*MOI}G +zFGfco1(fIshQjQhyE<*sO1km+31<-uYaWds3I^$VZ^ie9f? +zt7VyJ0Ferlnyl;uvMK8!^o&VfWQV3wnR_EPLRla%_S?Q{V<7P1P&KwueAHQPxb`#I +z>CVjM4+!32^?k*htNl3(I$;wSvd7f9S-1)|?12e=Cw8pBD9WdyQ9hcP-G<7RSNfIQ +z3-;QjXsIu_0X@)mqg?3DkeCY-61|0pA)GHrFJE#*^FC}OCf*pwTw?Npa&3~f9a?)R +zRt6#4@lEv?qp6;%u~j)Z+S!Vkpe&}SD6nuf6f{HdiVr960}29%^tA^kX#HxtK+k0e +zs62%jR{BXHMV;kAhx2;wa*Qk+Nb=N$gX}zTLwvSqU-;I=K^hWR2)RWEOX#4yM8DE;b9_UAZ6mbrlQ(0BauO$z@Bp-KcDF +zBe38i(U=qTLQq=!tr07pE<0g`%v}2m_PiMz0w*Bqsitu{O1g(^lp20}1)H+Y`g5fZ*Z4&@YvHJ5=kakCZv+Yk4%l2tZ+R#P89Ro| +zR41M$*yha~m>M6;_C&+CE;_u!7*|vk4Hbo(A5gTGmkFTNu+5W+51^@K=KgpD8vE(u +zZ@~^CAVG_RnP0yhvs+{t%8HobmL9InMTot&Trc>_bl<5^-%1hBfrFuH#peagCMa}T +zf&)r0EWl9k^S|y479`{b%ckxS0bVMegl?ys^*J=eF1_Fm5=fJ4JFuoqyT*!Wd%&Tg +zD5$T!R2t>|&e;hZG#&Cl3L4|3B!CfmK9$x{3H=s)LvZ&)xu-8)M7Dd@hSJBmg<7IKl=briL#VJCv1 +zai)wm=+yQ5$GT9`?Jf#F;6e@$M== +z2M!`1L7jcHJJ++PWm&KAOuA^fI3t0PgHf_f-x9o{Qdk3O0P%PNcpVAP9~2v^@oz>ELbio#F`Kvh?U +zm~gllm@u5`$-#*Nmmf(kfTe<)Eh(e`iU6r5=IIfg0=dShFfI<~>&0FK07O_B@&yxx +z-A^=gkEO0qD_va(ikl;#&E_F|T?kc56AiybLJ1upZ6Q#3J}zQBBuiKWG`86s6lKf= +zK!t_Pt4MLhnv;PvJe1cXyk3S+#d~ywi +z$Qtf9`~@aDD08LvY-kr=jbL7lY}Ad0opGXkTm{9ZLD08n4u)^mkI?C}?P|%o_ +zmEInMw#vcbr_V%8V7F4*2p|p+pnAvy({N!C0{XcB;y>4Xf597qb^B)Sg^1r(5)y_Wn`sk2@2J?GxTn1 +z3qQd>TEuqHukNMKH*`P&1Q^FXh{GNzVg!c~0wMy(<`2AdcGiiB>cNnRh%kqZijEY4 +zZHcAi35kc=;}b3j<&>s+FakiJ8IO!@!R{*9paBF^+u$SffTr^4`Ak#vYfBG4JeQl; +z4FUzcb@*3?LINK}9|Vw;9a^08j2@k_#fTxw5Jn`hVQgrZc%ouk53ME|)#4r0N=UU! +z00aA?YA!3qupv6^0x>x!A6NIOKFK|XZ-vK6)SsVlI`J_O2yhdN;P5Ay*oQ+10R*O& +zEB@n+jrNa|!}HM*A!awlH(CgLqTm?ZbW24SevQzi5y5b6E<1V$UBz8UApqB0f>kW_ +zWuIZ3hGmZ*EZklW`6jp^u!gq}|1ctuz+>oz0I1rvyXdaE--(Hl1r6M9ozU@6)zv +z=#*jKB*;p(I35CTS +z?o$^b(5`sKhFD0lkRSsx3d5)>2cY@Uv&?^bLLQHSKm(@{j0L|X25}TI5Lol%nY8ue +z9r8pBuu#-SlpC$6x=L&rr9yb|*r&RfrUInAgk{Ab1_6eLG!G&W80ZUxl^czM&Vfd<|d}0WliR<+y^okYF?rB!;jDArPu4I2`vJ8oBZq#stD=w!w|2!Hs|SVHXz=gAS^8 +zdZ|<@RVGAPP`bx~AgU%^H5>&EzqiCVGL}?|I-qR^^^xTCZNt`MXI}@e +zkBh%M5dvxKMjQlOlOi72ctLAD!U(0B?-6DK@x+BJ8!kKBC=Yf+*Q4>qkGlE!kEpMe +zy4Emyg=iOUcxUduANUUax6npJhsMJ9+=Ku4f3{t03+L8DFuNijDho{Wp7zqZ}hRG>UhyAtNL%-rTj +zhcFD_CaQn9Zg!U~NPxg1zC1eqc5T?$jxGoQEn?NdZr1d;!AHgr`}b6Q!No#crHYJY +z2;ur!O%y<$z14@4JtBysj4D)JHz4DNFcOyysU=G1rcqjX$A(6AKV%qEd--Ge_QmA7 +zrd?PE0+qhd=pZyI+1zYdPN2N7kJPt5@&9`%Ul#gf>hB&HP$0cKN} +zT~xd(AR*E=Eg4e%ySw-?gEVB0CT4U@(ADXH|5X!`h~DD0v;~m7Q_ho +zH$$KkgRnrz0&ixoZC&Rw4VdfLSJ;DHXgiRdwbnpo#^%>tC{Z-91_&0Fv7dT`^Xo6z +zwci$AqL%==h$p$Hzn=gQi|hiRnD$%y^N(Eq)9Lef)}isjx&V)53?Vj-wh6WZtwkHk +zDbbfp1P)6Q$o`kZ;43oRea$Y`7A;B!V_Ou#aH+u=ZOn6EgNg+ +zSsK3o#Xm2nf211~4#<+r2S68sHx2j+@^3!OD|+XYwsq(rzzXdZcJ+$@ +zS{gRwn%XrQ{`=JPW4!)OLa;%jwAA1p)LHYr&Iqq9iw$VAM4hKBDM5ii8E-&kX(J>F +z3G^e{vR! +zOE4uJei^s);XDcP-p+TUIv$zil#tI>c7C0b6QrbVt-E9$lthK!8wy~zcLVh75 +z#&Xy?fS5x*yee<^Y^aEWoN)rm28}W?AS~uFq%ur)rCHEgYs*AD9o#O-^*elA$LSS? +zMuzo=1e#%ifJmV>o8s>zVkgj-$gl@tpbHC`=I(0oYEh&=M2`h=vbwz6e(Q0|;@)m8 +zCrBEuQZbvOX-8+<{CC_pPi+zV1#vNWbE+T$1}5eAjbGY1vEYO*nz!b8RTq3!< +zKnI8*5Da{q<&)J_yW9z)Bf4JH*iY0m!JV;+ItU2ZNFW0N#tp$l2nMd;HN1qsd19UE +z<$S5V0cKWh51QubPCs}?18CR2UWTF5-!y6V$`Dmoi8ti}KP~_Wop4T&zo~b%LP|46 +zZISScS^hsC^=J4#+(rOkA_iyd>=7^k{u|HZD<~mz)rB_h1!w0tKQsS!K%z`Fu!n+* +z$&XgWdQNKqB4!Yg-6Tf{zeim121AJ#u6Lw+E$?fLQU`9aR*+ZJgnpsR#TW57eiP?V +zMHKe<*`oz6;_tD7CiJ86(0#_w?Nixp$nPY<&|+LEV)=PMiIh5+K!7ahQ{&GKH}&n_ +z{Qg+^Mtg4n`(bwl)5F|dq3#VhzxOu_)8h)}~6Du^Q#$Zeg)K6W`{uPM13 +zWFIw1BIMbh^HMu3AtZDKB;#HMPj@K0RacA +z_$vMl3t$`p8XjDLG8Pdaw9zN^+@$DkM{F0d2fGme8QV$#m`x62M1u08V9SL3Cb^)j +z;rqXL?Bq!R@2pmOq6AHA3SwR`E&wzFcrakXLj?-NSc)_R4Wgm3GsWLnWnh0;ZLeQr +z2QC0da!@L!EO=gCAk|R2#Et>PK{CkJ)=vm?Bu8)n!z2kdlH7?>_{LHgGN9o@K@Pbi +zYJ&*_s=+=zU)xPLJF$L-8@n~)`I{z@2B)@*&f!Y4>_(?BB&%WQo6{fywsVxWp6(IC +zQRhJHD)nSC|4RO5yHI|C2y8_2f6d#d+5!PoKp;4?%GlbFnP+!%|NAp1L^D_bk~3Ht +zCCUtV0$eCA&;u&(Jr7Dd^=;=CV1^Ld8vor#sqsPmQ@OcNsJ(f>_$jIYbp&u=g7HOA +zEuhiB;t&3Y@tu<<=JVa$DX3l8gUG!s`MOSp!B7LN0`$TvE?h;gpgY2(c+_rX6n6yB +zLL>4T*Zi +zKkXp@@Ojnd>na{#gK%Ld!0#vbXb6kzB6o`IPF7OuvRNU7d9f{170ITBXqFVu3Imm=I9^>XRCeZ?cj`{l|XO +z{rz6~A!uzlluj{_O;tk{<9lkSQOf~^FzJL4{v9TjdX;jUiMPhe$?#v7htcahQU=Iu +z06+sO#u^YH3<*0R0Q`+h+&j72rLEKa-(H<6ZX?-&%rY-#J^elbTi#txz_6pV15U`s +z_r?x|l~wYDRg47MCiRzvlUIkY+?O@|^Faax3`htt?g&NHAP_)6e%lkoKluvx4Ii|$ +zml(4iSN7^EJCJ1#mU+oLVv>-*i&XEyOv4UCAVn~8!h&q7!mnNyI7x!5vI{4}_!|m0 +zw)=#$Yd_T30~Gv*DikDug!t+!s#mY^fAb+1(u6a6b&c8~GL|VJd!rjc(s35s)EX|3 +zxle{96d_M<&Kt_wf|`VD8=QG(GI~{4_>O)C7=U`M)_5xgA39nO1C_e-_iL<^!gM8A053S*&nw|PeV +z%UkyU`fgvj5s7VNy3O@{3T)G4A=A&=TBEkc;?e>qco`+*n~8Ls +z2b*MX=B>xV+k068#J@wp=uJ!%kb?j*CN}^ZCJb1xAwP8+f!eEAnAz?4WY;wP%g_1N +zb0=)tZ`-b5NGJ^ev#q-{$%a(EWT(0-i8*Lxo|FKD)f$7#nBL^tO_fir^;&ssK=>CC +z;2s1FIEcW+8kQlz=p?vkz#rxz@IG)2_vNdt{n&GU|A!tW_Kw@sC81}JdUDp)EfIxP +zpp+!<^F-XCMpkz0NYUB&XdSxC$|u6tmPz(ZHuv_@)U&fl5I=%M1QG!}iDfo<5}|1v_EXMn(n}T1j?Odx}Ci +zkZx`Xrl^g?I}r-kh7-Ho(B++Lk+bDY@V5~#VZ(uoD%=JQ#wtNW0T_@CKE%5&qii+62xZ!hSM?X&Re_tN-9(|<9xXcVxm^j3PHlgZi!^*80sZ}{;Hr^4A=3SZd| +z`g|e&!6f>**(5~!cEq4|0UrSvy_67;Frc7YKtMtRD2VtAD;CN7Q|Z3EMnBq-{Cba< +z)+;T?gL$r|_r-?vGGWqzgw_`ks5&E)RURfm%Y=>fK}Sf}%p8 +z#0pQBc9l`4iA-HK{3$JO7;yT<8E4+ubmC-I`GpSo)fxvt1L(XgM2u}p7?C@zApYta +z;?E^T)KEP!0EWgn;bZXi(8W1Y|&m0!n1zMt2+|AfO_kF@XqdNch>~@3p*|0GQ7cBymS*1`@E97wo +z*P+IA8a|F6MK>gL9cUoeQA;IXNvz-(SrD{LA&f29jPR;a7R&fI{0A7*z<1)8&<{{Y +zqZ)xiIVEfJhVWYA2-K|y!i1cCKc(tKX&r?W67>_<2ti;GDTq;Rn`0iJ6$-PMM^G1K +znfT9yEz@3|xtSM`fMSveJV)qPwZ@0};smp<6_98_KWwbPg*mE$Nn|mMWRmlbwqxn3 +zik>7IC=sDRel`o{$q_@7K}`aAZ$4oOkkIpl()@$Uy0h|MPEw-+hGSN4DaGX)akD}5 +zMCm~dQrJKg-*c>$ +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 +J{D03|0RTPdajF0S + +diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png +deleted file mode 100644 +index 26357af1e557f992d58827ada472bb089488186b..0000000000000000000000000000000000000000 +GIT binary patch +literal 0 +Hc$@`M~KZnoq-p=@8GNk~@#drM4m-V&Hlez2-`wv>cW +z5+@1Cs-Fv*me1R`>EB35+9NfeKS?8cy-*JlILSfJ%}>?nbh~j^Nz|zMwHqsHB&l;# +zoknXdzU>dBuSE@C}z}jk$#dCMN$0G +z+232~haKGje^E5H0@%P!Cjd9wl|_{YG`%sFBTG$Gv*yVhn%bK>8Xxtwfj(VmtV&(0 +zKPnD}ZQF(s|37<-F6?$8i;1)~|8F|EEZjn}2mrFB}OE&CJZq%*@R8GrsXNGcz+Y+i%Rw7US+o +z2Qyt2QT6|4S6BD$nO^WQp!O@1WMo#& +zFPCIyY^6+POliSX#)UGsPagMD%4&^=(pVZ=ENhve%giv8S}e&+Wmsl98Cz+p@y!~x +zZO0=?*ZY3V7nIw!HMVWrHh-=0%eHOr4%>DQ8&f^3?ow9@9p`&x3){9slBDPR5y+^_ +zsGdTPZQHhWw6=E-caP08+qP|uZF{C!u8OG4!2f^Nux&FO+jUS1VBhD8(yCQf!nE1ox_SimlS5XVQzxCpd!ifmLC +zb%k?r23V&ptb=pQ5xLG7XPg$TKrvEIEy*!r6thZ>4e4?KiztM0gco5(juUhPCE*lv +zLyn`>FosX?WuD&|(`ODw%coic4d@zTF=7#N1V#{x5DSs(BDchCjolnOhCAUHY=PSX +z%ls%fCtgt7XLAK^*<=?EVmqJ_FX0n`af}_cm0t&O3%>(KIwgxz>L2)P)2SKF> +zDsCugkXOK#!6ZPxt&4y(AvRzbfnESQjnF(6nzNvNB((Q{eig)5GC-v;m3mgHD+Q2t +z0nl1I3t$51-*(0sSrkB0ct$F9#iD&1K!N}^z?17daAa0q9)EV`ZWkpIM$ynIYg-pS +z>sN+=u@2qBLI5@0jf(t`X~;G0X-esL1-Ndk4=TO +zgW<}q(3%QiH3-|eVSr4r833&TGK&GMTm8kZkTv1S0AB^T58w{~KK{?6t|%1YV2=V2 +zS!Q^J~pjemGJ +zzE4yEjZtj4Om#reU^abtBq5rhoBNM) +zd`&O!W6%4U*!5^%V?R4&6jXxtl2CeSI8g;lAz%yy2la3!hz!3?n!~E?%WV^S_JwRD +zd@PmxC#u9$iK3UF>e^CNOVqScit3=I4ZcK9J5Z$n1t0~W1ds|@%2XDBEd)(7Gc1p7 +zaR2z&5$<1%w4Tv+hVwPZ%u*_>NrVsqio?JU=jOs~JHpK$4d;)9@Pv*L&ijnJ`yf{f +zF$aQN2PSquY``DBr0Bjo98wwEpcIf2gE8NQr2GQFCQPB*|o6NVfV2mErx$7_h% +zD>akuaf8{%n1~)P61M}gU9%O{;`7pK+Tu&T5Tdy5K@PuA +zgua(FFaS~%QKUq{K^uT`7nomsqOEng>EL15xuBeHDq;=X7yzW;AmGqJ03rYrjj{gj +zM_udx(qkPNM9}jKF@&c9z2YTUd^Rpv!nr0y83s}pq1%}2`&AR4*ZqahnFV6o5yCD* +zRTWWGL$2VlxP>o~(;hU9u;;Z9C1?WBU@4?l2{SE(cYp{@zi|s@Uj;|k;Uy_kSOn1o +zq&hU8i+?|h_uh_Qzl=N@AqHH9Oa_Hj#jO%y0s&_LO3}9zv;eG=#5qMs7~oNLc@y36 +z{xUkIeUqo2{yRP3;);e~KtO=Qz#bF`lI!h$W&Q0hZ1~4tu2LhsMg&x2RA0~Rz1SGT +zjv$_t|2%Iy@LICs +zNAyKC8`_q}oB)stgh^B8?d!8i7leH2YXUmwR8x(L5)r~Ty0FVJW{D42e +z-w!zoPy;1WM3T~i^*#-Ys4sjEW?P){%D?s7KFSte|7=eUv+~z)2pi$P9pG0V0Cz?} +zeh56{oGglDC`n@iu!7!R5~%E;vOs|z83OSF<%Gxul(K2*<@(E@^~H)h+9CEaF~Oc0mU??-22bTFuL+auLAx3D +zGKez~Nl???gtKPdX}RNt+AwiGEz(y(vGd?KB+-^~AgxdE1%C%NPDP5MNO=R%2(o5q +zC|l1*W?P^4<_5p;t%-BdsZ|;eoMZ&HXT$&f&>jyjNr6)drX#{j(c9Y#&Vb4SW#|FD +zt`j2XLJS1#a8q!!eG9;~e%O10L!3CwdV(0jFo7^a{Fy`L&-HnB +zAzsrS9~JmB`ARrj0kau~N`K7G%*i`^z3r*#;YZqRvAzh3-46s0TXzBxHK_29f`5RZ +zfsa#)Vagkb_zK*T&EoB8S}e=#8~jy)1;M=WZFkn}C(DCM+jb8>v=3aRJ#p8x-4mr= +zgFghn1^+)FdeDfdlN>9+P=yu*Wu-a(xV~}w`#trdYYS3-Xai7gLL{ObP_7slMK4$b +zzyfgS?Dit82OvcbqJ)7*Mu4C|3&8y!*KU|YA2d8*1LRlT8!uK(Zu8Bp*tWPVq|-?h +zjLCHQV{2}@*%DIWV1!)5rra=kEf*umS~ZL@)th +z5YlNxUmqw@!Y@<((${swz3)xvv;z#j<^Lh%N^!vj;0!w3J39e{8PWjAkuy(l4L;Ei +z1+Xx*_JRTSO}qIsj)sk4KoqD9?sqk%VF&Xku0aTa93K1V0HWeY2Vcm~?aX&TL^*t5 +z$Mk?t4u-Qj10ftx2m=bR6x(=``hD;Z!Y(0&A#A(^jlYE=+L`G?;WEjl9p{;gPV$xQ +zw`8qSoiXV4<_CB@M2RG)B^JqAOMvjh%6o$PHV4;c(YL(^Y`rtA7Qms3W5!)~bG5a@ +zMZ0Y?sJXy|xHK25tg&lHPBs(WGjb?omEfkfe*D#Rv^Dh!{$coOsz4FuI5t@TOqwV_ +zG+s0!B-)ie&;oE4r+0QnEreNwT;Sy@d^wlcEk#76=q7CK3AfAy9l$bhuD#MT_Jml# +zHEY70W|>-l*T7JZ&$RYA?Im|KewTTM2JWVdxpY{JxY*u}3wa9s8ny#~f(!^j+0(<43^Og8@wE&_1Y(9MK_KkHf+va%qh@ +zJr>F8r&FX+BWb}10Hhp=hz6*I>jMRqKxI(8 +zkbpsZv=*=yU<~MgB50h4Is|7w4f_|+Iswsk%(VrDdE;($(>u2Cbbp-1o^h7KWrF~AWCA+q9m7mx({Zufg9Umy@*sRjBO@Z;EFNkx&#=c2FrOf}E~h#tn1< +z3owpA&O$hV41@%<9z>iU*4b7j&|0HZcSQ+2DQ@4xNM!}8fL2odlAlMfd?SRmMjlcR +zM1xt$xC=0$Q87bg8#i4+W#Qiel}zE_xD!SR9}LK*mXrZUwJV{u*0avGI18tSt)cz$ +z@XL+h8xcOdt)*M+ksrUq^bEd!O;tvDT5$e6+r=0BJp=(cQHus#j362e^)zsOthhc! +zn4=8W=cC0l5_n+{MlRcbo5r4F72N#3@Q?iF9Xvp@A?7kh=))_-7;LqIg0ax914jCDqlnRIJmb+RJFoY1%RmXfv56! +zU^-1aYbfQjM8y(CA@$fjCYRAk!3%g#KxfqX+j_*@wmJ|_Vi!!l5IlP_WLvlM))5|2 +zwg|JaP7;p=ZxB=!a3%XOgIVoz??oz`o?aAjrhXrG&ylhvmMo%*0;;NwWTFUd4SHQQ +zl_5Q#2Oxv=abm-dSEN4DbqVkRaOSPxum&{d(=(2@cuc-|CBqdTVPEk3`45@@+!Buq+U5L=_iD{6_IbJt)@$=P) +zJ0b;28^B9n405r+T1)tvD0Q;nT!_cZya@dw$Wkz<`vf)ImWE^(xuO&oT?D_uKf><2 +zL|Q>2fYi}ao?K<|e#}wgl7g2Mr0GVrl)@R1@_@>J5GObsE@J`geIq!rJ~H0RqxB3? +zByQ+!lmZQ5&yV6JClo0k)?|xC#WR|!dZH+xXmHW^E&Trj8zBCK!9^5+43SVSndK=9 +zDxeI7+?vk!dVmn!>k9gbIdB6=hl-r??amQZA`15E@a&gmp)s%-SejWPg-bMbwv1H= +z&qe2JdGjvReinjFsp=Kek%Aj4sX+SF7vS%OpaDYOP{=+tN$LQx04x9ll11eTB0N3}l9Z8~BNgkU^9*r<&#%K^hnPiEvvC_ey4S<6Tq=^o7`+)`+ +zpWgD8V8AVJc}t*>in@`dDYz0BGAP2z-yQbkAazxWLWmW^3DE;+Lg>b*hfnOcJj8~a +z_Vg^oG$MUtf6%}sM(dkVY%F+1s5QbA2N-VE)6Jz#(4Y+ru&iD_E|D)%rDyr5KRmZ` +zj3WJj(4h#leg=Ge*NgT5Dg93~o28xL)6m^Orlfv`` +zcY9FTv4SZFDCVf(xE +z;v?cs@E3-H55_Zw+3?)`w@iTsy-|M4wp6Vwib)};NJS%|Om+~@#;M1*rU0bAlO_ng +zo@hn@7!F@7P}~ub^cZvLhasR`K=*MuHG+sYAoI?YvoqEtbVyc3AP)TOM3`|x6;Q<+ +z4<)4W5({F14-B{RqV_|;2_CX2=5}OX)Vru>5zfs~LNQXL;agKx8DdN+L@|aw)HS{n +z-8{<)yrbxb!%bjV@hBK&uxlKc*ebe!G0qa<8r}RUO%rJe@Rebig2kOiP`f21^))T9 +zP^8SzbERA`So9FYrG0&1K%cc~5n3L+CP;(1Lg+W9D!7Re(riKO&9S!udEG+YN}$Qv +zSvrCT`ms=z81rFnG8FMRh*_Hqxe6|pUj^(^SR}x>87HiCe+10{vJgew!1J&u%7lmH +zap4m(4%SgnL5kW$O2bPQs6au1rBNNh6I`&6s|TLtR#QRdd5uMz3K9qdB7`;qjt3%@ +zvD;!hvs)K@L)|63S0NfIIitRZm?Kc3ybetKY^ys%m-`SUT=R!~h?(Lz$a!HhW$4wO +zcKness7LbejSNkS4|xb6N(8Bj%$RGCc~c~Og6XQ6GA|2E2C+H19B7;WS*P3nO{D{m +zx}>r}uVuvq&!E3I&T!Z5$;kTE2;a%b%)J_L`COt+tR`j1dj<2+!HGk{M^}dC-g^e}cR!Pr +z-mEgDsl0y?NYEDheG^OEUT>aj3QEpDGiBgW&O6TXMXjl0GE#Af-34w?*ZL_gP+@3C +zro~NtYt?oBHkPC#>VPx27JQ*@sJkQwJdj@k!r>I=m+}TP1O#{q%>EX(PF%nlZ3#nU +zO{N-f7l_-d5im``6iy>`?Ea$ESg|vVrfLuffs^di1AXZX$vB*jgFvyWrc9A=6A}Rt +z5ojbxbRvW;5jG0`vu`G${qCanr*@nUiP;lpEN0bJY&|ODwz}Dx|Hw^V(g>%SG%4>7 +z(rmg|G$rLuBBa7tG%{30%8wH%!{GG(viu7&nrJb%eCW&fh=I~PSwhz~jM{CQs` +z<%HRLi9KsTMFZuyvFlCfp*yuc`?)kQF_B)9UT}YMp-HV?2`Lu>4w}O(R`o7zARm=G +ztt}%ZNsDQm-wxo%8E1W#`t6dO%i+fmKsX*EpbOKlZ7bz~k%5qD^=fM12<#gGn1s@S+twx +z!Sou#z*j6|m-l4siQDNRImiF}9~Uyx4D9Ce^xl;_cU(N^efopKc{^F42d$gl%YLWt +zZ@9NDtmwk&f-$!wLy1_+gLmnU`0l`@uOAqMX@)SzzvhK9CH#7j)>Q`qN1#AdBl;pqk?ALF>osi(iA2&=%}J;_7FZ*2 +zzD*k@kg|R}o>E3(%Qm@*nKL8 +zj0y$Rb=MTtacDugH1FvF=aqT+8+>jFiG)KQstA)CJlZXV^9YQgP5tA)+tZ)j94gAC +zv@%Owvo1@&@edcdo}OWO>!{FVH}Ex;W$G$%4i_X0`3S2@FJ)xKrpWJ0anLn<}}2#IRKV{ +zBY}ymwib#?EuWkb5*fCzCo=xvmbU8nT9?!*roYo&+GT!r?Pgcl7RI56+W%hHZ;Fqy +zI$bKnBN6M;U~VYNfYwilK$x|znR7lLf!MAXTnZdL +zeGkk_)eR?;5v`^@QRgkm%%drF>c|!+Z@HUXWwXxv6J-46ZPg}!3J%CRfKu9H-aKbx +z@c75)cuiR^%J4QD!6}Abhe0^6;%?e{g}WTTjpNGB8aK}$ES}iy7LdAz4X@oEB&h>T +zCv7~RK#s5d+GbbyOh|Nw1F{}q7~9Re(#)PXUe@uniimGlz2GdloYX6w)B}eG$ndR_ +z5GwpE;R_FsoG|W6sE^LMGZjbS7$19Ew;6uVb~q#?77C|N)Zn3QZWGENSm&d%FmH_%ZA +zvkbJ>k8BqRvqX4)xcCQsJ`VAn6D$CBb)L2%G(hIb1zV?cSSW%ljVwK;rwJ*Rj_2ey +zj*nD!qqTs#E}Jx<7diM(btF>F{Q}IEn(;7eSgLT9_Apl3<3^L@QOpdSBy_9h* +zkN|fA!}!SnL`8VBu}qIcls3T)lz?YbB8_S0$rfWVYzhcfJdA>ZHt1BIzDt>pwuFI2 +ztLN2iK8xTCPCW5Mg*-665Wr;sL}n?XlQ1PMVXU9oTMkbBqH@aWIJZ$n91v27ea*tF +zhM91bDUL`aKvf_Z-%Z16A|R%4@wB?rO&v}BQGl~FjH4-%ccDNUont=)K|#n+Lofez +zEknVXLR!FwRR-7A{G+;)3Gu~+nKb|VnV$6wS+QslrU$PjQcakQf$U38YcV4t@;l*U +znzVpd&u!gTGktrT*HO%9>>G*F*=3D%GRDO_P;J}@t`elHWbvPf?|jhZK16O~fq_E} +zJBn-WWMTW10VO6QuIPt0JI(oSteKVw0+e$G)iujFE#SkY{eEcmGjqLz-IRIomp~Y+gPA`2gE^=R+pH +zWKS$8V9-orO`n)(jac4l-5k&W8d32529G+x10W34ZYugnvnd=2O|S>d(*qx|HM#1; +z_oT>(Emt*P{Fe<_pxPjrnNBBhl)eROQ{No{Pg@KiwTO=TspF!54ih%==xc{r{U +zsJ+zqnFF7*Y;H5=BA6zY(n@NEY(Fg2hP^Q9Qybb}Y;u6Vk_T1m^09$ev>-7+#F)%{ +z0x#@KOTZrx_LL|CzQl*4OTePafas +z7uVRIR!)tLHJxJMW({gOoYM^!E=4OoNoxw6u)Y0E>b;ly*rBMlfIv~t4kbK;-lll{b +zE(ix+%kSTK@c+use)QJq6YnxRy|bp%3)C$1rT=FNFSUb(NyHbHoWK#Jp)n&DwO!t+ +zL1$;@=>RZ4U^Ji$Utj=6fix)^P5+CFq~f^knH{U1`CWxeOWl4l@-q9GEUd#3QB#pd +z>OOkcQ|2C@KW+C^L)#Mp>mlI|Id5RDGvnvS&aQHI2YI5xR^B%AgD+ne&e3dU8bo!s +zPFN5l(#^tvLjzCIbC>CbEx#@^`kFYj&In* +z@XMwk8_+l~Pu7^DoI5bET}~-;B`15vB|sb#+7fiO45Wc&x3kX+vN#a-u3O;%L}DRy +zXHsk2Q1rcSxvwQ53qq9K<}q-3v3EjCdGuZ0DbBv@?%7lCym#xV_us$g(?ipq-#yj6 +z*lBW;1}h?-?5q2&z5a719Y6QEvBDD+Q~=v}k)Fu29J~h$=4cjh#d0TiDH|gLS3)<< +z0={gdC|0hF4I_7N%{d=EydGLRTxY5A|4af~4z%DtznTd(^ooHDBx!6%pcOf1O-V`y +zfHK5qsTq>U5U?K67jQbeT_%{-%3U;>yC<5G_p)h0? +zb5rK@FL~8P-7DUB;ri>}cFEd%!#V^RUIWaL){aeW?E3P#WOTB8df3H}L5daWBqq}s +zm#Tr08N5K#V9id%P{-f~YA}j*P5N6-f`5FSqX^;%30=SggjamBkIj3f&#syB*>oF8 +z1#791lqne5WEQz2GKa%}{Tt8UDJU1lSA}7Kh}w%Xfu=6hZ3M0Wm{kc5iDSO7G!Ogk +zdEL|3G|Nu$j#jX)Gv{1))@RkwmxvHy)VfHobbg@R`QZw3_JPUPA>SwutaL&?J?8ET +zG^Y@AHOu9lLv*HrCJSmvLsW!M1dUjn*mUb=S#}2d_&SGfkNfW6u6G6D0g;#HWa+U9 +zCJLV-Tk`4Rx)M7zi4hjrXvt_|SB36y)U`u|ae`-@pE%*t!t*2}WfB1jUEWa~v? +zbTZ^wL9=EgKotqxq3hrWor%FsxaO--L%8bdg?F{2B>{TfyWSN@aM!ghkV^^W^W&Q4Z1jq}Y(f8IQ&ga +z`kQfF+N}bOx5l1t?VXj3FOuy{%eOx`_*0)*bsVUplI%x7Dys; +zuDn}AEP@*D0N1Ri7FJR0V3_JWPYHL8I-@MII;+Ofg|Da^>aNHYGLcKmZl7l2B*_Oa +zL9L-pLv3KYc{)V1;7~&t!tUe{Cv83=Vef%*F~x>Z4R?*!6jE8P8z|~_)SbvZ!&C#4 +zo6Y)>9Z03b^*g1))LnzY_1Dpk#hCh$AL67g~#0bXE9i{QXU_7em3*YX$ +zBnMPM<7E5OyrFdyQX@zr0~p=!$09eK0^~@q$7%`RW9`bfP|OA3+!AI7uL^S_N^`E}d)_JqSb3 +zdNN)&_%7gslZOa$6`7BhLK0VpTqXn!*g1)au$u5(BdeJJ?leNB)@9iSpA>rXO`PxX +zZtQl&gYqdhP#ITil9ADPqLP)L;9j(B=9M47iZIGNgpX^UFpGvs^XLMCI%R+z|8v2{ +z$4T(1UQ2F|>WaEyR{-3cgWHo_VgLk9p$EMJPUSoK=$leVt`Z-d`p9I>vH*mNVRo91 +z7SX6oV~G~iqfH;QW#D(C?B=X#>-bLiFqO#% +z%(XLP%LYIudZSX}`+KvYKhFQh@i%GSU-}8tozxKq3I4HoG;t~)`Kp3yNxO8j_jsZ +z%2}>Z7K&x#M4o54DEy~+I=$ah3l>mF +zX-ph(iVfcV|FAYJ^X&n+bTpU>RGa5)LCwwKPCu-nv?4S3B?KTcJf7#7P(?F=Fy_Ez +znTb*BH4!Oh@dO5PHb+#BrLqYnQZl+ACLjE_G?ARNS!?;7yeN}(*Ie5oUl4TQNtXz1@qZ9vNJ%1rX{l2f>t +z9Li}gC%*a5UNA1}86T`}30D+2t^jYooUqA51Eik@H#ET`L_p|CP7mB4E{Zr!y~9yr +z)rGEDD&}L3Ts8-$GJqNMEYIZHDwoq(w8WzQf1Xy&_2qYK{^C=2BQVpzqkFBaz-@dEyH77nX!6KHQoAYXjB&0UDMXOyzPvA<` +zV&uzm_Im6!qkqkoy1pvX+R!@fv}McAT?Q#rK^-WL9x- +zkPcI*i}Olr;g&$VH|^c#_AHE9;-&?7nbd*+3s5#a)_m*kXu|$G3LG>O +zqGRAM506RaH&TotX1YN&jie%Mh&OsJV_JrBgeDYrJ1fLU1{lJ}5E-+SQ>ef|W-=8c +z6+m^sU!Jty`Kh}DNBQ|UUr(YA*SgJZzWrK*dLRf?=AeRLh7C{tZ~fg_e%R~fB@#e7 +zqA`a^3&om6%8UraDpubAK=ATweoagvGYpbCxcw8M4J6`c+#0PeNc68r9US%0?5$$l +zXf@qDFULz~cPu&nVK6B$_|rHGlw8Ucotvma(8eC|*L5+d8(zZzgM5(rf$LK@%*|0F +zD$_pL^zfH|Mo_c_*z5$C2Hu0Tqrbny;^2e1qn~tBj^&K~Na>*#(SLLXdm3R6JK2Ex{p0Xv!Jq$GAXekuMpCXh+6S-(^um~!NEkUeUysqK4X7ZK= +z%`ikP*B_;h4)9T1E>^o_busd#GBu)yWUxLG2}OhngO0TmNyt4s$-49_=!3S(8XxVy +zLoV+POGKj)03ydCX)BfGLZB3qjlkb*1vwBIdRjNs24)&l8UUJunfb=h(cfb+;R!J# +zrw()KqXn5tf7>BwZ;X$m91#%Pgw-)BI-L}<=hm=*#uohX>AdjyAVb2M-fdyZ^TA|L%9-;OMz7W_S<3Djdq9p;#&WQ_=`j{^PoxsSEAzM~Wex6jLyZ^s#@&0CEUux +zvw-Bx8Oun)#9%Dob)ZEvuh@%DPSS`Wbtw2USMn=dLq1vS9wD4|%LuzGZa;pw2*X~+UZ +zTzI71V7p~U`M3Z6g1=|9%^eSom+bFyk76t_A?i}c@ca)CCg{40feTQ-R{dEgJgwq6 +zM~*!}hWr6y&=D8oO>HX}eyWrgUf2BE{Gb&xjdhpru0+4GU2p0i#| +zjwgG#b@pG~^ro3$K1p~eQxtT7bgx3J%7vERe(pYOp&}NFQMH@9hnF@>-?;CF&|iP@ +z=mCL0Ifi>M^o}PyVX|NYLJ-3+k5~n66jj3_VdsbR+5c4Q*U@3za#1Ng{gc6!lN@LH +zfG4x+)IMQ_BX4Vh66`C;WbdXw%YLs*R0gm;?o+S;QSIh5ZTs?v&;2L=rQhB&t#`+4 +z08S`S`s)__DT6VZb*4m7S{*OaglA2{(5{2}F9`@%6&~6N@{c>ll=oe6q-QMXmARv) +z$_muTD8Ik>1T}fnBm8mfEm(F+M~y +zLHyx+mug0rj~Bw}VsMIp&w!*FVk8d%Nl6Teim*Cn1l+cU)ro@%>Pm$`J-`+O{iXP5 +z{<2cQfBv`8NzeVgX#U>P=DQ~^_qvN^S}iTZhAPTbaQ>PQOj6TP59}q)y;rxaTK^fY +zyZ`04)i*zVd+CQ?grVW3!wQ@l3nL|<&xB}a@P1GMqZRo&W1=K%$HrRqI%CZ2G+N^J +zT#NvmK%F>h^4c-(pEVQm&w7eKIrz99XO4&rz2OZZ1UI}PL;xh{I9(xkMw|sZ{u5$w +zLyJf_QK|{TbG*OE`{V!k+vX1Wzy7>&0RK)8h29Fcg!*XIN2NX?l6SU3QKeBj;+9J$OhqdE<;Ngg?kXRLl7NI-wbUTFcg +z&Mi_fOVfY5?}iX5xjv$_L+9ASBhFga8ScM03{o)14S#>lnLoRxCP&mmt;a2}Wbb`6PAkqJ6l4N~6yhzR +zF#5bjK#1%Daq4@ +z$1F$aeh95X6d*s?jSDa^`uTH@a{F7fLWa6Gck0;q+}_7ltUhT@Z#(_usDc8r<}hk$ +z5!yv4jTv~!S^uwJ`oRJ3+;V+Qy^aE-yCTp*CH~I`1KJfDMB#w=Nr(n`gayz-f(*FQ +zf*%a%l?0C%`_;-D!f26Mw`39Zv4RwwiMMfZi9L?l`MUjM^Gn~nc6ac95WDeF2B-xI +zO|C*WKpEqJ*ufcno4!R$kpc2_Q6;(_O!Egc*2nj7Eh8NQ^{9jk5CRH8Q^D+y +zR_Q52c?)YtFi$}9h~xsk=qL#1Ly{;N01*?j=KJR0(7NQc*#C@NxPU+a0ahx_ZiSwY +zE(-t!CKc^fjNFvtug*OIl>igs?ctX9f=3fmCm}qFD#@QGayaIQ`o{>VTO{#=x$a4L +zVuUE56!)Dda26U_O%eeA-8u&Ksohx0*XQ8FaFh7Cevd)CFMMAC@`DLUMD3%9=(Q|<&wiXd|(Vb#fre^=c=EKIb5@c6MZU`4)O$XS^6Ml?S{!{* +zp8bZ+qv?x1@X5}DmYy*xLLt_|*RtsQM%)TP0jkXzF4^@FUwhgAi&whtb+2^YD|zkg +z#=c!QR_c}vcM|RjS8om71pS5F2nn$b{)<^nCmPzv62Yq0_(vstd+vE?x%xe>`rwau +zf8?7d8oFkkyI2W)a6?F$REIO1VRiVGnumSY+5MBf;Wx&QjO?}5c~)-x7>+k_8ULFp{J^Y0)!QFt*i`iaQQ%c&%$mCdvt~>SZls4?Z|& +zJ{kR%q}Ucdc}FrOlCgTt2$4r!q80AJ&~M5mc}Z6q;Xl6J7)^7LGsME}3asefbTVc9 +zGK1y4q4(1N$cH|dd3sJfD1K`eEFM{v&cE1FCzNqfqA?o~bX;p%a3yG^*>#tp-AFFi +zASdJ*&v*t_P-|B2L2Y1l89n-*o1+oLg#RZi%|1__1J^x1jS&P|;eMb(G9y$n5+lJ7 +zdUn{F7K+;OwV3Q6@8!@Jmm14DYKc8oPyUeNYE_Sw5T_XQq3OKPrh(fCbYHl31zG_f +z>FgH7eH13Hn4Vne(l<|^K0cL`KLeq&{KwUJDuedAz_o?bV91IJ5&jsne7|n +zlGZh*Z%lx;tw6h98fpV!f7m1V&=<{QI8$pDLM()?D0#XbDgU@9r{??-UODV}8JPIl +z1n9O-{?Fk67~_%+4q$z)q52O*3*2DPCc_>#)c0B@&6%?%HuKiWzGP$VzH1|t6zpCt +zxKuK4s<(GMjh+*#>%;O;&@lnj>DSPXgG>5^g!OgSv?O^6sE&Zc!I0o{ZtDtF)9cdO +z`N*y1lu%^#6kpi*=A_shYi|e~emSUhCnA{upNAGq7{`$7wZxYK-vB0xVD}*lI)9f- +zHYWC6JI_*pabx$3(&+=2#$g`Jt^>2Z30dvVI>)Ik@h)O@t_8_hFjt4G^dKhNomVG| +zZFxl5cP(e>VWBBDzZ?>b30Hx$D?=s`(is;~S^~~71aPgGGYXK`f!)P0U!9GON3Z_! +zV%*xb-LMkBXYK{}V0DJ`^I<&?re72GJqi=D^WaaeHER%wR1w!pzQ?PBwK6Qr;J*G> +zr?1zyWg*MH`>q8uo9j|OQ@j103bDq(@&s`3cn6`h<_8%qJV)Y*MnCbM@A%BDY{tn! +zMF${@e!VJw +z$D;&**5hIsR+fY}n{*51AlwGGynh@vc6FAJWAZOo0>+5`_d)To-hYXg3N#6-`@_FC +z#YeI*6A*Y5^4oq}9HK@jk^jaICj1i^&{csA?iGX7#v6Ln@GD*EU)uk_O-YdxUotB+ +zkb`sJ!R=ui$->=c>tq&M?6TVewAS#*T|k&D*Bl55blgtL!w$zFm>YBEZ_V(5liuZONfN6dCeU_S +z0cQl5`zZ|jNJK+xm4K+#m5^GMqu*wIop<(_Y=CqJxF!UL704$?ML=>lpQ*?80oF1~ +z8v*#w;W5@ka^eKfLq>qPJ{(a5*=^1!c4p5r6)r34CwZ6)54{h+tA^EJ9+xv7l@Q6f +z08^z6k1h6|SVn21$ZGZ|uI)41J)r*XGAX3{m0)}j?61$V^Evlsi;W#!cF9Si54pdN +z9RB#MZ^DV^W_MyH)4G7>+0J)hM6udNrEP{-OVnC-oJX<^QLNSpA;Upm3l=nK>U*5z +zqobCY{8wC6?N7bs|7u~He+&*iJSMSPSF6&oK3i)vN^2R`W)S((+EHv3vD(LcYk<7N +zYPdv_36LKLXPVyi! +zG#5Z8AlyBGJ3KHEq!8H4!$B|9s}nYkxb*ir%s&Tb92}L*&qb&!RA`^rV|A!?$pKoU +z9nmrb<0CJy11f|balR%IWJAb~gymb05I-cxpMq90m`|@lzYJKtcK8S4%g`zC_Cu_- +z9b=sFp$Z<^y*#3G28iJf+BKcLJS>&K@*z3$TpZgFFM;=M)~8ot;Cxm+*6gWmrz|_x +z+BU_Y8OpYT<^pai-}`t0UWhcyz8*MZVDVu&@Dwzge<1=)K)*uQKCr_7TI){P>2O@5 +zZJo7_J0CF(k6D1N5F9Tc&^y@X*mrzN8a3p{Flv(Dob3t~nrj07_gL3<6j7GYaK>7+ +zwbL3H`C^gYG)p +z@yUqk?ao@FNme!~&2@+v3DNGC-PszrW27&aAwoDSfK*pXj>ZZMD|dvK0(?H6LhqEm)KquyEQr2jdJJmR+a8 +z!NNfsxKX4t2BxE(dMspJ3u|FDtm6iNuOYDPJFT@9+cXEGIO&bN5q}L%YdJ%-=pd&! +zIK4aoMC?q1fPwVl?No&jL>ZW#b69RT&2(T?#8U3Tyym?U>P53MEN3WS6OpABqONmL +r42@9kyeh*&&{*C$&CR1`&sy+H5IHWxVL9fMUTF-8 +=================================================================== +diff --git a/app/build.gradle.kts b/app/build.gradle.kts +--- a/app/build.gradle.kts (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/app/build.gradle.kts (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -6,7 +6,7 @@ + } + + android { +- namespace = "com.looker.droidify" ++ namespace = "com.leos.droidify" + defaultConfig { + vectorDrawables.useSupportLibrary = true + +Index: app/src/main/AndroidManifest.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml +--- a/app/src/main/AndroidManifest.xml (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/app/src/main/AndroidManifest.xml (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -144,7 +144,7 @@ + android:foregroundServiceType="dataSync" /> + + + + + + +Index: app/src/main/kotlin/com/leos/droidify/MainActivity.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/MainActivity.kt b/app/src/main/kotlin/com/leos/droidify/MainActivity.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/MainActivity.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,29 @@ ++package com.leos.droidify ++ ++import android.content.Intent ++import com.leos.core.common.getInstallPackageName ++import dagger.hilt.android.AndroidEntryPoint ++ ++@AndroidEntryPoint ++class MainActivity : ScreenActivity() { ++ companion object { ++ const val ACTION_UPDATES = "${BuildConfig.APPLICATION_ID}.intent.action.UPDATES" ++ const val ACTION_INSTALL = "${BuildConfig.APPLICATION_ID}.intent.action.INSTALL" ++ const val EXTRA_CACHE_FILE_NAME = ++ "${BuildConfig.APPLICATION_ID}.intent.extra.CACHE_FILE_NAME" ++ } ++ ++ override fun handleIntent(intent: Intent?) { ++ when (intent?.action) { ++ ACTION_UPDATES -> handleSpecialIntent(SpecialIntent.Updates) ++ ACTION_INSTALL -> handleSpecialIntent( ++ SpecialIntent.Install( ++ intent.getInstallPackageName, ++ intent.getStringExtra(EXTRA_CACHE_FILE_NAME) ++ ) ++ ) ++ ++ else -> super.handleIntent(intent) ++ } ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/MainApplication.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/MainApplication.kt b/app/src/main/kotlin/com/leos/droidify/MainApplication.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/MainApplication.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,276 @@ ++package com.leos.droidify ++ ++import android.annotation.SuppressLint ++import android.app.Application ++import android.content.BroadcastReceiver ++import android.content.Context ++import android.content.Intent ++import android.content.IntentFilter ++import androidx.appcompat.app.AppCompatDelegate ++import androidx.hilt.work.HiltWorkerFactory ++import androidx.work.Configuration ++import androidx.work.NetworkType ++import coil.ImageLoader ++import coil.ImageLoaderFactory ++import coil.disk.DiskCache ++import coil.memory.MemoryCache ++import com.leos.core.common.Constants ++import com.leos.core.common.cache.Cache ++import com.leos.core.common.extension.getInstalledPackagesCompat ++import com.leos.core.common.extension.jobScheduler ++import com.leos.core.common.log ++import com.leos.core.datastore.SettingsRepository ++import com.leos.core.datastore.get ++import com.leos.core.datastore.model.AutoSync ++import com.leos.core.datastore.model.InstallerType ++import com.leos.core.datastore.model.ProxyPreference ++import com.leos.core.datastore.model.ProxyType ++import com.leos.droidify.content.ProductPreferences ++import com.leos.droidify.database.Database ++import com.leos.droidify.index.RepositoryUpdater ++import com.leos.droidify.receivers.InstalledAppReceiver ++import com.leos.droidify.service.Connection ++import com.leos.droidify.service.SyncService ++import com.leos.droidify.sync.SyncPreference ++import com.leos.droidify.sync.toJobNetworkType ++import com.leos.droidify.utility.extension.toInstalledItem ++import com.leos.droidify.work.CleanUpWorker ++import com.leos.installer.InstallManager ++import com.leos.installer.installers.root.RootPermissionHandler ++import com.leos.installer.installers.shizuku.ShizukuPermissionHandler ++import com.leos.network.Downloader ++import dagger.hilt.android.HiltAndroidApp ++import kotlinx.coroutines.CoroutineScope ++import kotlinx.coroutines.Dispatchers ++import kotlinx.coroutines.SupervisorJob ++import kotlinx.coroutines.cancel ++import kotlinx.coroutines.flow.collectIndexed ++import kotlinx.coroutines.flow.drop ++import kotlinx.coroutines.launch ++import java.net.InetSocketAddress ++import java.net.Proxy ++import javax.inject.Inject ++import kotlin.time.Duration.Companion.INFINITE ++import kotlin.time.Duration.Companion.hours ++import com.leos.core.common.R as CommonR ++ ++@HiltAndroidApp ++class MainApplication : Application(), ImageLoaderFactory, Configuration.Provider { ++ ++ private val parentJob = SupervisorJob() ++ private val appScope = CoroutineScope(Dispatchers.Default + parentJob) ++ ++ @Inject ++ lateinit var settingsRepository: SettingsRepository ++ ++ @Inject ++ lateinit var installer: InstallManager ++ ++ @Inject ++ lateinit var downloader: Downloader ++ ++ @Inject ++ lateinit var shizukuPermissionHandler: ShizukuPermissionHandler ++ ++ @Inject ++ lateinit var rootPermissionHandler: RootPermissionHandler ++ ++ @Inject ++ lateinit var workerFactory: HiltWorkerFactory ++ ++ override fun onCreate() { ++ super.onCreate() ++ ++ val databaseUpdated = Database.init(this) ++ ProductPreferences.init(this, appScope) ++ RepositoryUpdater.init(appScope, downloader) ++ listenApplications() ++ checkLanguage() ++ updatePreference() ++ setupInstaller() ++ ++ if (databaseUpdated) forceSyncAll() ++ } ++ ++ override fun onTerminate() { ++ super.onTerminate() ++ appScope.cancel("Application Terminated") ++ installer.close() ++ } ++ ++ private fun setupInstaller() { ++ appScope.launch { ++ launch { ++ settingsRepository.get { installerType }.collect { ++ if (it == InstallerType.SHIZUKU) handleShizukuInstaller() ++ if (it == InstallerType.ROOT) { ++ if (!rootPermissionHandler.isGranted) { ++ settingsRepository.setInstallerType(InstallerType.Default) ++ } ++ } ++ } ++ } ++ installer() ++ } ++ } ++ ++ private fun CoroutineScope.handleShizukuInstaller() = launch { ++ shizukuPermissionHandler.state.collect { (isGranted, isAlive, _) -> ++ if (isAlive && isGranted) { ++ settingsRepository.setInstallerType(InstallerType.SHIZUKU) ++ return@collect ++ } ++ if (isAlive) { ++ settingsRepository.setInstallerType(InstallerType.Default) ++ shizukuPermissionHandler.requestPermission() ++ return@collect ++ } ++ settingsRepository.setInstallerType(InstallerType.Default) ++ } ++ } ++ ++ private fun listenApplications() { ++ registerReceiver( ++ InstalledAppReceiver(packageManager), ++ IntentFilter().apply { ++ addAction(Intent.ACTION_PACKAGE_ADDED) ++ addAction(Intent.ACTION_PACKAGE_REMOVED) ++ addDataScheme("package") ++ } ++ ) ++ val installedItems = ++ packageManager.getInstalledPackagesCompat() ++ ?.map { it.toInstalledItem() } ++ ?: return ++ Database.InstalledAdapter.putAll(installedItems) ++ } ++ ++ private fun checkLanguage() { ++ appScope.launch { ++ val lastSetLanguage = settingsRepository.getInitial().language ++ val systemSetLanguage = AppCompatDelegate.getApplicationLocales().toLanguageTags() ++ if (systemSetLanguage != lastSetLanguage && lastSetLanguage != "system") { ++ settingsRepository.setLanguage(systemSetLanguage) ++ } ++ } ++ } ++ ++ private fun updatePreference() { ++ appScope.launch { ++ launch { ++ settingsRepository.get { unstableUpdate }.drop(1).collect { ++ forceSyncAll() ++ } ++ } ++ launch { ++ settingsRepository.get { autoSync }.collectIndexed { index, syncMode -> ++ // Don't update sync job on initial collect ++ updateSyncJob(index > 0, syncMode) ++ } ++ } ++ launch { ++ settingsRepository.get { cleanUpInterval }.drop(1).collect { ++ if (it == INFINITE) { ++ CleanUpWorker.removeAllSchedules(applicationContext) ++ } else { ++ CleanUpWorker.scheduleCleanup(applicationContext, it) ++ } ++ } ++ } ++ launch { ++ settingsRepository.get { proxy }.collect(::updateProxy) ++ } ++ } ++ } ++ ++ private fun updateProxy(proxyPreference: ProxyPreference) { ++ val type = proxyPreference.type ++ val host = proxyPreference.host ++ val port = proxyPreference.port ++ val socketAddress = when (type) { ++ ProxyType.DIRECT -> null ++ ProxyType.HTTP, ProxyType.SOCKS -> { ++ try { ++ InetSocketAddress.createUnresolved(host, port) ++ } catch (e: IllegalArgumentException) { ++ log(e) ++ null ++ } ++ } ++ } ++ val androidProxyType = when (type) { ++ ProxyType.DIRECT -> Proxy.Type.DIRECT ++ ProxyType.HTTP -> Proxy.Type.HTTP ++ ProxyType.SOCKS -> Proxy.Type.SOCKS ++ } ++ val determinedProxy = socketAddress?.let { Proxy(androidProxyType, it) } ?: Proxy.NO_PROXY ++ downloader.setProxy(determinedProxy) ++ } ++ ++ private fun updateSyncJob(force: Boolean, autoSync: AutoSync) { ++ if (autoSync == AutoSync.NEVER) { ++ jobScheduler?.cancel(Constants.JOB_ID_SYNC) ++ return ++ } ++ val jobScheduler = jobScheduler ++ val syncConditions = when (autoSync) { ++ AutoSync.ALWAYS -> SyncPreference(NetworkType.CONNECTED) ++ AutoSync.WIFI_ONLY -> SyncPreference(NetworkType.UNMETERED) ++ AutoSync.WIFI_PLUGGED_IN -> SyncPreference(NetworkType.UNMETERED, pluggedIn = true) ++ else -> null ++ } ++ val isPreviousJobPending = jobScheduler?.allPendingJobs ++ ?.any { it.id == Constants.JOB_ID_SYNC } == false ++ if ((force || !isPreviousJobPending) && syncConditions != null) { ++ val period = 12.hours.inWholeMilliseconds ++ val job = SyncService.Job.create( ++ context = this, ++ periodMillis = period, ++ networkType = syncConditions.toJobNetworkType(), ++ isCharging = syncConditions.pluggedIn, ++ isBatteryLow = syncConditions.batteryNotLow ++ ) ++ jobScheduler?.schedule(job) ++ } ++ } ++ ++ private fun forceSyncAll() { ++ Database.RepositoryAdapter.getAll().forEach { ++ if (it.lastModified.isNotEmpty() || it.entityTag.isNotEmpty()) { ++ Database.RepositoryAdapter.put(it.copy(lastModified = "", entityTag = "")) ++ } ++ } ++ Connection(SyncService::class.java, onBind = { connection, binder -> ++ binder.sync(SyncService.SyncRequest.FORCE) ++ connection.unbind(this) ++ }).bind(this) ++ } ++ ++ class BootReceiver : BroadcastReceiver() { ++ @SuppressLint("UnsafeProtectedBroadcastReceiver") ++ override fun onReceive(context: Context, intent: Intent) = Unit ++ } ++ ++ override fun newImageLoader(): ImageLoader { ++ val memoryCache = MemoryCache.Builder(this) ++ .maxSizePercent(0.25) ++ .build() ++ ++ val diskCache = DiskCache.Builder() ++ .directory(Cache.getImagesDir(this)) ++ .maxSizePercent(0.05) ++ .build() ++ ++ return ImageLoader.Builder(this) ++ .memoryCache(memoryCache) ++ .diskCache(diskCache) ++ .error(CommonR.drawable.ic_cannot_load) ++ .crossfade(350) ++ .build() ++ } ++ ++ override val workManagerConfiguration: Configuration ++ get() = Configuration.Builder() ++ .setWorkerFactory(workerFactory) ++ .build() ++} +Index: app/src/main/kotlin/com/looker/droidify/ScreenActivity.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/looker/droidify/ScreenActivity.kt b/app/src/main/kotlin/com/leos/droidify/ScreenActivity.kt +rename from app/src/main/kotlin/com/looker/droidify/ScreenActivity.kt +rename to app/src/main/kotlin/com/leos/droidify/ScreenActivity.kt +--- a/app/src/main/kotlin/com/looker/droidify/ScreenActivity.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/app/src/main/kotlin/com/leos/droidify/ScreenActivity.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.droidify ++package com.leos.droidify + + import android.Manifest + import android.content.Intent +@@ -16,22 +16,22 @@ + import androidx.fragment.app.Fragment + import androidx.fragment.app.commit + import androidx.lifecycle.lifecycleScope +-import com.looker.core.common.* +-import com.looker.core.common.extension.* +-import com.looker.core.datastore.SettingsRepository +-import com.looker.core.datastore.extension.getThemeRes +-import com.looker.core.datastore.get +-import com.looker.droidify.database.CursorOwner +-import com.looker.droidify.ui.ScreenFragment +-import com.looker.droidify.ui.appDetail.AppDetailFragment +-import com.looker.droidify.ui.favourites.FavouritesFragment +-import com.looker.droidify.ui.repository.EditRepositoryFragment +-import com.looker.droidify.ui.repository.RepositoriesFragment +-import com.looker.droidify.ui.repository.RepositoryFragment +-import com.looker.droidify.ui.settings.SettingsFragment +-import com.looker.droidify.ui.tabsFragment.TabsFragment +-import com.looker.installer.InstallManager +-import com.looker.installer.model.installFrom ++import com.leos.core.common.* ++import com.leos.core.common.extension.* ++import com.leos.core.datastore.SettingsRepository ++import com.leos.core.datastore.extension.getThemeRes ++import com.leos.core.datastore.get ++import com.leos.droidify.database.CursorOwner ++import com.leos.droidify.ui.ScreenFragment ++import com.leos.droidify.ui.appDetail.AppDetailFragment ++import com.leos.droidify.ui.favourites.FavouritesFragment ++import com.leos.droidify.ui.repository.EditRepositoryFragment ++import com.leos.droidify.ui.repository.RepositoriesFragment ++import com.leos.droidify.ui.repository.RepositoryFragment ++import com.leos.droidify.ui.settings.SettingsFragment ++import com.leos.droidify.ui.tabsFragment.TabsFragment ++import com.leos.installer.InstallManager ++import com.leos.installer.model.installFrom + import dagger.hilt.EntryPoint + import dagger.hilt.InstallIn + import dagger.hilt.android.AndroidEntryPoint +Index: app/src/main/kotlin/com/leos/droidify/content/ProductPreferences.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/content/ProductPreferences.kt b/app/src/main/kotlin/com/leos/droidify/content/ProductPreferences.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/content/ProductPreferences.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,79 @@ ++package com.leos.droidify.content ++ ++import android.content.Context ++import android.content.SharedPreferences ++import com.leos.core.common.extension.Json ++import com.leos.core.common.extension.parseDictionary ++import com.leos.core.common.extension.writeDictionary ++import com.leos.core.domain.ProductPreference ++import com.leos.droidify.database.Database ++import com.leos.droidify.utility.serialization.productPreference ++import com.leos.droidify.utility.serialization.serialize ++import java.io.ByteArrayOutputStream ++import java.nio.charset.Charset ++import kotlinx.coroutines.CoroutineScope ++import kotlinx.coroutines.flow.MutableSharedFlow ++import kotlinx.coroutines.flow.asSharedFlow ++import kotlinx.coroutines.launch ++ ++object ProductPreferences { ++ private val defaultProductPreference = ProductPreference(false, 0L) ++ private lateinit var preferences: SharedPreferences ++ private val mutableSubject = MutableSharedFlow>() ++ private val subject = mutableSubject.asSharedFlow() ++ ++ fun init(context: Context, scope: CoroutineScope) { ++ preferences = context.getSharedPreferences("product_preferences", Context.MODE_PRIVATE) ++ Database.LockAdapter.putAll( ++ preferences.all.keys.mapNotNull { packageName -> ++ this[packageName].databaseVersionCode?.let { Pair(packageName, it) } ++ } ++ ) ++ scope.launch { ++ subject.collect { (packageName, versionCode) -> ++ if (versionCode != null) { ++ Database.LockAdapter.put(Pair(packageName, versionCode)) ++ } else { ++ Database.LockAdapter.delete(packageName) ++ } ++ } ++ } ++ } ++ ++ private val ProductPreference.databaseVersionCode: Long? ++ get() = when { ++ ignoreUpdates -> 0L ++ ignoreVersionCode > 0L -> ignoreVersionCode ++ else -> null ++ } ++ ++ operator fun get(packageName: String): ProductPreference { ++ return if (preferences.contains(packageName)) { ++ try { ++ Json.factory.createParser(preferences.getString(packageName, "{}")) ++ .use { it.parseDictionary { productPreference() } } ++ } catch (e: Exception) { ++ e.printStackTrace() ++ defaultProductPreference ++ } ++ } else { ++ defaultProductPreference ++ } ++ } ++ ++ operator fun set(packageName: String, productPreference: ProductPreference) { ++ val oldProductPreference = this[packageName] ++ preferences.edit().putString( ++ packageName, ++ ByteArrayOutputStream().apply { ++ Json.factory.createGenerator(this) ++ .use { it.writeDictionary(productPreference::serialize) } ++ }.toByteArray().toString(Charset.defaultCharset()) ++ ).apply() ++ if (oldProductPreference.ignoreUpdates != productPreference.ignoreUpdates || ++ oldProductPreference.ignoreVersionCode != productPreference.ignoreVersionCode ++ ) { ++ mutableSubject.tryEmit(Pair(packageName, productPreference.databaseVersionCode)) ++ } ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/database/CursorOwner.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/database/CursorOwner.kt b/app/src/main/kotlin/com/leos/droidify/database/CursorOwner.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/database/CursorOwner.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,143 @@ ++package com.leos.droidify.database ++ ++import android.database.Cursor ++import android.os.Bundle ++import androidx.fragment.app.Fragment ++import androidx.loader.app.LoaderManager ++import androidx.loader.content.Loader ++import com.leos.core.datastore.model.SortOrder ++import com.leos.core.domain.ProductItem ++ ++class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks { ++ sealed class Request { ++ internal abstract val id: Int ++ ++ data class ProductsAvailable( ++ val searchQuery: String, ++ val section: ProductItem.Section, ++ val order: SortOrder ++ ) : Request() { ++ override val id: Int ++ get() = 1 ++ } ++ ++ data class ProductsInstalled( ++ val searchQuery: String, ++ val section: ProductItem.Section, ++ val order: SortOrder ++ ) : Request() { ++ override val id: Int ++ get() = 2 ++ } ++ ++ data class ProductsUpdates( ++ val searchQuery: String, ++ val section: ProductItem.Section, ++ val order: SortOrder ++ ) : Request() { ++ override val id: Int ++ get() = 3 ++ } ++ ++ object Repositories : Request() { ++ override val id: Int ++ get() = 4 ++ } ++ } ++ ++ interface Callback { ++ fun onCursorData(request: Request, cursor: Cursor?) ++ } ++ ++ private data class ActiveRequest( ++ val request: Request, ++ val callback: Callback?, ++ val cursor: Cursor? ++ ) ++ ++ init { ++ retainInstance = true ++ } ++ ++ private val activeRequests = mutableMapOf() ++ ++ fun attach(callback: Callback, request: Request) { ++ val oldActiveRequest = activeRequests[request.id] ++ if (oldActiveRequest?.callback != null && ++ oldActiveRequest.callback != callback && oldActiveRequest.cursor != null ++ ) { ++ oldActiveRequest.callback.onCursorData(oldActiveRequest.request, null) ++ } ++ val cursor = if (oldActiveRequest?.request == request && oldActiveRequest.cursor != null) { ++ callback.onCursorData(request, oldActiveRequest.cursor) ++ oldActiveRequest.cursor ++ } else { ++ null ++ } ++ activeRequests[request.id] = ActiveRequest(request, callback, cursor) ++ if (cursor == null) { ++ LoaderManager.getInstance(this).restartLoader(request.id, null, this) ++ } ++ } ++ ++ fun detach(callback: Callback) { ++ for (id in activeRequests.keys) { ++ val activeRequest = activeRequests[id]!! ++ if (activeRequest.callback == callback) { ++ activeRequests[id] = activeRequest.copy(callback = null) ++ } ++ } ++ } ++ ++ override fun onCreateLoader(id: Int, args: Bundle?): Loader { ++ val request = activeRequests[id]!!.request ++ return QueryLoader(requireContext()) { ++ when (request) { ++ is Request.ProductsAvailable -> ++ Database.ProductAdapter ++ .query( ++ installed = false, ++ updates = false, ++ searchQuery = request.searchQuery, ++ section = request.section, ++ order = request.order, ++ signal = it ++ ) ++ ++ is Request.ProductsInstalled -> ++ Database.ProductAdapter ++ .query( ++ installed = true, ++ updates = false, ++ searchQuery = request.searchQuery, ++ section = request.section, ++ order = request.order, ++ signal = it ++ ) ++ ++ is Request.ProductsUpdates -> ++ Database.ProductAdapter ++ .query( ++ installed = true, ++ updates = true, ++ searchQuery = request.searchQuery, ++ section = request.section, ++ order = request.order, ++ signal = it ++ ) ++ ++ is Request.Repositories -> Database.RepositoryAdapter.query(it) ++ } ++ } ++ } ++ ++ override fun onLoadFinished(loader: Loader, data: Cursor?) { ++ val activeRequest = activeRequests[loader.id] ++ if (activeRequest != null) { ++ activeRequests[loader.id] = activeRequest.copy(cursor = data) ++ activeRequest.callback?.onCursorData(activeRequest.request, data) ++ } ++ } ++ ++ override fun onLoaderReset(loader: Loader) = onLoadFinished(loader, null) ++} +Index: app/src/main/kotlin/com/leos/droidify/database/Database.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/database/Database.kt b/app/src/main/kotlin/com/leos/droidify/database/Database.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/database/Database.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,964 @@ ++package com.leos.droidify.database ++ ++import android.content.ContentValues ++import android.content.Context ++import android.database.Cursor ++import android.database.sqlite.SQLiteDatabase ++import android.database.sqlite.SQLiteOpenHelper ++import android.os.CancellationSignal ++import androidx.core.database.sqlite.transaction ++import com.fasterxml.jackson.core.JsonGenerator ++import com.fasterxml.jackson.core.JsonParser ++import com.leos.core.common.extension.Json ++import com.leos.core.common.extension.asSequence ++import com.leos.core.common.extension.firstOrNull ++import com.leos.core.common.extension.parseDictionary ++import com.leos.core.common.extension.writeDictionary ++import com.leos.core.common.log ++import com.leos.core.datastore.model.SortOrder ++import com.leos.core.domain.InstalledItem ++import com.leos.core.domain.Product ++import com.leos.core.domain.ProductItem ++import com.leos.core.domain.Repository ++import com.leos.droidify.BuildConfig ++import com.leos.droidify.utility.serialization.product ++import com.leos.droidify.utility.serialization.productItem ++import com.leos.droidify.utility.serialization.repository ++import com.leos.droidify.utility.serialization.serialize ++import kotlinx.coroutines.Dispatchers ++import kotlinx.coroutines.channels.awaitClose ++import kotlinx.coroutines.delay ++import kotlinx.coroutines.flow.Flow ++import kotlinx.coroutines.flow.callbackFlow ++import kotlinx.coroutines.flow.emitAll ++import kotlinx.coroutines.flow.flowOf ++import kotlinx.coroutines.flow.flowOn ++import kotlinx.coroutines.flow.map ++import kotlinx.coroutines.flow.onCompletion ++import kotlinx.coroutines.flow.onEach ++import kotlinx.coroutines.withContext ++import java.io.ByteArrayOutputStream ++import kotlin.collections.component1 ++import kotlin.collections.component2 ++import kotlin.collections.set ++ ++object Database { ++ fun init(context: Context): Boolean { ++ val helper = Helper(context) ++ db = helper.writableDatabase ++ if (helper.created) { ++ for (repository in Repository.defaultRepositories) { ++ RepositoryAdapter.put(repository) ++ } ++ } ++ RepositoryAdapter.removeDuplicates() ++ return helper.created || helper.updated ++ } ++ ++ private lateinit var db: SQLiteDatabase ++ ++ private interface Table { ++ val memory: Boolean ++ val innerName: String ++ val createTable: String ++ val createIndex: String? ++ get() = null ++ ++ val databasePrefix: String ++ get() = if (memory) "memory." else "" ++ ++ val name: String ++ get() = "$databasePrefix$innerName" ++ ++ fun formatCreateTable(name: String): String { ++ return "CREATE TABLE $name (${QueryBuilder.trimQuery(createTable)})" ++ } ++ ++ val createIndexPairFormatted: Pair? ++ get() = createIndex?.let { ++ Pair( ++ "CREATE INDEX ${innerName}_index ON $innerName ($it)", ++ "CREATE INDEX ${name}_index ON $innerName ($it)" ++ ) ++ } ++ } ++ ++ private object Schema { ++ object Repository : Table { ++ const val ROW_ID = "_id" ++ const val ROW_ENABLED = "enabled" ++ const val ROW_DELETED = "deleted" ++ const val ROW_DATA = "data" ++ ++ override val memory = false ++ override val innerName = "repository" ++ override val createTable = """ ++ $ROW_ID INTEGER PRIMARY KEY AUTOINCREMENT, ++ $ROW_ENABLED INTEGER NOT NULL, ++ $ROW_DELETED INTEGER NOT NULL, ++ $ROW_DATA BLOB NOT NULL ++ """ ++ } ++ ++ object Product : Table { ++ const val ROW_REPOSITORY_ID = "repository_id" ++ const val ROW_PACKAGE_NAME = "package_name" ++ const val ROW_NAME = "name" ++ const val ROW_SUMMARY = "summary" ++ const val ROW_DESCRIPTION = "description" ++ const val ROW_ADDED = "added" ++ const val ROW_UPDATED = "updated" ++ const val ROW_VERSION_CODE = "version_code" ++ const val ROW_SIGNATURES = "signatures" ++ const val ROW_COMPATIBLE = "compatible" ++ const val ROW_DATA = "data" ++ const val ROW_DATA_ITEM = "data_item" ++ ++ override val memory = false ++ override val innerName = "product" ++ override val createTable = """ ++ $ROW_REPOSITORY_ID INTEGER NOT NULL, ++ $ROW_PACKAGE_NAME TEXT NOT NULL, ++ $ROW_NAME TEXT NOT NULL, ++ $ROW_SUMMARY TEXT NOT NULL, ++ $ROW_DESCRIPTION TEXT NOT NULL, ++ $ROW_ADDED INTEGER NOT NULL, ++ $ROW_UPDATED INTEGER NOT NULL, ++ $ROW_VERSION_CODE INTEGER NOT NULL, ++ $ROW_SIGNATURES TEXT NOT NULL, ++ $ROW_COMPATIBLE INTEGER NOT NULL, ++ $ROW_DATA BLOB NOT NULL, ++ $ROW_DATA_ITEM BLOB NOT NULL, ++ PRIMARY KEY ($ROW_REPOSITORY_ID, $ROW_PACKAGE_NAME) ++ """ ++ override val createIndex = ROW_PACKAGE_NAME ++ } ++ ++ object Category : Table { ++ const val ROW_REPOSITORY_ID = "repository_id" ++ const val ROW_PACKAGE_NAME = "package_name" ++ const val ROW_NAME = "name" ++ ++ override val memory = false ++ override val innerName = "category" ++ override val createTable = """ ++ $ROW_REPOSITORY_ID INTEGER NOT NULL, ++ $ROW_PACKAGE_NAME TEXT NOT NULL, ++ $ROW_NAME TEXT NOT NULL, ++ PRIMARY KEY ($ROW_REPOSITORY_ID, $ROW_PACKAGE_NAME, $ROW_NAME) ++ """ ++ override val createIndex = "$ROW_PACKAGE_NAME, $ROW_NAME" ++ } ++ ++ object Installed : Table { ++ const val ROW_PACKAGE_NAME = "package_name" ++ const val ROW_VERSION = "version" ++ const val ROW_VERSION_CODE = "version_code" ++ const val ROW_SIGNATURE = "signature" ++ ++ override val memory = true ++ override val innerName = "installed" ++ override val createTable = """ ++ $ROW_PACKAGE_NAME TEXT PRIMARY KEY, ++ $ROW_VERSION TEXT NOT NULL, ++ $ROW_VERSION_CODE INTEGER NOT NULL, ++ $ROW_SIGNATURE TEXT NOT NULL ++ """ ++ } ++ ++ object Lock : Table { ++ const val ROW_PACKAGE_NAME = "package_name" ++ const val ROW_VERSION_CODE = "version_code" ++ ++ override val memory = true ++ override val innerName = "lock" ++ override val createTable = """ ++ $ROW_PACKAGE_NAME TEXT PRIMARY KEY, ++ $ROW_VERSION_CODE INTEGER NOT NULL ++ """ ++ } ++ ++ object Synthetic { ++ const val ROW_CAN_UPDATE = "can_update" ++ const val ROW_MATCH_RANK = "match_rank" ++ } ++ } ++ ++ private class Helper(context: Context) : SQLiteOpenHelper(context, "droidify", null, 2) { ++ var created = false ++ private set ++ var updated = false ++ private set ++ ++ override fun onCreate(db: SQLiteDatabase) = Unit ++ override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = ++ onVersionChange(db) ++ ++ override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = ++ onVersionChange(db) ++ ++ private fun onVersionChange(db: SQLiteDatabase) { ++ handleTables(db, true, Schema.Product, Schema.Category) ++ addRepos(db, Repository.newlyAdded) ++ this.updated = true ++ } ++ ++ override fun onOpen(db: SQLiteDatabase) { ++ val create = handleTables(db, false, Schema.Repository) ++ val updated = handleTables(db, create, Schema.Product, Schema.Category) ++ db.execSQL("ATTACH DATABASE ':memory:' AS memory") ++ handleTables(db, false, Schema.Installed, Schema.Lock) ++ handleIndexes( ++ db, ++ Schema.Repository, ++ Schema.Product, ++ Schema.Category, ++ Schema.Installed, ++ Schema.Lock ++ ) ++ dropOldTables(db, Schema.Repository, Schema.Product, Schema.Category) ++ this.created = this.created || create ++ this.updated = this.updated || create || updated ++ } ++ } ++ ++ private fun handleTables(db: SQLiteDatabase, recreate: Boolean, vararg tables: Table): Boolean { ++ val shouldRecreate = recreate || tables.any { table -> ++ val sql = db.query( ++ "${table.databasePrefix}sqlite_master", ++ columns = arrayOf("sql"), ++ selection = Pair("type = ? AND name = ?", arrayOf("table", table.innerName)) ++ ).use { it.firstOrNull()?.getString(0) }.orEmpty() ++ table.formatCreateTable(table.innerName) != sql ++ } ++ return shouldRecreate && run { ++ val shouldVacuum = tables.map { ++ db.execSQL("DROP TABLE IF EXISTS ${it.name}") ++ db.execSQL(it.formatCreateTable(it.name)) ++ !it.memory ++ } ++ if (shouldVacuum.any { it } && !db.inTransaction()) { ++ db.execSQL("VACUUM") ++ } ++ true ++ } ++ } ++ ++ private fun addRepos(db: SQLiteDatabase, repos: List) { ++ if (BuildConfig.DEBUG) { ++ log("Add Repos: $repos", "RepositoryAdapter") ++ } ++ if (repos.isEmpty()) return ++ db.transaction { ++ repos.forEach { ++ RepositoryAdapter.put(it) ++ } ++ } ++ } ++ ++ private fun handleIndexes(db: SQLiteDatabase, vararg tables: Table) { ++ val shouldVacuum = tables.map { table -> ++ val sqls = db.query( ++ "${table.databasePrefix}sqlite_master", ++ columns = arrayOf("name", "sql"), ++ selection = Pair("type = ? AND tbl_name = ?", arrayOf("index", table.innerName)) ++ ) ++ .use { cursor -> ++ cursor.asSequence() ++ .mapNotNull { it.getString(1)?.let { sql -> Pair(it.getString(0), sql) } } ++ .toList() ++ } ++ .filter { !it.first.startsWith("sqlite_") } ++ val createIndexes = table.createIndexPairFormatted?.let { listOf(it) }.orEmpty() ++ createIndexes.map { it.first } != sqls.map { it.second } && run { ++ for (name in sqls.map { it.first }) { ++ db.execSQL("DROP INDEX IF EXISTS $name") ++ } ++ for (createIndexPair in createIndexes) { ++ db.execSQL(createIndexPair.second) ++ } ++ !table.memory ++ } ++ } ++ if (shouldVacuum.any { it } && !db.inTransaction()) { ++ db.execSQL("VACUUM") ++ } ++ } ++ ++ private fun dropOldTables(db: SQLiteDatabase, vararg neededTables: Table) { ++ val tables = db.query( ++ "sqlite_master", ++ columns = arrayOf("name"), ++ selection = Pair("type = ?", arrayOf("table")) ++ ) ++ .use { cursor -> cursor.asSequence().mapNotNull { it.getString(0) }.toList() } ++ .filter { !it.startsWith("sqlite_") && !it.startsWith("android_") } ++ .toSet() - neededTables.mapNotNull { if (it.memory) null else it.name }.toSet() ++ if (tables.isNotEmpty()) { ++ for (table in tables) { ++ db.execSQL("DROP TABLE IF EXISTS $table") ++ } ++ if (!db.inTransaction()) { ++ db.execSQL("VACUUM") ++ } ++ } ++ } ++ ++ sealed class Subject { ++ data object Repositories : Subject() ++ data class Repository(val id: Long) : Subject() ++ data object Products : Subject() ++ } ++ ++ private val observers = mutableMapOf Unit>>() ++ ++ private fun dataObservable(subject: Subject): (Boolean, () -> Unit) -> Unit = ++ { register, observer -> ++ synchronized(observers) { ++ val set = observers[subject] ?: run { ++ val set = mutableSetOf<() -> Unit>() ++ observers[subject] = set ++ set ++ } ++ if (register) { ++ set += observer ++ } else { ++ set -= observer ++ } ++ } ++ } ++ ++ fun flowCollection(subject: Subject): Flow = callbackFlow { ++ val callback: () -> Unit = { trySend(Unit) } ++ val dataObservable = dataObservable(subject) ++ dataObservable(true, callback) ++ ++ awaitClose { dataObservable(false, callback) } ++ }.flowOn(Dispatchers.IO) ++ ++ private fun notifyChanged(vararg subjects: Subject) { ++ synchronized(observers) { ++ subjects.asSequence().mapNotNull { observers[it] }.flatten().forEach { it() } ++ } ++ } ++ ++ private fun SQLiteDatabase.insertOrReplace( ++ replace: Boolean, ++ table: String, ++ contentValues: ContentValues ++ ): Long { ++ return if (replace) { ++ replace(table, null, contentValues) ++ } else { ++ insert( ++ table, ++ null, ++ contentValues ++ ) ++ } ++ } ++ ++ private fun SQLiteDatabase.query( ++ table: String, ++ columns: Array? = null, ++ selection: Pair>? = null, ++ orderBy: String? = null, ++ signal: CancellationSignal? = null ++ ): Cursor { ++ return query( ++ false, ++ table, ++ columns, ++ selection?.first, ++ selection?.second, ++ null, ++ null, ++ orderBy, ++ null, ++ signal ++ ) ++ } ++ ++ private fun Cursor.observable(subject: Subject): ObservableCursor { ++ return ObservableCursor(this, dataObservable(subject)) ++ } ++ ++ fun ByteArray.jsonParse(callback: (JsonParser) -> T): T { ++ return Json.factory.createParser(this).use { it.parseDictionary(callback) } ++ } ++ ++ fun jsonGenerate(callback: (JsonGenerator) -> Unit): ByteArray { ++ val outputStream = ByteArrayOutputStream() ++ Json.factory.createGenerator(outputStream).use { it.writeDictionary(callback) } ++ return outputStream.toByteArray() ++ } ++ ++ object RepositoryAdapter { ++ internal fun putWithoutNotification(repository: Repository, shouldReplace: Boolean): Long { ++ return db.insertOrReplace( ++ shouldReplace, ++ Schema.Repository.name, ++ ContentValues().apply { ++ if (shouldReplace) { ++ put(Schema.Repository.ROW_ID, repository.id) ++ } ++ put(Schema.Repository.ROW_ENABLED, if (repository.enabled) 1 else 0) ++ put(Schema.Repository.ROW_DELETED, 0) ++ put(Schema.Repository.ROW_DATA, jsonGenerate(repository::serialize)) ++ } ++ ) ++ } ++ ++ fun put(repository: Repository): Repository { ++ val shouldReplace = repository.id >= 0L ++ val newId = putWithoutNotification(repository, shouldReplace) ++ val id = if (shouldReplace) repository.id else newId ++ notifyChanged(Subject.Repositories, Subject.Repository(id), Subject.Products) ++ return if (newId != repository.id) repository.copy(id = newId) else repository ++ } ++ ++ fun removeDuplicates() { ++ db.transaction { ++ val all = getAll() ++ val different = all.distinctBy { it.address } ++ val duplicates = all - different.toSet() ++ duplicates.forEach { ++ markAsDeleted(it.id) ++ } ++ } ++ } ++ ++ fun getStream(id: Long): Flow = flowOf(Unit) ++ .onCompletion { if (it == null) emitAll(flowCollection(Subject.Repositories)) } ++ .map { get(id) } ++ .flowOn(Dispatchers.IO) ++ ++ fun get(id: Long): Repository? { ++ return db.query( ++ Schema.Repository.name, ++ selection = Pair( ++ "${Schema.Repository.ROW_ID} = ? AND ${Schema.Repository.ROW_DELETED} == 0", ++ arrayOf(id.toString()) ++ ) ++ ).use { it.firstOrNull()?.let(::transform) } ++ } ++ ++ fun getAllStream(): Flow> = flowOf(Unit) ++ .onCompletion { if (it == null) emitAll(flowCollection(Subject.Repositories)) } ++ .map { getAll() } ++ .flowOn(Dispatchers.IO) ++ ++ fun getEnabledStream(): Flow> = flowOf(Unit) ++ .onCompletion { if (it == null) emitAll(flowCollection(Subject.Repositories)) } ++ .map { getEnabled() } ++ .flowOn(Dispatchers.IO) ++ ++ private suspend fun getEnabled(): List = withContext(Dispatchers.IO) { ++ db.query( ++ Schema.Repository.name, ++ selection = Pair( ++ "${Schema.Repository.ROW_ENABLED} != 0 AND " + ++ "${Schema.Repository.ROW_DELETED} == 0", ++ emptyArray() ++ ), ++ signal = null ++ ).use { it.asSequence().map(::transform).toList() } ++ } ++ ++ fun getAll(): List { ++ return db.query( ++ Schema.Repository.name, ++ selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()), ++ signal = null ++ ).use { it.asSequence().map(::transform).toList() } ++ } ++ ++ fun getAllRemovedStream(): Flow> = flowOf(Unit) ++ .onCompletion { if (it == null) emitAll(flowCollection(Subject.Repositories)) } ++ .map { getAllDisabledDeleted() } ++ .flowOn(Dispatchers.IO) ++ ++ private fun getAllDisabledDeleted(): Map { ++ return db.query( ++ Schema.Repository.name, ++ columns = arrayOf(Schema.Repository.ROW_ID, Schema.Repository.ROW_DELETED), ++ selection = Pair( ++ "${Schema.Repository.ROW_ENABLED} == 0 OR " + ++ "${Schema.Repository.ROW_DELETED} != 0", ++ emptyArray() ++ ), ++ signal = null ++ ).use { parentCursor -> ++ parentCursor.asSequence().associate { ++ val idIndex = it.getColumnIndexOrThrow(Schema.Repository.ROW_ID) ++ val isDeletedIndex = it.getColumnIndexOrThrow(Schema.Repository.ROW_DELETED) ++ it.getLong(idIndex) to (it.getInt(isDeletedIndex) != 0) ++ } ++ } ++ } ++ ++ fun markAsDeleted(id: Long) { ++ db.update( ++ Schema.Repository.name, ++ ContentValues().apply { ++ put(Schema.Repository.ROW_DELETED, 1) ++ }, ++ "${Schema.Repository.ROW_ID} = ?", ++ arrayOf(id.toString()) ++ ) ++ notifyChanged(Subject.Repositories, Subject.Repository(id), Subject.Products) ++ } ++ ++ fun cleanup(removedRepos: Map) { ++ val result = removedRepos.map { (id, isDeleted) -> ++ val idsString = id.toString() ++ val productsCount = db.delete( ++ Schema.Product.name, ++ "${Schema.Product.ROW_REPOSITORY_ID} IN ($idsString)", ++ null ++ ) ++ val categoriesCount = db.delete( ++ Schema.Category.name, ++ "${Schema.Category.ROW_REPOSITORY_ID} IN ($idsString)", ++ null ++ ) ++ if (isDeleted) { ++ db.delete( ++ Schema.Repository.name, ++ "${Schema.Repository.ROW_ID} IN ($id)", ++ null ++ ) ++ } ++ productsCount != 0 || categoriesCount != 0 ++ } ++ if (result.any { it }) { ++ notifyChanged(Subject.Products) ++ } ++ } ++ ++ fun importRepos(list: List) { ++ db.transaction { ++ val currentAddresses = getAll().map { it.address } ++ val newRepos = list ++ .filter { it.address !in currentAddresses } ++ newRepos.forEach { put(it) } ++ removeDuplicates() ++ } ++ } ++ ++ fun query(signal: CancellationSignal?): Cursor { ++ return db.query( ++ Schema.Repository.name, ++ selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()), ++ orderBy = "${Schema.Repository.ROW_ENABLED} DESC", ++ signal = signal ++ ).observable(Subject.Repositories) ++ } ++ ++ fun transform(cursor: Cursor): Repository { ++ return cursor.getBlob(cursor.getColumnIndexOrThrow(Schema.Repository.ROW_DATA)) ++ .jsonParse { ++ it.repository().apply { ++ this.id = ++ cursor.getLong(cursor.getColumnIndexOrThrow(Schema.Repository.ROW_ID)) ++ } ++ } ++ } ++ } ++ ++ object ProductAdapter { ++ ++ fun getStream(packageName: String): Flow> = flowOf(Unit) ++ .onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) } ++ .map { get(packageName, null) } ++ .flowOn(Dispatchers.IO) ++ ++ suspend fun getUpdates(): List = withContext(Dispatchers.IO) { ++ query( ++ installed = true, ++ updates = true, ++ searchQuery = "", ++ section = ProductItem.Section.All, ++ order = SortOrder.NAME, ++ signal = null ++ ).use { ++ it.asSequence() ++ .map(ProductAdapter::transformItem) ++ .toList() ++ } ++ } ++ ++ fun getUpdatesStream(): Flow> = flowOf(Unit) ++ .onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) } ++ // Crashes due to immediate retrieval of data? ++ .onEach { delay(50) } ++ .map { getUpdates() } ++ .flowOn(Dispatchers.IO) ++ ++ fun get(packageName: String, signal: CancellationSignal?): List { ++ return db.query( ++ Schema.Product.name, ++ columns = arrayOf( ++ Schema.Product.ROW_REPOSITORY_ID, ++ Schema.Product.ROW_DESCRIPTION, ++ Schema.Product.ROW_DATA ++ ), ++ selection = Pair("${Schema.Product.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)), ++ signal = signal ++ ).use { it.asSequence().map(::transform).toList() } ++ } ++ ++ fun getCountStream(repositoryId: Long): Flow = flowOf(Unit) ++ .onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) } ++ .map { getCount(repositoryId) } ++ .flowOn(Dispatchers.IO) ++ ++ private fun getCount(repositoryId: Long): Int { ++ return db.query( ++ Schema.Product.name, ++ columns = arrayOf("COUNT (*)"), ++ selection = Pair( ++ "${Schema.Product.ROW_REPOSITORY_ID} = ?", ++ arrayOf(repositoryId.toString()) ++ ) ++ ).use { it.firstOrNull()?.getInt(0) ?: 0 } ++ } ++ ++ fun query( ++ installed: Boolean, ++ updates: Boolean, ++ searchQuery: String, ++ section: ProductItem.Section, ++ order: SortOrder, ++ signal: CancellationSignal? ++ ): Cursor { ++ val builder = QueryBuilder() ++ ++ val signatureMatches = """installed.${Schema.Installed.ROW_SIGNATURE} IS NOT NULL AND ++ product.${Schema.Product.ROW_SIGNATURES} LIKE ('%.' || installed.${Schema.Installed.ROW_SIGNATURE} || '.%') AND ++ product.${Schema.Product.ROW_SIGNATURES} != ''""" ++ ++ builder += """SELECT product.rowid AS _id, product.${Schema.Product.ROW_REPOSITORY_ID}, ++ product.${Schema.Product.ROW_PACKAGE_NAME}, product.${Schema.Product.ROW_NAME}, ++ product.${Schema.Product.ROW_SUMMARY}, installed.${Schema.Installed.ROW_VERSION}, ++ (COALESCE(lock.${Schema.Lock.ROW_VERSION_CODE}, -1) NOT IN (0, product.${Schema.Product.ROW_VERSION_CODE}) AND ++ product.${Schema.Product.ROW_COMPATIBLE} != 0 AND product.${Schema.Product.ROW_VERSION_CODE} > ++ COALESCE(installed.${Schema.Installed.ROW_VERSION_CODE}, 0xffffffff) AND $signatureMatches) ++ AS ${Schema.Synthetic.ROW_CAN_UPDATE}, product.${Schema.Product.ROW_COMPATIBLE}, ++ product.${Schema.Product.ROW_DATA_ITEM},""" ++ ++ if (searchQuery.isNotEmpty()) { ++ builder += """(((product.${Schema.Product.ROW_NAME} LIKE ? OR ++ product.${Schema.Product.ROW_SUMMARY} LIKE ?) * 7) | ++ ((product.${Schema.Product.ROW_PACKAGE_NAME} LIKE ?) * 3) | ++ (product.${Schema.Product.ROW_DESCRIPTION} LIKE ?)) AS ${Schema.Synthetic.ROW_MATCH_RANK},""" ++ builder %= List(4) { "%$searchQuery%" } ++ } else { ++ builder += "0 AS ${Schema.Synthetic.ROW_MATCH_RANK}," ++ } ++ ++ builder += """MAX((product.${Schema.Product.ROW_COMPATIBLE} AND ++ (installed.${Schema.Installed.ROW_SIGNATURE} IS NULL OR $signatureMatches)) || ++ PRINTF('%016X', product.${Schema.Product.ROW_VERSION_CODE})) FROM ${Schema.Product.name} AS product""" ++ builder += """JOIN ${Schema.Repository.name} AS repository ++ ON product.${Schema.Product.ROW_REPOSITORY_ID} = repository.${Schema.Repository.ROW_ID}""" ++ builder += """LEFT JOIN ${Schema.Lock.name} AS lock ++ ON product.${Schema.Product.ROW_PACKAGE_NAME} = lock.${Schema.Lock.ROW_PACKAGE_NAME}""" ++ ++ if (!installed && !updates) { ++ builder += "LEFT" ++ } ++ builder += """JOIN ${Schema.Installed.name} AS installed ++ ON product.${Schema.Product.ROW_PACKAGE_NAME} = installed.${Schema.Installed.ROW_PACKAGE_NAME}""" ++ ++ if (section is ProductItem.Section.Category) { ++ builder += """JOIN ${Schema.Category.name} AS category ++ ON product.${Schema.Product.ROW_PACKAGE_NAME} = category.${Schema.Product.ROW_PACKAGE_NAME}""" ++ } ++ ++ builder += """WHERE repository.${Schema.Repository.ROW_ENABLED} != 0 AND ++ repository.${Schema.Repository.ROW_DELETED} == 0""" ++ ++ if (section is ProductItem.Section.Category) { ++ builder += "AND category.${Schema.Category.ROW_NAME} = ?" ++ builder %= section.name ++ } else if (section is ProductItem.Section.Repository) { ++ builder += "AND product.${Schema.Product.ROW_REPOSITORY_ID} = ?" ++ builder %= section.id.toString() ++ } ++ ++ if (searchQuery.isNotEmpty()) { ++ builder += """AND ${Schema.Synthetic.ROW_MATCH_RANK} > 0""" ++ } ++ ++ builder += "GROUP BY product.${Schema.Product.ROW_PACKAGE_NAME} HAVING 1" ++ ++ if (updates) { ++ builder += "AND ${Schema.Synthetic.ROW_CAN_UPDATE}" ++ } ++ builder += "ORDER BY" ++ ++ if (searchQuery.isNotEmpty()) { ++ builder += """${Schema.Synthetic.ROW_MATCH_RANK} DESC,""" ++ } ++ ++ when (order) { ++ SortOrder.UPDATED -> builder += "product.${Schema.Product.ROW_UPDATED} DESC," ++ SortOrder.ADDED -> builder += "product.${Schema.Product.ROW_ADDED} DESC," ++ SortOrder.NAME -> Unit ++ }::class ++ builder += "product.${Schema.Product.ROW_NAME} COLLATE LOCALIZED ASC" ++ ++ return builder.query(db, signal).observable(Subject.Products) ++ } ++ ++ private fun transform(cursor: Cursor): Product { ++ return cursor.getBlob(cursor.getColumnIndexOrThrow(Schema.Product.ROW_DATA)) ++ .jsonParse { ++ it.product().apply { ++ this.repositoryId = cursor ++ .getLong(cursor.getColumnIndexOrThrow(Schema.Product.ROW_REPOSITORY_ID)) ++ this.description = cursor ++ .getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_DESCRIPTION)) ++ } ++ } ++ } ++ ++ fun transformItem(cursor: Cursor): ProductItem { ++ return cursor.getBlob(cursor.getColumnIndexOrThrow(Schema.Product.ROW_DATA_ITEM)) ++ .jsonParse { ++ it.productItem().apply { ++ this.repositoryId = cursor ++ .getLong(cursor.getColumnIndexOrThrow(Schema.Product.ROW_REPOSITORY_ID)) ++ this.packageName = cursor ++ .getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_PACKAGE_NAME)) ++ this.name = cursor ++ .getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_NAME)) ++ this.summary = cursor ++ .getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_SUMMARY)) ++ this.installedVersion = cursor ++ .getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION)) ++ .orEmpty() ++ this.compatible = cursor ++ .getInt(cursor.getColumnIndexOrThrow(Schema.Product.ROW_COMPATIBLE)) != 0 ++ this.canUpdate = cursor ++ .getInt(cursor.getColumnIndexOrThrow(Schema.Synthetic.ROW_CAN_UPDATE)) != 0 ++ this.matchRank = cursor ++ .getInt(cursor.getColumnIndexOrThrow(Schema.Synthetic.ROW_MATCH_RANK)) ++ } ++ } ++ } ++ } ++ ++ object CategoryAdapter { ++ ++ fun getAllStream(): Flow> = flowOf(Unit) ++ .onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) } ++ .map { getAll() } ++ .flowOn(Dispatchers.IO) ++ ++ private suspend fun getAll(): Set = withContext(Dispatchers.IO) { ++ val builder = QueryBuilder() ++ ++ builder += """SELECT DISTINCT category.${Schema.Category.ROW_NAME} ++ FROM ${Schema.Category.name} AS category ++ JOIN ${Schema.Repository.name} AS repository ++ ON category.${Schema.Category.ROW_REPOSITORY_ID} = repository.${Schema.Repository.ROW_ID} ++ WHERE repository.${Schema.Repository.ROW_ENABLED} != 0 AND ++ repository.${Schema.Repository.ROW_DELETED} == 0""" ++ ++ builder.query(db, null).use { cursor -> ++ cursor.asSequence().map { ++ it.getString(it.getColumnIndexOrThrow(Schema.Category.ROW_NAME)) ++ }.toSet() ++ } ++ } ++ } ++ ++ object InstalledAdapter { ++ ++ fun getStream(packageName: String): Flow = flowOf(Unit) ++ .onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) } ++ .map { get(packageName, null) } ++ .flowOn(Dispatchers.IO) ++ ++ fun get(packageName: String, signal: CancellationSignal?): InstalledItem? { ++ return db.query( ++ Schema.Installed.name, ++ columns = arrayOf( ++ Schema.Installed.ROW_PACKAGE_NAME, ++ Schema.Installed.ROW_VERSION, ++ Schema.Installed.ROW_VERSION_CODE, ++ Schema.Installed.ROW_SIGNATURE ++ ), ++ selection = Pair("${Schema.Installed.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)), ++ signal = signal ++ ).use { it.firstOrNull()?.let(::transform) } ++ } ++ ++ private fun put(installedItem: InstalledItem, notify: Boolean) { ++ db.insertOrReplace( ++ true, ++ Schema.Installed.name, ++ ContentValues().apply { ++ put(Schema.Installed.ROW_PACKAGE_NAME, installedItem.packageName) ++ put(Schema.Installed.ROW_VERSION, installedItem.version) ++ put(Schema.Installed.ROW_VERSION_CODE, installedItem.versionCode) ++ put(Schema.Installed.ROW_SIGNATURE, installedItem.signature) ++ } ++ ) ++ if (notify) { ++ notifyChanged(Subject.Products) ++ } ++ } ++ ++ fun put(installedItem: InstalledItem) = put(installedItem, true) ++ ++ fun putAll(installedItems: List) { ++ db.transaction { ++ db.delete(Schema.Installed.name, null, null) ++ installedItems.forEach { put(it, false) } ++ } ++ } ++ ++ fun delete(packageName: String) { ++ val count = db.delete( ++ Schema.Installed.name, ++ "${Schema.Installed.ROW_PACKAGE_NAME} = ?", ++ arrayOf(packageName) ++ ) ++ if (count > 0) { ++ notifyChanged(Subject.Products) ++ } ++ } ++ ++ private fun transform(cursor: Cursor): InstalledItem { ++ return InstalledItem( ++ cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_PACKAGE_NAME)), ++ cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION)), ++ cursor.getLong(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION_CODE)), ++ cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_SIGNATURE)) ++ ) ++ } ++ } ++ ++ object LockAdapter { ++ private fun put(lock: Pair, notify: Boolean) { ++ db.insertOrReplace( ++ true, ++ Schema.Lock.name, ++ ContentValues().apply { ++ put(Schema.Lock.ROW_PACKAGE_NAME, lock.first) ++ put(Schema.Lock.ROW_VERSION_CODE, lock.second) ++ } ++ ) ++ if (notify) { ++ notifyChanged(Subject.Products) ++ } ++ } ++ ++ fun put(lock: Pair) = put(lock, true) ++ ++ fun putAll(locks: List>) { ++ db.transaction { ++ db.delete(Schema.Lock.name, null, null) ++ locks.forEach { put(it, false) } ++ } ++ } ++ ++ fun delete(packageName: String) { ++ db.delete(Schema.Lock.name, "${Schema.Lock.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)) ++ notifyChanged(Subject.Products) ++ } ++ } ++ ++ object UpdaterAdapter { ++ private val Table.temporaryName: String ++ get() = "${name}_temporary" ++ ++ fun createTemporaryTable() { ++ db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}") ++ db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}") ++ db.execSQL(Schema.Product.formatCreateTable(Schema.Product.temporaryName)) ++ db.execSQL(Schema.Category.formatCreateTable(Schema.Category.temporaryName)) ++ } ++ ++ fun putTemporary(products: List) { ++ db.transaction { ++ for (product in products) { ++ // Format signatures like ".signature1.signature2." for easier select ++ val signatures = product.signatures.joinToString { ".$it" } ++ .let { if (it.isNotEmpty()) "$it." else "" } ++ db.insertOrReplace( ++ true, ++ Schema.Product.temporaryName, ++ ContentValues().apply { ++ put(Schema.Product.ROW_REPOSITORY_ID, product.repositoryId) ++ put(Schema.Product.ROW_PACKAGE_NAME, product.packageName) ++ put(Schema.Product.ROW_NAME, product.name) ++ put(Schema.Product.ROW_SUMMARY, product.summary) ++ put(Schema.Product.ROW_DESCRIPTION, product.description) ++ put(Schema.Product.ROW_ADDED, product.added) ++ put(Schema.Product.ROW_UPDATED, product.updated) ++ put(Schema.Product.ROW_VERSION_CODE, product.versionCode) ++ put(Schema.Product.ROW_SIGNATURES, signatures) ++ put(Schema.Product.ROW_COMPATIBLE, if (product.compatible) 1 else 0) ++ put(Schema.Product.ROW_DATA, jsonGenerate(product::serialize)) ++ put( ++ Schema.Product.ROW_DATA_ITEM, ++ jsonGenerate(product.item()::serialize) ++ ) ++ } ++ ) ++ for (category in product.categories) { ++ db.insertOrReplace( ++ true, ++ Schema.Category.temporaryName, ++ ContentValues().apply { ++ put(Schema.Category.ROW_REPOSITORY_ID, product.repositoryId) ++ put(Schema.Category.ROW_PACKAGE_NAME, product.packageName) ++ put(Schema.Category.ROW_NAME, category) ++ } ++ ) ++ } ++ } ++ } ++ } ++ ++ fun finishTemporary(repository: Repository, success: Boolean) { ++ if (success) { ++ db.transaction { ++ db.delete( ++ Schema.Product.name, ++ "${Schema.Product.ROW_REPOSITORY_ID} = ?", ++ arrayOf(repository.id.toString()) ++ ) ++ db.delete( ++ Schema.Category.name, ++ "${Schema.Category.ROW_REPOSITORY_ID} = ?", ++ arrayOf(repository.id.toString()) ++ ) ++ db.execSQL( ++ "INSERT INTO ${Schema.Product.name} SELECT * " + ++ "FROM ${Schema.Product.temporaryName}" ++ ) ++ db.execSQL( ++ "INSERT INTO ${Schema.Category.name} SELECT * " + ++ "FROM ${Schema.Category.temporaryName}" ++ ) ++ RepositoryAdapter.putWithoutNotification(repository, true) ++ db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}") ++ db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}") ++ } ++ notifyChanged( ++ Subject.Repositories, ++ Subject.Repository(repository.id), ++ Subject.Products ++ ) ++ } else { ++ db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}") ++ db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}") ++ } ++ } ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/database/ObservableCursor.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/database/ObservableCursor.kt b/app/src/main/kotlin/com/leos/droidify/database/ObservableCursor.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/database/ObservableCursor.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,64 @@ ++package com.leos.droidify.database ++ ++import android.database.ContentObservable ++import android.database.ContentObserver ++import android.database.Cursor ++import android.database.CursorWrapper ++ ++class ObservableCursor( ++ cursor: Cursor, ++ private val observable: ( ++ register: Boolean, ++ observer: () -> Unit ++ ) -> Unit ++) : CursorWrapper(cursor) { ++ private var registered = false ++ private val contentObservable = ContentObservable() ++ ++ private val onChange: () -> Unit = { ++ contentObservable.dispatchChange(false, null) ++ } ++ ++ init { ++ observable(true, onChange) ++ registered = true ++ } ++ ++ override fun registerContentObserver(observer: ContentObserver) { ++ super.registerContentObserver(observer) ++ contentObservable.registerObserver(observer) ++ } ++ ++ override fun unregisterContentObserver(observer: ContentObserver) { ++ super.unregisterContentObserver(observer) ++ contentObservable.unregisterObserver(observer) ++ } ++ ++ @Deprecated("Deprecated in Java") ++ @Suppress("DEPRECATION") ++ override fun requery(): Boolean { ++ if (!registered) { ++ observable(true, onChange) ++ registered = true ++ } ++ return super.requery() ++ } ++ ++ @Deprecated("Deprecated in Java") ++ @Suppress("DEPRECATION") ++ override fun deactivate() { ++ super.deactivate() ++ deactivateOrClose() ++ } ++ ++ override fun close() { ++ super.close() ++ contentObservable.unregisterAll() ++ deactivateOrClose() ++ } ++ ++ private fun deactivateOrClose() { ++ observable(false, onChange) ++ registered = false ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/database/QueryBuilder.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/database/QueryBuilder.kt b/app/src/main/kotlin/com/leos/droidify/database/QueryBuilder.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/database/QueryBuilder.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,50 @@ ++package com.leos.droidify.database ++ ++import android.database.Cursor ++import android.database.sqlite.SQLiteDatabase ++import android.os.CancellationSignal ++import com.leos.core.common.extension.asSequence ++import com.leos.core.common.log ++import com.leos.droidify.BuildConfig ++ ++class QueryBuilder { ++ companion object { ++ fun trimQuery(query: String): String { ++ return query.lines().map { it.trim() }.filter { it.isNotEmpty() } ++ .joinToString(separator = " ") ++ } ++ } ++ ++ private val builder = StringBuilder() ++ private val arguments = mutableListOf() ++ ++ operator fun plusAssign(query: String) { ++ if (builder.isNotEmpty()) { ++ builder.append(" ") ++ } ++ builder.append(trimQuery(query)) ++ } ++ ++ operator fun remAssign(argument: String) { ++ this.arguments += argument ++ } ++ ++ operator fun remAssign(arguments: List) { ++ this.arguments += arguments ++ } ++ ++ fun query(db: SQLiteDatabase, signal: CancellationSignal?): Cursor { ++ val query = builder.toString() ++ val arguments = arguments.toTypedArray() ++ if (BuildConfig.DEBUG) { ++ synchronized(QueryBuilder::class.java) { ++ log(query) ++ db.rawQuery("EXPLAIN QUERY PLAN $query", arguments).use { ++ it.asSequence() ++ .forEach { log(":: ${it.getString(it.getColumnIndex("detail"))}") } ++ } ++ } ++ } ++ return db.rawQuery(query, arguments, signal) ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/database/QueryLoader.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/database/QueryLoader.kt b/app/src/main/kotlin/com/leos/droidify/database/QueryLoader.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/database/QueryLoader.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,94 @@ ++package com.leos.droidify.database ++ ++import android.content.Context ++import android.database.Cursor ++import android.os.CancellationSignal ++import android.os.OperationCanceledException ++import androidx.loader.content.AsyncTaskLoader ++ ++class QueryLoader(context: Context, private val query: (CancellationSignal) -> Cursor?) : ++ AsyncTaskLoader(context) { ++ private val observer = ForceLoadContentObserver() ++ private var cancellationSignal: CancellationSignal? = null ++ private var cursor: Cursor? = null ++ ++ override fun loadInBackground(): Cursor? { ++ val cancellationSignal = synchronized(this) { ++ if (isLoadInBackgroundCanceled) { ++ throw OperationCanceledException() ++ } ++ val cancellationSignal = CancellationSignal() ++ this.cancellationSignal = cancellationSignal ++ cancellationSignal ++ } ++ try { ++ val cursor = query(cancellationSignal) ++ if (cursor != null) { ++ try { ++ cursor.count // Ensure the cursor window is filled ++ cursor.registerContentObserver(observer) ++ } catch (e: Exception) { ++ cursor.close() ++ throw e ++ } ++ } ++ return cursor ++ } finally { ++ synchronized(this) { ++ this.cancellationSignal = null ++ } ++ } ++ } ++ ++ override fun cancelLoadInBackground() { ++ super.cancelLoadInBackground() ++ ++ synchronized(this) { ++ cancellationSignal?.cancel() ++ } ++ } ++ ++ override fun deliverResult(data: Cursor?) { ++ if (isReset) { ++ data?.close() ++ } else { ++ val oldCursor = cursor ++ cursor = data ++ if (isStarted) { ++ super.deliverResult(data) ++ } ++ if (oldCursor != data) { ++ oldCursor.closeIfNeeded() ++ } ++ } ++ } ++ ++ override fun onStartLoading() { ++ cursor?.let(this::deliverResult) ++ if (takeContentChanged() || cursor == null) { ++ forceLoad() ++ } ++ } ++ ++ override fun onStopLoading() { ++ cancelLoad() ++ } ++ ++ override fun onCanceled(data: Cursor?) { ++ data.closeIfNeeded() ++ } ++ ++ override fun onReset() { ++ super.onReset() ++ ++ stopLoading() ++ cursor.closeIfNeeded() ++ cursor = null ++ } ++ ++ private fun Cursor?.closeIfNeeded() { ++ if (this != null && !isClosed) { ++ close() ++ } ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/database/RepositoryExporter.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/database/RepositoryExporter.kt b/app/src/main/kotlin/com/leos/droidify/database/RepositoryExporter.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/database/RepositoryExporter.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,73 @@ ++package com.leos.droidify.database ++ ++import android.content.Context ++import android.net.Uri ++import com.fasterxml.jackson.core.JsonToken ++import com.leos.core.common.Exporter ++import com.leos.core.common.extension.Json ++import com.leos.core.common.extension.forEach ++import com.leos.core.common.extension.forEachKey ++import com.leos.core.common.extension.parseDictionary ++import com.leos.core.common.extension.writeArray ++import com.leos.core.common.extension.writeDictionary ++import com.leos.core.di.ApplicationScope ++import com.leos.core.di.IoDispatcher ++import com.leos.core.domain.Repository ++import com.leos.droidify.utility.serialization.repository ++import com.leos.droidify.utility.serialization.serialize ++import dagger.hilt.android.qualifiers.ApplicationContext ++import javax.inject.Inject ++import javax.inject.Singleton ++import kotlinx.coroutines.CoroutineDispatcher ++import kotlinx.coroutines.CoroutineScope ++import kotlinx.coroutines.launch ++import kotlinx.coroutines.withContext ++ ++@Singleton ++class RepositoryExporter @Inject constructor( ++ @ApplicationContext private val context: Context, ++ @ApplicationScope private val scope: CoroutineScope, ++ @IoDispatcher private val ioDispatcher: CoroutineDispatcher ++) : Exporter> { ++ override suspend fun export(item: List, target: Uri) { ++ scope.launch(ioDispatcher) { ++ val stream = context.contentResolver.openOutputStream(target) ++ Json.factory.createGenerator(stream).use { generator -> ++ generator.writeDictionary { ++ writeArray("repositories") { ++ item.map { ++ it.copy( ++ id = -1, ++ mirrors = if (it.enabled) it.mirrors else emptyList(), ++ lastModified = "", ++ entityTag = "" ++ ) ++ }.forEach { repo -> ++ writeDictionary { ++ repo.serialize(this) ++ } ++ } ++ } ++ } ++ } ++ } ++ } ++ ++ override suspend fun import(target: Uri): List = withContext(ioDispatcher) { ++ val list = mutableListOf() ++ val stream = context.contentResolver.openInputStream(target) ++ Json.factory.createParser(stream).use { generator -> ++ generator?.parseDictionary { ++ forEachKey { ++ if (it.array("repositories")) { ++ forEach(JsonToken.START_OBJECT) { ++ val repo = repository() ++ list.add(repo) ++ } ++ } ++ } ++ } ++ } ++ list ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/graphics/DrawableWrapper.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/graphics/DrawableWrapper.kt b/app/src/main/kotlin/com/leos/droidify/graphics/DrawableWrapper.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/graphics/DrawableWrapper.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,57 @@ ++package com.leos.droidify.graphics ++ ++import android.graphics.Canvas ++import android.graphics.ColorFilter ++import android.graphics.Rect ++import android.graphics.drawable.Drawable ++ ++open class DrawableWrapper(val drawable: Drawable) : Drawable() { ++ init { ++ drawable.callback = object : Callback { ++ override fun invalidateDrawable(who: Drawable) { ++ callback?.invalidateDrawable(who) ++ } ++ ++ override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) { ++ callback?.scheduleDrawable(who, what, `when`) ++ } ++ ++ override fun unscheduleDrawable(who: Drawable, what: Runnable) { ++ callback?.unscheduleDrawable(who, what) ++ } ++ } ++ } ++ ++ override fun onBoundsChange(bounds: Rect) { ++ drawable.bounds = bounds ++ } ++ ++ override fun getIntrinsicWidth(): Int = drawable.intrinsicWidth ++ override fun getIntrinsicHeight(): Int = drawable.intrinsicHeight ++ override fun getMinimumWidth(): Int = drawable.minimumWidth ++ override fun getMinimumHeight(): Int = drawable.minimumHeight ++ ++ override fun draw(canvas: Canvas) { ++ drawable.draw(canvas) ++ } ++ ++ override fun getAlpha(): Int { ++ return drawable.alpha ++ } ++ ++ override fun setAlpha(alpha: Int) { ++ drawable.alpha = alpha ++ } ++ ++ override fun getColorFilter(): ColorFilter? { ++ return drawable.colorFilter ++ } ++ ++ override fun setColorFilter(colorFilter: ColorFilter?) { ++ drawable.colorFilter = colorFilter ++ } ++ ++ @Deprecated("Deprecated in Java") ++ @Suppress("DEPRECATION") ++ override fun getOpacity(): Int = drawable.opacity ++} +Index: app/src/main/kotlin/com/leos/droidify/graphics/PaddingDrawable.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/graphics/PaddingDrawable.kt b/app/src/main/kotlin/com/leos/droidify/graphics/PaddingDrawable.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/graphics/PaddingDrawable.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,30 @@ ++package com.leos.droidify.graphics ++ ++import android.graphics.Rect ++import android.graphics.drawable.Drawable ++import kotlin.math.roundToInt ++ ++class PaddingDrawable( ++ drawable: Drawable, ++ private val horizontalFactor: Float, ++ private val aspectRatio: Float = 16f / 9f ++) : DrawableWrapper(drawable) { ++ override fun getIntrinsicWidth(): Int = ++ (horizontalFactor * super.getIntrinsicWidth()).roundToInt() ++ ++ override fun getIntrinsicHeight(): Int = ++ ((horizontalFactor * aspectRatio) * super.getIntrinsicHeight()).roundToInt() ++ ++ override fun onBoundsChange(bounds: Rect) { ++ val width = (bounds.width() / horizontalFactor).roundToInt() ++ val height = (bounds.height() / (horizontalFactor * aspectRatio)).roundToInt() ++ val left = (bounds.width() - width) / 2 ++ val top = (bounds.height() - height) / 2 ++ drawable.setBounds( ++ bounds.left + left, ++ bounds.top + top, ++ bounds.left + left + width, ++ bounds.top + top + height ++ ) ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/index/IndexMerger.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/index/IndexMerger.kt b/app/src/main/kotlin/com/leos/droidify/index/IndexMerger.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/index/IndexMerger.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,115 @@ ++package com.leos.droidify.index ++ ++import android.content.ContentValues ++import android.database.sqlite.SQLiteDatabase ++import com.fasterxml.jackson.core.JsonToken ++import com.leos.core.common.extension.Json ++import com.leos.core.common.extension.asSequence ++import com.leos.core.common.extension.collectNotNull ++import com.leos.core.common.extension.execWithResult ++import com.leos.core.common.extension.writeDictionary ++import com.leos.core.domain.Product ++import com.leos.core.domain.Release ++import com.leos.droidify.utility.serialization.product ++import com.leos.droidify.utility.serialization.release ++import com.leos.droidify.utility.serialization.serialize ++import java.io.ByteArrayOutputStream ++import java.io.Closeable ++import java.io.File ++ ++class IndexMerger(file: File) : Closeable { ++ private val db = SQLiteDatabase.openOrCreateDatabase(file, null) ++ ++ init { ++ db.execWithResult("PRAGMA synchronous = OFF") ++ db.execWithResult("PRAGMA journal_mode = OFF") ++ db.execSQL( ++ "CREATE TABLE product (" + ++ "package_name TEXT PRIMARY KEY," + ++ "description TEXT NOT NULL, " + ++ "data BLOB NOT NULL)" ++ ) ++ db.execSQL("CREATE TABLE releases (package_name TEXT PRIMARY KEY, data BLOB NOT NULL)") ++ db.beginTransaction() ++ } ++ ++ fun addProducts(products: List) { ++ for (product in products) { ++ val outputStream = ByteArrayOutputStream() ++ Json.factory.createGenerator(outputStream) ++ .use { it.writeDictionary(product::serialize) } ++ db.insert( ++ "product", ++ null, ++ ContentValues().apply { ++ put("package_name", product.packageName) ++ put("description", product.description) ++ put("data", outputStream.toByteArray()) ++ } ++ ) ++ } ++ } ++ ++ fun addReleases(pairs: List>>) { ++ for (pair in pairs) { ++ val (packageName, releases) = pair ++ val outputStream = ByteArrayOutputStream() ++ Json.factory.createGenerator(outputStream).use { ++ it.writeStartArray() ++ for (release in releases) { ++ it.writeDictionary(release::serialize) ++ } ++ it.writeEndArray() ++ } ++ db.insert( ++ "releases", ++ null, ++ ContentValues().apply { ++ put("package_name", packageName) ++ put("data", outputStream.toByteArray()) ++ } ++ ) ++ } ++ } ++ ++ private fun closeTransaction() { ++ if (db.inTransaction()) { ++ db.setTransactionSuccessful() ++ db.endTransaction() ++ } ++ } ++ ++ fun forEach(repositoryId: Long, windowSize: Int, callback: (List, Int) -> Unit) { ++ closeTransaction() ++ db.rawQuery( ++ """SELECT product.description, product.data AS pd, releases.data AS rd FROM product ++ LEFT JOIN releases ON product.package_name = releases.package_name""", ++ null ++ )?.use { cursor -> ++ cursor.asSequence().map { currentCursor -> ++ val description = currentCursor.getString(0) ++ val product = Json.factory.createParser(currentCursor.getBlob(1)).use { ++ it.nextToken() ++ it.product().apply { ++ this.repositoryId = repositoryId ++ this.description = description ++ } ++ } ++ val releases = currentCursor.getBlob(2)?.let { bytes -> ++ Json.factory.createParser(bytes).use { ++ it.nextToken() ++ it.collectNotNull( ++ JsonToken.START_OBJECT ++ ) { release() } ++ } ++ }.orEmpty() ++ product.copy(releases = releases) ++ }.windowed(windowSize, windowSize, true) ++ .forEach { products -> callback(products, cursor.count) } ++ } ++ } ++ ++ override fun close() { ++ db.use { closeTransaction() } ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/index/IndexV1Parser.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/index/IndexV1Parser.kt b/app/src/main/kotlin/com/leos/droidify/index/IndexV1Parser.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/index/IndexV1Parser.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,489 @@ ++package com.leos.droidify.index ++ ++import android.content.res.Resources ++import androidx.core.os.ConfigurationCompat.getLocales ++import androidx.core.os.LocaleListCompat ++import com.fasterxml.jackson.core.JsonParser ++import com.fasterxml.jackson.core.JsonToken ++import com.leos.core.common.SdkCheck ++import com.leos.core.common.extension.Json ++import com.leos.core.common.extension.collectDistinctNotEmptyStrings ++import com.leos.core.common.extension.collectNotNull ++import com.leos.core.common.extension.forEach ++import com.leos.core.common.extension.forEachKey ++import com.leos.core.common.extension.illegal ++import com.leos.core.common.nullIfEmpty ++import com.leos.core.domain.Product ++import com.leos.core.domain.Release ++import java.io.InputStream ++ ++object IndexV1Parser { ++ interface Callback { ++ fun onRepository( ++ mirrors: List, ++ name: String, ++ description: String, ++ version: Int, ++ timestamp: Long ++ ) ++ ++ fun onProduct(product: Product) ++ fun onReleases(packageName: String, releases: List) ++ } ++ ++ private class Screenshots( ++ val phone: List, ++ val smallTablet: List, ++ val largeTablet: List ++ ) ++ ++ private class Localized( ++ val name: String, ++ val summary: String, ++ val description: String, ++ val whatsNew: String, ++ val metadataIcon: String, ++ val screenshots: Screenshots? ++ ) ++ ++ private fun Map.getAndCall( ++ key: String, ++ callback: (String, Localized) -> T? ++ ): T? { ++ return this[key]?.let { callback(key, it) } ++ } ++ ++ /** ++ * Gets the best localization for the given [localeList] ++ * from collections. ++ */ ++ private fun Map?.getBestLocale(localeList: LocaleListCompat): T? { ++ if (isNullOrEmpty()) return null ++ val firstMatch = localeList.getFirstMatch(keys.toTypedArray()) ?: return null ++ val tag = firstMatch.toLanguageTag() ++ // try first matched tag first (usually has region tag, e.g. de-DE) ++ return get(tag) ?: run { ++ // split away stuff like script and try language and region only ++ val langCountryTag = "${firstMatch.language}-${firstMatch.country}" ++ getOrStartsWith(langCountryTag) ?: run { ++ // split away region tag and try language only ++ val langTag = firstMatch.language ++ // try language, then English and then just take the first of the list ++ getOrStartsWith(langTag) ?: get("en-US") ?: get("en") ?: values.first() ++ } ++ } ++ } ++ ++ /** ++ * Returns the value from the map with the given key or if that key is not contained in the map, ++ * tries the first map key that starts with the given key. ++ * If nothing matches, null is returned. ++ * ++ * This is useful when looking for a language tag like `fr_CH` and falling back to `fr` ++ * in a map that has `fr_FR` as a key. ++ */ ++ private fun Map.getOrStartsWith(s: String): T? = get(s) ?: run { ++ entries.forEach { (key, value) -> ++ if (key.startsWith(s)) return value ++ } ++ return null ++ } ++ ++ private fun Map.find(callback: (String, Localized) -> T?): T? { ++ return getAndCall("en-US", callback) ?: getAndCall("en_US", callback) ?: getAndCall( ++ "en", ++ callback ++ ) ++ } ++ ++ private fun Map.findLocalized(callback: (Localized) -> T?): T? { ++ return getBestLocale(getLocales(Resources.getSystem().configuration))?.let { callback(it) } ++ } ++ ++ private fun Map.findString( ++ fallback: String, ++ callback: (Localized) -> String ++ ): String { ++ return (find { _, localized -> callback(localized).nullIfEmpty() } ?: fallback).trim() ++ } ++ ++ private fun Map.findLocalizedString( ++ fallback: String, ++ callback: (Localized) -> String ++ ): String { ++ // @BLumia: it's possible a key of a certain Localized object is empty, so we still need a fallback ++ return ( ++ findLocalized { localized -> callback(localized).trim().nullIfEmpty() } ?: findString( ++ fallback, ++ callback ++ ) ++ ).trim() ++ } ++ ++ internal object DonateComparator : Comparator { ++ private val classes = listOf( ++ Product.Donate.Regular::class, ++ Product.Donate.Bitcoin::class, ++ Product.Donate.Litecoin::class, ++ Product.Donate.Flattr::class, ++ Product.Donate.Liberapay::class, ++ Product.Donate.OpenCollective::class ++ ) ++ ++ override fun compare(donate1: Product.Donate, donate2: Product.Donate): Int { ++ val index1 = classes.indexOf(donate1::class) ++ val index2 = classes.indexOf(donate2::class) ++ return when { ++ index1 >= 0 && index2 == -1 -> -1 ++ index2 >= 0 && index1 == -1 -> 1 ++ else -> index1.compareTo(index2) ++ } ++ } ++ } ++ ++ fun parse(repositoryId: Long, inputStream: InputStream, callback: Callback) { ++ val jsonParser = Json.factory.createParser(inputStream) ++ if (jsonParser.nextToken() != JsonToken.START_OBJECT) { ++ jsonParser.illegal() ++ } else { ++ jsonParser.forEachKey { it -> ++ when { ++ it.dictionary("repo") -> { ++ var address = "" ++ var mirrors = emptyList() ++ var name = "" ++ var description = "" ++ var version = 0 ++ var timestamp = 0L ++ forEachKey { ++ when { ++ it.string("address") -> address = valueAsString ++ it.array("mirrors") -> mirrors = collectDistinctNotEmptyStrings() ++ it.string("name") -> name = valueAsString ++ it.string("description") -> description = valueAsString ++ it.number("version") -> version = valueAsInt ++ it.number("timestamp") -> timestamp = valueAsLong ++ else -> skipChildren() ++ } ++ } ++ val realMirrors = ( ++ if (address.isNotEmpty()) { ++ listOf(address) ++ } else { ++ emptyList() ++ } ++ ) + mirrors ++ callback.onRepository( ++ mirrors = realMirrors.distinct(), ++ name = name, ++ description = description, ++ version = version, ++ timestamp = timestamp ++ ) ++ } ++ ++ it.array("apps") -> forEach(JsonToken.START_OBJECT) { ++ val product = parseProduct(repositoryId) ++ callback.onProduct(product) ++ } ++ ++ it.dictionary("packages") -> forEachKey { ++ if (it.token == JsonToken.START_ARRAY) { ++ val packageName = it.key ++ val releases = collectNotNull(JsonToken.START_OBJECT) { parseRelease() } ++ callback.onReleases(packageName, releases) ++ } else { ++ skipChildren() ++ } ++ } ++ ++ else -> skipChildren() ++ } ++ } ++ } ++ } ++ ++ private fun JsonParser.parseProduct(repositoryId: Long): Product { ++ var packageName = "" ++ var nameFallback = "" ++ var summaryFallback = "" ++ var descriptionFallback = "" ++ var icon = "" ++ var authorName = "" ++ var authorEmail = "" ++ var authorWeb = "" ++ var source = "" ++ var changelog = "" ++ var web = "" ++ var tracker = "" ++ var added = 0L ++ var updated = 0L ++ var suggestedVersionCode = 0L ++ var categories = emptyList() ++ var antiFeatures = emptyList() ++ val licenses = mutableListOf() ++ val donates = mutableListOf() ++ val localizedMap = mutableMapOf() ++ forEachKey { it -> ++ when { ++ it.string("packageName") -> packageName = valueAsString ++ it.string("name") -> nameFallback = valueAsString ++ it.string("summary") -> summaryFallback = valueAsString ++ it.string("description") -> descriptionFallback = valueAsString ++ it.string("icon") -> icon = validateIcon(valueAsString) ++ it.string("authorName") -> authorName = valueAsString ++ it.string("authorEmail") -> authorEmail = valueAsString ++ it.string("authorWebSite") -> authorWeb = valueAsString ++ it.string("sourceCode") -> source = valueAsString ++ it.string("changelog") -> changelog = valueAsString ++ it.string("webSite") -> web = valueAsString ++ it.string("issueTracker") -> tracker = valueAsString ++ it.number("added") -> added = valueAsLong ++ it.number("lastUpdated") -> updated = valueAsLong ++ it.string("suggestedVersionCode") -> ++ suggestedVersionCode = ++ valueAsString.toLongOrNull() ?: 0L ++ ++ it.array("categories") -> categories = collectDistinctNotEmptyStrings() ++ it.array("antiFeatures") -> antiFeatures = collectDistinctNotEmptyStrings() ++ it.string("license") -> licenses += valueAsString.split(',') ++ .filter { it.isNotEmpty() } ++ ++ it.string("donate") -> donates += Product.Donate.Regular(valueAsString) ++ it.string("bitcoin") -> donates += Product.Donate.Bitcoin(valueAsString) ++ it.string("flattrID") -> donates += Product.Donate.Flattr(valueAsString) ++ it.string("liberapayID") -> donates += Product.Donate.Liberapay(valueAsString) ++ it.string("openCollective") -> donates += Product.Donate.OpenCollective( ++ valueAsString ++ ) ++ ++ it.dictionary("localized") -> forEachKey { it -> ++ if (it.token == JsonToken.START_OBJECT) { ++ val locale = it.key ++ var name = "" ++ var summary = "" ++ var description = "" ++ var whatsNew = "" ++ var metadataIcon = "" ++ var phone = emptyList() ++ var smallTablet = emptyList() ++ var largeTablet = emptyList() ++ forEachKey { ++ when { ++ it.string("name") -> name = valueAsString ++ it.string("summary") -> summary = valueAsString ++ it.string("description") -> description = valueAsString ++ it.string("whatsNew") -> whatsNew = valueAsString ++ it.string("icon") -> metadataIcon = valueAsString ++ it.array("phoneScreenshots") -> ++ phone = ++ collectDistinctNotEmptyStrings() ++ ++ it.array("sevenInchScreenshots") -> ++ smallTablet = ++ collectDistinctNotEmptyStrings() ++ ++ it.array("tenInchScreenshots") -> ++ largeTablet = ++ collectDistinctNotEmptyStrings() ++ ++ else -> skipChildren() ++ } ++ } ++ val screenshots = ++ if (sequenceOf( ++ phone, ++ smallTablet, ++ largeTablet ++ ).any { it.isNotEmpty() } ++ ) { ++ Screenshots(phone, smallTablet, largeTablet) ++ } else { ++ null ++ } ++ localizedMap[locale] = Localized( ++ name, ++ summary, ++ description, ++ whatsNew, ++ metadataIcon.nullIfEmpty()?.let { "$locale/$it" }.orEmpty(), ++ screenshots ++ ) ++ } else { ++ skipChildren() ++ } ++ } ++ ++ else -> skipChildren() ++ } ++ } ++ val name = localizedMap.findLocalizedString(nameFallback) { it.name } ++ val summary = localizedMap.findLocalizedString(summaryFallback) { it.summary } ++ val description = ++ localizedMap.findLocalizedString(descriptionFallback) { it.description }.replace( ++ "\n", ++ "
" ++ ) ++ val whatsNew = localizedMap.findLocalizedString("") { it.whatsNew }.replace("\n", "
") ++ val metadataIcon = localizedMap.findLocalizedString("") { it.metadataIcon }.ifEmpty { ++ localizedMap.firstNotNullOfOrNull { it.value.metadataIcon }.orEmpty() ++ } ++ val screenshotPairs = ++ localizedMap.find { key, localized -> localized.screenshots?.let { Pair(key, it) } } ++ val screenshots = screenshotPairs ++ ?.let { (key, screenshots) -> ++ screenshots.phone.asSequence() ++ .map { Product.Screenshot(key, Product.Screenshot.Type.PHONE, it) } + ++ screenshots.smallTablet.asSequence() ++ .map { ++ Product.Screenshot( ++ key, ++ Product.Screenshot.Type.SMALL_TABLET, ++ it ++ ) ++ } + ++ screenshots.largeTablet.asSequence() ++ .map { ++ Product.Screenshot( ++ key, ++ Product.Screenshot.Type.LARGE_TABLET, ++ it ++ ) ++ } ++ } ++ .orEmpty().toList() ++ return Product( ++ repositoryId, ++ packageName, ++ name, ++ summary, ++ description, ++ whatsNew, ++ icon, ++ metadataIcon, ++ Product.Author(authorName, authorEmail, authorWeb), ++ source, ++ changelog, ++ web, ++ tracker, ++ added, ++ updated, ++ suggestedVersionCode, ++ categories, ++ antiFeatures, ++ licenses, ++ donates.sortedWith(DonateComparator), ++ screenshots, ++ emptyList() ++ ) ++ } ++ ++ private fun JsonParser.parseRelease(): Release { ++ var version = "" ++ var versionCode = 0L ++ var added = 0L ++ var size = 0L ++ var minSdkVersion = 0 ++ var targetSdkVersion = 0 ++ var maxSdkVersion = 0 ++ var source = "" ++ var release = "" ++ var hash = "" ++ var hashTypeCandidate = "" ++ var signature = "" ++ var obbMain = "" ++ var obbMainHash = "" ++ var obbPatch = "" ++ var obbPatchHash = "" ++ val permissions = linkedSetOf() ++ var features = emptyList() ++ var platforms = emptyList() ++ forEachKey { ++ when { ++ it.string("versionName") -> version = valueAsString ++ it.number("versionCode") -> versionCode = valueAsLong ++ it.number("added") -> added = valueAsLong ++ it.number("size") -> size = valueAsLong ++ it.number("minSdkVersion") -> minSdkVersion = valueAsInt ++ it.number("targetSdkVersion") -> targetSdkVersion = valueAsInt ++ it.number("maxSdkVersion") -> maxSdkVersion = valueAsInt ++ it.string("srcname") -> source = valueAsString ++ it.string("apkName") -> release = valueAsString ++ it.string("hash") -> hash = valueAsString ++ it.string("hashType") -> hashTypeCandidate = valueAsString ++ it.string("sig") -> signature = valueAsString ++ it.string("obbMainFile") -> obbMain = valueAsString ++ it.string("obbMainFileSha256") -> obbMainHash = valueAsString ++ it.string("obbPatchFile") -> obbPatch = valueAsString ++ it.string("obbPatchFileSha256") -> obbPatchHash = valueAsString ++ it.array("uses-permission") -> collectPermissions(permissions, 0) ++ it.array("uses-permission-sdk-23") -> collectPermissions(permissions, 23) ++ it.array("features") -> features = collectDistinctNotEmptyStrings() ++ it.array("nativecode") -> platforms = collectDistinctNotEmptyStrings() ++ else -> skipChildren() ++ } ++ } ++ val hashType = ++ if (hash.isNotEmpty() && hashTypeCandidate.isEmpty()) "sha256" else hashTypeCandidate ++ val obbMainHashType = if (obbMainHash.isNotEmpty()) "sha256" else "" ++ val obbPatchHashType = if (obbPatchHash.isNotEmpty()) "sha256" else "" ++ return Release( ++ false, ++ version, ++ versionCode, ++ added, ++ size, ++ minSdkVersion, ++ targetSdkVersion, ++ maxSdkVersion, ++ source, ++ release, ++ hash, ++ hashType, ++ signature, ++ obbMain, ++ obbMainHash, ++ obbMainHashType, ++ obbPatch, ++ obbPatchHash, ++ obbPatchHashType, ++ permissions.toList(), ++ features, ++ platforms, ++ emptyList() ++ ) ++ } ++ ++ private fun JsonParser.collectPermissions(permissions: LinkedHashSet, minSdk: Int) { ++ forEach(JsonToken.START_ARRAY) { ++ val firstToken = nextToken() ++ val permission = if (firstToken == JsonToken.VALUE_STRING) valueAsString else "" ++ if (firstToken != JsonToken.END_ARRAY) { ++ val secondToken = nextToken() ++ val maxSdk = if (secondToken == JsonToken.VALUE_NUMBER_INT) valueAsInt else 0 ++ if (permission.isNotEmpty() && ++ SdkCheck.sdk >= minSdk && ( ++ maxSdk <= 0 || ++ SdkCheck.sdk <= maxSdk ++ ) ++ ) { ++ permissions.add(permission) ++ } ++ if (secondToken != JsonToken.END_ARRAY) { ++ while (true) { ++ val token = nextToken() ++ if (token == JsonToken.END_ARRAY) { ++ break ++ } else if (token.isStructStart) { ++ skipChildren() ++ } ++ } ++ } ++ } ++ } ++ } ++ ++ private fun validateIcon(icon: String): String { ++ return if (icon.endsWith(".xml")) "" else icon ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/index/RepositoryUpdater.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/index/RepositoryUpdater.kt b/app/src/main/kotlin/com/leos/droidify/index/RepositoryUpdater.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/index/RepositoryUpdater.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,460 @@ ++package com.leos.droidify.index ++ ++import android.content.Context ++import android.net.Uri ++import com.leos.core.common.SdkCheck ++import com.leos.core.common.cache.Cache ++import com.leos.core.common.extension.fingerprint ++import com.leos.core.common.extension.toFormattedString ++import com.leos.core.common.result.Result ++import com.leos.core.domain.Product ++import com.leos.core.domain.Release ++import com.leos.core.domain.Repository ++import com.leos.droidify.database.Database ++import com.leos.droidify.utility.extension.android.Android ++import com.leos.droidify.utility.getProgress ++import com.leos.network.Downloader ++import com.leos.network.NetworkResponse ++import java.io.File ++import java.security.CodeSigner ++import java.security.cert.Certificate ++import java.util.jar.JarEntry ++import java.util.jar.JarFile ++import kotlinx.coroutines.* ++import kotlinx.coroutines.flow.drop ++import kotlinx.coroutines.flow.filter ++import kotlinx.coroutines.flow.map ++ ++object RepositoryUpdater { ++ enum class Stage { ++ DOWNLOAD, PROCESS, MERGE, COMMIT ++ } ++ ++ // TODO Add support for Index-V2 and also cleanup everything here ++ private enum class IndexType( ++ val jarName: String, ++ val contentName: String ++ ) { ++ INDEX_V1("index-v1.jar", "index-v1.json") ++ } ++ ++ enum class ErrorType { ++ NETWORK, HTTP, VALIDATION, PARSING ++ } ++ ++ class UpdateException : Exception { ++ val errorType: ErrorType ++ ++ constructor(errorType: ErrorType, message: String) : super(message) { ++ this.errorType = errorType ++ } ++ ++ constructor(errorType: ErrorType, message: String, cause: Exception) : super( ++ message, ++ cause ++ ) { ++ this.errorType = errorType ++ } ++ } ++ ++ private val updaterLock = Any() ++ private val cleanupLock = Any() ++ ++ private lateinit var downloader: Downloader ++ ++ fun init(scope: CoroutineScope, downloader: Downloader) { ++ this.downloader = downloader ++ scope.launch { ++ // No need of mutex because it is in same coroutine scope ++ var lastDisabled = emptyMap() ++ Database.RepositoryAdapter ++ .getAllRemovedStream() ++ .map { deletedRepos -> ++ deletedRepos ++ .filterNot { it.key in lastDisabled.keys } ++ .also { lastDisabled = deletedRepos } ++ } ++ // To not perform complete cleanup on startup ++ .drop(1) ++ .filter { it.isNotEmpty() } ++ .collect(Database.RepositoryAdapter::cleanup) ++ } ++ } ++ ++ fun await() { ++ synchronized(updaterLock) { } ++ } ++ ++ suspend fun update( ++ context: Context, ++ repository: Repository, ++ unstable: Boolean, ++ callback: (Stage, Long, Long?) -> Unit ++ ) = update( ++ context = context, ++ repository = repository, ++ unstable = unstable, ++ indexTypes = listOf(IndexType.INDEX_V1), ++ callback = callback ++ ) ++ ++ private suspend fun update( ++ context: Context, ++ repository: Repository, ++ unstable: Boolean, ++ indexTypes: List, ++ callback: (Stage, Long, Long?) -> Unit ++ ): Result = withContext(Dispatchers.IO) { ++ val indexType = indexTypes[0] ++ when (val request = downloadIndex(context, repository, indexType, callback)) { ++ is Result.Error -> { ++ val result = request.data ++ ?: return@withContext Result.Error(request.exception, false) ++ ++ val file = request.data?.file ++ ?: return@withContext Result.Error(request.exception, false) ++ file.delete() ++ if (result.statusCode == 404 && indexTypes.isNotEmpty()) { ++ update( ++ context = context, ++ repository = repository, ++ indexTypes = indexTypes.subList(1, indexTypes.size), ++ unstable = unstable, ++ callback = callback ++ ) ++ } else { ++ Result.Error( ++ UpdateException( ++ ErrorType.HTTP, ++ "Invalid response: HTTP ${result.statusCode}" ++ ) ++ ) ++ } ++ } ++ ++ is Result.Success -> { ++ if (request.data.isUnmodified) { ++ request.data.file.delete() ++ Result.Success(false) ++ } else { ++ try { ++ val isFileParsedSuccessfully = processFile( ++ context = context, ++ repository = repository, ++ indexType = indexType, ++ unstable = unstable, ++ file = request.data.file, ++ lastModified = request.data.lastModified, ++ entityTag = request.data.entityTag, ++ callback = callback ++ ) ++ Result.Success(isFileParsedSuccessfully) ++ } catch (e: UpdateException) { ++ Result.Error(e) ++ } ++ } ++ } ++ } ++ } ++ ++ private suspend fun downloadIndex( ++ context: Context, ++ repository: Repository, ++ indexType: IndexType, ++ callback: (Stage, Long, Long?) -> Unit ++ ): Result = withContext(Dispatchers.IO) { ++ val file = Cache.getTemporaryFile(context) ++ val result = downloader.downloadToFile( ++ url = Uri.parse(repository.address).buildUpon() ++ .appendPath(indexType.jarName).build().toString(), ++ target = file, ++ headers = { ++ ifModifiedSince(repository.lastModified) ++ etag(repository.entityTag) ++ authentication(repository.authentication) ++ } ++ ) { read, total -> ++ callback(Stage.DOWNLOAD, read.value, total.value) ++ } ++ ++ when (result) { ++ is NetworkResponse.Success -> { ++ Result.Success( ++ IndexFile( ++ isUnmodified = result.statusCode == 304, ++ lastModified = result.lastModified?.toFormattedString() ?: "", ++ entityTag = result.etag ?: "", ++ statusCode = result.statusCode, ++ file = file ++ ) ++ ) ++ } ++ ++ is NetworkResponse.Error -> { ++ file.delete() ++ when (result) { ++ is NetworkResponse.Error.Http -> { ++ val errorType = if (result.statusCode in 400..499) { ++ ErrorType.HTTP ++ } else { ++ ErrorType.NETWORK ++ } ++ ++ Result.Error( ++ UpdateException( ++ errorType = errorType, ++ message = "Failed with Status: ${result.statusCode}" ++ ) ++ ) ++ } ++ ++ is NetworkResponse.Error.ConnectionTimeout -> Result.Error(result.exception) ++ is NetworkResponse.Error.IO -> Result.Error(result.exception) ++ is NetworkResponse.Error.SocketTimeout -> Result.Error(result.exception) ++ is NetworkResponse.Error.Unknown -> Result.Error(result.exception) ++ // TODO: Add Validator ++ is NetworkResponse.Error.Validation -> Result.Error() ++ } ++ } ++ } ++ } ++ ++ private fun processFile( ++ context: Context, ++ repository: Repository, ++ indexType: IndexType, ++ unstable: Boolean, ++ file: File, ++ lastModified: String, ++ entityTag: String, ++ callback: (Stage, Long, Long?) -> Unit ++ ): Boolean { ++ var rollback = true ++ return synchronized(updaterLock) { ++ try { ++ val jarFile = JarFile(file, true) ++ val indexEntry = jarFile.getEntry(indexType.contentName) as JarEntry ++ val total = indexEntry.size ++ Database.UpdaterAdapter.createTemporaryTable() ++ val features = context.packageManager.systemAvailableFeatures ++ .asSequence().map { it.name }.toSet() + setOf("android.hardware.touchscreen") ++ ++ var changedRepository: Repository? = null ++ ++ val mergerFile = Cache.getTemporaryFile(context) ++ try { ++ val unmergedProducts = mutableListOf() ++ val unmergedReleases = mutableListOf>>() ++ IndexMerger(mergerFile).use { indexMerger -> ++ jarFile.getInputStream(indexEntry).getProgress { ++ callback(Stage.PROCESS, it, total) ++ }.use { entryStream -> ++ IndexV1Parser.parse( ++ repository.id, ++ entryStream, ++ object : IndexV1Parser.Callback { ++ override fun onRepository( ++ mirrors: List, ++ name: String, ++ description: String, ++ version: Int, ++ timestamp: Long ++ ) { ++ changedRepository = repository.update( ++ mirrors, ++ name, ++ description, ++ version, ++ lastModified, ++ entityTag, ++ timestamp ++ ) ++ } ++ ++ override fun onProduct(product: Product) { ++ if (Thread.interrupted()) { ++ throw InterruptedException() ++ } ++ unmergedProducts += product ++ if (unmergedProducts.size >= 50) { ++ indexMerger.addProducts(unmergedProducts) ++ unmergedProducts.clear() ++ } ++ } ++ ++ override fun onReleases( ++ packageName: String, ++ releases: List ++ ) { ++ if (Thread.interrupted()) { ++ throw InterruptedException() ++ } ++ unmergedReleases += Pair(packageName, releases) ++ if (unmergedReleases.size >= 50) { ++ indexMerger.addReleases(unmergedReleases) ++ unmergedReleases.clear() ++ } ++ } ++ } ++ ) ++ ++ if (Thread.interrupted()) { ++ throw InterruptedException() ++ } ++ if (unmergedProducts.isNotEmpty()) { ++ indexMerger.addProducts(unmergedProducts) ++ unmergedProducts.clear() ++ } ++ if (unmergedReleases.isNotEmpty()) { ++ indexMerger.addReleases(unmergedReleases) ++ unmergedReleases.clear() ++ } ++ var progress = 0 ++ indexMerger.forEach(repository.id, 50) { products, totalCount -> ++ if (Thread.interrupted()) { ++ throw InterruptedException() ++ } ++ progress += products.size ++ callback( ++ Stage.MERGE, ++ progress.toLong(), ++ totalCount.toLong() ++ ) ++ Database.UpdaterAdapter.putTemporary( ++ products ++ .map { transformProduct(it, features, unstable) } ++ ) ++ } ++ } ++ } ++ } finally { ++ mergerFile.delete() ++ } ++ ++ val workRepository = changedRepository ?: repository ++ if (workRepository.timestamp < repository.timestamp) { ++ throw UpdateException( ++ ErrorType.VALIDATION, ++ "New index is older than current index:" + ++ " ${workRepository.timestamp} < ${repository.timestamp}" ++ ) ++ } ++ ++ val fingerprint = indexEntry ++ .codeSigner ++ .certificate ++ .fingerprint() ++ .uppercase() ++ ++ val commitRepository = if (!workRepository.fingerprint.equals( ++ fingerprint, ++ ignoreCase = true ++ ) ++ ) { ++ if (workRepository.fingerprint.isNotEmpty()) { ++ throw UpdateException( ++ ErrorType.VALIDATION, ++ "Certificate fingerprints do not match" ++ ) ++ } ++ ++ workRepository.copy(fingerprint = fingerprint) ++ } else { ++ workRepository ++ } ++ if (Thread.interrupted()) { ++ throw InterruptedException() ++ } ++ callback(Stage.COMMIT, 0, null) ++ synchronized(cleanupLock) { ++ Database.UpdaterAdapter.finishTemporary(commitRepository, true) ++ } ++ rollback = false ++ true ++ } catch (e: Exception) { ++ throw when (e) { ++ is UpdateException, is InterruptedException -> e ++ else -> UpdateException(ErrorType.PARSING, "Error parsing index", e) ++ } ++ } finally { ++ file.delete() ++ if (rollback) { ++ Database.UpdaterAdapter.finishTemporary(repository, false) ++ } ++ } ++ } ++ } ++ ++ @get:Throws(UpdateException::class) ++ private val JarEntry.codeSigner: CodeSigner ++ get() = codeSigners?.singleOrNull() ++ ?: throw UpdateException( ++ ErrorType.VALIDATION, ++ "index.jar must be signed by a single code signer" ++ ) ++ ++ @get:Throws(UpdateException::class) ++ private val CodeSigner.certificate: Certificate ++ get() = signerCertPath?.certificates?.singleOrNull() ++ ?: throw UpdateException( ++ ErrorType.VALIDATION, ++ "index.jar code signer should have only one certificate" ++ ) ++ ++ private fun transformProduct( ++ product: Product, ++ features: Set, ++ unstable: Boolean ++ ): Product { ++ val releasePairs = product.releases ++ .distinctBy { it.identifier } ++ .sortedByDescending { it.versionCode } ++ .map { release -> ++ val incompatibilities = mutableListOf() ++ if (release.minSdkVersion > 0 && SdkCheck.sdk < release.minSdkVersion) { ++ incompatibilities += Release.Incompatibility.MinSdk ++ } ++ if (release.maxSdkVersion > 0 && SdkCheck.sdk > release.maxSdkVersion) { ++ incompatibilities += Release.Incompatibility.MaxSdk ++ } ++ if (release.platforms.isNotEmpty() && ++ (release.platforms intersect Android.platforms).isEmpty() ++ ) { ++ incompatibilities += Release.Incompatibility.Platform ++ } ++ incompatibilities += (release.features - features).sorted() ++ .map { Release.Incompatibility.Feature(it) } ++ Pair(release, incompatibilities.toList()) ++ } ++ ++ val predicate: (Release) -> Boolean = { ++ unstable || ++ product.suggestedVersionCode <= 0 || ++ it.versionCode <= product.suggestedVersionCode ++ } ++ ++ val firstSelected = ++ releasePairs.firstOrNull { it.second.isEmpty() && predicate(it.first) } ++ ?: releasePairs.firstOrNull { predicate(it.first) } ++ ++ val releases = releasePairs ++ .map { (release, incompatibilities) -> ++ release.copy( ++ incompatibilities = incompatibilities, ++ selected = firstSelected?.let { ++ it.first.versionCode == release.versionCode && ++ it.second == incompatibilities ++ } ?: false ++ ) ++ } ++ return product.copy(releases = releases) ++ } ++} ++ ++data class IndexFile( ++ val isUnmodified: Boolean, ++ val lastModified: String, ++ val entityTag: String, ++ val statusCode: Int, ++ val file: File ++) +Index: app/src/main/kotlin/com/leos/droidify/receivers/InstalledAppReceiver.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/receivers/InstalledAppReceiver.kt b/app/src/main/kotlin/com/leos/droidify/receivers/InstalledAppReceiver.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/receivers/InstalledAppReceiver.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,30 @@ ++package com.leos.droidify.receivers ++ ++import android.content.BroadcastReceiver ++import android.content.Context ++import android.content.Intent ++import android.content.pm.PackageManager ++import com.leos.core.common.extension.getPackageInfoCompat ++import com.leos.droidify.database.Database ++import com.leos.droidify.utility.extension.toInstalledItem ++ ++class InstalledAppReceiver(private val packageManager: PackageManager) : BroadcastReceiver() { ++ override fun onReceive(context: Context, intent: Intent) { ++ val packageName = ++ intent.data?.let { if (it.scheme == "package") it.schemeSpecificPart else null } ++ if (packageName != null) { ++ when (intent.action.orEmpty()) { ++ Intent.ACTION_PACKAGE_ADDED, ++ Intent.ACTION_PACKAGE_REMOVED ++ -> { ++ val packageInfo = packageManager.getPackageInfoCompat(packageName) ++ if (packageInfo != null) { ++ Database.InstalledAdapter.put(packageInfo.toInstalledItem()) ++ } else { ++ Database.InstalledAdapter.delete(packageName) ++ } ++ } ++ } ++ } ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/service/Connection.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/service/Connection.kt b/app/src/main/kotlin/com/leos/droidify/service/Connection.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/service/Connection.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,43 @@ ++package com.leos.droidify.service ++ ++import android.content.ComponentName ++import android.content.Context ++import android.content.Intent ++import android.content.ServiceConnection ++import android.os.IBinder ++ ++class Connection>( ++ private val serviceClass: Class, ++ private val onBind: ((Connection, B) -> Unit)? = null, ++ private val onUnbind: ((Connection, B) -> Unit)? = null ++) : ServiceConnection { ++ var binder: B? = null ++ private set ++ ++ private fun handleUnbind() { ++ binder?.let { ++ binder = null ++ onUnbind?.invoke(this, it) ++ } ++ } ++ ++ override fun onServiceConnected(componentName: ComponentName, binder: IBinder) { ++ @Suppress("UNCHECKED_CAST") ++ binder as B ++ this.binder = binder ++ onBind?.invoke(this, binder) ++ } ++ ++ override fun onServiceDisconnected(componentName: ComponentName) { ++ handleUnbind() ++ } ++ ++ fun bind(context: Context) { ++ context.bindService(Intent(context, serviceClass), this, Context.BIND_AUTO_CREATE) ++ } ++ ++ fun unbind(context: Context) { ++ context.unbindService(this) ++ handleUnbind() ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/service/ConnectionService.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/service/ConnectionService.kt b/app/src/main/kotlin/com/leos/droidify/service/ConnectionService.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/service/ConnectionService.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,22 @@ ++package com.leos.droidify.service ++ ++import android.app.Service ++import android.content.Intent ++import android.os.IBinder ++import kotlinx.coroutines.CoroutineScope ++import kotlinx.coroutines.Dispatchers ++import kotlinx.coroutines.SupervisorJob ++import kotlinx.coroutines.cancel ++ ++abstract class ConnectionService : Service() { ++ ++ private val supervisorJob = SupervisorJob() ++ val lifecycleScope = CoroutineScope(Dispatchers.Main + supervisorJob) ++ ++ abstract override fun onBind(intent: Intent): T ++ ++ override fun onDestroy() { ++ super.onDestroy() ++ lifecycleScope.cancel() ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/service/DownloadService.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/service/DownloadService.kt b/app/src/main/kotlin/com/leos/droidify/service/DownloadService.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/service/DownloadService.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,487 @@ ++package com.leos.droidify.service ++ ++import android.app.NotificationChannel ++import android.app.NotificationManager ++import android.app.PendingIntent ++import android.content.Intent ++import android.net.Uri ++import android.os.Build ++import android.util.Log ++import android.view.ContextThemeWrapper ++import androidx.core.app.NotificationCompat ++import com.leos.core.common.Constants ++import com.leos.core.common.DataSize ++import com.leos.core.common.R as CommonR ++import com.leos.core.common.R.string as stringRes ++import com.leos.core.common.R.style as styleRes ++import com.leos.core.common.SdkCheck ++import com.leos.core.common.cache.Cache ++import com.leos.core.common.extension.notificationManager ++import com.leos.core.common.extension.percentBy ++import com.leos.core.common.extension.startSelf ++import com.leos.core.common.extension.stopForegroundCompat ++import com.leos.core.common.extension.toPendingIntent ++import com.leos.core.common.extension.updateAsMutable ++import com.leos.core.common.log ++import com.leos.core.common.sdkAbove ++import com.leos.core.common.signature.ValidationException ++import com.leos.core.datastore.SettingsRepository ++import com.leos.core.datastore.get ++import com.leos.core.datastore.model.InstallerType ++import com.leos.core.domain.Release ++import com.leos.core.domain.Repository ++import com.leos.droidify.BuildConfig ++import com.leos.droidify.MainActivity ++import com.leos.installer.InstallManager ++import com.leos.installer.model.installFrom ++import com.leos.network.Downloader ++import com.leos.network.NetworkResponse ++import dagger.hilt.android.AndroidEntryPoint ++import java.io.File ++import javax.inject.Inject ++import kotlinx.coroutines.CoroutineScope ++import kotlinx.coroutines.Job ++import kotlinx.coroutines.flow.MutableStateFlow ++import kotlinx.coroutines.flow.asStateFlow ++import kotlinx.coroutines.flow.collectLatest ++import kotlinx.coroutines.flow.filter ++import kotlinx.coroutines.flow.first ++import kotlinx.coroutines.flow.sample ++import kotlinx.coroutines.flow.update ++import kotlinx.coroutines.launch ++import kotlinx.coroutines.runBlocking ++import kotlinx.coroutines.sync.Mutex ++import kotlinx.coroutines.sync.withLock ++import kotlinx.coroutines.yield ++ ++@AndroidEntryPoint ++class DownloadService : ConnectionService() { ++ companion object { ++ private const val ACTION_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL" ++ } ++ ++ @Inject ++ lateinit var settingsRepository: SettingsRepository ++ ++ @Inject ++ lateinit var downloader: Downloader ++ ++ private val installerType ++ get() = settingsRepository.get { installerType } ++ ++ @Inject ++ lateinit var installer: InstallManager ++ ++ sealed class State(val packageName: String) { ++ data object Idle : State("") ++ data class Connecting(val name: String) : State(name) ++ data class Downloading(val name: String, val read: DataSize, val total: DataSize?) : State( ++ name ++ ) ++ ++ data class Error(val name: String) : State(name) ++ data class Cancel(val name: String) : State(name) ++ data class Success(val name: String, val release: Release) : State(name) ++ } ++ ++ data class DownloadState( ++ val currentItem: State = State.Idle, ++ val queue: List = emptyList() ++ ) { ++ infix fun isDownloading(packageName: String): Boolean = ++ currentItem.packageName == packageName && ( ++ currentItem is State.Connecting || currentItem is State.Downloading ++ ) ++ ++ infix fun isComplete(packageName: String): Boolean = ++ currentItem.packageName == packageName && ( ++ currentItem is State.Error || ++ currentItem is State.Cancel || ++ currentItem is State.Success || ++ currentItem is State.Idle ++ ) ++ } ++ ++ private val _downloadState = MutableStateFlow(DownloadState()) ++ ++ private class Task( ++ val packageName: String, ++ val name: String, ++ val release: Release, ++ val url: String, ++ val authentication: String, ++ val isUpdate: Boolean = false ++ ) { ++ val notificationTag: String ++ get() = "download-$packageName" ++ } ++ ++ private data class CurrentTask(val task: Task, val job: Job, val lastState: State) ++ ++ private var started = false ++ private val tasks = mutableListOf() ++ private var currentTask: CurrentTask? = null ++ ++ private val lock = Mutex() ++ ++ inner class Binder : android.os.Binder() { ++ val downloadState = _downloadState.asStateFlow() ++ fun enqueue( ++ packageName: String, ++ name: String, ++ repository: Repository, ++ release: Release, ++ isUpdate: Boolean = false ++ ) { ++ val task = Task( ++ packageName = packageName, ++ name = name, ++ release = release, ++ url = release.getDownloadUrl(repository), ++ authentication = repository.authentication, ++ isUpdate = isUpdate ++ ) ++ if (Cache.getReleaseFile(this@DownloadService, release.cacheFileName).exists()) { ++ lifecycleScope.launch { publishSuccess(task) } ++ return ++ } ++ cancelTasks(packageName) ++ cancelCurrentTask(packageName) ++ notificationManager?.cancel( ++ task.notificationTag, ++ Constants.NOTIFICATION_ID_DOWNLOADING ++ ) ++ tasks += task ++ if (currentTask == null) { ++ handleDownload() ++ } else { ++ updateCurrentQueue { add(packageName) } ++ } ++ } ++ ++ fun cancel(packageName: String) { ++ cancelTasks(packageName) ++ cancelCurrentTask(packageName) ++ } ++ } ++ ++ private val binder = Binder() ++ override fun onBind(intent: Intent): Binder = binder ++ ++ override fun onCreate() { ++ super.onCreate() ++ ++ sdkAbove(Build.VERSION_CODES.O) { ++ NotificationChannel( ++ Constants.NOTIFICATION_CHANNEL_DOWNLOADING, ++ getString(stringRes.downloading), ++ NotificationManager.IMPORTANCE_LOW ++ ).apply { setShowBadge(false) } ++ .let { ++ notificationManager?.createNotificationChannel(it) ++ } ++ } ++ ++ lifecycleScope.launch { ++ _downloadState ++ .filter { currentTask != null } ++ .sample(400) ++ .collectLatest { ++ publishForegroundState(false, it.currentItem) ++ } ++ } ++ } ++ ++ override fun onDestroy() { ++ super.onDestroy() ++ cancelTasks(null) ++ cancelCurrentTask(null) ++ } ++ ++ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { ++ if (intent?.action == ACTION_CANCEL) { ++ currentTask?.let { binder.cancel(it.task.packageName) } ++ } ++ return START_NOT_STICKY ++ } ++ ++ private fun cancelTasks(packageName: String?) { ++ tasks.removeAll { ++ (packageName == null || it.packageName == packageName) && run { ++ updateCurrentState(State.Cancel(it.packageName)) ++ true ++ } ++ } ++ } ++ ++ private fun cancelCurrentTask(packageName: String?) { ++ currentTask?.let { ++ if (packageName == null || it.task.packageName == packageName) { ++ it.job.cancel() ++ currentTask = null ++ updateCurrentState(State.Cancel(it.task.packageName)) ++ } ++ } ++ } ++ ++ private sealed interface ErrorType { ++ data object IO : ErrorType ++ data object Http : ErrorType ++ data object SocketTimeout : ErrorType ++ data object ConnectionTimeout : ErrorType ++ class Validation(val exception: ValidationException) : ErrorType ++ } ++ ++ private fun showNotificationError(task: Task, errorType: ErrorType) { ++ val intent = Intent(this, MainActivity::class.java) ++ .setAction(Intent.ACTION_VIEW) ++ .setData(Uri.parse("package:${task.packageName}")) ++ .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) ++ .toPendingIntent(this) ++ notificationManager?.notify( ++ task.notificationTag, ++ Constants.NOTIFICATION_ID_DOWNLOADING, ++ NotificationCompat ++ .Builder(this, Constants.NOTIFICATION_CHANNEL_DOWNLOADING) ++ .setAutoCancel(true) ++ .setSmallIcon(android.R.drawable.stat_notify_error) ++ .setColor( ++ ContextThemeWrapper(this, styleRes.Theme_Main_Light) ++ .getColor(CommonR.color.md_theme_dark_errorContainer) ++ ) ++ .setOnlyAlertOnce(true) ++ .setContentIntent(intent) ++ .errorNotificationContent(task, errorType) ++ .build() ++ ) ++ } ++ ++ private fun NotificationCompat.Builder.errorNotificationContent( ++ task: Task, ++ errorType: ErrorType ++ ): NotificationCompat.Builder { ++ val title = if (errorType is ErrorType.Validation) { ++ stringRes.could_not_validate_FORMAT ++ } else { ++ stringRes.could_not_download_FORMAT ++ } ++ val description = when (errorType) { ++ ErrorType.ConnectionTimeout -> getString(stringRes.connection_error_DESC) ++ ErrorType.Http -> getString(stringRes.http_error_DESC) ++ ErrorType.IO -> getString(stringRes.io_error_DESC) ++ ErrorType.SocketTimeout -> getString(stringRes.socket_error_DESC) ++ is ErrorType.Validation -> errorType.exception.message ++ } ++ setContentTitle(getString(title, task.name)) ++ return setContentText(description) ++ } ++ ++ private fun showNotificationInstall(task: Task) { ++ val intent = Intent(this, MainActivity::class.java) ++ .setAction(MainActivity.ACTION_INSTALL) ++ .setData(Uri.parse("package:${task.packageName}")) ++ .putExtra(MainActivity.EXTRA_CACHE_FILE_NAME, task.release.cacheFileName) ++ .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) ++ .toPendingIntent(this) ++ notificationManager?.notify( ++ task.notificationTag, ++ Constants.NOTIFICATION_ID_DOWNLOADING, ++ NotificationCompat ++ .Builder(this, Constants.NOTIFICATION_CHANNEL_DOWNLOADING) ++ .setAutoCancel(true) ++ .setOngoing(false) ++ .setSmallIcon(android.R.drawable.stat_sys_download_done) ++ .setColor( ++ ContextThemeWrapper(this, styleRes.Theme_Main_Light) ++ .getColor(CommonR.color.md_theme_dark_primaryContainer) ++ ) ++ .setOnlyAlertOnce(true) ++ .setContentIntent(intent) ++ .setContentTitle(getString(stringRes.downloaded_FORMAT, task.name)) ++ .setContentText(getString(stringRes.tap_to_install_DESC)) ++ .build() ++ ) ++ } ++ ++ private suspend fun publishSuccess(task: Task) { ++ val currentInstaller = installerType.first() ++ updateCurrentQueue { add("") } ++ updateCurrentState(State.Success(task.packageName, task.release)) ++ val autoInstallWithSessionInstaller = ++ SdkCheck.canAutoInstall(task.release.targetSdkVersion) && ++ currentInstaller == InstallerType.SESSION && ++ task.isUpdate ++ ++ showNotificationInstall(task) ++ if (currentInstaller == InstallerType.ROOT || ++ currentInstaller == InstallerType.SHIZUKU || ++ autoInstallWithSessionInstaller ++ ) { ++ val installItem = task.packageName installFrom task.release.cacheFileName ++ installer install installItem ++ } ++ } ++ ++ private val stateNotificationBuilder by lazy { ++ NotificationCompat ++ .Builder(this, Constants.NOTIFICATION_CHANNEL_DOWNLOADING) ++ .setSmallIcon(android.R.drawable.stat_sys_download) ++ .setColor( ++ ContextThemeWrapper(this, styleRes.Theme_Main_Light) ++ .getColor(CommonR.color.md_theme_dark_primaryContainer) ++ ) ++ .addAction( ++ 0, ++ getString(stringRes.cancel), ++ PendingIntent.getService( ++ this, ++ 0, ++ Intent(this, this::class.java).setAction(ACTION_CANCEL), ++ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ++ ) ++ ) ++ } ++ ++ private fun publishForegroundState(force: Boolean, state: State) { ++ if (!force && currentTask == null) return ++ currentTask = currentTask!!.copy(lastState = state) ++ stateNotificationBuilder.downloadingNotificationContent(state) ++ ?.let { notification -> ++ startForeground( ++ Constants.NOTIFICATION_ID_DOWNLOADING, ++ notification.build() ++ ) ++ } ?: run { ++ log("Invalid Download State: $state", "DownloadService", Log.ERROR) ++ } ++ } ++ ++ private fun NotificationCompat.Builder.downloadingNotificationContent( ++ state: State ++ ): NotificationCompat.Builder? { ++ return when (state) { ++ is State.Connecting -> { ++ setContentTitle(getString(stringRes.downloading_FORMAT, currentTask!!.task.name)) ++ setContentText(getString(stringRes.connecting)) ++ setProgress(1, 0, true) ++ } ++ ++ is State.Downloading -> { ++ setContentTitle(getString(stringRes.downloading_FORMAT, currentTask!!.task.name)) ++ if (state.total != null) { ++ setContentText("${state.read} / ${state.total}") ++ setProgress(100, state.read.value percentBy state.total.value, false) ++ } else { ++ setContentText(state.read.toString()) ++ setProgress(0, 0, true) ++ } ++ } ++ ++ else -> null ++ } ++ } ++ ++ private fun handleDownload() { ++ if (currentTask != null) return ++ if (tasks.isEmpty() && started) { ++ started = false ++ stopForegroundCompat() ++ return ++ } ++ if (!started) { ++ started = true ++ startSelf() ++ } ++ val task = tasks.removeFirstOrNull() ?: return ++ with(stateNotificationBuilder) { ++ setWhen(System.currentTimeMillis()) ++ setContentIntent(createNotificationIntent(task.packageName)) ++ } ++ val connectionState = State.Connecting(task.packageName) ++ val partialReleaseFile = ++ Cache.getPartialReleaseFile(this, task.release.cacheFileName) ++ val job = lifecycleScope.downloadFile(task, partialReleaseFile) ++ currentTask = CurrentTask(task, job, connectionState) ++ publishForegroundState(true, connectionState) ++ updateCurrentState(State.Connecting(task.packageName)) ++ } ++ ++ private fun createNotificationIntent(packageName: String): PendingIntent? = ++ Intent(this, MainActivity::class.java) ++ .setAction(Intent.ACTION_VIEW) ++ .setData(Uri.parse("package:$packageName")) ++ .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) ++ .toPendingIntent(this) ++ ++ private fun CoroutineScope.downloadFile( ++ task: Task, ++ target: File ++ ) = launch { ++ try { ++ val releaseValidator = ReleaseFileValidator( ++ context = this@DownloadService, ++ packageName = task.packageName, ++ release = task.release ++ ) ++ val response = downloader.downloadToFile( ++ url = task.url, ++ target = target, ++ validator = releaseValidator, ++ headers = { authentication(task.authentication) } ++ ) { read, total -> ++ yield() ++ updateCurrentState(State.Downloading(task.packageName, read, total)) ++ } ++ ++ when (response) { ++ is NetworkResponse.Success -> { ++ val releaseFile = Cache.getReleaseFile( ++ this@DownloadService, ++ task.release.cacheFileName ++ ) ++ target.renameTo(releaseFile) ++ publishSuccess(task) ++ } ++ ++ is NetworkResponse.Error -> { ++ updateCurrentState(State.Error(task.packageName)) ++ val errorType = when (response) { ++ is NetworkResponse.Error.ConnectionTimeout -> ErrorType.ConnectionTimeout ++ is NetworkResponse.Error.IO -> ErrorType.IO ++ is NetworkResponse.Error.SocketTimeout -> ErrorType.SocketTimeout ++ is NetworkResponse.Error.Validation -> ErrorType.Validation( ++ response.exception ++ ) ++ ++ else -> ErrorType.Http ++ } ++ showNotificationError(task, errorType) ++ } ++ } ++ } finally { ++ lock.withLock { currentTask = null } ++ handleDownload() ++ } ++ } ++ ++ private fun updateCurrentState(state: State) { ++ _downloadState.update { ++ val newQueue = ++ if (state.packageName in it.queue) { ++ it.queue.updateAsMutable { ++ removeAll { name -> name == "" } ++ remove(state.packageName) ++ } ++ } else { ++ it.queue ++ } ++ it.copy(currentItem = state, queue = newQueue) ++ } ++ } ++ ++ private fun updateCurrentQueue(block: MutableList.() -> Unit) { ++ _downloadState.update { state -> ++ state.copy(queue = state.queue.updateAsMutable(block)) ++ } ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/service/ReleaseFileValidator.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/service/ReleaseFileValidator.kt b/app/src/main/kotlin/com/leos/droidify/service/ReleaseFileValidator.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/service/ReleaseFileValidator.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,53 @@ ++package com.leos.droidify.service ++ ++import android.content.Context ++import androidx.annotation.StringRes ++import com.leos.core.common.R.string as strings ++import com.leos.core.common.extension.calculateHash ++import com.leos.core.common.extension.getPackageArchiveInfoCompat ++import com.leos.core.common.extension.singleSignature ++import com.leos.core.common.extension.versionCodeCompat ++import com.leos.core.common.signature.FileValidator ++import com.leos.core.common.signature.Hash ++import com.leos.core.common.signature.ValidationException ++import com.leos.core.common.signature.verifyHash ++import com.leos.core.domain.Release ++import java.io.File ++ ++class ReleaseFileValidator( ++ private val context: Context, ++ private val packageName: String, ++ private val release: Release ++) : FileValidator { ++ ++ override suspend fun validate(file: File) { ++ val hash = Hash(release.hashType, release.hash) ++ if (!file.verifyHash(hash)) { ++ throw ValidationException( ++ getString(strings.integrity_check_error_DESC) ++ ) ++ } ++ val packageInfo = context.packageManager.getPackageArchiveInfoCompat(file.path) ++ ?: throw ValidationException(getString(strings.file_format_error_DESC)) ++ if (packageInfo.packageName != packageName || ++ packageInfo.versionCodeCompat != release.versionCode ++ ) { ++ throw ValidationException(getString(strings.invalid_metadata_error_DESC)) ++ } ++ ++ packageInfo.singleSignature ++ ?.calculateHash() ++ ?.takeIf { it.isNotBlank() || it == release.signature } ++ ?: throw ValidationException(getString(strings.invalid_signature_error_DESC)) ++ ++ packageInfo.permissions ++ ?.asSequence() ++ .orEmpty() ++ .map { it.name } ++ .toSet() ++ .takeIf { release.permissions.containsAll(it) } ++ ?: throw ValidationException(getString(strings.invalid_permissions_error_DESC)) ++ } ++ ++ private fun getString(@StringRes id: Int): String = context.getString(id) ++} +Index: app/src/main/kotlin/com/leos/droidify/service/SyncService.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/service/SyncService.kt b/app/src/main/kotlin/com/leos/droidify/service/SyncService.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/service/SyncService.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,636 @@ ++package com.leos.droidify.service ++ ++import android.annotation.SuppressLint ++import android.app.NotificationChannel ++import android.app.NotificationManager ++import android.app.PendingIntent ++import android.app.job.JobInfo ++import android.app.job.JobParameters ++import android.app.job.JobService ++import android.content.ComponentName ++import android.content.Context ++import android.content.Intent ++import android.graphics.Color ++import android.os.Build ++import android.text.SpannableStringBuilder ++import android.text.style.ForegroundColorSpan ++import android.view.ContextThemeWrapper ++import androidx.core.app.NotificationCompat ++import androidx.fragment.app.Fragment ++import com.leos.core.common.Constants ++import com.leos.core.common.DataSize ++import com.leos.core.common.SdkCheck ++import com.leos.core.common.extension.getColorFromAttr ++import com.leos.core.common.extension.notificationManager ++import com.leos.core.common.extension.percentBy ++import com.leos.core.common.extension.startSelf ++import com.leos.core.common.extension.stopForegroundCompat ++import com.leos.core.common.result.Result ++import com.leos.core.common.sdkAbove ++import com.leos.core.datastore.SettingsRepository ++import com.leos.core.domain.ProductItem ++import com.leos.core.domain.Repository ++import com.leos.droidify.BuildConfig ++import com.leos.droidify.MainActivity ++import com.leos.droidify.database.Database ++import com.leos.droidify.index.RepositoryUpdater ++import com.leos.droidify.utility.extension.startUpdate ++import dagger.hilt.android.AndroidEntryPoint ++import kotlinx.coroutines.CoroutineScope ++import kotlinx.coroutines.Dispatchers ++import kotlinx.coroutines.NonCancellable ++import kotlinx.coroutines.cancel ++import kotlinx.coroutines.flow.MutableSharedFlow ++import kotlinx.coroutines.flow.SharedFlow ++import kotlinx.coroutines.flow.asSharedFlow ++import kotlinx.coroutines.flow.collectLatest ++import kotlinx.coroutines.flow.sample ++import kotlinx.coroutines.launch ++import kotlinx.coroutines.sync.Mutex ++import kotlinx.coroutines.sync.withLock ++import kotlinx.coroutines.withContext ++import java.lang.ref.WeakReference ++import javax.inject.Inject ++import com.leos.core.common.R as CommonR ++import com.leos.core.common.R.string as stringRes ++import com.leos.core.common.R.style as styleRes ++import kotlinx.coroutines.Job as CoroutinesJob ++ ++@AndroidEntryPoint ++class SyncService : ConnectionService() { ++ ++ companion object { ++ private const val MAX_PROGRESS = 100 ++ ++ private const val NOTIFICATION_UPDATE_SAMPLING = 400L ++ ++ private const val MAX_UPDATE_NOTIFICATION = 5 ++ private const val ACTION_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL" ++ ++ private val syncState = MutableSharedFlow() ++ private val onFinishState = MutableSharedFlow() ++ } ++ ++ @Inject ++ lateinit var settingsRepository: SettingsRepository ++ ++ private sealed class State(val name: String) { ++ data class Connecting(val appName: String) : State(appName) ++ data class Syncing( ++ val appName: String, ++ val stage: RepositoryUpdater.Stage, ++ val read: DataSize, ++ val total: DataSize? ++ ) : State(appName) ++ } ++ ++ private class Task(val repositoryId: Long, val manual: Boolean) ++ private data class CurrentTask( ++ val task: Task?, ++ val job: CoroutinesJob, ++ val hasUpdates: Boolean, ++ val lastState: State ++ ) ++ ++ private enum class Started { NO, AUTO, MANUAL } ++ ++ private var started = Started.NO ++ private val tasks = mutableListOf() ++ private var currentTask: CurrentTask? = null ++ ++ private var updateNotificationBlockerFragment: WeakReference? = null ++ ++ private val downloadConnection = Connection(DownloadService::class.java) ++ private val lock = Mutex() ++ ++ enum class SyncRequest { AUTO, MANUAL, FORCE } ++ ++ inner class Binder : android.os.Binder() { ++ ++ val onFinish: SharedFlow ++ get() = onFinishState.asSharedFlow() ++ ++ private fun sync(ids: List, request: SyncRequest) { ++ val cancelledTask = ++ cancelCurrentTask { request == SyncRequest.FORCE && it.task?.repositoryId in ids } ++ cancelTasks { !it.manual && it.repositoryId in ids } ++ val currentIds = tasks.asSequence().map { it.repositoryId }.toSet() ++ val manual = request != SyncRequest.AUTO ++ tasks += ids.asSequence().filter { ++ it !in currentIds && ++ it != currentTask?.task?.repositoryId ++ }.map { Task(it, manual) } ++ handleNextTask(cancelledTask?.hasUpdates == true) ++ if (request != SyncRequest.AUTO && started == Started.AUTO) { ++ started = Started.MANUAL ++ startSelf() ++ handleSetStarted() ++ currentTask?.lastState?.let { publishForegroundState(true, it) } ++ } ++ } ++ ++ fun sync(request: SyncRequest) { ++ val ids = Database.RepositoryAdapter.getAll() ++ .asSequence().filter { it.enabled }.map { it.id }.toList() ++ sync(ids, request) ++ } ++ ++ fun sync(repository: Repository) { ++ if (repository.enabled) { ++ sync(listOf(repository.id), SyncRequest.FORCE) ++ } ++ } ++ ++ suspend fun updateAllApps() { ++ updateAllAppsInternal() ++ } ++ ++ fun setUpdateNotificationBlocker(fragment: Fragment?) { ++ updateNotificationBlockerFragment = fragment?.let(::WeakReference) ++ if (fragment != null) { ++ notificationManager?.cancel(Constants.NOTIFICATION_ID_UPDATES) ++ } ++ } ++ ++ fun setEnabled(repository: Repository, enabled: Boolean): Boolean { ++ Database.RepositoryAdapter.put(repository.enable(enabled)) ++ if (enabled) { ++ val isRepoInTasks = repository.id != currentTask?.task?.repositoryId && ++ !tasks.any { it.repositoryId == repository.id } ++ if (isRepoInTasks) { ++ tasks += Task(repository.id, true) ++ handleNextTask(false) ++ } ++ } else { ++ cancelTasks { it.repositoryId == repository.id } ++ val cancelledTask = cancelCurrentTask { ++ it.task?.repositoryId == repository.id ++ } ++ handleNextTask(cancelledTask?.hasUpdates == true) ++ } ++ return true ++ } ++ ++ fun isCurrentlySyncing(repositoryId: Long): Boolean { ++ return currentTask?.task?.repositoryId == repositoryId ++ } ++ ++ fun deleteRepository(repositoryId: Long): Boolean { ++ val repository = Database.RepositoryAdapter.get(repositoryId) ++ return repository != null && run { ++ setEnabled(repository, false) ++ Database.RepositoryAdapter.markAsDeleted(repository.id) ++ true ++ } ++ } ++ ++ fun cancelAuto(): Boolean { ++ val removed = cancelTasks { !it.manual } ++ val currentTask = cancelCurrentTask { it.task?.manual == false } ++ handleNextTask(currentTask?.hasUpdates == true) ++ return removed || currentTask != null ++ } ++ } ++ ++ private val binder = Binder() ++ override fun onBind(intent: Intent): Binder = binder ++ ++ override fun onCreate() { ++ super.onCreate() ++ ++ sdkAbove(Build.VERSION_CODES.O) { ++ val channels = listOf( ++ NotificationChannel( ++ Constants.NOTIFICATION_CHANNEL_SYNCING, ++ getString(stringRes.syncing), ++ NotificationManager.IMPORTANCE_LOW ++ ).apply { setShowBadge(false) }, ++ NotificationChannel( ++ Constants.NOTIFICATION_CHANNEL_UPDATES, ++ getString(stringRes.updates), ++ NotificationManager.IMPORTANCE_LOW ++ ) ++ ) ++ notificationManager?.createNotificationChannels(channels) ++ } ++ downloadConnection.bind(this) ++ lifecycleScope.launch { ++ syncState ++ .sample(NOTIFICATION_UPDATE_SAMPLING) ++ .collectLatest { ++ publishForegroundState(false, it) ++ } ++ } ++ } ++ ++ override fun onDestroy() { ++ super.onDestroy() ++ downloadConnection.unbind(this) ++ cancelTasks { true } ++ cancelCurrentTask { true } ++ } ++ ++ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { ++ if (intent?.action == ACTION_CANCEL) { ++ tasks.clear() ++ val cancelledTask = cancelCurrentTask { it.task != null } ++ handleNextTask(cancelledTask?.hasUpdates == true) ++ } ++ return START_NOT_STICKY ++ } ++ ++ private fun cancelTasks(condition: (Task) -> Boolean): Boolean { ++ return tasks.removeAll(condition) ++ } ++ ++ private fun cancelCurrentTask(condition: ((CurrentTask) -> Boolean)): CurrentTask? { ++ return currentTask?.let { ++ if (condition(it)) { ++ currentTask = null ++ it.job.cancel() ++ RepositoryUpdater.await() ++ it ++ } else { ++ null ++ } ++ } ++ } ++ ++ private fun showNotificationError(repository: Repository, exception: Exception) { ++ val description = getString( ++ when (exception) { ++ is RepositoryUpdater.UpdateException -> when (exception.errorType) { ++ RepositoryUpdater.ErrorType.NETWORK -> stringRes.network_error_DESC ++ RepositoryUpdater.ErrorType.HTTP -> stringRes.http_error_DESC ++ RepositoryUpdater.ErrorType.VALIDATION -> stringRes.validation_index_error_DESC ++ RepositoryUpdater.ErrorType.PARSING -> stringRes.parsing_index_error_DESC ++ } ++ ++ else -> stringRes.unknown_error_DESC ++ } ++ ) ++ notificationManager?.notify( ++ "repository-${repository.id}", ++ Constants.NOTIFICATION_ID_SYNCING, ++ NotificationCompat ++ .Builder(this, Constants.NOTIFICATION_CHANNEL_SYNCING) ++ .setSmallIcon(android.R.drawable.stat_sys_warning) ++ .setColor( ++ ContextThemeWrapper(this, styleRes.Theme_Main_Light) ++ .getColorFromAttr(android.R.attr.colorPrimary).defaultColor ++ ) ++ .setContentTitle(getString(stringRes.could_not_sync_FORMAT, repository.name)) ++ .setContentText(description) ++ .build() ++ ) ++ } ++ ++ private val stateNotificationBuilder by lazy { ++ NotificationCompat ++ .Builder(this, Constants.NOTIFICATION_CHANNEL_SYNCING) ++ .setSmallIcon(CommonR.drawable.ic_sync) ++ .setColor( ++ ContextThemeWrapper(this, styleRes.Theme_Main_Light) ++ .getColorFromAttr(android.R.attr.colorPrimary).defaultColor ++ ) ++ .addAction( ++ 0, ++ getString(stringRes.cancel), ++ PendingIntent.getService( ++ this, ++ 0, ++ Intent(this, this::class.java).setAction(ACTION_CANCEL), ++ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ++ ) ++ ) ++ } ++ ++ private fun publishForegroundState(force: Boolean, state: State) { ++ if (force || currentTask?.lastState != state) { ++ currentTask = currentTask?.copy(lastState = state) ++ if (started == Started.MANUAL) { ++ startForeground( ++ Constants.NOTIFICATION_ID_SYNCING, ++ stateNotificationBuilder.apply { ++ setContentTitle(getString(stringRes.syncing_FORMAT, state.name)) ++ when (state) { ++ is State.Connecting -> { ++ setContentText(getString(stringRes.connecting)) ++ setProgress(0, 0, true) ++ } ++ ++ is State.Syncing -> { ++ when (state.stage) { ++ RepositoryUpdater.Stage.DOWNLOAD -> { ++ if (state.total != null) { ++ setContentText("${state.read} / ${state.total}") ++ setProgress( ++ MAX_PROGRESS, ++ state.read percentBy state.total, ++ false ++ ) ++ } else { ++ setContentText(state.read.toString()) ++ setProgress(0, 0, true) ++ } ++ } ++ ++ RepositoryUpdater.Stage.PROCESS -> { ++ val progress = (state.read percentBy state.total) ++ .takeIf { it != -1 } ++ setContentText( ++ getString( ++ stringRes.processing_FORMAT, ++ "${progress ?: 0}%" ++ ) ++ ) ++ setProgress(MAX_PROGRESS, progress ?: 0, progress == null) ++ } ++ ++ RepositoryUpdater.Stage.MERGE -> { ++ val progress = (state.read percentBy state.total) ++ setContentText( ++ getString( ++ stringRes.merging_FORMAT, ++ "${state.read.value} / ${state.total?.value ?: state.read.value}" ++ ) ++ ) ++ setProgress(MAX_PROGRESS, progress, false) ++ } ++ ++ RepositoryUpdater.Stage.COMMIT -> { ++ setContentText(getString(stringRes.saving_details)) ++ setProgress(0, 0, true) ++ } ++ } ++ } ++ }::class ++ }.build() ++ ) ++ } ++ } ++ } ++ ++ private fun handleSetStarted() { ++ stateNotificationBuilder.setWhen(System.currentTimeMillis()) ++ } ++ ++ private fun handleNextTask(hasUpdates: Boolean) { ++ if (currentTask != null) return ++ if (tasks.isEmpty()) { ++ if (started != Started.NO) { ++ lifecycleScope.launch { ++ val setting = settingsRepository.getInitial() ++ handleUpdates( ++ hasUpdates = hasUpdates, ++ notifyUpdates = setting.notifyUpdate, ++ autoUpdate = setting.autoUpdate ++ ) ++ } ++ } ++ return ++ } ++ val task = tasks.removeFirst() ++ val repository = Database.RepositoryAdapter.get(task.repositoryId) ++ if (repository == null || !repository.enabled) handleNextTask(hasUpdates) ++ val lastStarted = started ++ val newStarted = if (task.manual || lastStarted == Started.MANUAL) { ++ Started.MANUAL ++ } else { ++ Started.AUTO ++ } ++ started = newStarted ++ if (newStarted == Started.MANUAL && lastStarted != Started.MANUAL) { ++ startSelf() ++ handleSetStarted() ++ } ++ val initialState = State.Connecting(repository!!.name) ++ publishForegroundState(true, initialState) ++ lifecycleScope.launch { ++ val unstableUpdates = ++ settingsRepository.getInitial().unstableUpdate ++ val downloadJob = downloadFile( ++ task = task, ++ repository = repository, ++ hasUpdates = hasUpdates, ++ unstableUpdates = unstableUpdates ++ ) ++ currentTask = CurrentTask(task, downloadJob, hasUpdates, initialState) ++ } ++ } ++ ++ private fun CoroutineScope.downloadFile( ++ task: Task, ++ repository: Repository, ++ hasUpdates: Boolean, ++ unstableUpdates: Boolean ++ ): CoroutinesJob = launch(Dispatchers.Default) { ++ var passedHasUpdates = hasUpdates ++ try { ++ val response = RepositoryUpdater.update( ++ this@SyncService, ++ repository, ++ unstableUpdates ++ ) { stage, progress, total -> ++ launch { ++ syncState.emit( ++ State.Syncing( ++ appName = repository.name, ++ stage = stage, ++ read = DataSize(progress), ++ total = total?.let { DataSize(it) } ++ ) ++ ) ++ } ++ } ++ passedHasUpdates = when (response) { ++ is Result.Error -> { ++ response.exception?.let { ++ it.printStackTrace() ++ if (task.manual) showNotificationError(repository, it as Exception) ++ } ++ response.data == true || hasUpdates ++ } ++ ++ is Result.Success -> response.data || hasUpdates ++ } ++ } finally { ++ withContext(NonCancellable) { ++ lock.withLock { currentTask = null } ++ handleNextTask(passedHasUpdates) ++ } ++ } ++ } ++ ++ private suspend fun handleUpdates( ++ hasUpdates: Boolean, ++ notifyUpdates: Boolean, ++ autoUpdate: Boolean ++ ) { ++ try { ++ if (!hasUpdates || !notifyUpdates) { ++ onFinishState.emit(Unit) ++ val needStop = started == Started.MANUAL ++ started = Started.NO ++ if (needStop) stopForegroundCompat() ++ return ++ } ++ val blocked = updateNotificationBlockerFragment?.get()?.isAdded == true ++ val updates = Database.ProductAdapter.getUpdates() ++ if (!blocked && updates.isNotEmpty()) { ++ displayUpdatesNotification(updates) ++ if (autoUpdate) updateAllAppsInternal() ++ } ++ handleUpdates(hasUpdates = false, notifyUpdates = true, autoUpdate = autoUpdate) ++ } finally { ++ withContext(NonCancellable) { ++ lock.withLock { currentTask = null } ++ handleNextTask(false) ++ } ++ } ++ } ++ ++ private suspend fun updateAllAppsInternal() { ++ Database.ProductAdapter ++ .getUpdates() ++ // Update LeOS-Doid the last ++ .sortedBy { if (it.packageName == packageName) 1 else -1 } ++ .map { ++ Database.InstalledAdapter.get(it.packageName, null) to ++ Database.RepositoryAdapter.get(it.repositoryId) ++ } ++ .filter { it.first != null && it.second != null } ++ .forEach { (installItem, repo) -> ++ val productRepo = Database.ProductAdapter.get(installItem!!.packageName, null) ++ .filter { it.repositoryId == repo!!.id } ++ .map { it to repo!! } ++ downloadConnection.startUpdate( ++ installItem.packageName, ++ installItem, ++ productRepo ++ ) ++ } ++ } ++ ++ private fun displayUpdatesNotification(productItems: List) { ++ fun T.applyHack(callback: T.() -> Unit): T = apply(callback) ++ notificationManager?.notify( ++ Constants.NOTIFICATION_ID_UPDATES, ++ NotificationCompat ++ .Builder(this, Constants.NOTIFICATION_CHANNEL_UPDATES) ++ .setSmallIcon(CommonR.drawable.ic_new_releases) ++ .setContentTitle(getString(stringRes.new_updates_available)) ++ .setContentText( ++ resources.getQuantityString( ++ CommonR.plurals.new_updates_DESC_FORMAT, ++ productItems.size, ++ productItems.size ++ ) ++ ) ++ .setColor( ++ ContextThemeWrapper(this, styleRes.Theme_Main_Light) ++ .getColorFromAttr(android.R.attr.colorPrimary).defaultColor ++ ) ++ .setContentIntent( ++ PendingIntent.getActivity( ++ this, ++ 0, ++ Intent(this, MainActivity::class.java) ++ .setAction(MainActivity.ACTION_UPDATES), ++ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ++ ) ++ ) ++ .setStyle( ++ NotificationCompat.InboxStyle().applyHack { ++ for (productItem in productItems.take(MAX_UPDATE_NOTIFICATION)) { ++ val builder = SpannableStringBuilder(productItem.name) ++ builder.setSpan( ++ ForegroundColorSpan(Color.BLACK), ++ 0, ++ builder.length, ++ SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE ++ ) ++ builder.append(' ').append(productItem.version) ++ addLine(builder) ++ } ++ if (productItems.size > MAX_UPDATE_NOTIFICATION) { ++ val summary = ++ getString( ++ stringRes.plus_more_FORMAT, ++ productItems.size - MAX_UPDATE_NOTIFICATION ++ ) ++ if (SdkCheck.isNougat) addLine(summary) else setSummaryText(summary) ++ } ++ } ++ ) ++ .build() ++ ) ++ } ++ ++ @SuppressLint("SpecifyJobSchedulerIdRange") ++ class Job : JobService() { ++ private val jobScope = CoroutineScope(Dispatchers.Default) ++ private var syncParams: JobParameters? = null ++ private val syncConnection = ++ Connection(SyncService::class.java, onBind = { connection, binder -> ++ jobScope.launch { ++ binder.onFinish.collect { ++ val params = syncParams ++ if (params != null) { ++ syncParams = null ++ connection.unbind(this@Job) ++ jobFinished(params, false) ++ } ++ } ++ } ++ binder.sync(SyncRequest.AUTO) ++ }, onUnbind = { _, binder -> ++ binder.cancelAuto() ++ jobScope.cancel() ++ val params = syncParams ++ if (params != null) { ++ syncParams = null ++ jobFinished(params, true) ++ } ++ }) ++ ++ override fun onStartJob(params: JobParameters): Boolean { ++ syncParams = params ++ syncConnection.bind(this) ++ return true ++ } ++ ++ override fun onStopJob(params: JobParameters): Boolean { ++ syncParams = null ++ jobScope.cancel() ++ val reschedule = syncConnection.binder?.cancelAuto() == true ++ syncConnection.unbind(this) ++ return reschedule ++ } ++ ++ companion object { ++ fun create( ++ context: Context, ++ periodMillis: Long, ++ networkType: Int, ++ isCharging: Boolean, ++ isBatteryLow: Boolean ++ ): JobInfo = JobInfo.Builder( ++ Constants.JOB_ID_SYNC, ++ ComponentName(context, Job::class.java) ++ ).apply { ++ setRequiredNetworkType(networkType) ++ sdkAbove(sdk = Build.VERSION_CODES.O) { ++ setRequiresCharging(isCharging) ++ setRequiresBatteryNotLow(isBatteryLow) ++ setRequiresStorageNotLow(true) ++ } ++ if (SdkCheck.isNougat) { ++ setPeriodic(periodMillis, JobInfo.getMinFlexMillis()) ++ } else { ++ setPeriodic(periodMillis) ++ } ++ }.build() ++ } ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/sync/SyncPreference.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/sync/SyncPreference.kt b/app/src/main/kotlin/com/leos/droidify/sync/SyncPreference.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/sync/SyncPreference.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,23 @@ ++package com.leos.droidify.sync ++ ++import android.app.job.JobInfo ++import androidx.work.Constraints ++import androidx.work.NetworkType ++ ++data class SyncPreference( ++ val networkType: NetworkType, ++ val pluggedIn: Boolean = false, ++ val batteryNotLow: Boolean = true, ++) ++ ++fun SyncPreference.toJobNetworkType() = when (networkType) { ++ NetworkType.NOT_REQUIRED -> JobInfo.NETWORK_TYPE_NONE ++ NetworkType.UNMETERED -> JobInfo.NETWORK_TYPE_UNMETERED ++ else -> JobInfo.NETWORK_TYPE_ANY ++} ++ ++fun SyncPreference.toWorkConstraints(): Constraints = Constraints( ++ requiredNetworkType = networkType, ++ requiresCharging = pluggedIn, ++ requiresBatteryNotLow = batteryNotLow ++) +Index: app/src/main/kotlin/com/leos/droidify/ui/MessageDialog.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/ui/MessageDialog.kt b/app/src/main/kotlin/com/leos/droidify/ui/MessageDialog.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/ui/MessageDialog.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,265 @@ ++package com.leos.droidify.ui ++ ++import android.content.ActivityNotFoundException ++import android.content.Intent ++import android.net.Uri ++import android.os.Bundle ++import android.os.Parcel ++import android.os.Parcelable ++import androidx.appcompat.app.AlertDialog ++import androidx.core.os.bundleOf ++import androidx.fragment.app.DialogFragment ++import androidx.fragment.app.FragmentManager ++import com.google.android.material.dialog.MaterialAlertDialogBuilder ++import com.leos.core.common.SdkCheck ++import com.leos.core.common.nullIfEmpty ++import com.leos.core.domain.Release ++import com.leos.droidify.ui.repository.RepositoryFragment ++import com.leos.droidify.utility.PackageItemResolver ++import com.leos.droidify.utility.extension.android.Android ++import kotlinx.parcelize.Parceler ++import kotlinx.parcelize.Parcelize ++import kotlinx.parcelize.TypeParceler ++import com.leos.core.common.R.string as stringRes ++ ++class MessageDialog() : DialogFragment() { ++ companion object { ++ private const val EXTRA_MESSAGE = "message" ++ } ++ ++ constructor(message: Message) : this() { ++ arguments = bundleOf(EXTRA_MESSAGE to message) ++ } ++ ++ fun show(fragmentManager: FragmentManager) { ++ show(fragmentManager, this::class.java.name) ++ } ++ ++ override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog { ++ val dialog = MaterialAlertDialogBuilder(requireContext()) ++ val message = if (SdkCheck.isTiramisu) { ++ arguments?.getParcelable(EXTRA_MESSAGE, Message::class.java)!! ++ } else { ++ arguments?.getParcelable(EXTRA_MESSAGE)!! ++ } ++ when (message) { ++ is Message.DeleteRepositoryConfirm -> { ++ dialog.setTitle(stringRes.confirmation) ++ dialog.setMessage(stringRes.delete_repository_DESC) ++ dialog.setPositiveButton(stringRes.delete) { _, _ -> ++ (parentFragment as RepositoryFragment).onDeleteConfirm() ++ } ++ dialog.setNegativeButton(stringRes.cancel, null) ++ } ++ ++ is Message.CantEditSyncing -> { ++ dialog.setTitle(stringRes.action_failed) ++ dialog.setMessage(stringRes.cant_edit_sync_DESC) ++ dialog.setPositiveButton(stringRes.ok, null) ++ } ++ ++ is Message.Link -> { ++ dialog.setTitle(stringRes.confirmation) ++ dialog.setMessage(getString(stringRes.open_DESC_FORMAT, message.uri.toString())) ++ dialog.setPositiveButton(stringRes.ok) { _, _ -> ++ try { ++ startActivity(Intent(Intent.ACTION_VIEW, message.uri)) ++ } catch (e: ActivityNotFoundException) { ++ e.printStackTrace() ++ } ++ } ++ dialog.setNegativeButton(stringRes.cancel, null) ++ } ++ ++ is Message.Permissions -> { ++ val packageManager = requireContext().packageManager ++ val builder = StringBuilder() ++ val localCache = PackageItemResolver.LocalCache() ++ val title = if (message.group != null) { ++ val name = try { ++ val permissionGroupInfo = ++ packageManager.getPermissionGroupInfo(message.group, 0) ++ PackageItemResolver.loadLabel( ++ requireContext(), ++ localCache, ++ permissionGroupInfo ++ )?.nullIfEmpty()?.let { if (it == message.group) null else it } ++ } catch (e: Exception) { ++ null ++ } ++ name ?: getString(stringRes.unknown) ++ } else { ++ getString(stringRes.other) ++ } ++ for (permission in message.permissions) { ++ kotlin.runCatching { ++ val permissionInfo = packageManager.getPermissionInfo(permission, 0) ++ PackageItemResolver.loadDescription( ++ requireContext(), ++ localCache, ++ permissionInfo ++ )?.nullIfEmpty()?.let { if (it == permission) null else it } ++ ?: error("Invalid Permission Description") ++ }.onSuccess { ++ builder.append(it).append("\n\n") ++ } ++ } ++ if (builder.isNotEmpty()) { ++ builder.delete(builder.length - 2, builder.length) ++ } else { ++ builder.append(getString(stringRes.no_description_available_DESC)) ++ } ++ dialog.setTitle(title) ++ dialog.setMessage(builder) ++ dialog.setPositiveButton(stringRes.ok, null) ++ } ++ ++ is Message.ReleaseIncompatible -> { ++ val builder = StringBuilder() ++ val minSdkVersion = ++ if (Release.Incompatibility.MinSdk in message.incompatibilities) { ++ message.minSdkVersion ++ } else { ++ null ++ } ++ val maxSdkVersion = ++ if (Release.Incompatibility.MaxSdk in message.incompatibilities) { ++ message.maxSdkVersion ++ } else { ++ null ++ } ++ if (minSdkVersion != null || maxSdkVersion != null) { ++ val versionMessage = minSdkVersion?.let { ++ getString( ++ stringRes.incompatible_api_min_DESC_FORMAT, ++ it ++ ) ++ } ++ ?: maxSdkVersion?.let { ++ getString( ++ stringRes.incompatible_api_max_DESC_FORMAT, ++ it ++ ) ++ } ++ builder.append( ++ getString( ++ stringRes.incompatible_api_DESC_FORMAT, ++ Android.name, ++ SdkCheck.sdk, ++ versionMessage.orEmpty() ++ ) ++ ).append("\n\n") ++ } ++ if (Release.Incompatibility.Platform in message.incompatibilities) { ++ builder.append( ++ getString( ++ stringRes.incompatible_platforms_DESC_FORMAT, ++ Android.primaryPlatform ?: getString(stringRes.unknown), ++ message.platforms.joinToString(separator = ", ") ++ ) ++ ).append("\n\n") ++ } ++ val features = ++ message.incompatibilities.mapNotNull { it as? Release.Incompatibility.Feature } ++ if (features.isNotEmpty()) { ++ builder.append(getString(stringRes.incompatible_features_DESC)) ++ for (feature in features) { ++ builder.append("\n\u2022 ").append(feature.feature) ++ } ++ builder.append("\n\n") ++ } ++ if (builder.isNotEmpty()) { ++ builder.delete(builder.length - 2, builder.length) ++ } ++ dialog.setTitle(stringRes.incompatible_version) ++ dialog.setMessage(builder) ++ dialog.setPositiveButton(stringRes.ok, null) ++ } ++ ++ is Message.ReleaseOlder -> { ++ dialog.setTitle(stringRes.incompatible_version) ++ dialog.setMessage(stringRes.incompatible_older_DESC) ++ dialog.setPositiveButton(stringRes.ok, null) ++ } ++ ++ is Message.ReleaseSignatureMismatch -> { ++ dialog.setTitle(stringRes.incompatible_version) ++ dialog.setMessage(stringRes.incompatible_signature_DESC) ++ dialog.setPositiveButton(stringRes.ok, null) ++ } ++ }::class ++ return dialog.create() ++ } ++} ++ ++@Parcelize ++sealed interface Message : Parcelable { ++ @Parcelize ++ data object DeleteRepositoryConfirm : Message ++ ++ @Parcelize ++ data object CantEditSyncing : Message ++ ++ @Parcelize ++ class Link(val uri: Uri) : Message ++ ++ @Parcelize ++ class Permissions(val group: String?, val permissions: List) : Message ++ ++ @Parcelize ++ @TypeParceler ++ class ReleaseIncompatible( ++ val incompatibilities: List, ++ val platforms: List, ++ val minSdkVersion: Int, ++ val maxSdkVersion: Int ++ ) : Message ++ ++ @Parcelize ++ data object ReleaseOlder : Message ++ ++ @Parcelize ++ data object ReleaseSignatureMismatch : Message ++} ++ ++class ReleaseIncompatibilityParceler : Parceler { ++ ++ private companion object { ++ // Incompatibility indices in `Parcel` ++ const val MIN_SDK_INDEX = 0 ++ const val MAX_SDK_INDEX = 1 ++ const val PLATFORM_INDEX = 2 ++ const val FEATURE_INDEX = 3 ++ } ++ ++ override fun create(parcel: Parcel): Release.Incompatibility { ++ return when (parcel.readInt()) { ++ MIN_SDK_INDEX -> Release.Incompatibility.MinSdk ++ MAX_SDK_INDEX -> Release.Incompatibility.MaxSdk ++ PLATFORM_INDEX -> Release.Incompatibility.Platform ++ FEATURE_INDEX -> Release.Incompatibility.Feature(requireNotNull(parcel.readString())) ++ else -> error("Invalid Index for Incompatibility") ++ } ++ } ++ ++ override fun Release.Incompatibility.write(parcel: Parcel, flags: Int) { ++ when (this) { ++ is Release.Incompatibility.MinSdk -> { ++ parcel.writeInt(MIN_SDK_INDEX) ++ } ++ ++ is Release.Incompatibility.MaxSdk -> { ++ parcel.writeInt(MAX_SDK_INDEX) ++ } ++ ++ is Release.Incompatibility.Platform -> { ++ parcel.writeInt(PLATFORM_INDEX) ++ } ++ ++ is Release.Incompatibility.Feature -> { ++ parcel.writeInt(FEATURE_INDEX) ++ parcel.writeString(feature) ++ } ++ } ++ } ++} +Index: app/src/main/kotlin/com/looker/droidify/ui/ScreenFragment.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/looker/droidify/ui/ScreenFragment.kt b/app/src/main/kotlin/com/leos/droidify/ui/ScreenFragment.kt +rename from app/src/main/kotlin/com/looker/droidify/ui/ScreenFragment.kt +rename to app/src/main/kotlin/com/leos/droidify/ui/ScreenFragment.kt +--- a/app/src/main/kotlin/com/looker/droidify/ui/ScreenFragment.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/app/src/main/kotlin/com/leos/droidify/ui/ScreenFragment.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.droidify.ui ++package com.leos.droidify.ui + + import android.os.Bundle + import android.view.LayoutInflater +@@ -6,7 +6,7 @@ + import android.view.ViewGroup + import androidx.fragment.app.Fragment + import com.google.android.material.appbar.MaterialToolbar +-import com.looker.droidify.databinding.FragmentBinding ++import com.leos.droidify.databinding.FragmentBinding + + open class ScreenFragment : Fragment() { + private var _fragmentBinding: FragmentBinding? = null +Index: app/src/main/kotlin/com/leos/droidify/ui/appDetail/AppDetailAdapter.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/ui/appDetail/AppDetailAdapter.kt b/app/src/main/kotlin/com/leos/droidify/ui/appDetail/AppDetailAdapter.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/ui/appDetail/AppDetailAdapter.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,1808 @@ ++package com.leos.droidify.ui.appDetail ++ ++import android.annotation.SuppressLint ++import android.content.* ++import android.content.pm.PermissionGroupInfo ++import android.content.pm.PermissionInfo ++import android.content.res.Resources ++import android.graphics.* ++import android.net.Uri ++import android.os.Parcelable ++import android.text.SpannableStringBuilder ++import android.text.format.DateFormat ++import android.text.method.LinkMovementMethod ++import android.text.style.* ++import android.text.util.Linkify ++import android.view.* ++import android.widget.* ++import androidx.annotation.DrawableRes ++import androidx.annotation.StringRes ++import androidx.core.net.toUri ++import androidx.core.text.HtmlCompat ++import androidx.core.text.bold ++import androidx.core.text.buildSpannedString ++import androidx.core.text.util.LinkifyCompat ++import androidx.core.view.isVisible ++import androidx.recyclerview.widget.LinearLayoutManager ++import androidx.recyclerview.widget.RecyclerView ++import coil.load ++import com.google.android.material.button.MaterialButton ++import com.google.android.material.card.MaterialCardView ++import com.google.android.material.imageview.ShapeableImageView ++import com.google.android.material.materialswitch.MaterialSwitch ++import com.google.android.material.progressindicator.LinearProgressIndicator ++import com.google.android.material.snackbar.Snackbar ++import com.leos.core.common.DataSize ++import com.leos.core.common.extension.* ++import com.leos.core.common.formatSize ++import com.leos.core.common.nullIfEmpty ++import com.leos.core.domain.* ++import com.leos.droidify.R ++import com.leos.droidify.content.ProductPreferences ++import com.leos.droidify.utility.PackageItemResolver ++import com.leos.droidify.utility.extension.ImageUtils.icon ++import com.leos.droidify.utility.extension.android.Android ++import com.leos.droidify.utility.extension.resources.TypefaceExtra ++import com.leos.droidify.utility.extension.resources.sizeScaled ++import com.leos.droidify.widget.StableRecyclerAdapter ++import kotlinx.datetime.Instant ++import kotlinx.datetime.TimeZone ++import kotlinx.datetime.toJavaLocalDateTime ++import kotlinx.datetime.toLocalDateTime ++import kotlinx.parcelize.Parcelize ++import java.lang.ref.WeakReference ++import java.time.format.DateTimeFormatter ++import java.time.format.FormatStyle ++import java.util.Locale ++import kotlin.math.PI ++import kotlin.math.roundToInt ++import kotlin.math.sin ++import com.google.android.material.R as MaterialR ++import com.leos.core.common.R.drawable as drawableRes ++import com.leos.core.common.R.string as stringRes ++ ++class AppDetailAdapter(private val callbacks: Callbacks) : ++ StableRecyclerAdapter() { ++ ++ companion object { ++ private const val MAX_RELEASE_ITEMS = 5 ++ } ++ ++ interface Callbacks { ++ fun onActionClick(action: Action) ++ fun onFavouriteClicked() ++ fun onPreferenceChanged(preference: ProductPreference) ++ fun onPermissionsClick(group: String?, permissions: List) ++ fun onScreenshotClick(screenshot: Product.Screenshot, parentView: ImageView) ++ fun onReleaseClick(release: Release) ++ fun onRequestAddRepository(address: String) ++ fun onUriClick(uri: Uri, shouldConfirm: Boolean): Boolean ++ } ++ ++ enum class Action(@StringRes val titleResId: Int, @DrawableRes val iconResId: Int) { ++ INSTALL(stringRes.install, drawableRes.ic_download), ++ UPDATE(stringRes.update, drawableRes.ic_download), ++ LAUNCH(stringRes.launch, drawableRes.ic_launch), ++ DETAILS(stringRes.details, drawableRes.ic_tune), ++ UNINSTALL(stringRes.uninstall, drawableRes.ic_delete), ++ CANCEL(stringRes.cancel, drawableRes.ic_cancel), ++ SHARE(stringRes.share, drawableRes.ic_share) ++ } ++ ++ sealed interface Status { ++ data object Idle : Status ++ data object Pending : Status ++ data object Connecting : Status ++ data class Downloading(val read: DataSize, val total: DataSize?) : Status ++ data object PendingInstall : Status ++ data object Installing : Status ++ } ++ ++ enum class ViewType { ++ APP_INFO, ++ DOWNLOAD_STATUS, ++ INSTALL_BUTTON, ++ SCREENSHOT, ++ SWITCH, ++ SECTION, ++ EXPAND, ++ TEXT, ++ LINK, ++ PERMISSIONS, ++ RELEASE, ++ EMPTY ++ } ++ ++ private enum class SwitchType(val titleResId: Int) { ++ IGNORE_ALL_UPDATES(stringRes.ignore_all_updates), ++ IGNORE_THIS_UPDATE(stringRes.ignore_this_update) ++ } ++ ++ private enum class SectionType( ++ val titleResId: Int, ++ val colorAttrResId: Int = MaterialR.attr.colorPrimary ++ ) { ++ ANTI_FEATURES(stringRes.anti_features, MaterialR.attr.colorError), ++ CHANGES(stringRes.changes), ++ LINKS(stringRes.links), ++ DONATE(stringRes.donate), ++ PERMISSIONS(stringRes.permissions), ++ VERSIONS(stringRes.versions) ++ } ++ ++ internal enum class ExpandType { ++ NOTHING, DESCRIPTION, CHANGES, ++ LINKS, DONATES, PERMISSIONS, VERSIONS ++ } ++ ++ private enum class TextType { DESCRIPTION, ANTI_FEATURES, CHANGES } ++ ++ private enum class LinkType( ++ val iconResId: Int, ++ val titleResId: Int, ++ val format: ((Context, String) -> String)? = null ++ ) { ++ SOURCE(drawableRes.ic_code, stringRes.source_code), ++ AUTHOR(drawableRes.ic_person, stringRes.author_website), ++ EMAIL(drawableRes.ic_email, stringRes.author_email), ++ LICENSE( ++ drawableRes.ic_copyright, ++ stringRes.license, ++ format = { context, text -> context.getString(stringRes.license_FORMAT, text) } ++ ), ++ TRACKER(drawableRes.ic_bug_report, stringRes.bug_tracker), ++ CHANGELOG(drawableRes.ic_history, stringRes.changelog), ++ WEB(drawableRes.ic_public, stringRes.project_website) ++ } ++ ++ private sealed class Item { ++ abstract val descriptor: String ++ abstract val viewType: ViewType ++ ++ class AppInfoItem( ++ val repository: Repository, ++ val product: Product ++ ) : Item() { ++ override val descriptor: String ++ get() = "app_info.${product.name}" ++ ++ override val viewType: ViewType ++ get() = ViewType.APP_INFO ++ } ++ ++ data object DownloadStatusItem : Item() { ++ override val descriptor: String ++ get() = "download_status" ++ override val viewType: ViewType ++ get() = ViewType.DOWNLOAD_STATUS ++ } ++ ++ data object InstallButtonItem : Item() { ++ override val descriptor: String ++ get() = "install_button" ++ override val viewType: ViewType ++ get() = ViewType.INSTALL_BUTTON ++ } ++ ++ class ScreenshotItem( ++ val screenshots: List, ++ val packageName: String, ++ val repository: Repository ++ ) : Item() { ++ override val descriptor: String ++ get() = "screenshot.${screenshots.size}" ++ override val viewType: ViewType ++ get() = ViewType.SCREENSHOT ++ } ++ ++ class SwitchItem( ++ val switchType: SwitchType, ++ val packageName: String, ++ val versionCode: Long ++ ) : Item() { ++ override val descriptor: String ++ get() = "switch.${switchType.name}" ++ ++ override val viewType: ViewType ++ get() = ViewType.SWITCH ++ } ++ ++ class SectionItem( ++ val sectionType: SectionType, ++ val expandType: ExpandType, ++ val items: List, ++ val collapseCount: Int ++ ) : Item() { ++ constructor(sectionType: SectionType) : this( ++ sectionType, ++ ExpandType.NOTHING, ++ emptyList(), ++ 0 ++ ) ++ ++ override val descriptor: String ++ get() = "section.${sectionType.name}" ++ ++ override val viewType: ViewType ++ get() = ViewType.SECTION ++ } ++ ++ class ExpandItem( ++ val expandType: ExpandType, ++ val replace: Boolean, ++ val items: List ++ ) : Item() { ++ override val descriptor: String ++ get() = "expand.${expandType.name}" ++ ++ override val viewType: ViewType ++ get() = ViewType.EXPAND ++ } ++ ++ class TextItem(val textType: TextType, val text: CharSequence) : Item() { ++ override val descriptor: String ++ get() = "text.${textType.name}" ++ ++ override val viewType: ViewType ++ get() = ViewType.TEXT ++ } ++ ++ sealed class LinkItem : Item() { ++ override val viewType: ViewType ++ get() = ViewType.LINK ++ ++ abstract val iconResId: Int ++ abstract fun getTitle(context: Context): String ++ abstract val uri: Uri? ++ ++ val displayLink: String? ++ get() = uri?.schemeSpecificPart?.nullIfEmpty() ++ ?.let { if (it.startsWith("//")) null else it } ?: uri?.toString() ++ ++ class Typed( ++ val linkType: LinkType, ++ val text: String, ++ override val uri: Uri? ++ ) : LinkItem() { ++ override val descriptor: String ++ get() = "link.typed.${linkType.name}" ++ ++ override val iconResId: Int ++ get() = linkType.iconResId ++ ++ override fun getTitle(context: Context): String { ++ return text.nullIfEmpty()?.let { linkType.format?.invoke(context, it) ?: it } ++ ?: context.getString(linkType.titleResId) ++ } ++ } ++ ++ class Donate(val donate: Product.Donate) : LinkItem() { ++ override val descriptor: String ++ get() = "link.donate.$donate" ++ ++ override val iconResId: Int ++ get() = when (donate) { ++ is Product.Donate.Regular -> drawableRes.ic_donate ++ is Product.Donate.Bitcoin -> drawableRes.ic_donate_bitcoin ++ is Product.Donate.Litecoin -> drawableRes.ic_donate_litecoin ++ is Product.Donate.Flattr -> drawableRes.ic_donate_flattr ++ is Product.Donate.Liberapay -> drawableRes.ic_donate_liberapay ++ is Product.Donate.OpenCollective -> drawableRes.ic_donate_opencollective ++ } ++ ++ override fun getTitle(context: Context): String = when (donate) { ++ is Product.Donate.Regular -> context.getString(stringRes.website) ++ is Product.Donate.Bitcoin -> "Bitcoin" ++ is Product.Donate.Litecoin -> "Litecoin" ++ is Product.Donate.Flattr -> "Flattr" ++ is Product.Donate.Liberapay -> "Liberapay" ++ is Product.Donate.OpenCollective -> "Open Collective" ++ } ++ ++ override val uri: Uri? = when (donate) { ++ is Product.Donate.Regular -> Uri.parse(donate.url) ++ is Product.Donate.Bitcoin -> Uri.parse("bitcoin:${donate.address}") ++ is Product.Donate.Litecoin -> Uri.parse("litecoin:${donate.address}") ++ is Product.Donate.Flattr -> Uri.parse( ++ "https://flattr.com/thing/${donate.id}" ++ ) ++ ++ is Product.Donate.Liberapay -> Uri.parse( ++ "https://liberapay.com/~${donate.id}" ++ ) ++ ++ is Product.Donate.OpenCollective -> Uri.parse( ++ "https://opencollective.com/${donate.id}" ++ ) ++ } ++ } ++ } ++ ++ class PermissionsItem( ++ val group: PermissionGroupInfo?, ++ val permissions: List ++ ) : Item() { ++ override val descriptor: String ++ get() = "permissions.${group?.name}" + ++ ".${permissions.joinToString(separator = ".") { it.name }}" ++ ++ override val viewType: ViewType ++ get() = ViewType.PERMISSIONS ++ } ++ ++ class ReleaseItem( ++ val repository: Repository, ++ val release: Release, ++ val selectedRepository: Boolean, ++ val showSignature: Boolean ++ ) : Item() { ++ override val descriptor: String ++ get() = "release.${repository.id}.${release.identifier}" ++ ++ override val viewType: ViewType ++ get() = ViewType.RELEASE ++ } ++ ++ class EmptyItem(val packageName: String, val repoAddress: String?) : Item() { ++ override val descriptor: String ++ get() = "empty" ++ ++ override val viewType: ViewType ++ get() = ViewType.EMPTY ++ } ++ } ++ ++ private class Measurement { ++ private var density = 0f ++ private var scaledDensity = 0f ++ private lateinit var metric: T ++ ++ fun measure(view: View) { ++ View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) ++ .let { view.measure(it, it) } ++ } ++ ++ fun invalidate(resources: Resources, callback: () -> T): T { ++ val (density, scaledDensity) = resources.displayMetrics.let { ++ Pair( ++ it.density, ++ it.scaledDensity ++ ) ++ } ++ if (this.density != density || this.scaledDensity != scaledDensity) { ++ this.density = density ++ this.scaledDensity = scaledDensity ++ metric = callback() ++ } ++ return metric ++ } ++ } ++ ++ @Volatile ++ private var isFavourite: Boolean = false ++ ++ private class AppInfoViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { ++ val icon = itemView.findViewById(R.id.app_icon)!! ++ val name = itemView.findViewById(R.id.app_name)!! ++ val authorName = itemView.findViewById(R.id.author_name)!! ++ val packageName = itemView.findViewById(R.id.package_name)!! ++ val textSwitcher = itemView.findViewById(R.id.author_package_name)!! ++ ++ init { ++ textSwitcher.setInAnimation(itemView.context!!, R.anim.slide_right_fade_in) ++ textSwitcher.setOutAnimation(itemView.context!!, R.anim.slide_right_fade_out) ++ } ++ ++ val version = itemView.findViewById(R.id.version)!! ++ val size = itemView.findViewById(R.id.size)!! ++ val dev = itemView.findViewById(R.id.dev_block)!! ++ ++ val favouriteButton = itemView.findViewById(R.id.favourite)!! ++ } ++ ++ private class DownloadStatusViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { ++ val statusText = itemView.findViewById(R.id.status)!! ++ val progress = itemView.findViewById(R.id.progress)!! ++ } ++ ++ private class InstallButtonViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { ++ val button = itemView.findViewById(R.id.action)!! ++ ++ val actionTintNormal = button.context.getColorFromAttr(MaterialR.attr.colorPrimary) ++ val actionTintOnNormal = button.context.getColorFromAttr(MaterialR.attr.colorOnPrimary) ++ val actionTintCancel = button.context.getColorFromAttr(MaterialR.attr.colorError) ++ val actionTintOnCancel = button.context.getColorFromAttr(MaterialR.attr.colorOnError) ++ val actionTintDisabled = button.context.getColorFromAttr(MaterialR.attr.colorOutline) ++ val actionTintOnDisabled = button.context.getColorFromAttr(android.R.attr.colorBackground) ++ ++ init { ++ button.height = itemView.resources.sizeScaled(48) ++ } ++ } ++ ++ private class ScreenShotViewHolder(context: Context) : ++ RecyclerView.ViewHolder(RecyclerView(context)) { ++ ++ val screenshotsRecycler: RecyclerView ++ get() = itemView as RecyclerView ++ } ++ ++ private class SwitchViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { ++ val switch = itemView.findViewById(R.id.update_state_switch)!! ++ ++ val statefulViews: Sequence ++ get() = sequenceOf(itemView, switch) ++ } ++ ++ private class SectionViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { ++ val title = itemView.findViewById(R.id.title)!! ++ val icon = itemView.findViewById(R.id.icon)!! ++ } ++ ++ private class ExpandViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { ++ val button = itemView.findViewById(R.id.expand_view_button)!! ++ } ++ ++ private class TextViewHolder(context: Context) : ++ RecyclerView.ViewHolder(TextView(context)) { ++ val text: TextView ++ get() = itemView as TextView ++ ++ init { ++ with(itemView as TextView) { ++ setTextIsSelectable(true) ++ setTextSizeScaled(15) ++ isFocusable = false ++ 16.dp.let { itemView.setPadding(it, it, it, it) } ++ movementMethod = LinkMovementMethod() ++ layoutParams = RecyclerView.LayoutParams( ++ RecyclerView.LayoutParams.MATCH_PARENT, ++ RecyclerView.LayoutParams.WRAP_CONTENT ++ ) ++ } ++ } ++ } ++ ++ @SuppressLint("ClickableViewAccessibility") ++ private open class OverlappingViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { ++ init { ++ // Block touch events if touched above negative margin ++ itemView.setOnTouchListener { _, event -> ++ event.action == MotionEvent.ACTION_DOWN && run { ++ val top = (itemView.layoutParams as ViewGroup.MarginLayoutParams).topMargin ++ top < 0 && event.y < -top ++ } ++ } ++ } ++ } ++ ++ private class LinkViewHolder(itemView: View) : OverlappingViewHolder(itemView) { ++ companion object { ++ private val measurement = Measurement() ++ } ++ ++ val icon = itemView.findViewById(R.id.icon)!! ++ val text = itemView.findViewById(R.id.text)!! ++ val link = itemView.findViewById(R.id.link)!! ++ ++ init { ++ text.typeface = TypefaceExtra.medium ++ val margin = measurement.invalidate(itemView.resources) { ++ @SuppressLint("SetTextI18n") ++ text.text = "measure" ++ link.visibility = View.GONE ++ measurement.measure(itemView) ++ ((itemView.measuredHeight - icon.measuredHeight) / 2f).roundToInt() ++ } ++ (icon.layoutParams as ViewGroup.MarginLayoutParams).apply { ++ topMargin += margin ++ bottomMargin += margin ++ } ++ } ++ } ++ ++ private class PermissionsViewHolder(itemView: View) : OverlappingViewHolder(itemView) { ++ companion object { ++ private val measurement = Measurement() ++ } ++ ++ val icon = itemView.findViewById(R.id.icon)!! ++ val text = itemView.findViewById(R.id.text)!! ++ ++ init { ++ val margin = measurement.invalidate(itemView.resources) { ++ @SuppressLint("SetTextI18n") ++ text.text = "measure" ++ measurement.measure(itemView) ++ ((itemView.measuredHeight - icon.measuredHeight) / 2f).roundToInt() ++ } ++ (icon.layoutParams as ViewGroup.MarginLayoutParams).apply { ++ topMargin += margin ++ bottomMargin += margin ++ } ++ } ++ } ++ ++ private class ReleaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { ++ val dateFormat = DateFormat.getDateFormat(itemView.context)!! ++ ++ val version = itemView.findViewById(R.id.version)!! ++ val status = itemView.findViewById(R.id.installation_status)!! ++ val source = itemView.findViewById(R.id.source)!! ++ val added = itemView.findViewById(R.id.added)!! ++ val size = itemView.findViewById(R.id.size)!! ++ val signature = itemView.findViewById(R.id.signature)!! ++ val compatibility = itemView.findViewById(R.id.compatibility)!! ++ ++ val statefulViews: Sequence ++ get() = sequenceOf( ++ itemView, ++ version, ++ status, ++ source, ++ added, ++ size, ++ signature, ++ compatibility ++ ) ++ } ++ ++ private class EmptyViewHolder(context: Context) : ++ RecyclerView.ViewHolder(LinearLayout(context)) { ++ val packageName = TextView(context) ++ val repoTitle = TextView(context) ++ val repoAddress = TextView(context) ++ val copyRepoAddress = MaterialButton(context) ++ ++ init { ++ with(itemView as LinearLayout) { ++ layoutParams = RecyclerView.LayoutParams( ++ RecyclerView.LayoutParams.MATCH_PARENT, ++ RecyclerView.LayoutParams.MATCH_PARENT ++ ) ++ orientation = LinearLayout.VERTICAL ++ gravity = Gravity.CENTER ++ setPadding(20.dp, 20.dp, 20.dp, 20.dp) ++ val imageView = ImageView(context) ++ val bitmap = Bitmap.createBitmap( ++ 64.dp.px.roundToInt(), ++ 32.dp.px.roundToInt(), ++ Bitmap.Config.ARGB_8888 ++ ) ++ val canvas = Canvas(bitmap) ++ val title = TextView(context) ++ with(title) { ++ gravity = Gravity.CENTER ++ typeface = TypefaceExtra.medium ++ setTextColor(context.getColorFromAttr(MaterialR.attr.colorPrimary)) ++ setTextSizeScaled(20) ++ setText(stringRes.application_not_found) ++ setPadding(0, 12.dp, 0, 12.dp) ++ } ++ with(packageName) { ++ gravity = Gravity.CENTER ++ setTextColor(context.getColorFromAttr(MaterialR.attr.colorOutline)) ++ typeface = Typeface.DEFAULT_BOLD ++ setTextSizeScaled(16) ++ background = context.corneredBackground ++ setPadding(0, 12.dp, 0, 12.dp) ++ } ++ val waveHeight = 2.dp.px ++ val waveWidth = 12.dp.px ++ with(canvas) { ++ val linePaint = Paint().apply { ++ color = context.getColorFromAttr(MaterialR.attr.colorOutline).defaultColor ++ strokeWidth = 8f ++ strokeCap = Paint.Cap.ROUND ++ strokeJoin = Paint.Join.ROUND ++ } ++ for (x in 12..(width - 12)) { ++ val yValue = ++ ( ++ ( ++ sin(x * (2f * PI / waveWidth)) * ++ (waveHeight / (2)) + ++ (waveHeight / 2) ++ ).toFloat() + ++ (0 - (waveHeight / 2)) ++ ) + height / 2 ++ drawPoint(x.toFloat(), yValue, linePaint) ++ } ++ } ++ imageView.load(bitmap) ++ with(repoTitle) { ++ gravity = Gravity.CENTER ++ typeface = TypefaceExtra.medium ++ setTextColor(context.getColorFromAttr(MaterialR.attr.colorPrimary)) ++ setTextSizeScaled(20) ++ setPadding(0, 0, 0, 12.dp) ++ } ++ with(repoAddress) { ++ gravity = Gravity.CENTER ++ setTextColor(context.getColorFromAttr(MaterialR.attr.colorOutline)) ++ typeface = Typeface.DEFAULT_BOLD ++ setTextSizeScaled(16) ++ background = context.corneredBackground ++ setPadding(0, 12.dp, 0, 12.dp) ++ } ++ with(copyRepoAddress) { ++ icon = context.open ++ setText(stringRes.add_repository) ++ setBackgroundColor(context.getColor(android.R.color.transparent)) ++ setTextColor(context.getColorFromAttr(MaterialR.attr.colorPrimary)) ++ iconTint = context.getColorFromAttr(MaterialR.attr.colorPrimary) ++ } ++ addView( ++ title, ++ LinearLayout.LayoutParams.MATCH_PARENT, ++ LinearLayout.LayoutParams.WRAP_CONTENT ++ ) ++ addView( ++ packageName, ++ LinearLayout.LayoutParams.MATCH_PARENT, ++ LinearLayout.LayoutParams.WRAP_CONTENT ++ ) ++ addView( ++ imageView, ++ LinearLayout.LayoutParams.MATCH_PARENT, ++ LinearLayout.LayoutParams.WRAP_CONTENT ++ ) ++ addView( ++ repoTitle, ++ LinearLayout.LayoutParams.MATCH_PARENT, ++ LinearLayout.LayoutParams.WRAP_CONTENT ++ ) ++ addView( ++ repoAddress, ++ LinearLayout.LayoutParams.MATCH_PARENT, ++ LinearLayout.LayoutParams.WRAP_CONTENT ++ ) ++ addView( ++ copyRepoAddress, ++ LinearLayout.LayoutParams.WRAP_CONTENT, ++ LinearLayout.LayoutParams.WRAP_CONTENT ++ ) ++ } ++ } ++ } ++ ++ private val items = mutableListOf() ++ private val expanded = mutableSetOf() ++ private var product: Product? = null ++ private var installedItem: InstalledItem? = null ++ ++ fun setProducts( ++ context: Context, ++ packageName: String, ++ suggestedRepo: String? = null, ++ products: List>, ++ installedItem: InstalledItem?, ++ isFavourite: Boolean, ++ allowIncompatibleVersion: Boolean ++ ) { ++ items.clear() ++ val productRepository = products.findSuggested(installedItem) ?: run { ++ items += Item.EmptyItem(packageName, suggestedRepo) ++ notifyDataSetChanged() ++ return ++ } ++ ++ this.product = productRepository.first ++ this.installedItem = installedItem ++ this.isFavourite = isFavourite ++ ++ items += Item.AppInfoItem( ++ productRepository.second, ++ productRepository.first ++ ) ++ ++ items += Item.DownloadStatusItem ++ items += Item.InstallButtonItem ++ ++ if (productRepository.first.screenshots.isNotEmpty()) { ++ val screenShotItem = mutableListOf() ++ screenShotItem += Item.ScreenshotItem( ++ productRepository.first.screenshots, ++ packageName, ++ productRepository.second ++ ) ++ items += screenShotItem ++ } ++ ++ if (installedItem != null) { ++ items.add( ++ Item.SwitchItem( ++ SwitchType.IGNORE_ALL_UPDATES, ++ packageName, ++ productRepository.first.versionCode ++ ) ++ ) ++ if (productRepository.first.canUpdate(installedItem)) { ++ items.add( ++ Item.SwitchItem( ++ SwitchType.IGNORE_THIS_UPDATE, ++ packageName, ++ productRepository.first.versionCode ++ ) ++ ) ++ } ++ } ++ ++ val textViewHolder = TextViewHolder(context) ++ val textViewWidthSpec = context.resources.displayMetrics.widthPixels ++ .let { View.MeasureSpec.makeMeasureSpec(it, View.MeasureSpec.EXACTLY) } ++ val textViewHeightSpec = ++ View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) ++ ++ fun CharSequence.lineCropped(maxLines: Int, cropLines: Int): CharSequence? { ++ assert(cropLines <= maxLines) ++ textViewHolder.text.text = this ++ textViewHolder.text.measure(textViewWidthSpec, textViewHeightSpec) ++ textViewHolder.text.layout( ++ 0, ++ 0, ++ textViewHolder.text.measuredWidth, ++ textViewHolder.text.measuredHeight ++ ) ++ val layout = textViewHolder.text.layout ++ val cropLineOffset = ++ if (layout.lineCount <= maxLines) -1 else layout.getLineEnd(cropLines - 1) ++ val paragraphEndIndex = if (cropLineOffset < 0) { ++ -1 ++ } else { ++ indexOf("\n\n", cropLineOffset).let { if (it >= 0) it else length } ++ } ++ val paragraphEndLine = if (paragraphEndIndex < 0) { ++ -1 ++ } else { ++ layout.getLineForOffset(paragraphEndIndex).apply { assert(this >= 0) } ++ } ++ val end = when { ++ cropLineOffset < 0 -> -1 ++ paragraphEndLine >= 0 && paragraphEndLine - (cropLines - 1) <= 3 -> ++ if (paragraphEndIndex < length) paragraphEndIndex else -1 ++ ++ else -> cropLineOffset ++ } ++ val length = if (end < 0) { ++ -1 ++ } else { ++ asSequence().take(end) ++ .indexOfLast { it != '\n' }.let { if (it >= 0) it + 1 else end } ++ } ++ return if (length >= 0) subSequence(0, length) else null ++ } ++ ++ val description = formatHtml(productRepository.first.description).apply { ++ if (productRepository.first.let { it.summary.isNotEmpty() && it.name != it.summary }) { ++ if (isNotEmpty()) { ++ insert(0, "\n\n") ++ } ++ insert(0, productRepository.first.summary) ++ if (isNotEmpty()) { ++ setSpan( ++ TypefaceSpan("sans-serif-medium"), ++ 0, ++ productRepository.first.summary.length, ++ SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE ++ ) ++ } ++ } ++ } ++ if (description.isNotEmpty()) { ++ val cropped = if (ExpandType.DESCRIPTION !in expanded) { ++ description.lineCropped( ++ 12, ++ 10 ++ ) ++ } else { ++ null ++ } ++ val item = Item.TextItem(TextType.DESCRIPTION, description) ++ if (cropped != null) { ++ val croppedItem = Item.TextItem(TextType.DESCRIPTION, cropped) ++ items += listOf( ++ croppedItem, ++ Item.ExpandItem(ExpandType.DESCRIPTION, true, listOf(item, croppedItem)) ++ ) ++ } else { ++ items += item ++ } ++ } ++ ++ val antiFeatures = productRepository.first.antiFeatures.map { ++ when (it) { ++ "Ads" -> context.getString(stringRes.has_advertising) ++ "ApplicationDebuggable" -> context.getString(stringRes.compiled_for_debugging) ++ "DisabledAlgorithm" -> context.getString(stringRes.signed_using_unsafe_algorithm) ++ "KnownVuln" -> context.getString(stringRes.has_security_vulnerabilities) ++ "NoSourceSince" -> context.getString(stringRes.source_code_no_longer_available) ++ "NonFreeAdd" -> context.getString(stringRes.promotes_non_free_software) ++ "NonFreeAssets" -> context.getString(stringRes.contains_non_free_media) ++ "NonFreeDep" -> context.getString(stringRes.has_non_free_dependencies) ++ "NonFreeNet" -> context.getString(stringRes.promotes_non_free_network_services) ++ "NSFW" -> context.getString(stringRes.contains_nsfw) ++ "Tracking" -> context.getString(stringRes.tracks_or_reports_your_activity) ++ "UpstreamNonFree" -> context.getString(stringRes.upstream_source_code_is_not_free) ++ // special tag(https://floss.social/@IzzyOnDroid/110815951568369581) ++ // apps include non-free libraries ++ "NonFreeComp" -> context.getString(stringRes.has_non_free_components) ++ else -> context.getString(stringRes.unknown_FORMAT, it) ++ } ++ }.joinToString(separator = "\n") { "\u2022 $it" } ++ if (antiFeatures.isNotEmpty()) { ++ items += Item.SectionItem(SectionType.ANTI_FEATURES) ++ items += Item.TextItem(TextType.ANTI_FEATURES, antiFeatures) ++ } ++ ++ val changes = formatHtml(productRepository.first.whatsNew) ++ if (changes.isNotEmpty()) { ++ items += Item.SectionItem(SectionType.CHANGES) ++ val cropped = ++ if (ExpandType.CHANGES !in expanded) { ++ changes.lineCropped(12, 10) ++ } else { ++ null ++ } ++ val item = Item.TextItem(TextType.CHANGES, changes) ++ if (cropped != null) { ++ val croppedItem = Item.TextItem(TextType.CHANGES, cropped) ++ items += listOf( ++ croppedItem, ++ Item.ExpandItem(ExpandType.CHANGES, true, listOf(item, croppedItem)) ++ ) ++ } else { ++ items += item ++ } ++ } ++ ++ val linkItems = mutableListOf() ++ with(productRepository.first) { ++ source.let { link -> ++ if (link.isNotEmpty()) { ++ linkItems += Item.LinkItem.Typed( ++ LinkType.SOURCE, ++ "", ++ link.toUri() ++ ) ++ } ++ } ++ ++ if (author.name.isNotEmpty() || author.web.isNotEmpty()) { ++ linkItems += Item.LinkItem.Typed( ++ LinkType.AUTHOR, ++ author.name, ++ author.web.nullIfEmpty()?.let(Uri::parse) ++ ) ++ } ++ author.email.nullIfEmpty()?.let { ++ linkItems += Item.LinkItem.Typed(LinkType.EMAIL, "", Uri.parse("mailto:$it")) ++ } ++ linkItems += licenses.asSequence().map { ++ Item.LinkItem.Typed( ++ LinkType.LICENSE, ++ it, ++ Uri.parse("https://spdx.org/licenses/$it.html") ++ ) ++ } ++ tracker.nullIfEmpty() ++ ?.let { linkItems += Item.LinkItem.Typed(LinkType.TRACKER, "", Uri.parse(it)) } ++ changelog.nullIfEmpty()?.let { ++ linkItems += Item.LinkItem.Typed( ++ LinkType.CHANGELOG, ++ "", ++ Uri.parse(it) ++ ) ++ } ++ web.nullIfEmpty() ++ ?.let { linkItems += Item.LinkItem.Typed(LinkType.WEB, "", Uri.parse(it)) } ++ } ++ if (linkItems.isNotEmpty()) { ++ if (ExpandType.LINKS in expanded) { ++ items += Item.SectionItem( ++ SectionType.LINKS, ++ ExpandType.LINKS, ++ emptyList(), ++ linkItems.size ++ ) ++ items += linkItems ++ } else { ++ items += Item.SectionItem(SectionType.LINKS, ExpandType.LINKS, linkItems, 0) ++ } ++ } ++ ++ val donateItems = productRepository.first.donates.map(Item.LinkItem::Donate) ++ if (donateItems.isNotEmpty()) { ++ if (ExpandType.DONATES in expanded) { ++ items += Item.SectionItem( ++ SectionType.DONATE, ++ ExpandType.DONATES, ++ emptyList(), ++ donateItems.size ++ ) ++ items += donateItems ++ } else { ++ items += Item.SectionItem( ++ SectionType.DONATE, ++ ExpandType.DONATES, ++ donateItems, ++ 0 ++ ) ++ } ++ } ++ ++ val release = productRepository.first.displayRelease ++ if (release != null) { ++ val packageManager = context.packageManager ++ val permissions = release.permissions ++ .asSequence().mapNotNull { ++ try { ++ packageManager.getPermissionInfo(it, 0) ++ } catch (e: Exception) { ++ null ++ } ++ } ++ .groupBy(PackageItemResolver::getPermissionGroup) ++ .asSequence().map { (group, permissionInfo) -> ++ val permissionGroupInfo = try { ++ group?.let { packageManager.getPermissionGroupInfo(it, 0) } ++ } catch (e: Exception) { ++ null ++ } ++ Pair(permissionGroupInfo, permissionInfo) ++ } ++ .groupBy({ it.first }, { it.second }) ++ if (permissions.isNotEmpty()) { ++ val permissionsItems = mutableListOf() ++ permissionsItems += permissions.asSequence().filter { it.key != null } ++ .map { Item.PermissionsItem(it.key, it.value.flatten()) } ++ permissions.asSequence().find { it.key == null } ++ ?.let { ++ permissionsItems += Item.PermissionsItem(null, it.value.flatten()) ++ } ++ if (ExpandType.PERMISSIONS in expanded) { ++ items += Item.SectionItem( ++ SectionType.PERMISSIONS, ++ ExpandType.PERMISSIONS, ++ emptyList(), ++ permissionsItems.size ++ ) ++ items += permissionsItems ++ } else { ++ items += Item.SectionItem( ++ SectionType.PERMISSIONS, ++ ExpandType.PERMISSIONS, ++ permissionsItems, ++ 0 ++ ) ++ } ++ } ++ } ++ ++ val compatibleReleasePairs = products.asSequence() ++ .flatMap { (product, repository) -> ++ product.releases.asSequence() ++ .filter { allowIncompatibleVersion || it.incompatibilities.isEmpty() } ++ .map { Pair(it, repository) } ++ } ++ ++ val versionsWithMultiSignature = compatibleReleasePairs ++ .filterNot { release?.signature?.isEmpty() == true } ++ .map { (release, _) -> release.versionCode to release.signature } ++ .distinct() ++ .groupBy { it.first } ++ .filter { (_, entry) -> entry.size >= 2 } ++ .keys ++ ++ val releaseItems = compatibleReleasePairs ++ .map { (release, repository) -> ++ Item.ReleaseItem( ++ repository = repository, ++ release = release, ++ selectedRepository = repository.id == productRepository.second.id, ++ showSignature = release.versionCode in versionsWithMultiSignature ++ ) ++ } ++ .sortedByDescending { it.release.versionCode } ++ .toList() ++ if (releaseItems.isNotEmpty()) { ++ items += Item.SectionItem(SectionType.VERSIONS) ++ if (releaseItems.size > MAX_RELEASE_ITEMS && ExpandType.VERSIONS !in expanded) { ++ items += releaseItems.take(MAX_RELEASE_ITEMS) ++ items += Item.ExpandItem( ++ ExpandType.VERSIONS, ++ false, ++ releaseItems.takeLast(releaseItems.size - MAX_RELEASE_ITEMS) ++ ) ++ } else { ++ items += releaseItems ++ } ++ } ++ ++ this.product = productRepository.first ++ this.installedItem = installedItem ++ notifyDataSetChanged() ++ } ++ ++ var action: Action? = null ++ set(value) { ++ val index = items.indexOf(Item.InstallButtonItem) ++ val progressBarIndex = items.indexOf(Item.DownloadStatusItem) ++ if (index > 0 && progressBarIndex > 0) { ++ notifyItemChanged(index) ++ notifyItemChanged(progressBarIndex) ++ } ++ field = value ++ } ++ ++ var status: Status = Status.Idle ++ set(value) { ++ if (field != value) { ++ val index = items.indexOf(Item.DownloadStatusItem) ++ if (index > 0) notifyItemChanged(index) ++ } ++ field = value ++ } ++ ++ override val viewTypeClass: Class ++ get() = ViewType::class.java ++ ++ override fun getItemCount(): Int = items.size ++ override fun getItemDescriptor(position: Int): String = items[position].descriptor ++ override fun getItemEnumViewType(position: Int): ViewType = items[position].viewType ++ ++ override fun onCreateViewHolder( ++ parent: ViewGroup, ++ viewType: ViewType ++ ): RecyclerView.ViewHolder { ++ return when (viewType) { ++ ViewType.APP_INFO -> AppInfoViewHolder(parent.inflate(R.layout.app_detail_header)) ++ .apply { ++ favouriteButton.setOnClickListener { callbacks.onFavouriteClicked() } ++ } ++ ++ ViewType.DOWNLOAD_STATUS -> DownloadStatusViewHolder( ++ parent.inflate(R.layout.download_status) ++ ) ++ ++ ViewType.INSTALL_BUTTON -> InstallButtonViewHolder( ++ parent.inflate(R.layout.install_button) ++ ).apply { ++ button.setOnClickListener { action?.let(callbacks::onActionClick) } ++ } ++ ++ ViewType.SCREENSHOT -> ScreenShotViewHolder(parent.context) ++ ViewType.SWITCH -> SwitchViewHolder(parent.inflate(R.layout.switch_item)).apply { ++ itemView.setOnClickListener { ++ val switchItem = items[absoluteAdapterPosition] as Item.SwitchItem ++ val productPreference = when (switchItem.switchType) { ++ SwitchType.IGNORE_ALL_UPDATES -> { ++ ProductPreferences[switchItem.packageName].let { ++ it.copy( ++ ignoreUpdates = !it.ignoreUpdates ++ ) ++ } ++ } ++ ++ SwitchType.IGNORE_THIS_UPDATE -> { ++ ProductPreferences[switchItem.packageName].let { ++ it.copy( ++ ignoreVersionCode = ++ if (it.ignoreVersionCode == switchItem.versionCode) { ++ 0 ++ } else { ++ switchItem.versionCode ++ } ++ ) ++ } ++ } ++ } ++ ProductPreferences[switchItem.packageName] = productPreference ++ callbacks.onPreferenceChanged(productPreference) ++ } ++ } ++ ++ ViewType.SECTION -> SectionViewHolder(parent.inflate(R.layout.section_item)).apply { ++ itemView.setOnClickListener { ++ val position = absoluteAdapterPosition ++ val sectionItem = items[position] as Item.SectionItem ++ if (sectionItem.items.isNotEmpty()) { ++ expanded += sectionItem.expandType ++ items[position] = Item.SectionItem( ++ sectionItem.sectionType, ++ sectionItem.expandType, ++ emptyList(), ++ sectionItem.items.size + sectionItem.collapseCount ++ ) ++ notifyItemChanged(position) ++ items.addAll(position + 1, sectionItem.items) ++ notifyItemRangeInserted(position + 1, sectionItem.items.size) ++ } else if (sectionItem.collapseCount > 0) { ++ expanded -= sectionItem.expandType ++ items[position] = Item.SectionItem( ++ sectionItem.sectionType, ++ sectionItem.expandType, ++ items.subList(position + 1, position + 1 + sectionItem.collapseCount) ++ .toList(), ++ 0 ++ ) ++ notifyItemChanged(position) ++ repeat(sectionItem.collapseCount) { items.removeAt(position + 1) } ++ notifyItemRangeRemoved(position + 1, sectionItem.collapseCount) ++ } ++ } ++ } ++ ++ ViewType.EXPAND -> ExpandViewHolder(parent.inflate(R.layout.expand_view_button)) ++ .apply { ++ itemView.setOnClickListener { ++ val position = absoluteAdapterPosition ++ val expandItem = items[position] as Item.ExpandItem ++ if (expandItem.expandType !in expanded) { ++ expanded += expandItem.expandType ++ if (expandItem.replace) { ++ items[position - 1] = expandItem.items[0] ++ notifyItemRangeChanged(position - 1, 2) ++ } else { ++ items.addAll(position, expandItem.items) ++ if (position > 0) { ++ notifyItemRangeInserted(position, expandItem.items.size) ++ notifyItemChanged(position + expandItem.items.size) ++ } ++ } ++ } else { ++ expanded -= expandItem.expandType ++ if (expandItem.replace) { ++ items[position - 1] = expandItem.items[1] ++ notifyItemRangeChanged(position - 1, 2) ++ } else { ++ items.removeAll(expandItem.items) ++ if (position > 0) { ++ notifyItemRangeRemoved( ++ position - expandItem.items.size, ++ expandItem.items.size ++ ) ++ notifyItemChanged(position - expandItem.items.size) ++ } ++ } ++ } ++ } ++ } ++ ++ ViewType.TEXT -> TextViewHolder(parent.context) ++ ViewType.LINK -> LinkViewHolder(parent.inflate(R.layout.link_item)).apply { ++ itemView.setOnClickListener { ++ val linkItem = items[absoluteAdapterPosition] as Item.LinkItem ++ if (linkItem.uri?.let { callbacks.onUriClick(it, false) } != true) { ++ linkItem.displayLink?.let { copyLinkToClipboard(itemView, it) } ++ } ++ } ++ itemView.setOnLongClickListener { ++ val linkItem = items[absoluteAdapterPosition] as Item.LinkItem ++ linkItem.displayLink?.let { copyLinkToClipboard(itemView, it) } ++ true ++ } ++ } ++ ++ ViewType.PERMISSIONS -> PermissionsViewHolder(parent.inflate(R.layout.permissions_item)) ++ .apply { ++ itemView.setOnClickListener { ++ val permissionsItem = items[absoluteAdapterPosition] as Item.PermissionsItem ++ callbacks.onPermissionsClick( ++ permissionsItem.group?.name, ++ permissionsItem.permissions.map { it.name } ++ ) ++ } ++ } ++ ++ ViewType.RELEASE -> ReleaseViewHolder(parent.inflate(R.layout.release_item)).apply { ++ itemView.setOnClickListener { ++ val releaseItem = items[absoluteAdapterPosition] as Item.ReleaseItem ++ callbacks.onReleaseClick(releaseItem.release) ++ } ++ itemView.setOnLongClickListener { ++ val releaseItem = items[absoluteAdapterPosition] as Item.ReleaseItem ++ copyLinkToClipboard( ++ itemView, ++ releaseItem.release.getDownloadUrl(releaseItem.repository) ++ ) ++ true ++ } ++ } ++ ++ ViewType.EMPTY -> EmptyViewHolder(parent.context).apply { ++ copyRepoAddress.setOnClickListener { ++ repoAddress.text?.let { link -> ++ callbacks.onRequestAddRepository(link.toString()) ++ } ++ } ++ } ++ } ++ } ++ ++ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { ++ onBindViewHolder(holder, position, emptyList()) ++ } ++ ++ override fun onBindViewHolder( ++ holder: RecyclerView.ViewHolder, ++ position: Int, ++ payloads: List ++ ) { ++ val context = holder.itemView.context ++ val item = items[position] ++ when (getItemEnumViewType(position)) { ++ ViewType.APP_INFO -> { ++ holder as AppInfoViewHolder ++ item as Item.AppInfoItem ++ var showAuthor = item.product.author.name.isNotEmpty() ++ val iconUrl = ++ item.product.item().icon(view = holder.icon, repository = item.repository) ++ holder.icon.load(iconUrl) { ++ authentication(item.repository.authentication) ++ } ++ val authorText = ++ if (showAuthor) { ++ buildSpannedString { ++ append("by ") ++ bold { append(item.product.author.name) } ++ } ++ } else { ++ buildSpannedString { bold { append(item.product.packageName) } } ++ } ++ holder.authorName.text = authorText ++ holder.packageName.text = authorText ++ if (item.product.author.name.isNotEmpty()) { ++ holder.icon.setOnClickListener { ++ showAuthor = !showAuthor ++ val newText = if (showAuthor) { ++ buildSpannedString { ++ append("by ") ++ bold { append(item.product.author.name) } ++ } ++ } else { ++ buildSpannedString { bold { append(item.product.packageName) } } ++ } ++ holder.textSwitcher.setText(newText) ++ } ++ } ++ holder.name.text = item.product.name ++ ++ holder.version.apply { ++ text = installedItem?.version ?: product?.version ++ if (product?.canUpdate(installedItem) == true) { ++ if (background == null) { ++ background = context.corneredBackground ++ setPadding(8.dp, 4.dp, 8.dp, 4.dp) ++ backgroundTintList = ++ context.getColorFromAttr(MaterialR.attr.colorSecondaryContainer) ++ setTextColor(context.getColorFromAttr(MaterialR.attr.colorSecondary)) ++ } ++ } else { ++ if (background != null) { ++ setPadding(0, 0, 0, 0) ++ setTextColor( ++ context.getColorFromAttr(android.R.attr.colorControlNormal) ++ ) ++ background = null ++ } ++ } ++ } ++ holder.size.text = product?.displayRelease?.size?.formatSize() ++ ++ holder.dev.setOnClickListener { ++ product?.source?.let { link -> ++ if (link.isNotEmpty()) { ++ context.startActivity(Intent(Intent.ACTION_VIEW, link.toUri())) ++ } ++ } ++ } ++ holder.dev.setOnLongClickListener { ++ product?.source?.let { link -> ++ if (link.isNotEmpty()) copyLinkToClipboard(holder.dev, link) ++ } ++ true ++ } ++ holder.favouriteButton.isChecked = isFavourite ++ } ++ ++ ViewType.DOWNLOAD_STATUS -> { ++ holder as DownloadStatusViewHolder ++ item as Item.DownloadStatusItem ++ val status = status ++ holder.itemView.isVisible = status != Status.Idle ++ holder.statusText.isVisible = status != Status.Idle ++ holder.progress.isVisible = status != Status.Idle ++ if (status != Status.Idle) { ++ when (status) { ++ is Status.Pending -> { ++ holder.statusText.setText(stringRes.waiting_to_start_download) ++ holder.progress.isIndeterminate = true ++ } ++ ++ is Status.Connecting -> { ++ holder.statusText.setText(stringRes.connecting) ++ holder.progress.isIndeterminate = true ++ } ++ ++ is Status.Downloading -> { ++ holder.statusText.text = context.getString( ++ stringRes.downloading_FORMAT, ++ if (status.total == null) { ++ status.read.toString() ++ } else { ++ "${status.read} / ${status.total}" ++ } ++ ) ++ holder.progress.isIndeterminate = status.total == null ++ if (status.total != null) { ++ holder.progress.progress = ++ ( ++ holder.progress.max.toFloat() * ++ status.read.value / ++ status.total.value ++ ).roundToInt() ++ } ++ } ++ ++ Status.Installing -> { ++ holder.statusText.setText(stringRes.installing) ++ holder.progress.isIndeterminate = true ++ } ++ ++ Status.PendingInstall -> { ++ holder.statusText.setText(stringRes.waiting_to_start_installation) ++ holder.progress.isIndeterminate = true ++ } ++ ++ Status.Idle -> {} ++ } ++ } ++ Unit ++ } ++ ++ ViewType.INSTALL_BUTTON -> { ++ holder as InstallButtonViewHolder ++ item as Item.InstallButtonItem ++ val action = action ++ holder.button.apply { ++ isEnabled = action != null ++ if (action != null) { ++ icon = context.getDrawableCompat(action.iconResId) ++ setText(action.titleResId) ++ setTextColor( ++ if (action == Action.CANCEL) { ++ holder.actionTintOnCancel ++ } else { ++ holder.actionTintOnNormal ++ } ++ ) ++ backgroundTintList = if (action == Action.CANCEL) { ++ holder.actionTintCancel ++ } else { ++ holder.actionTintNormal ++ } ++ iconTint = if (action == Action.CANCEL) { ++ holder.actionTintOnCancel ++ } else { ++ holder.actionTintOnNormal ++ } ++ } else { ++ icon = context.getDrawableCompat(drawableRes.ic_cancel) ++ setText(stringRes.cancel) ++ setTextColor(holder.actionTintOnDisabled) ++ backgroundTintList = holder.actionTintDisabled ++ iconTint = holder.actionTintOnDisabled ++ } ++ } ++ } ++ ++ ViewType.SCREENSHOT -> { ++ holder as ScreenShotViewHolder ++ item as Item.ScreenshotItem ++ holder.screenshotsRecycler.run { ++ isNestedScrollingEnabled = false ++ clipToPadding = false ++ setPadding(8.dp, 8.dp, 8.dp, 8.dp) ++ layoutManager = ++ LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) ++ adapter = ++ ScreenshotsAdapter { screenshot, view -> ++ callbacks.onScreenshotClick(screenshot, view) ++ }.apply { ++ setScreenshots(item.repository, item.packageName, item.screenshots) ++ } ++ } ++ } ++ ++ ViewType.SWITCH -> { ++ holder as SwitchViewHolder ++ item as Item.SwitchItem ++ val (checked, enabled) = when (item.switchType) { ++ SwitchType.IGNORE_ALL_UPDATES -> { ++ val productPreference = ProductPreferences[item.packageName] ++ Pair(productPreference.ignoreUpdates, true) ++ } ++ ++ SwitchType.IGNORE_THIS_UPDATE -> { ++ val productPreference = ProductPreferences[item.packageName] ++ Pair( ++ productPreference.ignoreUpdates || ++ productPreference.ignoreVersionCode == item.versionCode, ++ !productPreference.ignoreUpdates ++ ) ++ } ++ } ++ with(holder) { ++ switch.setText(item.switchType.titleResId) ++ switch.isChecked = checked ++ statefulViews.forEach { it.isEnabled = enabled } ++ } ++ } ++ ++ ViewType.SECTION -> { ++ holder as SectionViewHolder ++ item as Item.SectionItem ++ val expandable = item.items.isNotEmpty() || item.collapseCount > 0 ++ holder.itemView.isEnabled = expandable ++ holder.itemView.let { ++ it.setPadding( ++ it.paddingLeft, ++ it.paddingTop, ++ it.paddingRight, ++ if (expandable) it.paddingTop else 0 ++ ) ++ } ++ val color = context.getColorFromAttr(item.sectionType.colorAttrResId) ++ holder.title.setTextColor(color) ++ holder.title.text = context.getString(item.sectionType.titleResId) ++ holder.icon.isVisible = expandable ++ holder.icon.scaleY = if (item.collapseCount > 0) -1f else 1f ++ holder.icon.imageTintList = color ++ } ++ ++ ViewType.EXPAND -> { ++ holder as ExpandViewHolder ++ item as Item.ExpandItem ++ holder.button.text = if (item.expandType !in expanded) { ++ when (item.expandType) { ++ ExpandType.VERSIONS -> context.getString(stringRes.show_older_versions) ++ else -> context.getString(stringRes.show_more) ++ } ++ } else { ++ context.getString(stringRes.show_less) ++ } ++ } ++ ++ ViewType.TEXT -> { ++ holder as TextViewHolder ++ item as Item.TextItem ++ holder.text.text = item.text ++ } ++ ++ ViewType.LINK -> { ++ holder as LinkViewHolder ++ item as Item.LinkItem ++ val layoutParams = holder.itemView.layoutParams as RecyclerView.LayoutParams ++ layoutParams.topMargin = ++ if (position > 0 && items[position - 1] !is Item.LinkItem) { ++ -context.resources.sizeScaled(8) ++ } else { ++ 0 ++ } ++ holder.itemView.isEnabled = item.uri != null ++ holder.icon.setImageResource(item.iconResId) ++ holder.text.text = item.getTitle(context) ++ holder.link.isVisible = item.uri != null ++ holder.link.text = item.displayLink ++ } ++ ++ ViewType.PERMISSIONS -> { ++ holder as PermissionsViewHolder ++ item as Item.PermissionsItem ++ val layoutParams = holder.itemView.layoutParams as RecyclerView.LayoutParams ++ layoutParams.topMargin = ++ if (position > 0 && items[position - 1] !is Item.PermissionsItem) { ++ -context.resources.sizeScaled(8) ++ } else { ++ 0 ++ } ++ val packageManager = context.packageManager ++ holder.icon.setImageDrawable( ++ if (item.group != null && item.group.icon != 0) { ++ item.group.loadUnbadgedIcon(packageManager) ++ } else { ++ null ++ } ?: context.getMutatedIcon(drawableRes.ic_perm_device_information) ++ ) ++ val localCache = PackageItemResolver.LocalCache() ++ val labels = item.permissions.map { permission -> ++ val labelFromPackage = ++ PackageItemResolver.loadLabel(context, localCache, permission) ++ val label = labelFromPackage ?: run { ++ val prefixes = ++ listOf("android.permission.", "com.android.browser.permission.") ++ prefixes.find { permission.name.startsWith(it) }?.let { it -> ++ val transform = permission.name.substring(it.length) ++ if (transform.matches("[A-Z_]+".toRegex())) { ++ transform.split("_") ++ .joinToString(separator = " ") { it.lowercase(Locale.US) } ++ } else { ++ null ++ } ++ } ++ } ++ if (label == null) { ++ Pair(false, permission.name) ++ } else { ++ Pair( ++ true, ++ label.first().uppercaseChar() + label.substring(1, label.length) ++ ) ++ } ++ } ++ val builder = SpannableStringBuilder() ++ ( ++ labels.asSequence().filter { it.first } + labels.asSequence() ++ .filter { !it.first } ++ ).forEach { ++ if (builder.isNotEmpty()) { ++ builder.append("\n\n") ++ builder.setSpan( ++ RelativeSizeSpan(1f / 3f), ++ builder.length - 2, ++ builder.length, ++ SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE ++ ) ++ } ++ builder.append(it.second) ++ if (!it.first) { ++ // Replace dots with spans to enable word wrap ++ it.second.asSequence() ++ .mapIndexedNotNull { index, c -> if (c == '.') index else null } ++ .map { index -> index + builder.length - it.second.length } ++ .forEach { index -> ++ builder.setSpan( ++ DotSpan(), ++ index, ++ index + 1, ++ SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE ++ ) ++ } ++ } ++ } ++ holder.text.text = builder ++ } ++ ++ ViewType.RELEASE -> { ++ holder as ReleaseViewHolder ++ item as Item.ReleaseItem ++ val incompatibility = item.release.incompatibilities.firstOrNull() ++ val singlePlatform = ++ if (item.release.platforms.size == 1) item.release.platforms.first() else null ++ val installed = installedItem?.versionCode == item.release.versionCode && ++ installedItem?.signature == item.release.signature ++ val suggested = ++ incompatibility == null && item.release.selected && item.selectedRepository ++ ++ if (suggested) { ++ holder.itemView.apply { ++ background = context.corneredBackground ++ backgroundTintList = ++ holder.itemView.context.getColorFromAttr(MaterialR.attr.colorSurface) ++ } ++ } else { ++ holder.itemView.background = null ++ } ++ holder.version.text = ++ context.getString(stringRes.version_FORMAT, item.release.version) ++ ++ holder.status.apply { ++ isVisible = installed || suggested ++ setText( ++ when { ++ installed -> stringRes.installed ++ suggested -> stringRes.suggested ++ else -> stringRes.unknown ++ } ++ ) ++ background = context.corneredBackground ++ setPadding(15, 15, 15, 15) ++ backgroundTintList = ++ context.getColorFromAttr(MaterialR.attr.colorSecondaryContainer) ++ setTextColor(context.getColorFromAttr(MaterialR.attr.colorOnSecondaryContainer)) ++ } ++ holder.source.text = ++ context.getString(stringRes.provided_by_FORMAT, item.repository.name) ++ val instant = Instant.fromEpochMilliseconds(item.release.added) ++ // FDroid uses UTC time ++ val date = instant.toLocalDateTime(TimeZone.UTC) ++ val dateFormat = try { ++ date.toJavaLocalDateTime() ++ .format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)) ++ } catch (e: Exception) { ++ e.printStackTrace() ++ holder.dateFormat.format(item.release.added) ++ } ++ holder.added.text = dateFormat ++ holder.size.text = item.release.size.formatSize() ++ holder.signature.isVisible = ++ item.showSignature && item.release.signature.isNotEmpty() ++ if (item.showSignature && item.release.signature.isNotEmpty()) { ++ val bytes = ++ item.release.signature ++ .uppercase(Locale.US) ++ .windowed(2, 2, false) ++ .take(8) ++ val signature = bytes.joinToString(separator = " ") ++ val builder = SpannableStringBuilder( ++ context.getString( ++ stringRes.signature_FORMAT, ++ signature ++ ) ++ ) ++ val index = builder.indexOf(signature) ++ if (index >= 0) { ++ bytes.forEachIndexed { i, _ -> ++ builder.setSpan( ++ TypefaceSpan("monospace"), ++ index + 3 * i, ++ index + 3 * i + 2, ++ SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE ++ ) ++ } ++ } ++ holder.signature.text = builder ++ } ++ holder.compatibility.isVisible = incompatibility != null || singlePlatform != null ++ if (incompatibility != null) { ++ holder.compatibility.setTextColor( ++ context.getColorFromAttr(MaterialR.attr.colorError) ++ ) ++ holder.compatibility.text = when (incompatibility) { ++ is Release.Incompatibility.MinSdk, ++ is Release.Incompatibility.MaxSdk ++ -> context.getString( ++ stringRes.incompatible_with_FORMAT, ++ Android.name ++ ) ++ ++ is Release.Incompatibility.Platform -> context.getString( ++ stringRes.incompatible_with_FORMAT, ++ Android.primaryPlatform ?: context.getString(stringRes.unknown) ++ ) ++ ++ is Release.Incompatibility.Feature -> context.getString( ++ stringRes.requires_FORMAT, ++ incompatibility.feature ++ ) ++ } ++ } else if (singlePlatform != null) { ++ holder.compatibility.setTextColor( ++ context.getColorFromAttr(android.R.attr.textColorSecondary) ++ ) ++ holder.compatibility.text = ++ context.getString(stringRes.only_compatible_with_FORMAT, singlePlatform) ++ } ++ val enabled = status == Status.Idle ++ holder.statefulViews.forEach { it.isEnabled = enabled } ++ } ++ ++ ViewType.EMPTY -> { ++ holder as EmptyViewHolder ++ item as Item.EmptyItem ++ holder.packageName.text = item.packageName ++ if (item.repoAddress != null) { ++ holder.repoTitle.setText(stringRes.repository_not_found) ++ holder.repoAddress.text = item.repoAddress ++ } ++ } ++ } ++ } ++ ++ private fun formatHtml(text: String): SpannableStringBuilder { ++ val html = HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_LEGACY) ++ val builder = run { ++ val builder = SpannableStringBuilder(html) ++ val last = builder.indexOfLast { it != '\n' } ++ val first = builder.indexOfFirst { it != '\n' } ++ if (last >= 0) { ++ builder.delete(last + 1, builder.length) ++ } ++ if (first in 1 until last) { ++ builder.delete(0, first - 1) ++ } ++ generateSequence(builder) { ++ val index = it.indexOf("\n\n\n") ++ if (index >= 0) it.delete(index, index + 1) else null ++ }.last() ++ } ++ LinkifyCompat.addLinks(builder, Linkify.WEB_URLS or Linkify.EMAIL_ADDRESSES) ++ val urlSpans = builder ++ .getSpans(0, builder.length, URLSpan::class.java) ++ .orEmpty() ++ for (span in urlSpans) { ++ val start = builder.getSpanStart(span) ++ val end = builder.getSpanEnd(span) ++ val flags = builder.getSpanFlags(span) ++ builder.removeSpan(span) ++ builder.setSpan(LinkSpan(span.url, this), start, end, flags) ++ } ++ val bulletSpans = builder ++ .getSpans(0, builder.length, BulletSpan::class.java) ++ .orEmpty() ++ .asSequence().map { Pair(it, builder.getSpanStart(it)) } ++ .sortedByDescending { it.second } ++ for (spanPair in bulletSpans) { ++ val (span, start) = spanPair ++ builder.removeSpan(span) ++ builder.insert(start, "\u2022 ") ++ } ++ return builder ++ } ++ ++ private fun copyLinkToClipboard(view: View, link: String) { ++ view.context.copyToClipboard(link) ++ Snackbar.make(view, stringRes.link_copied_to_clipboard, Snackbar.LENGTH_SHORT).show() ++ } ++ ++ private class LinkSpan(private val url: String, productAdapter: AppDetailAdapter) : ++ ClickableSpan() { ++ private val productAdapterReference = WeakReference(productAdapter) ++ ++ override fun onClick(view: View) { ++ val productAdapter = productAdapterReference.get() ++ val uri = try { ++ Uri.parse(url) ++ } catch (e: Exception) { ++ e.printStackTrace() ++ null ++ } ++ if (productAdapter != null && uri != null) { ++ productAdapter.callbacks.onUriClick(uri, true) ++ } ++ } ++ } ++ ++ private class DotSpan : ReplacementSpan() { ++ override fun getSize( ++ paint: Paint, ++ text: CharSequence?, ++ start: Int, ++ end: Int, ++ fm: Paint.FontMetricsInt? ++ ): Int { ++ return paint.measureText(".").roundToInt() ++ } ++ ++ override fun draw( ++ canvas: Canvas, ++ text: CharSequence?, ++ start: Int, ++ end: Int, ++ x: Float, ++ top: Int, ++ y: Int, ++ bottom: Int, ++ paint: Paint ++ ) { ++ canvas.drawText(".", x, y.toFloat(), paint) ++ } ++ } ++ ++ @Parcelize ++ class SavedState internal constructor(internal val expanded: Set) : Parcelable ++ ++ fun saveState(): SavedState? { ++ return if (expanded.isNotEmpty()) { ++ SavedState(expanded) ++ } else { ++ null ++ } ++ } ++ ++ fun restoreState(savedState: SavedState) { ++ expanded.clear() ++ expanded += savedState.expanded ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/ui/appDetail/AppDetailFragment.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/ui/appDetail/AppDetailFragment.kt b/app/src/main/kotlin/com/leos/droidify/ui/appDetail/AppDetailFragment.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/ui/appDetail/AppDetailFragment.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,537 @@ ++package com.leos.droidify.ui.appDetail ++ ++import android.content.ActivityNotFoundException ++import android.content.ComponentName ++import android.content.Intent ++import android.net.Uri ++import android.os.Bundle ++import android.provider.Settings ++import android.view.MenuItem ++import android.view.View ++import android.widget.ImageView ++import androidx.appcompat.app.AlertDialog ++import androidx.core.net.toUri ++import androidx.core.os.bundleOf ++import androidx.fragment.app.DialogFragment ++import androidx.fragment.app.viewModels ++import androidx.lifecycle.Lifecycle ++import androidx.lifecycle.lifecycleScope ++import androidx.lifecycle.repeatOnLifecycle ++import androidx.recyclerview.widget.LinearLayoutManager ++import androidx.recyclerview.widget.RecyclerView ++import androidx.recyclerview.widget.SimpleItemAnimator ++import coil.load ++import com.google.android.material.dialog.MaterialAlertDialogBuilder ++import com.leos.core.common.extension.getLauncherActivities ++import com.leos.core.common.extension.getMutatedIcon ++import com.leos.core.common.extension.isFirstItemVisible ++import com.leos.core.common.extension.isSystemApplication ++import com.leos.core.common.extension.systemBarsPadding ++import com.leos.core.common.extension.updateAsMutable ++import com.leos.core.domain.InstalledItem ++import com.leos.core.domain.Product ++import com.leos.core.domain.ProductPreference ++import com.leos.core.domain.Release ++import com.leos.core.domain.Repository ++import com.leos.core.domain.findSuggested ++import com.leos.droidify.content.ProductPreferences ++import com.leos.droidify.service.Connection ++import com.leos.droidify.service.DownloadService ++import com.leos.droidify.ui.Message ++import com.leos.droidify.ui.MessageDialog ++import com.leos.droidify.ui.ScreenFragment ++import com.leos.droidify.ui.appDetail.AppDetailViewModel.Companion.ARG_PACKAGE_NAME ++import com.leos.droidify.ui.appDetail.AppDetailViewModel.Companion.ARG_REPO_ADDRESS ++import com.leos.droidify.utility.extension.ImageUtils.url ++import com.leos.droidify.utility.extension.screenActivity ++import com.leos.droidify.utility.extension.startUpdate ++import com.leos.installer.model.InstallState ++import com.leos.installer.model.isCancellable ++import com.stfalcon.imageviewer.StfalconImageViewer ++import dagger.hilt.android.AndroidEntryPoint ++import kotlinx.coroutines.delay ++import kotlinx.coroutines.flow.collectLatest ++import kotlinx.coroutines.launch ++import com.leos.core.common.R.string as stringRes ++ ++@AndroidEntryPoint ++class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks { ++ companion object { ++ private const val STATE_LAYOUT_MANAGER = "layoutManager" ++ private const val STATE_ADAPTER = "adapter" ++ } ++ ++ constructor(packageName: String, repoAddress: String? = null) : this() { ++ arguments = bundleOf( ++ ARG_PACKAGE_NAME to packageName, ++ ARG_REPO_ADDRESS to repoAddress ++ ) ++ } ++ ++ private enum class Action( ++ val id: Int, ++ val adapterAction: AppDetailAdapter.Action ++ ) { ++ INSTALL(1, AppDetailAdapter.Action.INSTALL), ++ UPDATE(2, AppDetailAdapter.Action.UPDATE), ++ LAUNCH(3, AppDetailAdapter.Action.LAUNCH), ++ DETAILS(4, AppDetailAdapter.Action.DETAILS), ++ UNINSTALL(5, AppDetailAdapter.Action.UNINSTALL), ++ SHARE(6, AppDetailAdapter.Action.SHARE) ++ } ++ ++ private class Installed( ++ val installedItem: InstalledItem, ++ val isSystem: Boolean, ++ val launcherActivities: List> ++ ) ++ ++ private val viewModel: AppDetailViewModel by viewModels() ++ ++ private var layoutManagerState: LinearLayoutManager.SavedState? = null ++ ++ private var actions = Pair(emptySet(), null as Action?) ++ private var products = emptyList>() ++ private var installed: Installed? = null ++ private var downloading = false ++ private var installing: InstallState? = null ++ ++ private var recyclerView: RecyclerView? = null ++ private var detailAdapter: AppDetailAdapter? = null ++ ++ private val downloadConnection = Connection( ++ serviceClass = DownloadService::class.java, ++ onBind = { _, binder -> ++ lifecycleScope.launch { ++ binder.downloadState.collect(::updateDownloadState) ++ } ++ } ++ ) ++ ++ override fun onViewCreated(view: View, savedInstanceState: Bundle?) { ++ super.onViewCreated(view, savedInstanceState) ++ ++ detailAdapter = AppDetailAdapter(this@AppDetailFragment) ++ screenActivity.onToolbarCreated(toolbar) ++ toolbar.menu.apply { ++ Action.entries.forEach { action -> ++ add(0, action.id, 0, action.adapterAction.titleResId) ++ .setIcon(toolbar.context.getMutatedIcon(action.adapterAction.iconResId)) ++ .setVisible(false) ++ .setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS) ++ .setOnMenuItemClickListener { ++ onActionClick(action.adapterAction) ++ true ++ } ++ } ++ } ++ ++ val content = fragmentBinding.fragmentContent ++ content.addView( ++ RecyclerView(content.context).apply { ++ id = android.R.id.list ++ this.layoutManager = LinearLayoutManager( ++ context, ++ LinearLayoutManager.VERTICAL, ++ false ++ ) ++ isMotionEventSplittingEnabled = false ++ isVerticalScrollBarEnabled = false ++ adapter = detailAdapter ++ (itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false ++ if (detailAdapter != null) { ++ savedInstanceState?.getParcelable(STATE_ADAPTER) ++ ?.let(detailAdapter!!::restoreState) ++ } ++ layoutManagerState = savedInstanceState?.getParcelable(STATE_LAYOUT_MANAGER) ++ recyclerView = this ++ systemBarsPadding(includeFab = false) ++ } ++ ) ++ viewLifecycleOwner.lifecycleScope.launch { ++ repeatOnLifecycle(Lifecycle.State.CREATED) { ++ launch { ++ viewModel.state.collectLatest { state -> ++ products = state.products.mapNotNull { product -> ++ val requiredRepo = state.repos.find { it.id == product.repositoryId } ++ requiredRepo?.let { product to it } ++ } ++ layoutManagerState?.let { ++ recyclerView?.layoutManager!!.onRestoreInstanceState(it) ++ } ++ layoutManagerState = null ++ installed = state.installedItem?.let { ++ with(requireContext().packageManager) { ++ val isSystem = isSystemApplication(viewModel.packageName) ++ val launcherActivities = if (state.isSelf) { ++ emptyList() ++ } else { ++ getLauncherActivities(viewModel.packageName) ++ } ++ Installed(it, isSystem, launcherActivities) ++ } ++ } ++ val adapter = recyclerView?.adapter as? AppDetailAdapter ++ ++ // `delay` is cancellable hence it waits for 50 milliseconds to show empty page ++ if (products.isEmpty()) delay(50) ++ ++ adapter?.setProducts( ++ context = requireContext(), ++ packageName = viewModel.packageName, ++ suggestedRepo = state.addressIfUnavailable, ++ products = products, ++ installedItem = state.installedItem, ++ isFavourite = state.isFavourite, ++ allowIncompatibleVersion = state.allowIncompatibleVersions ++ ) ++ updateButtons() ++ } ++ } ++ launch { ++ viewModel.installerState.collect(::updateInstallState) ++ } ++ launch { ++ recyclerView?.isFirstItemVisible?.collect(::updateToolbarButtons) ++ } ++ } ++ } ++ ++ downloadConnection.bind(requireContext()) ++ } ++ ++ override fun onDestroyView() { ++ super.onDestroyView() ++ recyclerView = null ++ detailAdapter = null ++ ++ downloadConnection.unbind(requireContext()) ++ } ++ ++ override fun onSaveInstanceState(outState: Bundle) { ++ super.onSaveInstanceState(outState) ++ ++ val layoutManagerState = ++ layoutManagerState ?: recyclerView?.layoutManager?.onSaveInstanceState() ++ layoutManagerState?.let { outState.putParcelable(STATE_LAYOUT_MANAGER, it) } ++ val adapterState = (recyclerView?.adapter as? AppDetailAdapter)?.saveState() ++ adapterState?.let { outState.putParcelable(STATE_ADAPTER, it) } ++ } ++ ++ private fun updateButtons( ++ preference: ProductPreference = ProductPreferences[viewModel.packageName] ++ ) { ++ val installed = installed ++ val product = products.findSuggested(installed?.installedItem)?.first ++ val compatible = product != null && product.selectedReleases.firstOrNull() ++ .let { it != null && it.incompatibilities.isEmpty() } ++ val canInstall = product != null && installed == null && compatible ++ val canUpdate = ++ product != null && compatible && product.canUpdate(installed?.installedItem) && ++ !preference.shouldIgnoreUpdate(product.versionCode) ++ val canUninstall = product != null && installed != null && !installed.isSystem ++ val canLaunch = ++ product != null && installed != null && installed.launcherActivities.isNotEmpty() ++ ++ val actions = buildSet { ++ if (canInstall) add(Action.INSTALL) ++ if (canUpdate) add(Action.UPDATE) ++ if (canLaunch) add(Action.LAUNCH) ++ if (installed != null) add(Action.DETAILS) ++ if (canUninstall) add(Action.UNINSTALL) ++ add(Action.SHARE) ++ } ++ ++ val primaryAction = when { ++ canUpdate -> Action.UPDATE ++ canLaunch -> Action.LAUNCH ++ canInstall -> Action.INSTALL ++ installed != null -> Action.DETAILS ++ else -> Action.SHARE ++ } ++ ++ val adapterAction = when { ++ installing == InstallState.Installing -> null ++ installing == InstallState.Pending -> AppDetailAdapter.Action.CANCEL ++ downloading -> AppDetailAdapter.Action.CANCEL ++ else -> primaryAction.adapterAction ++ } ++ ++ (recyclerView?.adapter as? AppDetailAdapter)?.action = adapterAction ++ ++ for (action in sequenceOf( ++ Action.INSTALL, ++ Action.UPDATE, ++ )) { ++ toolbar.menu.findItem(action.id).isEnabled = !downloading ++ } ++ this.actions = Pair(actions, primaryAction) ++ updateToolbarButtons() ++ } ++ ++ private fun updateToolbarButtons( ++ isActionVisible: Boolean = (recyclerView?.layoutManager as LinearLayoutManager) ++ .findFirstVisibleItemPosition() == 0 ++ ) { ++ toolbar.title = if (isActionVisible) { ++ getString(stringRes.application) ++ } else { ++ products.firstOrNull()?.first?.name ?: getString(stringRes.application) ++ } ++ val (actions, primaryAction) = actions ++ val displayActions = actions.updateAsMutable { ++ if (isActionVisible && primaryAction != null) { ++ remove(primaryAction) ++ } ++ if (size >= 4 && resources.configuration.screenWidthDp < 400) { ++ remove(Action.DETAILS) ++ } ++ } ++ Action.entries.forEach { action -> ++ toolbar.menu.findItem(action.id).isVisible = action in displayActions ++ } ++ } ++ ++ private fun updateInstallState(installerState: InstallState?) { ++ val status = when (installerState) { ++ InstallState.Pending -> AppDetailAdapter.Status.PendingInstall ++ InstallState.Installing -> AppDetailAdapter.Status.Installing ++ else -> AppDetailAdapter.Status.Idle ++ } ++ (recyclerView?.adapter as? AppDetailAdapter)?.status = status ++ installing = installerState ++ updateButtons() ++ } ++ ++ private fun updateDownloadState(state: DownloadService.DownloadState) { ++ val packageName = viewModel.packageName ++ val isPending = packageName in state.queue ++ val isDownloading = state isDownloading packageName ++ val isCompleted = state isComplete packageName ++ val isActive = isPending || isDownloading ++ if (isPending) { ++ detailAdapter?.status = AppDetailAdapter.Status.Pending ++ } ++ if (isDownloading) { ++ detailAdapter?.status = when (state.currentItem) { ++ is DownloadService.State.Connecting -> AppDetailAdapter.Status.Connecting ++ is DownloadService.State.Downloading -> AppDetailAdapter.Status.Downloading( ++ state.currentItem.read, ++ state.currentItem.total ++ ) ++ ++ else -> AppDetailAdapter.Status.Idle ++ } ++ } ++ if (isCompleted) { ++ detailAdapter?.status = AppDetailAdapter.Status.Idle ++ } ++ if (this.downloading != isActive) { ++ this.downloading = isActive ++ updateButtons() ++ } ++ if (state.currentItem is DownloadService.State.Success && isResumed) { ++ viewModel.installPackage( ++ state.currentItem.packageName, ++ state.currentItem.release.cacheFileName ++ ) ++ } ++ } ++ ++ override fun onActionClick(action: AppDetailAdapter.Action) { ++ when (action) { ++ AppDetailAdapter.Action.INSTALL, ++ AppDetailAdapter.Action.UPDATE ++ -> downloadConnection.startUpdate( ++ viewModel.packageName, ++ installed?.installedItem, ++ products ++ ) ++ ++ AppDetailAdapter.Action.LAUNCH -> { ++ val launcherActivities = installed?.launcherActivities.orEmpty() ++ if (launcherActivities.size >= 2) { ++ LaunchDialog(launcherActivities).show( ++ childFragmentManager, ++ LaunchDialog::class.java.name ++ ) ++ } else { ++ launcherActivities.firstOrNull()?.let { startLauncherActivity(it.first) } ++ } ++ } ++ ++ AppDetailAdapter.Action.DETAILS -> { ++ startActivity( ++ Intent( ++ Settings.ACTION_APPLICATION_DETAILS_SETTINGS, ++ "package:${viewModel.packageName}".toUri() ++ ) ++ ) ++ } ++ ++ AppDetailAdapter.Action.UNINSTALL -> viewModel.uninstallPackage() ++ ++ AppDetailAdapter.Action.CANCEL -> { ++ val binder = downloadConnection.binder ++ if (installing?.isCancellable == true) { ++ viewModel.removeQueue() ++ } else if (downloading && binder != null) { ++ binder.cancel(viewModel.packageName) ++ } ++ } ++ ++ AppDetailAdapter.Action.SHARE -> { ++ val repo = products[0].second ++ val address = when { ++ repo.name == "F-Droid" -> ++ "https://www.f-droid.org/packages/" + ++ "${viewModel.packageName}/" ++ ++ "IzzyOnDroid" in repo.name -> { ++ "https://apt.izzysoft.de/fdroid/index/apk/${viewModel.packageName}" ++ } ++ ++ else -> { ++ "https://droidify.eu.org/app/?id=" + ++ "${viewModel.packageName}&repo_address=${repo.address}" ++ } ++ } ++ val sendIntent = Intent(Intent.ACTION_SEND) ++ .putExtra(Intent.EXTRA_TEXT, address) ++ .setType("text/plain") ++ startActivity(Intent.createChooser(sendIntent, null)) ++ } ++ } ++ } ++ ++ override fun onFavouriteClicked() { ++ viewModel.setFavouriteState() ++ } ++ ++ private fun startLauncherActivity(name: String) { ++ try { ++ startActivity( ++ Intent(Intent.ACTION_MAIN) ++ .addCategory(Intent.CATEGORY_LAUNCHER) ++ .setComponent(ComponentName(viewModel.packageName, name)) ++ .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) ++ ) ++ } catch (e: Exception) { ++ e.printStackTrace() ++ } ++ } ++ ++ override fun onPreferenceChanged(preference: ProductPreference) { ++ updateButtons(preference) ++ } ++ ++ override fun onPermissionsClick(group: String?, permissions: List) { ++ MessageDialog(Message.Permissions(group, permissions)) ++ .show(childFragmentManager) ++ } ++ ++ override fun onScreenshotClick(screenshot: Product.Screenshot, parentView: ImageView) { ++ val product = products ++ .firstOrNull { (product, _) -> ++ product.screenshots.find { it === screenshot }?.identifier != null ++ } ++ ?: return ++ val screenshots = product.first.screenshots ++ val position = screenshots.indexOfFirst { screenshot.identifier == it.identifier } ++ StfalconImageViewer ++ .Builder(context, screenshots) { view, current -> ++ view.load(current.url(product.second, viewModel.packageName)) ++ } ++ .withTransitionFrom(parentView) ++ .withStartPosition(position) ++ .show() ++ } ++ ++ override fun onReleaseClick(release: Release) { ++ val installedItem = installed?.installedItem ++ when { ++ release.incompatibilities.isNotEmpty() -> { ++ MessageDialog( ++ Message.ReleaseIncompatible( ++ release.incompatibilities, ++ release.platforms, ++ release.minSdkVersion, ++ release.maxSdkVersion ++ ) ++ ).show(childFragmentManager) ++ } ++ ++ installedItem != null && installedItem.versionCode > release.versionCode -> { ++ MessageDialog(Message.ReleaseOlder).show(childFragmentManager) ++ } ++ ++ installedItem != null && installedItem.signature != release.signature -> { ++ MessageDialog(Message.ReleaseSignatureMismatch).show( ++ childFragmentManager ++ ) ++ } ++ ++ else -> { ++ val productRepository = ++ products.asSequence().filter { (product, _) -> ++ product.releases.any { it === release } ++ }.firstOrNull() ++ if (productRepository != null) { ++ downloadConnection.binder?.enqueue( ++ viewModel.packageName, ++ productRepository.first.name, ++ productRepository.second, ++ release, ++ installedItem != null ++ ) ++ } ++ } ++ } ++ } ++ ++ override fun onRequestAddRepository(address: String) { ++ screenActivity.navigateAddRepository(address) ++ } ++ ++ override fun onUriClick(uri: Uri, shouldConfirm: Boolean): Boolean { ++ return if (shouldConfirm && (uri.scheme == "http" || uri.scheme == "https")) { ++ MessageDialog(Message.Link(uri)).show(childFragmentManager) ++ true ++ } else { ++ try { ++ startActivity(Intent(Intent.ACTION_VIEW, uri)) ++ true ++ } catch (e: ActivityNotFoundException) { ++ e.printStackTrace() ++ false ++ } ++ } ++ } ++ ++ class LaunchDialog() : DialogFragment() { ++ companion object { ++ private const val EXTRA_NAMES = "names" ++ private const val EXTRA_LABELS = "labels" ++ } ++ ++ constructor(launcherActivities: List>) : this() { ++ arguments = Bundle().apply { ++ putStringArrayList(EXTRA_NAMES, ArrayList(launcherActivities.map { it.first })) ++ putStringArrayList(EXTRA_LABELS, ArrayList(launcherActivities.map { it.second })) ++ } ++ } ++ ++ override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog { ++ val names = requireArguments().getStringArrayList(EXTRA_NAMES)!! ++ val labels = requireArguments().getStringArrayList(EXTRA_LABELS)!! ++ return MaterialAlertDialogBuilder(requireContext()) ++ .setTitle(stringRes.launch) ++ .setItems(labels.toTypedArray()) { _, position -> ++ (parentFragment as AppDetailFragment) ++ .startLauncherActivity(names[position]) ++ } ++ .setNegativeButton(stringRes.cancel, null) ++ .create() ++ } ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/ui/appDetail/AppDetailViewModel.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/ui/appDetail/AppDetailViewModel.kt b/app/src/main/kotlin/com/leos/droidify/ui/appDetail/AppDetailViewModel.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/ui/appDetail/AppDetailViewModel.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,103 @@ ++package com.leos.droidify.ui.appDetail ++ ++import androidx.lifecycle.SavedStateHandle ++import androidx.lifecycle.ViewModel ++import androidx.lifecycle.viewModelScope ++import com.leos.core.common.extension.asStateFlow ++import com.leos.core.common.toPackageName ++import com.leos.core.datastore.SettingsRepository ++import com.leos.core.domain.InstalledItem ++import com.leos.core.domain.Product ++import com.leos.core.domain.Repository ++import com.leos.droidify.BuildConfig ++import com.leos.droidify.database.Database ++import com.leos.installer.InstallManager ++import com.leos.installer.model.InstallState ++import com.leos.installer.model.installFrom ++import dagger.hilt.android.lifecycle.HiltViewModel ++import javax.inject.Inject ++import kotlinx.coroutines.flow.StateFlow ++import kotlinx.coroutines.flow.combine ++import kotlinx.coroutines.flow.flow ++import kotlinx.coroutines.flow.mapNotNull ++import kotlinx.coroutines.launch ++ ++@HiltViewModel ++class AppDetailViewModel @Inject constructor( ++ private val installer: InstallManager, ++ private val settingsRepository: SettingsRepository, ++ savedStateHandle: SavedStateHandle ++) : ViewModel() { ++ ++ val packageName: String = requireNotNull(savedStateHandle[ARG_PACKAGE_NAME]) ++ ++ private val repoAddress: StateFlow = ++ savedStateHandle.getStateFlow(ARG_REPO_ADDRESS, null) ++ ++ val installerState: StateFlow = ++ installer.state.mapNotNull { stateMap -> ++ stateMap[packageName.toPackageName()] ++ }.asStateFlow(null) ++ ++ val state = ++ combine( ++ Database.ProductAdapter.getStream(packageName), ++ Database.RepositoryAdapter.getAllStream(), ++ Database.InstalledAdapter.getStream(packageName), ++ repoAddress, ++ flow { emit(settingsRepository.getInitial()) } ++ ) { products, repositories, installedItem, suggestedAddress, initialSettings -> ++ val idAndRepos = repositories.associateBy { it.id } ++ val filteredProducts = products.filter { product -> ++ idAndRepos[product.repositoryId] != null ++ } ++ AppDetailUiState( ++ products = filteredProducts, ++ repos = repositories, ++ installedItem = installedItem, ++ isFavourite = packageName in initialSettings.favouriteApps, ++ allowIncompatibleVersions = initialSettings.incompatibleVersions, ++ isSelf = packageName == BuildConfig.APPLICATION_ID, ++ addressIfUnavailable = suggestedAddress ++ ) ++ }.asStateFlow(AppDetailUiState()) ++ ++ fun setFavouriteState() { ++ viewModelScope.launch { ++ settingsRepository.toggleFavourites(packageName) ++ } ++ } ++ ++ fun installPackage(packageName: String, fileName: String) { ++ viewModelScope.launch { ++ installer install (packageName installFrom fileName) ++ } ++ } ++ ++ fun uninstallPackage() { ++ viewModelScope.launch { ++ installer uninstall packageName.toPackageName() ++ } ++ } ++ ++ fun removeQueue() { ++ viewModelScope.launch { ++ installer remove packageName.toPackageName() ++ } ++ } ++ ++ companion object { ++ const val ARG_PACKAGE_NAME = "package_name" ++ const val ARG_REPO_ADDRESS = "repo_address" ++ } ++} ++ ++data class AppDetailUiState( ++ val products: List = emptyList(), ++ val repos: List = emptyList(), ++ val installedItem: InstalledItem? = null, ++ val isSelf: Boolean = false, ++ val isFavourite: Boolean = false, ++ val allowIncompatibleVersions: Boolean = false, ++ val addressIfUnavailable: String? = null ++) +Index: app/src/main/kotlin/com/leos/droidify/ui/appDetail/ScreenshotsAdapter.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/ui/appDetail/ScreenshotsAdapter.kt b/app/src/main/kotlin/com/leos/droidify/ui/appDetail/ScreenshotsAdapter.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/ui/appDetail/ScreenshotsAdapter.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,126 @@ ++package com.leos.droidify.ui.appDetail ++ ++import android.content.Context ++import android.graphics.drawable.Drawable ++import android.view.Gravity ++import android.view.View ++import android.view.ViewGroup ++import android.widget.FrameLayout ++import android.widget.ImageView ++import androidx.recyclerview.widget.RecyclerView ++import coil.load ++import coil.size.Scale ++import com.google.android.material.R as MaterialR ++import com.google.android.material.imageview.ShapeableImageView ++import com.leos.core.common.R.dimen as dimenRes ++import com.leos.core.common.extension.aspectRatio ++import com.leos.core.common.extension.authentication ++import com.leos.core.common.extension.camera ++import com.leos.core.common.extension.dp ++import com.leos.core.common.extension.getColorFromAttr ++import com.leos.core.common.extension.selectableBackground ++import com.leos.core.domain.Product ++import com.leos.core.domain.Repository ++import com.leos.droidify.graphics.PaddingDrawable ++import com.leos.droidify.utility.extension.ImageUtils.url ++import com.leos.droidify.widget.StableRecyclerAdapter ++ ++class ScreenshotsAdapter(private val onClick: (Product.Screenshot, ImageView) -> Unit) : ++ StableRecyclerAdapter() { ++ enum class ViewType { SCREENSHOT } ++ ++ private val items = mutableListOf() ++ ++ private class ViewHolder(context: Context) : ++ RecyclerView.ViewHolder(FrameLayout(context)) { ++ val image: ShapeableImageView = object : ShapeableImageView(context) { ++ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { ++ super.onMeasure(widthMeasureSpec, heightMeasureSpec) ++ setMeasuredDimension(measuredWidth, measuredHeight) ++ } ++ } ++ val placeholderColor = context.getColorFromAttr(MaterialR.attr.colorPrimaryContainer) ++ val radius = context.resources.getDimension(dimenRes.shape_small_corner) ++ ++ val imageShapeModel = image.shapeAppearanceModel.toBuilder() ++ .setAllCornerSizes(radius) ++ .build() ++ val cameraIcon = context.camera ++ .apply { setTintList(placeholderColor) } ++ val placeholder: Drawable = PaddingDrawable(cameraIcon, 3f, context.aspectRatio) ++ ++ init { ++ with(image) { ++ shapeAppearanceModel = imageShapeModel ++ background = context.selectableBackground ++ isFocusable = true ++ } ++ with(itemView as FrameLayout) { ++ layoutParams = RecyclerView.LayoutParams( ++ RecyclerView.LayoutParams.WRAP_CONTENT, ++ 150.dp ++ ).apply { ++ marginStart = radius.toInt() ++ marginEnd = radius.toInt() ++ } ++ foregroundGravity = Gravity.CENTER ++ addView(image) ++ } ++ } ++ } ++ ++ fun setScreenshots( ++ repository: Repository, ++ packageName: String, ++ screenshots: List ++ ) { ++ items.clear() ++ items += screenshots.map { Item.ScreenshotItem(repository, packageName, it) } ++ notifyItemRangeInserted(0, screenshots.size) ++ } ++ ++ override val viewTypeClass: Class ++ get() = ViewType::class.java ++ ++ override fun getItemEnumViewType(position: Int): ViewType { ++ return ViewType.SCREENSHOT ++ } ++ ++ override fun onCreateViewHolder( ++ parent: ViewGroup, ++ viewType: ViewType ++ ): RecyclerView.ViewHolder { ++ return ViewHolder(parent.context).apply { ++ image.setOnClickListener { onClick(items[absoluteAdapterPosition].screenshot, it as ImageView) } ++ } ++ } ++ ++ override fun getItemDescriptor(position: Int): String = items[position].descriptor ++ override fun getItemCount(): Int = items.size ++ ++ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { ++ holder as ViewHolder ++ val item = items[position] ++ holder.image.load( ++ item.screenshot.url(item.repository, item.packageName) ++ ) { ++ scale(Scale.FILL) ++ placeholder(holder.placeholder) ++ error(holder.placeholder) ++ authentication(item.repository.authentication) ++ } ++ } ++ ++ private sealed class Item { ++ abstract val descriptor: String ++ ++ class ScreenshotItem( ++ val repository: Repository, ++ val packageName: String, ++ val screenshot: Product.Screenshot ++ ) : Item() { ++ override val descriptor: String ++ get() = "screenshot.${repository.id}.${screenshot.identifier}" ++ } ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/ui/appList/AppListAdapter.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/ui/appList/AppListAdapter.kt b/app/src/main/kotlin/com/leos/droidify/ui/appList/AppListAdapter.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/ui/appList/AppListAdapter.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,200 @@ ++package com.leos.droidify.ui.appList ++ ++import android.content.Context ++import android.view.Gravity ++import android.view.View ++import android.view.ViewGroup ++import android.widget.FrameLayout ++import android.widget.TextView ++import androidx.core.view.isVisible ++import androidx.recyclerview.widget.RecyclerView ++import coil.load ++import com.google.android.material.R as MaterialR ++import com.google.android.material.imageview.ShapeableImageView ++import com.google.android.material.progressindicator.CircularProgressIndicator ++import com.leos.core.common.extension.authentication ++import com.leos.core.common.extension.corneredBackground ++import com.leos.core.common.extension.dp ++import com.leos.core.common.extension.getColorFromAttr ++import com.leos.core.common.extension.inflate ++import com.leos.core.common.extension.setTextSizeScaled ++import com.leos.core.common.nullIfEmpty ++import com.leos.core.domain.ProductItem ++import com.leos.core.domain.Repository ++import com.leos.droidify.R ++import com.leos.droidify.database.Database ++import com.leos.droidify.utility.extension.ImageUtils.icon ++import com.leos.droidify.utility.extension.resources.TypefaceExtra ++import com.leos.droidify.widget.CursorRecyclerAdapter ++ ++class AppListAdapter( ++ private val source: AppListFragment.Source, ++ private val onClick: (ProductItem) -> Unit ++) : CursorRecyclerAdapter() { ++ ++ enum class ViewType { PRODUCT, LOADING, EMPTY } ++ ++ private class ProductViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { ++ val name = itemView.findViewById(R.id.name)!! ++ val status = itemView.findViewById(R.id.status)!! ++ val summary = itemView.findViewById(R.id.summary)!! ++ val icon = itemView.findViewById(R.id.icon)!! ++ } ++ ++ private class LoadingViewHolder(context: Context) : ++ RecyclerView.ViewHolder(FrameLayout(context)) { ++ init { ++ with(itemView as FrameLayout) { ++ val progressBar = CircularProgressIndicator(context) ++ addView(progressBar) ++ layoutParams = RecyclerView.LayoutParams( ++ RecyclerView.LayoutParams.MATCH_PARENT, ++ RecyclerView.LayoutParams.MATCH_PARENT ++ ) ++ } ++ } ++ } ++ ++ private class EmptyViewHolder(context: Context) : ++ RecyclerView.ViewHolder(TextView(context)) { ++ val text: TextView ++ get() = itemView as TextView ++ ++ init { ++ with(itemView as TextView) { ++ gravity = Gravity.CENTER ++ setPadding(20.dp, 20.dp, 20.dp, 20.dp) ++ typeface = TypefaceExtra.light ++ setTextColor(context.getColorFromAttr(android.R.attr.colorPrimary)) ++ setTextSizeScaled(20) ++ layoutParams = RecyclerView.LayoutParams( ++ RecyclerView.LayoutParams.MATCH_PARENT, ++ RecyclerView.LayoutParams.MATCH_PARENT ++ ) ++ } ++ } ++ } ++ ++ var repositories: Map = emptyMap() ++ set(value) { ++ field = value ++ notifyDataSetChanged() ++ } ++ ++ var emptyText: String = "" ++ set(value) { ++ if (field != value) { ++ field = value ++ if (isEmpty) { ++ notifyDataSetChanged() ++ } ++ } ++ } ++ ++ override val viewTypeClass: Class ++ get() = ViewType::class.java ++ ++ private val isEmpty: Boolean ++ get() = super.getItemCount() == 0 ++ ++ override fun getItemCount(): Int = if (isEmpty) 1 else super.getItemCount() ++ override fun getItemId(position: Int): Long = if (isEmpty) -1 else super.getItemId(position) ++ ++ override fun getItemEnumViewType(position: Int): ViewType { ++ return when { ++ !isEmpty -> ViewType.PRODUCT ++ cursor == null -> ViewType.LOADING ++ else -> ViewType.EMPTY ++ } ++ } ++ ++ private fun getProductItem(position: Int): ProductItem { ++ return Database.ProductAdapter.transformItem(moveTo(position)) ++ } ++ ++ override fun onCreateViewHolder( ++ parent: ViewGroup, ++ viewType: ViewType ++ ): RecyclerView.ViewHolder { ++ return when (viewType) { ++ ViewType.PRODUCT -> ProductViewHolder(parent.inflate(R.layout.product_item)).apply { ++ itemView.setOnClickListener { onClick(getProductItem(absoluteAdapterPosition)) } ++ } ++ ++ ViewType.LOADING -> LoadingViewHolder(parent.context) ++ ViewType.EMPTY -> EmptyViewHolder(parent.context) ++ } ++ } ++ ++ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { ++ when (getItemEnumViewType(position)) { ++ ViewType.PRODUCT -> { ++ holder as ProductViewHolder ++ val productItem = getProductItem(if (position > -1) position else 0) ++ holder.name.text = productItem.name ++ holder.summary.text = productItem.summary ++ holder.summary.isVisible = ++ productItem.summary.isNotEmpty() && productItem.name != productItem.summary ++ val repository: Repository? = repositories[productItem.repositoryId] ++ if (repository != null) { ++ val iconUrl = productItem.icon(view = holder.icon, repository = repository) ++ holder.icon.load(iconUrl) { ++ authentication(repository.authentication) ++ } ++ } ++ with(holder.status) { ++ val versionText = if (source == AppListFragment.Source.UPDATES) { ++ productItem.version ++ } else { ++ productItem.installedVersion.nullIfEmpty() ?: productItem.version ++ } ++ text = versionText ++ val isInstalled = productItem.installedVersion.nullIfEmpty() != null ++ when { ++ productItem.canUpdate -> { ++ backgroundTintList = ++ context.getColorFromAttr(MaterialR.attr.colorSecondaryContainer) ++ setTextColor( ++ context.getColorFromAttr(MaterialR.attr.colorOnSecondaryContainer) ++ ) ++ } ++ ++ isInstalled -> { ++ backgroundTintList = ++ context.getColorFromAttr(MaterialR.attr.colorPrimaryContainer) ++ setTextColor( ++ context.getColorFromAttr(MaterialR.attr.colorOnPrimaryContainer) ++ ) ++ } ++ ++ else -> { ++ setPadding(0, 0, 0, 0) ++ setTextColor( ++ holder.status.context.getColorFromAttr( ++ MaterialR.attr.colorOnBackground ++ ) ++ ) ++ background = null ++ return@with ++ } ++ } ++ background = context.corneredBackground ++ 6.dp.let { setPadding(it, it, it, it) } ++ } ++ val enabled = productItem.compatible || productItem.installedVersion.isNotEmpty() ++ sequenceOf(holder.name, holder.status, holder.summary).forEach { ++ it.isEnabled = enabled ++ } ++ } ++ ++ ViewType.LOADING -> { ++ // Do nothing ++ } ++ ++ ViewType.EMPTY -> { ++ holder as EmptyViewHolder ++ holder.text.text = emptyText ++ } ++ }::class ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/ui/appList/AppListFragment.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/ui/appList/AppListFragment.kt b/app/src/main/kotlin/com/leos/droidify/ui/appList/AppListFragment.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/ui/appList/AppListFragment.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,198 @@ ++package com.leos.droidify.ui.appList ++ ++import android.database.Cursor ++import android.os.Bundle ++import android.os.Parcelable ++import android.view.LayoutInflater ++import android.view.View ++import android.view.ViewGroup ++import androidx.core.view.isVisible ++import androidx.fragment.app.Fragment ++import androidx.fragment.app.viewModels ++import androidx.lifecycle.Lifecycle ++import androidx.lifecycle.lifecycleScope ++import androidx.lifecycle.repeatOnLifecycle ++import androidx.recyclerview.widget.LinearLayoutManager ++import androidx.recyclerview.widget.RecyclerView ++import com.leos.core.common.R as CommonR ++import com.leos.core.common.R.string as stringRes ++import com.leos.core.common.extension.dp ++import com.leos.core.common.extension.isFirstItemVisible ++import com.leos.core.common.extension.systemBarsMargin ++import com.leos.core.common.extension.systemBarsPadding ++import com.leos.core.domain.ProductItem ++import com.leos.droidify.database.CursorOwner ++import com.leos.droidify.databinding.RecyclerViewWithFabBinding ++import com.leos.droidify.utility.extension.screenActivity ++import dagger.hilt.android.AndroidEntryPoint ++import kotlinx.coroutines.launch ++ ++@AndroidEntryPoint ++class AppListFragment() : Fragment(), CursorOwner.Callback { ++ ++ private val viewModel: AppListViewModel by viewModels() ++ ++ private var _binding: RecyclerViewWithFabBinding? = null ++ private val binding get() = _binding!! ++ ++ companion object { ++ private const val STATE_LAYOUT_MANAGER = "layoutManager" ++ ++ private const val EXTRA_SOURCE = "source" ++ } ++ ++ enum class Source( ++ val titleResId: Int, ++ val sections: Boolean, ++ val order: Boolean, ++ val updateAll: Boolean ++ ) { ++ AVAILABLE(stringRes.available, true, true, false), ++ INSTALLED(stringRes.installed, false, true, false), ++ UPDATES(stringRes.updates, false, false, true) ++ } ++ ++ constructor(source: Source) : this() { ++ arguments = Bundle().apply { ++ putString(EXTRA_SOURCE, source.name) ++ } ++ } ++ ++ val source: Source ++ get() = requireArguments().getString(EXTRA_SOURCE)!!.let(Source::valueOf) ++ ++ private lateinit var recyclerView: RecyclerView ++ private lateinit var recyclerViewAdapter: AppListAdapter ++ private var shortAnimationDuration: Int = 0 ++ private var layoutManagerState: Parcelable? = null ++ ++ override fun onCreateView( ++ inflater: LayoutInflater, ++ container: ViewGroup?, ++ savedInstanceState: Bundle? ++ ): View { ++ _binding = RecyclerViewWithFabBinding.inflate(inflater, container, false) ++ ++ shortAnimationDuration = resources.getInteger(android.R.integer.config_shortAnimTime) ++ ++ viewModel.syncConnection.bind(requireContext()) ++ ++ recyclerView = binding.recyclerView.apply { ++ layoutManager = LinearLayoutManager(context) ++ isMotionEventSplittingEnabled = false ++ setHasFixedSize(true) ++ recycledViewPool.setMaxRecycledViews(AppListAdapter.ViewType.PRODUCT.ordinal, 30) ++ recyclerViewAdapter = AppListAdapter(source) { ++ screenActivity.navigateProduct(it.packageName) ++ } ++ adapter = recyclerViewAdapter ++ systemBarsPadding() ++ } ++ val fab = binding.scrollUp ++ with(fab) { ++ if (source.updateAll) { ++ text = getString(CommonR.string.update_all) ++ setOnClickListener { viewModel.updateAll() } ++ setIconResource(CommonR.drawable.ic_download) ++ alpha = 1f ++ viewLifecycleOwner.lifecycleScope.launch { ++ viewModel.showUpdateAllButton.collect { ++ isVisible = it ++ } ++ } ++ systemBarsMargin(16.dp) ++ } else { ++ text = "" ++ setIconResource(CommonR.drawable.arrow_up) ++ setOnClickListener { recyclerView.smoothScrollToPosition(0) } ++ alpha = 0f ++ isVisible = true ++ systemBarsMargin(16.dp) ++ } ++ } ++ ++ viewLifecycleOwner.lifecycleScope.launch { ++ if (!source.updateAll) { ++ recyclerView.isFirstItemVisible.collect { showFab -> ++ fab.animate() ++ .alpha(if (!showFab) 1f else 0f) ++ .setDuration(shortAnimationDuration.toLong()) ++ .setListener(null) ++ } ++ } ++ } ++ return binding.root ++ } ++ ++ override fun onViewCreated(view: View, savedInstanceState: Bundle?) { ++ super.onViewCreated(view, savedInstanceState) ++ layoutManagerState = savedInstanceState?.getParcelable(STATE_LAYOUT_MANAGER) ++ ++ updateRequest() ++ viewLifecycleOwner.lifecycleScope.launch { ++ repeatOnLifecycle(Lifecycle.State.RESUMED) { ++ launch { ++ viewModel.reposStream.collect { repos -> ++ recyclerViewAdapter.repositories = repos.associateBy { it.id } ++ } ++ } ++ launch { ++ viewModel.sortOrderFlow.collect { ++ updateRequest() ++ } ++ } ++ } ++ } ++ } ++ ++ override fun onSaveInstanceState(outState: Bundle) { ++ super.onSaveInstanceState(outState) ++ (layoutManagerState ?: recyclerView.layoutManager?.onSaveInstanceState()) ++ ?.let { outState.putParcelable(STATE_LAYOUT_MANAGER, it) } ++ } ++ ++ override fun onDestroyView() { ++ super.onDestroyView() ++ viewModel.syncConnection.unbind(requireContext()) ++ _binding = null ++ screenActivity.cursorOwner.detach(this) ++ } ++ ++ override fun onCursorData(request: CursorOwner.Request, cursor: Cursor?) { ++ recyclerViewAdapter.cursor = cursor ++ recyclerViewAdapter.emptyText = when { ++ cursor == null -> "" ++ viewModel.searchQuery.value.isNotEmpty() -> { ++ getString(stringRes.no_matching_applications_found) ++ } ++ ++ else -> when (source) { ++ Source.AVAILABLE -> getString(stringRes.no_applications_available) ++ Source.INSTALLED -> getString(stringRes.no_applications_installed) ++ Source.UPDATES -> getString(stringRes.all_applications_up_to_date) ++ } ++ } ++ layoutManagerState?.let { ++ layoutManagerState = null ++ recyclerView.layoutManager?.onRestoreInstanceState(it) ++ } ++ } ++ ++ internal fun setSearchQuery(searchQuery: String) { ++ viewModel.setSearchQuery(searchQuery) { ++ updateRequest() ++ } ++ } ++ ++ internal fun setSection(section: ProductItem.Section) { ++ viewModel.setSection(section) { ++ updateRequest() ++ } ++ } ++ ++ private fun updateRequest() { ++ if (view != null) { ++ screenActivity.cursorOwner.attach(this, viewModel.request(source)) ++ } ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/ui/appList/AppListViewModel.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/ui/appList/AppListViewModel.kt b/app/src/main/kotlin/com/leos/droidify/ui/appList/AppListViewModel.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/ui/appList/AppListViewModel.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,91 @@ ++package com.leos.droidify.ui.appList ++ ++import androidx.lifecycle.ViewModel ++import androidx.lifecycle.viewModelScope ++import com.leos.core.common.extension.asStateFlow ++import com.leos.core.datastore.SettingsRepository ++import com.leos.core.datastore.get ++import com.leos.core.datastore.model.SortOrder ++import com.leos.core.domain.ProductItem ++import com.leos.core.domain.ProductItem.Section.All ++import com.leos.droidify.database.CursorOwner ++import com.leos.droidify.database.Database ++import com.leos.droidify.service.Connection ++import com.leos.droidify.service.SyncService ++import dagger.hilt.android.lifecycle.HiltViewModel ++import javax.inject.Inject ++import kotlinx.coroutines.Dispatchers ++import kotlinx.coroutines.flow.MutableStateFlow ++import kotlinx.coroutines.flow.map ++import kotlinx.coroutines.launch ++ ++@HiltViewModel ++class AppListViewModel ++@Inject constructor( ++ settingsRepository: SettingsRepository ++) : ViewModel() { ++ ++ val reposStream = Database.RepositoryAdapter ++ .getAllStream() ++ .asStateFlow(emptyList()) ++ ++ val showUpdateAllButton = Database.ProductAdapter ++ .getUpdatesStream() ++ .map { it.isNotEmpty() } ++ .asStateFlow(false) ++ ++ val sortOrderFlow = settingsRepository.get { sortOrder } ++ .asStateFlow(SortOrder.UPDATED) ++ ++ private val sections = MutableStateFlow(All) ++ ++ val searchQuery = MutableStateFlow("") ++ ++ val syncConnection = Connection(SyncService::class.java) ++ ++ fun updateAll() { ++ viewModelScope.launch { ++ syncConnection.binder?.updateAllApps() ++ } ++ } ++ ++ fun request(source: AppListFragment.Source): CursorOwner.Request { ++ return when (source) { ++ AppListFragment.Source.AVAILABLE -> CursorOwner.Request.ProductsAvailable( ++ searchQuery.value, ++ sections.value, ++ sortOrderFlow.value ++ ) ++ ++ AppListFragment.Source.INSTALLED -> CursorOwner.Request.ProductsInstalled( ++ searchQuery.value, ++ sections.value, ++ sortOrderFlow.value ++ ) ++ ++ AppListFragment.Source.UPDATES -> CursorOwner.Request.ProductsUpdates( ++ searchQuery.value, ++ sections.value, ++ sortOrderFlow.value ++ ) ++ } ++ } ++ ++ fun setSection(newSection: ProductItem.Section, perform: () -> Unit) { ++ viewModelScope.launch { ++ if (newSection != sections.value) { ++ sections.emit(newSection) ++ launch(Dispatchers.Main) { perform() } ++ } ++ } ++ } ++ ++ fun setSearchQuery(newSearchQuery: String, perform: () -> Unit) { ++ viewModelScope.launch { ++ if (newSearchQuery != searchQuery.value) { ++ searchQuery.emit(newSearchQuery) ++ launch(Dispatchers.Main) { perform() } ++ } ++ } ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/ui/favourites/FavouriteFragmentAdapter.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/ui/favourites/FavouriteFragmentAdapter.kt b/app/src/main/kotlin/com/leos/droidify/ui/favourites/FavouriteFragmentAdapter.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/ui/favourites/FavouriteFragmentAdapter.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,98 @@ ++package com.leos.droidify.ui.favourites ++ ++import android.view.LayoutInflater ++import android.view.ViewGroup ++import androidx.core.view.isVisible ++import androidx.recyclerview.widget.RecyclerView ++import coil.load ++import com.google.android.material.R as MaterialR ++import com.leos.core.common.extension.authentication ++import com.leos.core.common.extension.corneredBackground ++import com.leos.core.common.extension.dp ++import com.leos.core.common.extension.getColorFromAttr ++import com.leos.core.common.nullIfEmpty ++import com.leos.core.domain.Product ++import com.leos.core.domain.Repository ++import com.leos.droidify.databinding.ProductItemBinding ++import com.leos.droidify.utility.extension.ImageUtils.icon ++ ++class FavouriteFragmentAdapter( ++ private val onProductClick: (String) -> Unit ++) : RecyclerView.Adapter() { ++ ++ inner class ViewHolder(binding: ProductItemBinding) : RecyclerView.ViewHolder(binding.root) { ++ val icon = binding.icon ++ val name = binding.name ++ val summary = binding.summary ++ val version = binding.status ++ } ++ ++ var apps: List> = emptyList() ++ set(value) { ++ field = value ++ notifyDataSetChanged() ++ } ++ ++ var repositories: Map = emptyMap() ++ set(value) { ++ field = value ++ notifyDataSetChanged() ++ } ++ ++ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder = ++ ViewHolder( ++ ProductItemBinding.inflate( ++ LayoutInflater.from(parent.context), ++ parent, ++ false ++ ) ++ ).apply { ++ itemView.setOnClickListener { ++ if (apps.isNotEmpty() && apps[absoluteAdapterPosition].firstOrNull() != null) { ++ onProductClick(apps[absoluteAdapterPosition].first().packageName) ++ } ++ } ++ } ++ ++ override fun getItemCount(): Int = apps.size ++ ++ override fun onBindViewHolder(holder: ViewHolder, position: Int) { ++ val item = apps[position].first().item() ++ val repository: Repository? = repositories[item.repositoryId] ++ holder.name.text = item.name ++ holder.summary.isVisible = item.summary.isNotEmpty() ++ holder.summary.text = item.summary ++ if (repository != null) { ++ val iconUrl = item.icon(holder.icon, repository) ++ holder.icon.load(iconUrl) { ++ authentication(repository.authentication) ++ } ++ } ++ holder.version.apply { ++ text = item.installedVersion.nullIfEmpty() ?: item.version ++ val isInstalled = item.installedVersion.nullIfEmpty() != null ++ when { ++ item.canUpdate -> { ++ backgroundTintList = ++ context.getColorFromAttr(MaterialR.attr.colorSecondaryContainer) ++ setTextColor(context.getColorFromAttr(MaterialR.attr.colorOnSecondaryContainer)) ++ } ++ ++ isInstalled -> { ++ backgroundTintList = ++ context.getColorFromAttr(MaterialR.attr.colorPrimaryContainer) ++ setTextColor(context.getColorFromAttr(MaterialR.attr.colorOnPrimaryContainer)) ++ } ++ ++ else -> { ++ setPadding(0, 0, 0, 0) ++ setTextColor(context.getColorFromAttr(MaterialR.attr.colorOnBackground)) ++ background = null ++ return@apply ++ } ++ } ++ background = context.corneredBackground ++ 6.dp.let { setPadding(it, it, it, it) } ++ } ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/ui/favourites/FavouritesFragment.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/ui/favourites/FavouritesFragment.kt b/app/src/main/kotlin/com/leos/droidify/ui/favourites/FavouritesFragment.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/ui/favourites/FavouritesFragment.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,79 @@ ++package com.leos.droidify.ui.favourites ++ ++import android.os.Bundle ++import android.view.LayoutInflater ++import android.view.View ++import android.view.ViewGroup ++import androidx.fragment.app.viewModels ++import androidx.lifecycle.Lifecycle ++import androidx.lifecycle.flowWithLifecycle ++import androidx.lifecycle.lifecycleScope ++import androidx.lifecycle.repeatOnLifecycle ++import androidx.recyclerview.widget.LinearLayoutManager ++import androidx.recyclerview.widget.RecyclerView ++import com.leos.core.common.R as CommonR ++import com.leos.core.common.extension.systemBarsPadding ++import com.leos.droidify.database.Database ++import com.leos.droidify.ui.ScreenFragment ++import com.leos.droidify.utility.extension.screenActivity ++import dagger.hilt.android.AndroidEntryPoint ++import kotlinx.coroutines.flow.collectLatest ++import kotlinx.coroutines.launch ++ ++@AndroidEntryPoint ++class FavouritesFragment : ScreenFragment() { ++ ++ private val viewModel: FavouritesViewModel by viewModels() ++ ++ private lateinit var recyclerView: RecyclerView ++ private lateinit var recyclerViewAdapter: FavouriteFragmentAdapter ++ ++ override fun onCreateView( ++ inflater: LayoutInflater, ++ container: ViewGroup?, ++ savedInstanceState: Bundle? ++ ): View { ++ super.onCreateView(inflater, container, savedInstanceState) ++ val view = fragmentBinding.root.apply { ++ val content = fragmentBinding.fragmentContent ++ content.addView( ++ RecyclerView(content.context).apply { ++ id = android.R.id.list ++ layoutManager = LinearLayoutManager(context) ++ isVerticalScrollBarEnabled = false ++ setHasFixedSize(true) ++ recyclerViewAdapter = ++ FavouriteFragmentAdapter { screenActivity.navigateProduct(it) } ++ this.adapter = recyclerViewAdapter ++ systemBarsPadding(includeFab = false) ++ recyclerView = this ++ } ++ ) ++ } ++ viewLifecycleOwner.lifecycleScope.launch { ++ repeatOnLifecycle(Lifecycle.State.CREATED) { ++ launch { ++ viewModel.favouriteApps.collect { apps -> ++ recyclerViewAdapter.apps = apps ++ } ++ } ++ launch { ++ Database.RepositoryAdapter ++ .getAllStream() ++ .flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.RESUMED) ++ .collectLatest { repositories -> ++ recyclerViewAdapter.repositories = repositories.associateBy { it.id } ++ } ++ } ++ } ++ } ++ ++ toolbar.title = getString(CommonR.string.favourites) ++ return view ++ } ++ ++ override fun onViewCreated(view: View, savedInstanceState: Bundle?) { ++ super.onViewCreated(view, savedInstanceState) ++ screenActivity.onToolbarCreated(toolbar) ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/ui/favourites/FavouritesViewModel.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/ui/favourites/FavouritesViewModel.kt b/app/src/main/kotlin/com/leos/droidify/ui/favourites/FavouritesViewModel.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/ui/favourites/FavouritesViewModel.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,35 @@ ++package com.leos.droidify.ui.favourites ++ ++import androidx.lifecycle.ViewModel ++import androidx.lifecycle.viewModelScope ++import com.leos.core.common.extension.asStateFlow ++import com.leos.core.datastore.SettingsRepository ++import com.leos.core.datastore.get ++import com.leos.core.domain.Product ++import com.leos.droidify.database.Database ++import dagger.hilt.android.lifecycle.HiltViewModel ++import javax.inject.Inject ++import kotlinx.coroutines.flow.StateFlow ++import kotlinx.coroutines.flow.map ++import kotlinx.coroutines.launch ++ ++@HiltViewModel ++class FavouritesViewModel @Inject constructor( ++ private val settingsRepository: SettingsRepository ++) : ViewModel() { ++ ++ val favouriteApps: StateFlow>> = ++ settingsRepository ++ .get { favouriteApps } ++ .map { favourites -> ++ favourites.mapNotNull { app -> ++ Database.ProductAdapter.get(app, null).ifEmpty { null } ++ } ++ }.asStateFlow(emptyList()) ++ ++ fun updateFavourites(packageName: String) { ++ viewModelScope.launch { ++ settingsRepository.toggleFavourites(packageName) ++ } ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/ui/repository/EditRepositoryFragment.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/ui/repository/EditRepositoryFragment.kt b/app/src/main/kotlin/com/leos/droidify/ui/repository/EditRepositoryFragment.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/ui/repository/EditRepositoryFragment.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,475 @@ ++package com.leos.droidify.ui.repository ++ ++import android.os.Bundle ++import android.text.Selection ++import android.util.Base64 ++import android.view.MenuItem ++import android.view.View ++import android.view.ViewGroup ++import androidx.appcompat.app.AlertDialog ++import androidx.core.net.toUri ++import androidx.core.os.bundleOf ++import androidx.core.view.isVisible ++import androidx.core.widget.doAfterTextChanged ++import androidx.fragment.app.DialogFragment ++import androidx.lifecycle.lifecycleScope ++import com.google.android.material.dialog.MaterialAlertDialogBuilder ++import com.google.android.material.snackbar.Snackbar ++import com.leos.core.common.extension.clipboardManager ++import com.leos.core.common.extension.get ++import com.leos.core.common.extension.getMutatedIcon ++import com.leos.core.common.nullIfEmpty ++import com.leos.core.domain.Repository ++import com.leos.droidify.database.Database ++import com.leos.droidify.databinding.EditRepositoryBinding ++import com.leos.droidify.service.Connection ++import com.leos.droidify.service.SyncService ++import com.leos.droidify.ui.Message ++import com.leos.droidify.ui.MessageDialog ++import com.leos.droidify.ui.ScreenFragment ++import com.leos.droidify.utility.extension.screenActivity ++import com.leos.network.Downloader ++import com.leos.network.NetworkResponse ++import dagger.hilt.android.AndroidEntryPoint ++import kotlinx.coroutines.Dispatchers ++import kotlinx.coroutines.Job ++import kotlinx.coroutines.async ++import kotlinx.coroutines.awaitAll ++import kotlinx.coroutines.coroutineScope ++import kotlinx.coroutines.launch ++import java.net.URI ++import java.net.URISyntaxException ++import java.net.URL ++import java.nio.charset.Charset ++import java.util.Locale ++import javax.inject.Inject ++import kotlin.math.min ++import com.leos.core.common.R as CommonR ++import com.leos.core.common.R.string as stringRes ++ ++@AndroidEntryPoint ++class EditRepositoryFragment() : ScreenFragment() { ++ ++ constructor(repositoryId: Long?, repoAddress: String?) : this() { ++ arguments = ++ bundleOf(EXTRA_REPOSITORY_ID to repositoryId, EXTRA_REPOSITORY_ADDRESS to repoAddress) ++ } ++ ++ private var _binding: EditRepositoryBinding? = null ++ private val binding get() = _binding!! ++ ++ private val repoId: Long? ++ get() = arguments?.getLong(EXTRA_REPOSITORY_ID) ++ ++ private val repoAddress: String? ++ get() = arguments?.getString(EXTRA_REPOSITORY_ADDRESS) ++ ++ private var saveMenuItem: MenuItem? = null ++ ++ private val syncConnection = Connection(SyncService::class.java) ++ private var checkInProgress = false ++ private var checkJob: Job? = null ++ ++ private var takenAddresses = emptySet() ++ ++ @Inject ++ lateinit var downloader: Downloader ++ ++ override fun onViewCreated(view: View, savedInstanceState: Bundle?) { ++ super.onViewCreated(view, savedInstanceState) ++ ++ _binding = EditRepositoryBinding.inflate(layoutInflater) ++ ++ syncConnection.bind(requireContext()) ++ ++ screenActivity.onToolbarCreated(toolbar) ++ toolbar.title = ++ getString( ++ if (repoId != null) stringRes.edit_repository else stringRes.add_repository ++ ) ++ ++ saveMenuItem = toolbar.menu.add(stringRes.save) ++ .setIcon(toolbar.context.getMutatedIcon(CommonR.drawable.ic_save)) ++ .setEnabled(false) ++ .setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS).setOnMenuItemClickListener { ++ onSaveRepositoryClick(true) ++ true ++ } ++ ++ val content = fragmentBinding.fragmentContent ++ ++ content.addView(binding.root) ++ ++ val validChar: (Char) -> Boolean = { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' } ++ ++ binding.fingerprint.doAfterTextChanged { text -> ++ fun logicalPosition(text: String, position: Int): Int { ++ return if (position > 0) { ++ text.asSequence().take(position) ++ .count(validChar) ++ } else { ++ position ++ } ++ } ++ ++ fun realPosition(text: String, position: Int): Int { ++ return if (position > 0) { ++ var left = position ++ val index = text.indexOfFirst { ++ validChar(it) && run { ++ left -= 1 ++ left <= 0 ++ } ++ } ++ if (index >= 0) min(index + 1, text.length) else text.length ++ } else { ++ position ++ } ++ } ++ ++ val inputString = text.toString() ++ val outputString = inputString ++ .uppercase(Locale.US) ++ .filter(validChar) ++ .windowed(2, 2, true).take(32) ++ .joinToString(separator = " ") ++ if (inputString != outputString) { ++ val inputStart = logicalPosition(inputString, Selection.getSelectionStart(text)) ++ val inputEnd = logicalPosition(inputString, Selection.getSelectionEnd(text)) ++ text?.replace(0, text.length, outputString) ++ Selection.setSelection( ++ text, ++ realPosition(outputString, inputStart), ++ realPosition(outputString, inputEnd) ++ ) ++ } ++ } ++ ++ if (savedInstanceState == null) { ++ val repository = repoId?.let(Database.RepositoryAdapter::get) ++ if (repository == null) { ++ val text = repoAddress ?: kotlin.run { ++ context?.clipboardManager?.primaryClip?.takeIf { it.itemCount > 0 } ++ ?.getItemAt(0)?.text?.toString().orEmpty() ++ } ++ val (addressText, fingerprintText) = try { ++ val uri = URL(text).toString().toUri() ++ val fingerprintText = uri["fingerprint"]?.nullIfEmpty() ++ ?: uri["FINGERPRINT"]?.nullIfEmpty() ++ Pair( ++ uri.buildUpon().path(uri.path?.pathCropped).query(null).fragment(null) ++ .build().toString(), ++ fingerprintText ++ ) ++ } catch (e: Exception) { ++ Pair(null, null) ++ } ++ binding.address.setText(addressText) ++ binding.fingerprint.setText(fingerprintText) ++ } else { ++ binding.address.setText(repository.address) ++ val mirrors = repository.mirrors.map { it.withoutKnownPath } ++ binding.addressContainer.apply { ++ isEndIconVisible = mirrors.isNotEmpty() ++ setEndIconDrawable(CommonR.drawable.ic_arrow_down) ++ setEndIconOnClickListener { ++ SelectMirrorDialog(mirrors).show( ++ childFragmentManager, ++ SelectMirrorDialog::class.java.name ++ ) ++ } ++ } ++ binding.fingerprint.setText(repository.fingerprint) ++ val (usernameText, passwordText) = repository.authentication.nullIfEmpty() ++ ?.let { if (it.startsWith("Basic ")) it.substring(6) else null }?.let { ++ try { ++ Base64.decode(it, Base64.NO_WRAP).toString(Charset.defaultCharset()) ++ } catch (e: Exception) { ++ e.printStackTrace() ++ null ++ } ++ }?.let { ++ val index = it.indexOf(':') ++ if (index >= 0) { ++ Pair( ++ it.substring(0, index), ++ it.substring(index + 1) ++ ) ++ } else { ++ null ++ } ++ } ?: Pair(null, null) ++ binding.username.setText(usernameText) ++ binding.password.setText(passwordText) ++ } ++ } ++ ++ binding.address.doAfterTextChanged { invalidateAddress() } ++ binding.fingerprint.doAfterTextChanged { invalidateFingerprint() } ++ binding.username.doAfterTextChanged { invalidateUsernamePassword() } ++ binding.password.doAfterTextChanged { invalidateUsernamePassword() } ++ ++ (binding.overlay.parent as ViewGroup).layoutTransition?.setDuration(200L) ++ binding.overlay.background!!.apply { ++ mutate() ++ alpha = 0xcc ++ } ++ binding.skip.setOnClickListener { ++ if (checkInProgress) { ++ checkInProgress = false ++ checkJob?.cancel() ++ onSaveRepositoryClick(false) ++ } ++ } ++ ++ viewLifecycleOwner.lifecycleScope.launch { ++ val list = Database.RepositoryAdapter.getAll() ++ takenAddresses = list.asSequence().filter { it.id != repoId } ++ .flatMap { (it.mirrors + it.address).asSequence() } ++ .map { it.withoutKnownPath } ++ .toSet() ++ invalidateAddress() ++ } ++ invalidateAddress() ++ invalidateFingerprint() ++ invalidateUsernamePassword() ++ } ++ ++ override fun onDestroyView() { ++ super.onDestroyView() ++ ++ saveMenuItem = null ++ syncConnection.unbind(requireContext()) ++ _binding = null ++ } ++ ++ private var addressError = false ++ private var fingerprintError = false ++ private var usernamePasswordError = false ++ ++ private fun invalidateAddress() { ++ invalidateAddress(binding.address.text.toString()) ++ } ++ ++ private fun invalidateAddress(addressText: String) { ++ val normalizedAddress = normalizeAddress(addressText) ++ val addressErrorResId = if (normalizedAddress != null) { ++ if (normalizedAddress.withoutKnownPath in takenAddresses) { ++ stringRes.already_exists ++ } else { ++ null ++ } ++ } else { ++ stringRes.invalid_address ++ } ++ addressError = addressErrorResId != null ++ addressErrorResId?.let { binding.address.error = getString(it) } ++ invalidateState() ++ } ++ ++ private fun invalidateFingerprint() { ++ val fingerprint = binding.fingerprint.text.toString().replace(" ", "") ++ val fingerprintInvalid = fingerprint.isNotEmpty() && fingerprint.length != 64 ++ if (fingerprintInvalid) { ++ binding.fingerprint.error = getString(stringRes.invalid_fingerprint_format) ++ } ++ fingerprintError = fingerprintInvalid ++ invalidateState() ++ } ++ ++ private fun invalidateUsernamePassword() { ++ val username = binding.username.text.toString() ++ val password = binding.password.text.toString() ++ val usernameInvalid = username.contains(':') ++ val usernameEmpty = username.isEmpty() && password.isNotEmpty() ++ val passwordEmpty = username.isNotEmpty() && password.isEmpty() ++ if (usernameEmpty) { ++ binding.username.error = getString(stringRes.username_missing) ++ } else if (passwordEmpty) { ++ binding.password.error = getString(stringRes.password_missing) ++ } else if (usernameInvalid) { ++ binding.username.error = getString(stringRes.invalid_username_format) ++ } ++ usernamePasswordError = usernameInvalid || usernameEmpty || passwordEmpty ++ invalidateState() ++ } ++ ++ private fun invalidateState() { ++ saveMenuItem!!.isEnabled = ++ !addressError && !fingerprintError && !usernamePasswordError && !checkInProgress ++ binding.apply { ++ sequenceOf(address, fingerprint, username, password).forEach { ++ it.isEnabled = !checkInProgress ++ } ++ } ++ binding.overlay.isVisible = checkInProgress ++ } ++ ++ private val String.pathCropped: String ++ get() { ++ val index = indexOfLast { it != '/' } ++ return if (index >= 0 && index < length - 1) substring(0, index + 1) else this ++ } ++ ++ private val String.withoutKnownPath: String ++ get() { ++ val cropped = pathCropped ++ val endsWith = ++ addressSuffixes.asSequence() ++ .sortedByDescending { it.length } ++ .find { cropped.endsWith("/$it") } ++ return if (endsWith != null) { ++ cropped.substring( ++ 0, ++ cropped.length - endsWith.length - 1 ++ ) ++ } else { ++ cropped ++ } ++ } ++ ++ private fun normalizeAddress(address: String): String? { ++ val uri = try { ++ val uri = URI(address) ++ if (uri.isAbsolute) uri.normalize() else null ++ } catch (e: URISyntaxException) { ++ return null ++ } ++ return try { ++ uri?.toURL()?.toURI()?.toString()?.removeSuffix("/") ++ } catch (e: URISyntaxException) { ++ null ++ } ++ } ++ ++ private fun setMirror(address: String) { ++ binding.address.setText(address) ++ } ++ ++ private fun onSaveRepositoryClick(check: Boolean) { ++ if (!checkInProgress) { ++ val address = normalizeAddress(binding.address.text.toString())!! ++ val fingerprint = binding.fingerprint.text.toString().replace(" ", "") ++ val username = binding.username.text.toString().nullIfEmpty() ++ val password = binding.password.text.toString().nullIfEmpty() ++ val authentication = username?.let { u -> ++ password?.let { p -> ++ Base64.encodeToString( ++ "$u:$p".toByteArray(Charset.defaultCharset()), ++ Base64.NO_WRAP ++ ) ++ } ++ }?.let { "Basic $it" }.orEmpty() ++ ++ if (check) { ++ checkJob = viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) { ++ val resultAddress = try { ++ checkAddress(address, authentication) ++ } catch (e: Exception) { ++ e.printStackTrace() ++ failedAddressCheck() ++ null ++ } ++ val allow = resultAddress == address || run { ++ if (resultAddress == null) return@run false ++ binding.address.setText(resultAddress) ++ invalidateAddress(resultAddress) ++ !addressError ++ } ++ if (allow && resultAddress != null) { ++ onSaveRepositoryProceedInvalidate( ++ resultAddress, ++ fingerprint, ++ authentication ++ ) ++ } else { ++ invalidateState() ++ } ++ invalidateState() ++ } ++ } else { ++ onSaveRepositoryProceedInvalidate(address, fingerprint, authentication) ++ } ++ } ++ } ++ ++ private suspend fun checkAddress( ++ address: String, ++ authentication: String ++ ): String? = coroutineScope { ++ checkInProgress = true ++ invalidateState() ++ val allAddresses = addressSuffixes.map { "$address/$it" } + address ++ val pathCheck = allAddresses.map { ++ async { ++ downloader.headCall( ++ url = "$it/index-v1.jar", ++ headers = { authentication(authentication) } ++ ) is NetworkResponse.Success ++ } ++ } ++ val indexOfValidAddress = pathCheck.awaitAll().indexOf(true) ++ allAddresses[indexOfValidAddress].nullIfEmpty() ++ } ++ ++ private fun onSaveRepositoryProceedInvalidate( ++ address: String, ++ fingerprint: String, ++ authentication: String ++ ) { ++ val binder = syncConnection.binder ++ if (binder != null) { ++ val repositoryId = repoId ++ if (repositoryId != null && binder.isCurrentlySyncing(repositoryId)) { ++ MessageDialog(Message.CantEditSyncing).show(childFragmentManager) ++ invalidateState() ++ } else { ++ val repository = repositoryId?.let(Database.RepositoryAdapter::get) ++ ?.edit(address, fingerprint, authentication) ++ ?: Repository.newRepository(address, fingerprint, authentication) ++ val changedRepository = Database.RepositoryAdapter.put(repository) ++ if (repositoryId == null && changedRepository.enabled) { ++ binder.sync(changedRepository) ++ } ++ screenActivity.onBackPressed() ++ } ++ } else { ++ invalidateState() ++ } ++ } ++ ++ private fun failedAddressCheck() { ++ checkInProgress = false ++ invalidateState() ++ Snackbar.make( ++ requireView(), ++ CommonR.string.repository_unreachable, ++ Snackbar.LENGTH_SHORT ++ ).show() ++ } ++ ++ class SelectMirrorDialog() : DialogFragment() { ++ constructor(mirrors: List) : this() { ++ arguments = bundleOf(EXTRA_MIRRORS to ArrayList(mirrors)) ++ } ++ ++ override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog { ++ val mirrors = requireArguments().getStringArrayList(EXTRA_MIRRORS)!! ++ return MaterialAlertDialogBuilder(requireContext()).setTitle(stringRes.select_mirror) ++ .setItems(mirrors.toTypedArray()) { _, position -> ++ (parentFragment as EditRepositoryFragment).setMirror(mirrors[position]) ++ }.setNegativeButton(stringRes.cancel, null).create() ++ } ++ ++ private companion object { ++ const val EXTRA_MIRRORS = "mirrors" ++ } ++ } ++ ++ private companion object { ++ const val EXTRA_REPOSITORY_ID = "repositoryId" ++ const val EXTRA_REPOSITORY_ADDRESS = "repositoryAddress" ++ ++ val addressSuffixes = listOf("fdroid/repo", "repo") ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/ui/repository/RepositoriesAdapter.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/ui/repository/RepositoriesAdapter.kt b/app/src/main/kotlin/com/leos/droidify/ui/repository/RepositoriesAdapter.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/ui/repository/RepositoriesAdapter.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,74 @@ ++package com.leos.droidify.ui.repository ++ ++import android.view.LayoutInflater ++import android.view.View ++import android.view.ViewGroup ++import androidx.recyclerview.widget.RecyclerView ++import com.leos.core.domain.Repository ++import com.leos.droidify.database.Database ++import com.leos.droidify.databinding.RepositoryItemBinding ++import com.leos.droidify.widget.CursorRecyclerAdapter ++ ++class RepositoriesAdapter( ++ private val navigate: (Repository) -> Unit, ++ private val onSwitch: (repository: Repository, isEnabled: Boolean) -> Boolean ++) : CursorRecyclerAdapter() { ++ enum class ViewType { REPOSITORY } ++ ++ private class ViewHolder(itemView: RepositoryItemBinding) : ++ RecyclerView.ViewHolder(itemView.root) { ++ val checkMark = itemView.repositoryState ++ val repoName = itemView.repositoryName ++ val repoDesc = itemView.repositoryDescription ++ ++ var isEnabled = true ++ } ++ ++ override val viewTypeClass: Class ++ get() = ViewType::class.java ++ ++ override fun getItemEnumViewType(position: Int): ViewType { ++ return ViewType.REPOSITORY ++ } ++ ++ private fun getRepository(position: Int): Repository { ++ return Database.RepositoryAdapter.transform(moveTo(position.takeUnless { it < 0 } ?: 0)) ++ } ++ ++ override fun onCreateViewHolder( ++ parent: ViewGroup, ++ viewType: ViewType ++ ): RecyclerView.ViewHolder { ++ return ViewHolder( ++ RepositoryItemBinding.inflate( ++ LayoutInflater.from(parent.context), ++ parent, ++ false ++ ) ++ ).apply { ++ itemView.setOnLongClickListener { ++ navigate(getRepository(absoluteAdapterPosition)) ++ true ++ } ++ itemView.setOnClickListener { ++ isEnabled = !isEnabled ++ onSwitch(getRepository(absoluteAdapterPosition), isEnabled) ++ } ++ } ++ } ++ ++ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { ++ holder as ViewHolder ++ val repository = getRepository(position) ++ ++ holder.isEnabled = repository.enabled ++ holder.repoName.text = repository.name ++ holder.repoDesc.text = repository.description.trim() ++ ++ if (repository.enabled) { ++ holder.checkMark.visibility = View.VISIBLE ++ } else { ++ holder.checkMark.visibility = View.INVISIBLE ++ } ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/ui/repository/RepositoriesFragment.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/ui/repository/RepositoriesFragment.kt b/app/src/main/kotlin/com/leos/droidify/ui/repository/RepositoriesFragment.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/ui/repository/RepositoriesFragment.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,98 @@ ++package com.leos.droidify.ui.repository ++ ++import android.database.Cursor ++import android.os.Bundle ++import android.view.LayoutInflater ++import android.view.View ++import android.view.ViewGroup ++import androidx.recyclerview.widget.LinearLayoutManager ++import com.leos.core.common.R as CommonR ++import com.leos.core.common.extension.dp ++import com.leos.core.common.extension.systemBarsMargin ++import com.leos.core.common.extension.systemBarsPadding ++import com.leos.droidify.database.CursorOwner ++import com.leos.droidify.databinding.RecyclerViewWithFabBinding ++import com.leos.droidify.service.Connection ++import com.leos.droidify.service.SyncService ++import com.leos.droidify.ui.ScreenFragment ++import com.leos.droidify.utility.extension.screenActivity ++import com.leos.droidify.widget.addDivider ++ ++class RepositoriesFragment : ScreenFragment(), CursorOwner.Callback { ++ ++ private var _binding: RecyclerViewWithFabBinding? = null ++ private val binding get() = _binding!! ++ ++ private val syncConnection = Connection(SyncService::class.java) ++ ++ override fun onCreateView( ++ inflater: LayoutInflater, ++ container: ViewGroup?, ++ savedInstanceState: Bundle? ++ ): View { ++ super.onCreateView(inflater, container, savedInstanceState) ++ _binding = RecyclerViewWithFabBinding.inflate(inflater, container, false) ++ val view = fragmentBinding.root.apply { ++ binding.scrollUp.apply { ++ setIconResource(CommonR.drawable.ic_add) ++ setText(CommonR.string.add_repository) ++ setOnClickListener { screenActivity.navigateAddRepository() } ++ systemBarsMargin(16.dp) ++ } ++ binding.recyclerView.apply { ++ layoutManager = LinearLayoutManager(context) ++ isMotionEventSplittingEnabled = false ++ setHasFixedSize(true) ++ adapter = RepositoriesAdapter( ++ navigate = { screenActivity.navigateRepository(it.id) } ++ ) { repository, isEnabled -> ++ repository.enabled != isEnabled && ++ syncConnection.binder?.setEnabled(repository, isEnabled) == true ++ } ++ addDivider { _, _, configuration -> ++ configuration.set( ++ needDivider = true, ++ toTop = false, ++ paddingStart = 16.dp, ++ paddingEnd = 16.dp ++ ) ++ } ++ systemBarsPadding() ++ } ++ fragmentBinding.fragmentContent.addView(binding.root) ++ } ++ handleFab() ++ return view ++ } ++ ++ private fun handleFab() { ++ binding.recyclerView.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> ++ if (scrollY > oldScrollY) { ++ binding.scrollUp.shrink() ++ } else { ++ binding.scrollUp.extend() ++ } ++ } ++ } ++ ++ override fun onViewCreated(view: View, savedInstanceState: Bundle?) { ++ super.onViewCreated(view, savedInstanceState) ++ ++ syncConnection.bind(requireContext()) ++ screenActivity.cursorOwner.attach(this, CursorOwner.Request.Repositories) ++ screenActivity.onToolbarCreated(toolbar) ++ toolbar.title = getString(CommonR.string.repositories) ++ } ++ ++ override fun onDestroyView() { ++ super.onDestroyView() ++ ++ _binding = null ++ syncConnection.unbind(requireContext()) ++ screenActivity.cursorOwner.detach(this) ++ } ++ ++ override fun onCursorData(request: CursorOwner.Request, cursor: Cursor?) { ++ (binding.recyclerView.adapter as RepositoriesAdapter).cursor = cursor ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/ui/repository/RepositoryFragment.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/ui/repository/RepositoryFragment.kt b/app/src/main/kotlin/com/leos/droidify/ui/repository/RepositoryFragment.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/ui/repository/RepositoryFragment.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,168 @@ ++package com.leos.droidify.ui.repository ++ ++import android.os.Bundle ++import android.text.SpannableStringBuilder ++import android.text.format.DateUtils ++import android.text.style.ForegroundColorSpan ++import android.text.style.TypefaceSpan ++import android.view.LayoutInflater ++import android.view.View ++import android.view.ViewGroup ++import android.widget.LinearLayout ++import androidx.core.os.bundleOf ++import androidx.core.widget.NestedScrollView ++import androidx.fragment.app.viewModels ++import androidx.lifecycle.Lifecycle ++import androidx.lifecycle.lifecycleScope ++import androidx.lifecycle.repeatOnLifecycle ++import com.leos.core.common.extension.getColorFromAttr ++import com.leos.core.common.extension.systemBarsPadding ++import com.leos.core.domain.Repository ++import com.leos.droidify.databinding.RepositoryPageBinding ++import com.leos.droidify.ui.Message ++import com.leos.droidify.ui.MessageDialog ++import com.leos.droidify.ui.ScreenFragment ++import com.leos.droidify.utility.extension.screenActivity ++import dagger.hilt.android.AndroidEntryPoint ++import kotlinx.coroutines.flow.collectLatest ++import kotlinx.coroutines.launch ++import java.util.Date ++import java.util.Locale ++import com.google.android.material.R as MaterialR ++import com.leos.core.common.R.string as stringRes ++ ++@AndroidEntryPoint ++class RepositoryFragment() : ScreenFragment() { ++ ++ private var _binding: RepositoryPageBinding? = null ++ private val binding get() = _binding!! ++ ++ private val viewModel: RepositoryViewModel by viewModels() ++ ++ constructor(repositoryId: Long) : this() { ++ arguments = bundleOf(RepositoryViewModel.ARG_REPO_ID to repositoryId) ++ } ++ ++ private var layout: LinearLayout? = null ++ ++ override fun onCreateView( ++ inflater: LayoutInflater, ++ container: ViewGroup?, ++ savedInstanceState: Bundle? ++ ): View { ++ super.onCreateView(inflater, container, savedInstanceState) ++ _binding = RepositoryPageBinding.inflate(inflater, container, false) ++ viewModel.bindService(requireContext()) ++ screenActivity.onToolbarCreated(toolbar) ++ toolbar.title = getString(stringRes.repository) ++ val scroll = NestedScrollView(binding.root.context) ++ scroll.addView(binding.root) ++ scroll.systemBarsPadding() ++ fragmentBinding.fragmentContent.addView(scroll) ++ viewLifecycleOwner.lifecycleScope.launch { ++ repeatOnLifecycle(Lifecycle.State.RESUMED) { ++ viewModel.state.collectLatest { ++ setupView(it.repo, it.appCount) ++ } ++ } ++ } ++ return fragmentBinding.root ++ } ++ ++ override fun onDestroyView() { ++ super.onDestroyView() ++ ++ layout = null ++ viewModel.unbindService(requireContext()) ++ } ++ ++ private fun setupView(repository: Repository?, appCount: Int) { ++ with(binding) { ++ address.title.setText(stringRes.address) ++ if (repository == null) { ++ address.text.text = getString(stringRes.unknown) ++ } else { ++ repoSwitch.isChecked = repository.enabled ++ repoSwitch.setOnCheckedChangeListener { _, isChecked -> ++ viewModel.enabledRepository(isChecked) ++ } ++ ++ address.text.text = repository.address ++ toolbar.title = repository.name ++ repoName.title.setText(stringRes.name) ++ repoName.text.text = repository.name ++ ++ repoDescription.title.setText(stringRes.description) ++ repoDescription.text.text = repository.description.replace('\n', ' ').trim() ++ ++ recentlyUpdated.title.setText(stringRes.recently_updated) ++ recentlyUpdated.text.text = run { ++ val lastUpdated = repository.updated ++ if (lastUpdated > 0L) { ++ val date = Date(repository.updated) ++ val format = ++ if (DateUtils.isToday(date.time)) { ++ DateUtils.FORMAT_SHOW_TIME ++ } else { ++ DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_DATE ++ } ++ DateUtils.formatDateTime(requireContext(), date.time, format) ++ } else { ++ getString(stringRes.unknown) ++ } ++ } ++ ++ numberOfApps.title.setText(stringRes.number_of_applications) ++ numberOfApps.text.text = appCount.toString() ++ ++ repoFingerprint.title.setText(stringRes.fingerprint) ++ if (repository.fingerprint.isEmpty()) { ++ if (repository.updated > 0L) { ++ val builder = ++ SpannableStringBuilder(getString(stringRes.repository_unsigned_DESC)) ++ builder.setSpan( ++ ForegroundColorSpan( ++ requireContext() ++ .getColorFromAttr(MaterialR.attr.colorError) ++ .defaultColor ++ ), ++ 0, ++ builder.length, ++ SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE ++ ) ++ repoFingerprint.text.text = builder ++ } ++ } else { ++ val fingerprint = ++ SpannableStringBuilder( ++ repository.fingerprint.windowed(2, 2, false) ++ .take(32).joinToString(separator = " ") { it.uppercase(Locale.US) } ++ ) ++ fingerprint.setSpan( ++ TypefaceSpan("monospace"), ++ 0, ++ fingerprint.length, ++ SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE ++ ) ++ repoFingerprint.text.text = fingerprint ++ } ++ } ++ ++ editRepoButton.setOnClickListener { ++ screenActivity.navigateEditRepository(viewModel.id) ++ } ++ ++ deleteRepoButton.setOnClickListener { ++ MessageDialog( ++ Message.DeleteRepositoryConfirm ++ ).show(childFragmentManager) ++ } ++ } ++ } ++ ++ internal fun onDeleteConfirm() { ++ viewModel.deleteRepository( ++ onDelete = { requireActivity().onBackPressed() } ++ ) ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/ui/repository/RepositoryViewModel.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/ui/repository/RepositoryViewModel.kt b/app/src/main/kotlin/com/leos/droidify/ui/repository/RepositoryViewModel.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/ui/repository/RepositoryViewModel.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,64 @@ ++package com.leos.droidify.ui.repository ++ ++import android.content.Context ++import androidx.lifecycle.SavedStateHandle ++import androidx.lifecycle.ViewModel ++import androidx.lifecycle.viewModelScope ++import com.leos.core.common.extension.asStateFlow ++import com.leos.core.domain.Repository ++import com.leos.droidify.database.Database ++import com.leos.droidify.service.Connection ++import com.leos.droidify.service.SyncService ++import dagger.hilt.android.lifecycle.HiltViewModel ++import kotlinx.coroutines.flow.combine ++import kotlinx.coroutines.flow.first ++import kotlinx.coroutines.launch ++import javax.inject.Inject ++ ++@HiltViewModel ++class RepositoryViewModel @Inject constructor( ++ savedStateHandle: SavedStateHandle ++) : ViewModel() { ++ ++ val id: Long = savedStateHandle[ARG_REPO_ID] ?: -1 ++ ++ private val repoStream = Database.RepositoryAdapter.getStream(id) ++ ++ private val countStream = Database.ProductAdapter.getCountStream(id) ++ ++ val state = combine(repoStream, countStream) { repo, count -> ++ RepositoryPageItem(repo, count) ++ }.asStateFlow(RepositoryPageItem()) ++ ++ private val syncConnection = Connection(SyncService::class.java) ++ ++ fun bindService(context: Context) { ++ syncConnection.bind(context) ++ } ++ ++ fun unbindService(context: Context) { ++ syncConnection.unbind(context) ++ } ++ ++ fun enabledRepository(enable: Boolean) { ++ viewModelScope.launch { ++ val repo = repoStream.first { it != null }!! ++ syncConnection.binder?.setEnabled(repo, enable) ++ } ++ } ++ ++ fun deleteRepository(onDelete: () -> Unit) { ++ if (syncConnection.binder?.deleteRepository(id) == true) { ++ onDelete() ++ } ++ } ++ ++ companion object { ++ const val ARG_REPO_ID = "repo_id" ++ } ++} ++ ++data class RepositoryPageItem( ++ val repo: Repository? = null, ++ val appCount: Int = 0 ++) +Index: app/src/main/kotlin/com/leos/droidify/ui/settings/SettingsFragment.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/ui/settings/SettingsFragment.kt b/app/src/main/kotlin/com/leos/droidify/ui/settings/SettingsFragment.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/ui/settings/SettingsFragment.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,493 @@ ++package com.leos.droidify.ui.settings ++ ++import android.content.Context ++import android.content.Intent ++import android.net.Uri ++import android.os.Bundle ++import android.view.* ++import androidx.activity.result.contract.ActivityResultContracts.CreateDocument ++import androidx.activity.result.contract.ActivityResultContracts.OpenDocument ++import androidx.annotation.DrawableRes ++import androidx.annotation.StringRes ++import androidx.appcompat.app.AlertDialog ++import androidx.core.view.isVisible ++import androidx.core.widget.NestedScrollView ++import androidx.fragment.app.Fragment ++import androidx.fragment.app.viewModels ++import androidx.lifecycle.* ++import com.google.android.material.dialog.MaterialAlertDialogBuilder ++import com.google.android.material.snackbar.Snackbar ++import com.google.android.material.textfield.TextInputEditText ++import com.leos.core.common.BuildConfig as CommonBuildConfig ++import com.leos.core.common.R as CommonR ++import com.leos.core.common.SdkCheck ++import com.leos.core.common.extension.homeAsUp ++import com.leos.core.common.extension.systemBarsPadding ++import com.leos.core.common.extension.updateAsMutable ++import com.leos.core.datastore.Settings ++import com.leos.core.datastore.extension.* ++import com.leos.core.datastore.model.* ++import com.leos.droidify.BuildConfig ++import com.leos.droidify.databinding.EnumTypeBinding ++import com.leos.droidify.databinding.SettingsPageBinding ++import com.leos.droidify.databinding.SwitchTypeBinding ++import dagger.hilt.android.AndroidEntryPoint ++import java.util.Locale ++import kotlin.time.Duration ++import kotlin.time.Duration.Companion.days ++import kotlin.time.Duration.Companion.hours ++import kotlinx.coroutines.flow.Flow ++import kotlinx.coroutines.launch ++ ++@AndroidEntryPoint ++class SettingsFragment : Fragment() { ++ ++ companion object { ++ fun newInstance() = SettingsFragment() ++ ++ private const val BACKUP_MIME_TYPE = "application/json" ++ private const val REPO_BACKUP_NAME = "droidify_repos.json" ++ private const val SETTINGS_BACKUP_NAME = "droidify_settings.json" ++ ++ private val localeCodesList: List = CommonBuildConfig.DETECTED_LOCALES ++ .toList() ++ .updateAsMutable { add(0, "system") } ++ ++ private const val FOXY_DROID_TITLE = "FoxyDroid" ++ private const val FOXY_DROID_URL = "https://github.com/kitsunyan/foxy-droid" ++ ++ private const val DROID_IFY_TITLE = "LeOS-Droid" ++ private const val DROID_IFY_URL = "https://github.com/LeOS-GSI/LeOS-Droid-ify" ++ } ++ ++ private val viewModel: SettingsViewModel by viewModels() ++ private var _binding: SettingsPageBinding? = null ++ private val binding get() = _binding!! ++ ++ private val createExportFileForSettings = ++ registerForActivityResult(CreateDocument(BACKUP_MIME_TYPE)) { fileUri -> ++ if (fileUri != null) { ++ viewModel.exportSettings(fileUri) ++ } ++ } ++ ++ private val openImportFileForSettings = ++ registerForActivityResult(OpenDocument()) { fileUri -> ++ if (fileUri != null) { ++ viewModel.importSettings(fileUri) ++ } else { ++ viewModel.createSnackbar(CommonR.string.file_format_error_DESC) ++ } ++ } ++ ++ private val createExportFileForRepos = ++ registerForActivityResult(CreateDocument(BACKUP_MIME_TYPE)) { fileUri -> ++ if (fileUri != null) { ++ viewModel.exportRepos(fileUri) ++ } ++ } ++ ++ private val openImportFileForRepos = ++ registerForActivityResult(OpenDocument()) { fileUri -> ++ if (fileUri != null) { ++ viewModel.importRepos(fileUri) ++ } else { ++ viewModel.createSnackbar(CommonR.string.file_format_error_DESC) ++ } ++ } ++ ++ override fun onCreateView( ++ inflater: LayoutInflater, ++ container: ViewGroup?, ++ savedInstanceState: Bundle? ++ ): View { ++ _binding = SettingsPageBinding.inflate(inflater, container, false) ++ binding.nestedScrollView.systemBarsPadding() ++ val toolbar = binding.toolbar ++ toolbar.navigationIcon = toolbar.context.homeAsUp ++ toolbar.setNavigationOnClickListener { activity?.onBackPressed() } ++ toolbar.title = getString(CommonR.string.settings) ++ with(binding) { ++ dynamicTheme.root.isVisible = SdkCheck.isSnowCake ++ dynamicTheme.connect( ++ titleText = getString(CommonR.string.material_you), ++ contentText = getString(CommonR.string.material_you_desc), ++ setting = viewModel.getInitialSetting { dynamicTheme } ++ ) ++ homeScreenSwiping.connect( ++ titleText = getString(CommonR.string.home_screen_swiping), ++ contentText = getString(CommonR.string.home_screen_swiping_DESC), ++ setting = viewModel.getInitialSetting { homeScreenSwiping } ++ ) ++ autoUpdate.connect( ++ titleText = getString(CommonR.string.auto_update), ++ contentText = getString(CommonR.string.auto_update_apps), ++ setting = viewModel.getInitialSetting { autoUpdate } ++ ) ++ notifyUpdates.connect( ++ titleText = getString(CommonR.string.notify_about_updates), ++ contentText = getString(CommonR.string.notify_about_updates_summary), ++ setting = viewModel.getInitialSetting { notifyUpdate } ++ ) ++ unstableUpdates.connect( ++ titleText = getString(CommonR.string.unstable_updates), ++ contentText = getString(CommonR.string.unstable_updates_summary), ++ setting = viewModel.getInitialSetting { unstableUpdate } ++ ) ++ incompatibleUpdates.connect( ++ titleText = getString(CommonR.string.incompatible_versions), ++ contentText = getString(CommonR.string.incompatible_versions_summary), ++ setting = viewModel.getInitialSetting { incompatibleVersions } ++ ) ++ language.connect( ++ titleText = getString(CommonR.string.prefs_language_title), ++ map = { translateLocale(getLocaleOfCode(it)) }, ++ setting = viewModel.getSetting { language } ++ ) { selectedLocale, valueToString -> ++ addSingleCorrectDialog( ++ initialValue = selectedLocale, ++ values = localeCodesList, ++ title = CommonR.string.prefs_language_title, ++ iconRes = CommonR.drawable.ic_language, ++ valueToString = valueToString, ++ onClick = viewModel::setLanguage ++ ) ++ } ++ theme.connect( ++ titleText = getString(CommonR.string.theme), ++ setting = viewModel.getSetting { theme }, ++ map = { themeName(it) } ++ ) { theme, valueToString -> ++ addSingleCorrectDialog( ++ initialValue = theme, ++ values = Theme.entries, ++ title = CommonR.string.themes, ++ iconRes = CommonR.drawable.ic_themes, ++ valueToString = valueToString, ++ onClick = viewModel::setTheme ++ ) ++ } ++ cleanUp.connect( ++ titleText = getString(CommonR.string.cleanup_title), ++ setting = viewModel.getSetting { cleanUpInterval }, ++ map = { toTime(it) } ++ ) { duration, valueToString -> ++ addSingleCorrectDialog( ++ initialValue = duration, ++ values = cleanUpIntervals, ++ title = CommonR.string.cleanup_title, ++ iconRes = CommonR.drawable.ic_time, ++ valueToString = valueToString, ++ onClick = viewModel::setCleanUpInterval ++ ) ++ } ++ autoSync.connect( ++ titleText = getString(CommonR.string.sync_repositories_automatically), ++ setting = viewModel.getSetting { autoSync }, ++ map = { autoSyncName(it) } ++ ) { autoSync, valueToString -> ++ addSingleCorrectDialog( ++ initialValue = autoSync, ++ values = AutoSync.entries, ++ title = CommonR.string.sync_repositories_automatically, ++ iconRes = CommonR.drawable.ic_sync_type, ++ valueToString = valueToString, ++ onClick = viewModel::setAutoSync ++ ) ++ } ++ installer.connect( ++ titleText = getString(CommonR.string.installer), ++ setting = viewModel.getSetting { installerType }, ++ map = { installerName(it) } ++ ) { installerType, valueToString -> ++ addSingleCorrectDialog( ++ initialValue = installerType, ++ values = InstallerType.entries, ++ title = CommonR.string.installer, ++ iconRes = CommonR.drawable.ic_apk_install, ++ valueToString = valueToString, ++ onClick = viewModel::setInstaller ++ ) ++ } ++ proxyType.connect( ++ titleText = getString(CommonR.string.proxy_type), ++ setting = viewModel.getSetting { proxy.type }, ++ map = { proxyName(it) } ++ ) { proxyType, valueToString -> ++ addSingleCorrectDialog( ++ initialValue = proxyType, ++ values = ProxyType.entries, ++ title = CommonR.string.proxy_type, ++ iconRes = CommonR.drawable.ic_proxy, ++ valueToString = valueToString, ++ onClick = viewModel::setProxyType ++ ) ++ } ++ proxyHost.connect( ++ titleText = getString(CommonR.string.proxy_host), ++ setting = viewModel.getSetting { proxy.host }, ++ map = { it } ++ ) { host, _ -> ++ addEditTextDialog( ++ initialValue = host, ++ title = CommonR.string.proxy_host, ++ onFinish = viewModel::setProxyHost ++ ) ++ } ++ proxyPort.connect( ++ titleText = getString(CommonR.string.proxy_port), ++ setting = viewModel.getSetting { proxy.port }, ++ map = { it.toString() } ++ ) { port, _ -> ++ addEditTextDialog( ++ initialValue = port.toString(), ++ title = CommonR.string.proxy_port, ++ onFinish = viewModel::setProxyPort ++ ) ++ } ++ ++ forceCleanUp.title.text = getString(CommonR.string.force_clean_up) ++ forceCleanUp.content.text = getString(CommonR.string.force_clean_up_DESC) ++ ++ importSettings.title.text = getString(CommonR.string.import_settings_title) ++ importSettings.content.text = getString(CommonR.string.import_settings_DESC) ++ exportSettings.title.text = getString(CommonR.string.export_settings_title) ++ exportSettings.content.text = getString(CommonR.string.export_settings_DESC) ++ ++ importRepos.title.text = getString(CommonR.string.import_repos_title) ++ importRepos.content.text = getString(CommonR.string.import_repos_DESC) ++ exportRepos.title.text = getString(CommonR.string.export_repos_title) ++ exportRepos.content.text = getString(CommonR.string.export_repos_DESC) ++ ++ creditFoxy.title.text = getString(CommonR.string.special_credits) ++ creditFoxy.content.text = FOXY_DROID_TITLE ++ droidify.title.text = DROID_IFY_TITLE ++ droidify.content.text = BuildConfig.VERSION_NAME ++ } ++ setChangeListener() ++ viewLifecycleOwner.lifecycleScope.launch { ++ repeatOnLifecycle(Lifecycle.State.RESUMED) { ++ launch { ++ viewModel.snackbarStringId.collect { ++ Snackbar.make(binding.root, it, Snackbar.LENGTH_LONG).show() ++ } ++ } ++ launch { ++ viewModel.settingsFlow.collect(::updateSettings) ++ } ++ } ++ } ++ return binding.root ++ } ++ ++ override fun onDestroyView() { ++ super.onDestroyView() ++ _binding = null ++ } ++ ++ private fun setChangeListener() { ++ with(binding) { ++ dynamicTheme.checked.setOnCheckedChangeListener { _, checked -> ++ viewModel.setDynamicTheme(checked) ++ } ++ homeScreenSwiping.checked.setOnCheckedChangeListener { _, checked -> ++ viewModel.setHomeScreenSwiping(checked) ++ } ++ notifyUpdates.checked.setOnCheckedChangeListener { _, checked -> ++ viewModel.setNotifyUpdates(checked) ++ } ++ autoUpdate.checked.setOnCheckedChangeListener { _, checked -> ++ viewModel.setAutoUpdate(checked) ++ } ++ unstableUpdates.checked.setOnCheckedChangeListener { _, checked -> ++ viewModel.setUnstableUpdates(checked) ++ } ++ incompatibleUpdates.checked.setOnCheckedChangeListener { _, checked -> ++ viewModel.setIncompatibleUpdates(checked) ++ } ++ forceCleanUp.root.setOnClickListener { ++ viewModel.forceCleanup(it.context) ++ } ++ importSettings.root.setOnClickListener { ++ openImportFileForSettings.launch(arrayOf(BACKUP_MIME_TYPE)) ++ } ++ exportSettings.root.setOnClickListener { ++ createExportFileForSettings.launch(SETTINGS_BACKUP_NAME) ++ } ++ importRepos.root.setOnClickListener { ++ openImportFileForRepos.launch(arrayOf(BACKUP_MIME_TYPE)) ++ } ++ exportRepos.root.setOnClickListener { ++ createExportFileForRepos.launch(REPO_BACKUP_NAME) ++ } ++ creditFoxy.root.setOnClickListener { ++ openLink(FOXY_DROID_URL) ++ } ++ droidify.root.setOnClickListener { ++ openLink(DROID_IFY_URL) ++ } ++ } ++ } ++ ++ private fun updateSettings(settings: Settings) { ++ with(binding) { ++ val allowProxies = settings.proxy.type != ProxyType.DIRECT ++ proxyHost.root.isVisible = allowProxies ++ proxyPort.root.isVisible = allowProxies ++ forceCleanUp.root.isVisible = settings.cleanUpInterval == Duration.INFINITE ++ } ++ } ++ ++ private val cleanUpIntervals = ++ listOf(6.hours, 12.hours, 18.hours, 1.days, 2.days, Duration.INFINITE) ++ ++ private fun translateLocale(locale: Locale?): String { ++ val country = locale?.getDisplayCountry(locale) ++ val language = locale?.getDisplayLanguage(locale) ++ val languageDisplay = if (locale != null) { ++ ( ++ language?.replaceFirstChar { it.uppercase(Locale.getDefault()) } + ++ ( ++ if (country?.isNotEmpty() == true && country.compareTo( ++ language.toString(), ++ true ++ ) != 0 ++ ) { ++ "($country)" ++ } else { ++ "" ++ } ++ ) ++ ) ++ } else { ++ getString(CommonR.string.system) ++ } ++ return languageDisplay ++ } ++ ++ private fun openLink(link: String) { ++ try { ++ startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link))) ++ } catch (e: IllegalStateException) { ++ viewModel.createSnackbar(CommonR.string.cannot_open_link) ++ } ++ } ++ ++ @Suppress("DEPRECATION") ++ private fun Context.getLocaleOfCode(localeCode: String): Locale? = when { ++ localeCode.isEmpty() -> if (SdkCheck.isNougat) { ++ resources.configuration.locales[0] ++ } else { ++ resources.configuration.locale ++ } ++ ++ localeCode.contains("-r") -> Locale( ++ localeCode.substring(0, 2), ++ localeCode.substring(4) ++ ) ++ ++ localeCode.contains("_") -> Locale( ++ localeCode.substring(0, 2), ++ localeCode.substring(3) ++ ) ++ ++ localeCode == "system" -> null ++ else -> Locale(localeCode) ++ } ++ ++ private fun EnumTypeBinding.connect( ++ titleText: String, ++ setting: Flow, ++ map: Context.(T) -> String, ++ dialog: View.(T, valueToString: Context.(T) -> String) -> AlertDialog ++ ) { ++ title.text = titleText ++ viewLifecycleOwner.lifecycleScope.launch { ++ repeatOnLifecycle(Lifecycle.State.RESUMED) { ++ setting.collect { ++ with(root.context) { ++ content.text = map(it) ++ } ++ root.setOnClickListener { _ -> ++ root.dialog(it, map).show() ++ } ++ } ++ } ++ } ++ } ++ ++ private fun SwitchTypeBinding.connect( ++ titleText: String, ++ contentText: String, ++ setting: Flow ++ ) { ++ title.text = titleText ++ content.text = contentText ++ root.setOnClickListener { ++ checked.isChecked = !checked.isChecked ++ } ++ viewLifecycleOwner.lifecycleScope.launch { ++ repeatOnLifecycle(Lifecycle.State.RESUMED) { ++ setting.collect { ++ checked.isChecked = it ++ } ++ } ++ } ++ } ++ ++ private fun View.addSingleCorrectDialog( ++ initialValue: T, ++ values: List, ++ @StringRes title: Int, ++ @DrawableRes iconRes: Int, ++ onClick: (T) -> Unit, ++ valueToString: Context.(T) -> String ++ ) = MaterialAlertDialogBuilder(context) ++ .setTitle(title) ++ .setIcon(iconRes) ++ .setSingleChoiceItems( ++ values.map { context.valueToString(it) }.toTypedArray(), ++ values.indexOf(initialValue) ++ ) { dialog, newValue -> ++ dialog.dismiss() ++ post { ++ onClick(values.elementAt(newValue)) ++ } ++ } ++ .setNegativeButton(CommonR.string.cancel, null) ++ .create() ++ ++ private fun View.addEditTextDialog( ++ initialValue: String, ++ @StringRes title: Int, ++ onFinish: (String) -> Unit ++ ): AlertDialog { ++ val scroll = NestedScrollView(context) ++ val customEditText = TextInputEditText(context) ++ customEditText.id = android.R.id.edit ++ val paddingValue = context.resources.getDimension(CommonR.dimen.shape_margin_large).toInt() ++ scroll.setPadding(paddingValue, 0, paddingValue, 0) ++ customEditText.setText(initialValue) ++ customEditText.hint = customEditText.text.toString() ++ customEditText.text?.let { editable -> customEditText.setSelection(editable.length) } ++ customEditText.requestFocus() ++ scroll.addView( ++ customEditText, ++ ViewGroup.LayoutParams.MATCH_PARENT, ++ ViewGroup.LayoutParams.WRAP_CONTENT ++ ) ++ return MaterialAlertDialogBuilder(context) ++ .setTitle(title) ++ .setView(scroll) ++ .setPositiveButton(CommonR.string.ok) { _, _ -> ++ post { onFinish(customEditText.text.toString()) } ++ } ++ .setNegativeButton(CommonR.string.cancel, null) ++ .create() ++ .apply { ++ window!!.setSoftInputMode( ++ WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE ++ ) ++ } ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/ui/settings/SettingsViewModel.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/leos/droidify/ui/settings/SettingsViewModel.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/ui/settings/SettingsViewModel.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,197 @@ ++package com.leos.droidify.ui.settings ++ ++import android.content.Context ++import android.net.Uri ++import androidx.annotation.StringRes ++import androidx.appcompat.app.AppCompatDelegate ++import androidx.core.os.LocaleListCompat ++import androidx.lifecycle.ViewModel ++import androidx.lifecycle.viewModelScope ++import com.leos.core.common.extension.toLocale ++import com.leos.core.datastore.Settings ++import com.leos.core.datastore.SettingsRepository ++import com.leos.core.datastore.get ++import com.leos.core.datastore.model.AutoSync ++import com.leos.core.datastore.model.InstallerType ++import com.leos.core.datastore.model.ProxyType ++import com.leos.core.datastore.model.Theme ++import com.leos.droidify.database.Database ++import com.leos.droidify.database.RepositoryExporter ++import com.leos.droidify.work.CleanUpWorker ++import com.leos.installer.installers.shizuku.ShizukuPermissionHandler ++import dagger.hilt.android.lifecycle.HiltViewModel ++import kotlinx.coroutines.cancel ++import kotlinx.coroutines.flow.Flow ++import kotlinx.coroutines.flow.MutableSharedFlow ++import kotlinx.coroutines.flow.asSharedFlow ++import kotlinx.coroutines.flow.first ++import kotlinx.coroutines.flow.flow ++import kotlinx.coroutines.flow.map ++import kotlinx.coroutines.launch ++import javax.inject.Inject ++import kotlin.time.Duration ++import com.leos.core.common.R as CommonR ++ ++@HiltViewModel ++class SettingsViewModel ++@Inject constructor( ++ private val settingsRepository: SettingsRepository, ++ private val shizukuPermissionHandler: ShizukuPermissionHandler, ++ private val repositoryExporter: RepositoryExporter ++) : ViewModel() { ++ ++ private val initialSetting = flow { ++ emit(settingsRepository.getInitial()) ++ } ++ val settingsFlow get() = settingsRepository.data ++ ++ private val _snackbarStringId = MutableSharedFlow() ++ val snackbarStringId = _snackbarStringId.asSharedFlow() ++ ++ fun getSetting(block: Settings.() -> T): Flow = settingsRepository.get(block) ++ ++ fun getInitialSetting(block: Settings.() -> T): Flow = initialSetting.map { it.block() } ++ ++ fun setLanguage(language: String) { ++ viewModelScope.launch { ++ val appLocale = LocaleListCompat.create(language.toLocale()) ++ AppCompatDelegate.setApplicationLocales(appLocale) ++ settingsRepository.setLanguage(language) ++ } ++ } ++ ++ fun setTheme(theme: Theme) { ++ viewModelScope.launch { ++ settingsRepository.setTheme(theme) ++ } ++ } ++ ++ fun setDynamicTheme(enable: Boolean) { ++ viewModelScope.launch { ++ settingsRepository.setDynamicTheme(enable) ++ } ++ } ++ ++ fun setHomeScreenSwiping(enable: Boolean) { ++ viewModelScope.launch { ++ settingsRepository.setHomeScreenSwiping(enable) ++ } ++ } ++ ++ fun setCleanUpInterval(interval: Duration) { ++ viewModelScope.launch { ++ settingsRepository.setCleanUpInterval(interval) ++ } ++ } ++ ++ fun forceCleanup(context: Context) { ++ viewModelScope.launch { ++ CleanUpWorker.force(context) ++ } ++ } ++ ++ fun setAutoSync(autoSync: AutoSync) { ++ viewModelScope.launch { ++ settingsRepository.setAutoSync(autoSync) ++ } ++ } ++ ++ fun setNotifyUpdates(enable: Boolean) { ++ viewModelScope.launch { ++ settingsRepository.enableNotifyUpdates(enable) ++ } ++ } ++ ++ fun setAutoUpdate(enable: Boolean) { ++ viewModelScope.launch { ++ settingsRepository.setAutoUpdate(enable) ++ } ++ } ++ ++ fun setUnstableUpdates(enable: Boolean) { ++ viewModelScope.launch { ++ settingsRepository.enableUnstableUpdates(enable) ++ } ++ } ++ ++ fun setIncompatibleUpdates(enable: Boolean) { ++ viewModelScope.launch { ++ settingsRepository.enableIncompatibleVersion(enable) ++ } ++ } ++ ++ fun setProxyType(proxyType: ProxyType) { ++ viewModelScope.launch { ++ settingsRepository.setProxyType(proxyType) ++ } ++ } ++ ++ fun setProxyHost(proxyHost: String) { ++ viewModelScope.launch { ++ settingsRepository.setProxyHost(proxyHost) ++ } ++ } ++ ++ fun setProxyPort(proxyPort: String) { ++ viewModelScope.launch { ++ try { ++ settingsRepository.setProxyPort(proxyPort.toInt()) ++ } catch (e: NumberFormatException) { ++ createSnackbar(CommonR.string.proxy_port_error_not_int) ++ } ++ } ++ } ++ ++ fun setInstaller(installerType: InstallerType) { ++ viewModelScope.launch { ++ settingsRepository.setInstallerType(installerType) ++ if (installerType == InstallerType.SHIZUKU) handleShizuku() ++ } ++ } ++ ++ fun exportSettings(file: Uri) { ++ viewModelScope.launch { ++ settingsRepository.export(file) ++ } ++ } ++ ++ fun importSettings(file: Uri) { ++ viewModelScope.launch { ++ settingsRepository.import(file) ++ } ++ } ++ ++ fun exportRepos(file: Uri) { ++ viewModelScope.launch { ++ val repos = Database.RepositoryAdapter.getAll() ++ repositoryExporter.export(repos, file) ++ } ++ } ++ ++ fun importRepos(file: Uri) { ++ viewModelScope.launch { ++ val repos = repositoryExporter.import(file) ++ Database.RepositoryAdapter.importRepos(repos) ++ } ++ } ++ ++ fun createSnackbar(@StringRes message: Int) { ++ viewModelScope.launch { ++ _snackbarStringId.emit(message) ++ } ++ } ++ ++ private fun handleShizuku() { ++ viewModelScope.launch { ++ val state = shizukuPermissionHandler.state.first() ++ if (state.isAlive && state.isPermissionGranted) cancel() ++ if (state.isInstalled) { ++ if (!state.isAlive) { ++ createSnackbar(CommonR.string.shizuku_not_alive) ++ } ++ } else { ++ createSnackbar(CommonR.string.shizuku_not_installed) ++ } ++ } ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/ui/tabsFragment/TabsFragment.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/ui/tabsFragment/TabsFragment.kt b/app/src/main/kotlin/com/leos/droidify/ui/tabsFragment/TabsFragment.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/ui/tabsFragment/TabsFragment.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,635 @@ ++package com.leos.droidify.ui.tabsFragment ++ ++import android.animation.ValueAnimator ++import android.content.Context ++import android.content.res.ColorStateList ++import android.os.Build ++import android.os.Bundle ++import android.view.Gravity ++import android.view.MenuItem ++import android.view.View ++import android.view.ViewGroup ++import android.view.animation.DecelerateInterpolator ++import android.widget.FrameLayout ++import android.widget.TextView ++import androidx.appcompat.widget.SearchView ++import androidx.core.view.isVisible ++import androidx.fragment.app.Fragment ++import androidx.fragment.app.viewModels ++import androidx.lifecycle.Lifecycle ++import androidx.lifecycle.lifecycleScope ++import androidx.lifecycle.repeatOnLifecycle ++import androidx.recyclerview.widget.LinearLayoutManager ++import androidx.recyclerview.widget.RecyclerView ++import androidx.viewpager2.adapter.FragmentStateAdapter ++import androidx.viewpager2.widget.ViewPager2 ++import com.google.android.material.elevation.SurfaceColors ++import com.google.android.material.shape.MaterialShapeDrawable ++import com.google.android.material.shape.ShapeAppearanceModel ++import com.google.android.material.tabs.TabLayoutMediator ++import com.leos.core.common.R as CommonR ++import com.leos.core.common.R.string as stringRes ++import com.leos.core.common.device.Huawei ++import com.leos.core.common.extension.dp ++import com.leos.core.common.extension.getMutatedIcon ++import com.leos.core.common.extension.selectableBackground ++import com.leos.core.common.extension.systemBarsPadding ++import com.leos.core.common.sdkAbove ++import com.leos.core.datastore.extension.sortOrderName ++import com.leos.core.datastore.model.SortOrder ++import com.leos.core.domain.ProductItem ++import com.leos.droidify.R ++import com.leos.droidify.databinding.TabsToolbarBinding ++import com.leos.droidify.service.Connection ++import com.leos.droidify.service.SyncService ++import com.leos.droidify.ui.ScreenFragment ++import com.leos.droidify.ui.appList.AppListFragment ++import com.leos.droidify.utility.extension.resources.sizeScaled ++import com.leos.droidify.utility.extension.screenActivity ++import com.leos.droidify.widget.DividerConfiguration ++import com.leos.droidify.widget.FocusSearchView ++import com.leos.droidify.widget.StableRecyclerAdapter ++import com.leos.droidify.widget.addDivider ++import dagger.hilt.android.AndroidEntryPoint ++import kotlin.math.abs ++import kotlin.math.roundToInt ++import kotlinx.coroutines.launch ++ ++@AndroidEntryPoint ++class TabsFragment : ScreenFragment() { ++ ++ private var _tabsBinding: TabsToolbarBinding? = null ++ private val tabsBinding get() = _tabsBinding!! ++ ++ private val viewModel: TabsViewModel by viewModels() ++ ++ companion object { ++ private const val STATE_SEARCH_FOCUSED = "searchFocused" ++ private const val STATE_SEARCH_QUERY = "searchQuery" ++ private const val STATE_SHOW_SECTIONS = "showSections" ++ } ++ ++ private class Layout(view: TabsToolbarBinding) { ++ val tabs = view.tabs ++ val sectionLayout = view.sectionLayout ++ val sectionChange = view.sectionChange ++ val sectionName = view.sectionName ++ val sectionIcon = view.sectionIcon ++ } ++ ++ private var favouritesItem: MenuItem? = null ++ private var searchMenuItem: MenuItem? = null ++ private var sortOrderMenu: Pair>? = null ++ private var syncRepositoriesMenuItem: MenuItem? = null ++ private var layout: Layout? = null ++ private var sectionsList: RecyclerView? = null ++ private var sectionsAdapter: SectionsAdapter? = null ++ private var viewPager: ViewPager2? = null ++ ++ private var showSections = false ++ set(value) { ++ if (field != value) { ++ field = value ++ val layout = layout ++ layout?.tabs?.let { ++ (0 until it.childCount) ++ .forEach { index -> it.getChildAt(index)!!.isEnabled = !value } ++ } ++ layout?.sectionIcon?.scaleY = if (value) -1f else 1f ++ if (((sectionsList?.parent as? View)?.height ?: 0) > 0) { ++ animateSectionsList() ++ } ++ } ++ } ++ ++ private var searchQuery = "" ++ ++ private val syncConnection = Connection( ++ serviceClass = SyncService::class.java, ++ onBind = { _, _ -> ++ viewPager?.let { ++ val source = AppListFragment.Source.entries[it.currentItem] ++ updateUpdateNotificationBlocker(source) ++ } ++ } ++ ) ++ ++ private var sectionsAnimator: ValueAnimator? = null ++ ++ private var needSelectUpdates = false ++ ++ private val productFragments: Sequence ++ get() = if (host == null) { ++ emptySequence() ++ } else { ++ childFragmentManager.fragments.asSequence().mapNotNull { it as? AppListFragment } ++ } ++ ++ override fun onCreate(savedInstanceState: Bundle?) { ++ super.onCreate(savedInstanceState) ++ _tabsBinding = TabsToolbarBinding.inflate(layoutInflater) ++ } ++ ++ override fun onViewCreated(view: View, savedInstanceState: Bundle?) { ++ super.onViewCreated(view, savedInstanceState) ++ syncConnection.bind(requireContext()) ++ ++ sectionsAdapter = SectionsAdapter { ++ if (showSections) { ++ viewModel.setSection(it) ++ sectionsList?.scrollToPosition(0) ++ showSections = false ++ } ++ } ++ ++ screenActivity.onToolbarCreated(toolbar) ++ toolbar.title = getString(R.string.application_name) ++ // Move focus from SearchView to Toolbar ++ toolbar.isFocusable = true ++ ++ val searchView = FocusSearchView(toolbar.context).apply { ++ maxWidth = Int.MAX_VALUE ++ queryHint = getString(stringRes.search) ++ setOnQueryTextListener(object : SearchView.OnQueryTextListener { ++ override fun onQueryTextSubmit(query: String?): Boolean { ++ clearFocus() ++ return true ++ } ++ ++ override fun onQueryTextChange(newText: String?): Boolean { ++ if (isResumed) { ++ searchQuery = newText.orEmpty() ++ productFragments.forEach { it.setSearchQuery(newText.orEmpty()) } ++ } ++ return true ++ } ++ }) ++ } ++ ++ toolbar.menu.apply { ++ if (!Huawei.isHuaweiEmui) { ++ sdkAbove(Build.VERSION_CODES.P) { ++ setGroupDividerEnabled(true) ++ } ++ } ++ ++ searchMenuItem = add(0, R.id.toolbar_search, 0, stringRes.search) ++ .setIcon(toolbar.context.getMutatedIcon(CommonR.drawable.ic_search)) ++ .setActionView(searchView) ++ .setShowAsActionFlags( ++ MenuItem.SHOW_AS_ACTION_ALWAYS or MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW ++ ) ++ ++ syncRepositoriesMenuItem = add(0, 0, 0, stringRes.sync_repositories) ++ .setIcon(toolbar.context.getMutatedIcon(CommonR.drawable.ic_sync)) ++ .setOnMenuItemClickListener { ++ syncConnection.binder?.sync(SyncService.SyncRequest.MANUAL) ++ true ++ } ++ ++ sortOrderMenu = addSubMenu(0, 0, 0, stringRes.sorting_order) ++ .setIcon(toolbar.context.getMutatedIcon(CommonR.drawable.ic_sort)) ++ .let { menu -> ++ menu.item.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS) ++ val menuItems = SortOrder.entries.map { sortOrder -> ++ menu.add(context.sortOrderName(sortOrder)) ++ .setOnMenuItemClickListener { ++ viewModel.setSortOrder(sortOrder) ++ true ++ } ++ } ++ menu.setGroupCheckable(0, true, true) ++ Pair(menu.item, menuItems) ++ } ++ ++ favouritesItem = add(1, 0, 0, stringRes.favourites) ++ .setIcon( ++ toolbar.context.getMutatedIcon(CommonR.drawable.ic_favourite_checked) ++ ) ++ .setOnMenuItemClickListener { ++ view.post { screenActivity.navigateFavourites() } ++ true ++ } ++ ++ add(1, 0, 0, stringRes.repositories) ++ .setOnMenuItemClickListener { ++ view.post { screenActivity.navigateRepositories() } ++ true ++ } ++ ++ add(1, 0, 0, stringRes.settings) ++ .setOnMenuItemClickListener { ++ view.post { screenActivity.navigatePreferences() } ++ true ++ } ++ } ++ ++ searchQuery = savedInstanceState?.getString(STATE_SEARCH_QUERY).orEmpty() ++ productFragments.forEach { it.setSearchQuery(searchQuery) } ++ ++ val toolbarExtra = fragmentBinding.toolbarExtra ++ toolbarExtra.addView(tabsBinding.root) ++ val layout = Layout(tabsBinding) ++ this.layout = layout ++ ++ showSections = (savedInstanceState?.getByte(STATE_SHOW_SECTIONS)?.toInt() ?: 0) != 0 ++ ++ val content = fragmentBinding.fragmentContent ++ ++ viewPager = ViewPager2(content.context).apply { ++ id = R.id.fragment_pager ++ adapter = object : FragmentStateAdapter(this@TabsFragment) { ++ override fun getItemCount(): Int = AppListFragment.Source.entries.size ++ override fun createFragment(position: Int): Fragment = AppListFragment( ++ AppListFragment.Source.entries[position] ++ ) ++ } ++ content.addView(this) ++ registerOnPageChangeCallback(pageChangeCallback) ++ offscreenPageLimit = 1 ++ } ++ ++ viewPager?.let { ++ TabLayoutMediator(layout.tabs, it) { tab, position -> ++ tab.text = getString(AppListFragment.Source.entries[position].titleResId) ++ }.attach() ++ } ++ ++ viewLifecycleOwner.lifecycleScope.launch { ++ repeatOnLifecycle(Lifecycle.State.CREATED) { ++ launch { ++ viewModel.sections.collect(::updateSections) ++ } ++ launch { ++ viewModel.sortOrder.collect(::updateOrder) ++ } ++ launch { ++ viewModel.currentSection.collect(::updateSection) ++ } ++ launch { ++ viewModel.allowHomeScreenSwiping.collect { ++ viewPager?.isUserInputEnabled = it ++ } ++ } ++ } ++ } ++ ++ val backgroundPath = ShapeAppearanceModel.builder() ++ .setAllCornerSizes( ++ context?.resources?.getDimension(CommonR.dimen.shape_large_corner) ?: 0F ++ ) ++ .build() ++ val sectionBackground = MaterialShapeDrawable(backgroundPath) ++ val color = SurfaceColors.SURFACE_3.getColor(requireContext()) ++ sectionBackground.fillColor = ColorStateList.valueOf(color) ++ val sectionsList = RecyclerView(toolbar.context).apply { ++ id = R.id.sections_list ++ layoutManager = LinearLayoutManager(context) ++ isMotionEventSplittingEnabled = false ++ isVerticalScrollBarEnabled = false ++ setHasFixedSize(true) ++ adapter = sectionsAdapter ++ sectionsAdapter?.let { addDivider(it::configureDivider) } ++ background = sectionBackground ++ elevation = 4.dp.toFloat() ++ content.addView(this) ++ val margins = 8.dp ++ (layoutParams as ViewGroup.MarginLayoutParams).setMargins(margins, margins, margins, 0) ++ visibility = View.GONE ++ systemBarsPadding(includeFab = false) ++ } ++ this.sectionsList = sectionsList ++ ++ var lastContentHeight = -1 ++ content.viewTreeObserver.addOnGlobalLayoutListener { ++ if (this.view != null) { ++ val initial = lastContentHeight <= 0 ++ val contentHeight = content.height ++ if (lastContentHeight != contentHeight) { ++ lastContentHeight = contentHeight ++ if (initial) { ++ sectionsList.layoutParams.height = if (showSections) contentHeight else 0 ++ sectionsList.isVisible = showSections ++ sectionsList.requestLayout() ++ } else { ++ animateSectionsList() ++ } ++ } ++ } ++ } ++ } ++ ++ override fun onDestroyView() { ++ super.onDestroyView() ++ ++ favouritesItem = null ++ searchMenuItem = null ++ sortOrderMenu = null ++ syncRepositoriesMenuItem = null ++ layout = null ++ sectionsList = null ++ sectionsAdapter = null ++ viewPager = null ++ ++ syncConnection.unbind(requireContext()) ++ sectionsAnimator?.cancel() ++ sectionsAnimator = null ++ ++ _tabsBinding = null ++ } ++ ++ override fun onSaveInstanceState(outState: Bundle) { ++ super.onSaveInstanceState(outState) ++ ++ outState.putBoolean(STATE_SEARCH_FOCUSED, searchMenuItem?.actionView?.hasFocus() == true) ++ outState.putString(STATE_SEARCH_QUERY, searchQuery) ++ outState.putByte(STATE_SHOW_SECTIONS, if (showSections) 1 else 0) ++ } ++ ++ override fun onViewStateRestored(savedInstanceState: Bundle?) { ++ super.onViewStateRestored(savedInstanceState) ++ ++ (searchMenuItem?.actionView as FocusSearchView).allowFocus = true ++ if (needSelectUpdates) { ++ needSelectUpdates = false ++ selectUpdatesInternal(false) ++ } ++ } ++ ++ override fun onBackPressed(): Boolean { ++ return when { ++ viewModel.currentSection.value != ProductItem.Section.All -> { ++ viewModel.setSection(ProductItem.Section.All) ++ true ++ } ++ ++ searchMenuItem?.isActionViewExpanded == true -> { ++ searchMenuItem?.collapseActionView() ++ true ++ } ++ ++ showSections -> { ++ showSections = false ++ true ++ } ++ ++ else -> { ++ super.onBackPressed() ++ } ++ } ++ } ++ ++ internal fun selectUpdates() = selectUpdatesInternal(true) ++ ++ private fun updateUpdateNotificationBlocker(activeSource: AppListFragment.Source) { ++ val blockerFragment = if (activeSource == AppListFragment.Source.UPDATES) { ++ productFragments.find { it.source == activeSource } ++ } else { ++ null ++ } ++ syncConnection.binder?.setUpdateNotificationBlocker(blockerFragment) ++ } ++ ++ private fun selectUpdatesInternal(allowSmooth: Boolean) { ++ if (view != null) { ++ val viewPager = viewPager ++ viewPager?.setCurrentItem( ++ AppListFragment.Source.UPDATES.ordinal, ++ allowSmooth && viewPager.isLaidOut ++ ) ++ } else { ++ needSelectUpdates = true ++ } ++ } ++ ++ private fun updateOrder(sortOrder: SortOrder) { ++ sortOrderMenu!!.second[sortOrder.ordinal].isChecked = true ++ } ++ ++ private fun updateSections( ++ sectionsList: List ++ ) { ++ sectionsAdapter?.sections = sectionsList ++ layout?.run { ++ sectionIcon.isVisible = sectionsList.any { it !is ProductItem.Section.All } ++ sectionLayout.setOnClickListener { showSections = isVisible && !showSections } ++ } ++ } ++ ++ private fun updateSection(section: ProductItem.Section) { ++ layout?.sectionName?.text = when (section) { ++ is ProductItem.Section.All -> getString(stringRes.all_applications) ++ is ProductItem.Section.Category -> section.name ++ is ProductItem.Section.Repository -> section.name ++ } ++ productFragments.filter { it.source.sections }.forEach { it.setSection(section) } ++ } ++ ++ private fun animateSectionsList() { ++ val sectionsList = sectionsList!! ++ val value = if (sectionsList.visibility != View.VISIBLE) { ++ 0f ++ } else { ++ sectionsList.height.toFloat() / (sectionsList.parent as View).height ++ } ++ val target = if (showSections) 0.98f else 0f ++ sectionsAnimator?.cancel() ++ sectionsAnimator = null ++ ++ if (value != target) { ++ sectionsAnimator = ValueAnimator.ofFloat(value, target).apply { ++ duration = (250 * abs(target - value)).toLong() ++ interpolator = DecelerateInterpolator(2f) ++ addUpdateListener { ++ val newValue = animatedValue as Float ++ sectionsList.apply { ++ val height = ((parent as View).height * newValue).toInt() ++ val visible = height > 0 ++ if ((visibility == View.VISIBLE) != visible) isVisible = visible ++ if (layoutParams.height != height) { ++ layoutParams.height = height ++ requestLayout() ++ } ++ } ++ if (target <= 0f && newValue <= 0f) { ++ sectionsAnimator = null ++ } ++ } ++ start() ++ } ++ } ++ } ++ ++ private val pageChangeCallback = object : ViewPager2.OnPageChangeCallback() { ++ override fun onPageScrolled( ++ position: Int, ++ positionOffset: Float, ++ positionOffsetPixels: Int ++ ) { ++ val layout = layout!! ++ val fromSections = AppListFragment.Source.entries[position].sections ++ val toSections = if (positionOffset <= 0f) { ++ fromSections ++ } else { ++ AppListFragment.Source.entries[position + 1].sections ++ } ++ val offset = if (fromSections != toSections) { ++ if (fromSections) 1f - positionOffset else positionOffset ++ } else { ++ if (fromSections) 1f else 0f ++ } ++ assert(layout.sectionLayout.childCount == 1) ++ val child = layout.sectionLayout.getChildAt(0) ++ val height = child.layoutParams.height ++ assert(height > 0) ++ val currentHeight = (offset * height).roundToInt() ++ if (layout.sectionLayout.layoutParams.height != currentHeight) { ++ layout.sectionLayout.layoutParams.height = currentHeight ++ layout.sectionLayout.requestLayout() ++ } ++ } ++ ++ override fun onPageSelected(position: Int) { ++ val source = AppListFragment.Source.entries[position] ++ updateUpdateNotificationBlocker(source) ++ sortOrderMenu!!.first.apply { ++ isVisible = source.order ++ setShowAsActionFlags( ++ if (!source.order || ++ resources.configuration.screenWidthDp >= 300 ++ ) { ++ MenuItem.SHOW_AS_ACTION_ALWAYS ++ } else { ++ 0 ++ } ++ ) ++ } ++ syncRepositoriesMenuItem!!.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS) ++ if (showSections && !source.sections) { ++ showSections = false ++ } ++ } ++ ++ override fun onPageScrollStateChanged(state: Int) { ++ val source = AppListFragment.Source.entries[viewPager!!.currentItem] ++ layout!!.sectionChange.isEnabled = ++ state != ViewPager2.SCROLL_STATE_DRAGGING && source.sections ++ if (state == ViewPager2.SCROLL_STATE_IDLE) { ++ // onPageSelected can be called earlier than fragments created ++ updateUpdateNotificationBlocker(source) ++ } ++ } ++ } ++ ++ private class SectionsAdapter( ++ private val onClick: (ProductItem.Section) -> Unit ++ ) : StableRecyclerAdapter() { ++ enum class ViewType { SECTION } ++ ++ private class SectionViewHolder(context: Context) : ++ RecyclerView.ViewHolder(FrameLayout(context)) { ++ val title: TextView = TextView(context) ++ ++ init { ++ with(title) { ++ gravity = Gravity.CENTER_VERTICAL ++ setPadding(16.dp, 0, 16.dp, 0) ++ layoutParams = FrameLayout.LayoutParams( ++ FrameLayout.LayoutParams.WRAP_CONTENT, ++ FrameLayout.LayoutParams.MATCH_PARENT ++ ) ++ } ++ with(itemView as FrameLayout) { ++ layoutParams = RecyclerView.LayoutParams( ++ RecyclerView.LayoutParams.MATCH_PARENT, ++ 48.dp ++ ) ++ background = context.selectableBackground ++ addView(title) ++ } ++ } ++ } ++ ++ var sections: List = emptyList() ++ set(value) { ++ field = value ++ notifyDataSetChanged() ++ } ++ ++ fun configureDivider( ++ context: Context, ++ position: Int, ++ configuration: DividerConfiguration ++ ) { ++ val currentSection = sections[position] ++ val nextSection = sections.getOrNull(position + 1) ++ when { ++ nextSection != null && currentSection.javaClass != nextSection.javaClass -> { ++ val padding = context.resources.sizeScaled(16) ++ configuration.set( ++ needDivider = true, ++ toTop = false, ++ paddingStart = padding, ++ paddingEnd = padding ++ ) ++ } ++ ++ else -> { ++ configuration.set( ++ needDivider = false, ++ toTop = false, ++ paddingStart = 0, ++ paddingEnd = 0 ++ ) ++ } ++ } ++ } ++ ++ override val viewTypeClass: Class ++ get() = ViewType::class.java ++ ++ override fun getItemCount(): Int = sections.size ++ override fun getItemDescriptor(position: Int): String = sections[position].toString() ++ override fun getItemEnumViewType(position: Int): ViewType = ViewType.SECTION ++ ++ override fun onCreateViewHolder( ++ parent: ViewGroup, ++ viewType: ViewType ++ ): RecyclerView.ViewHolder { ++ return SectionViewHolder(parent.context).apply { ++ itemView.setOnClickListener { onClick(sections[absoluteAdapterPosition]) } ++ } ++ } ++ ++ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { ++ holder as SectionViewHolder ++ val section = sections[position] ++ val previousSection = sections.getOrNull(position - 1) ++ val nextSection = sections.getOrNull(position + 1) ++ val margin = holder.itemView.resources.sizeScaled(8) ++ val layoutParams = holder.itemView.layoutParams as RecyclerView.LayoutParams ++ layoutParams.topMargin = if (previousSection == null || ++ section.javaClass != previousSection.javaClass ++ ) { ++ margin ++ } else { ++ 0 ++ } ++ layoutParams.bottomMargin = if (nextSection == null || ++ section.javaClass != nextSection.javaClass ++ ) { ++ margin ++ } else { ++ 0 ++ } ++ holder.title.text = when (section) { ++ is ProductItem.Section.All -> holder.itemView.resources.getString( ++ stringRes.all_applications ++ ) ++ ++ is ProductItem.Section.Category -> section.name ++ is ProductItem.Section.Repository -> section.name ++ } ++ } ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/ui/tabsFragment/TabsViewModel.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/ui/tabsFragment/TabsViewModel.kt b/app/src/main/kotlin/com/leos/droidify/ui/tabsFragment/TabsViewModel.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/ui/tabsFragment/TabsViewModel.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,67 @@ ++package com.leos.droidify.ui.tabsFragment ++ ++import androidx.lifecycle.SavedStateHandle ++import androidx.lifecycle.ViewModel ++import androidx.lifecycle.viewModelScope ++import com.leos.core.common.extension.asStateFlow ++import com.leos.core.datastore.SettingsRepository ++import com.leos.core.datastore.get ++import com.leos.core.datastore.model.SortOrder ++import com.leos.core.domain.ProductItem ++import com.leos.droidify.database.Database ++import dagger.hilt.android.lifecycle.HiltViewModel ++import javax.inject.Inject ++import kotlinx.coroutines.flow.catch ++import kotlinx.coroutines.flow.combine ++import kotlinx.coroutines.launch ++ ++@HiltViewModel ++class TabsViewModel @Inject constructor( ++ private val settingsRepository: SettingsRepository, ++ private val savedStateHandle: SavedStateHandle ++) : ViewModel() { ++ ++ val currentSection = ++ savedStateHandle.getStateFlow(STATE_SECTION, ProductItem.Section.All) ++ ++ val sortOrder = settingsRepository ++ .get { sortOrder } ++ .asStateFlow(SortOrder.UPDATED) ++ ++ val allowHomeScreenSwiping = settingsRepository ++ .get { homeScreenSwiping } ++ .asStateFlow(false) ++ ++ val sections = ++ combine( ++ Database.CategoryAdapter.getAllStream(), ++ Database.RepositoryAdapter.getEnabledStream() ++ ) { categories, repos -> ++ val productCategories = categories ++ .asSequence() ++ .sorted() ++ .map(ProductItem.Section::Category) ++ .toList() ++ ++ val enabledRepositories = repos ++ .map { ProductItem.Section.Repository(it.id, it.name) } ++ enabledRepositories.ifEmpty { setSection(ProductItem.Section.All) } ++ listOf(ProductItem.Section.All) + productCategories + enabledRepositories ++ } ++ .catch { it.printStackTrace() } ++ .asStateFlow(emptyList()) ++ ++ fun setSection(section: ProductItem.Section) { ++ savedStateHandle[STATE_SECTION] = section ++ } ++ ++ fun setSortOrder(sortOrder: SortOrder) { ++ viewModelScope.launch { ++ settingsRepository.setSortOrder(sortOrder) ++ } ++ } ++ ++ companion object { ++ private const val STATE_SECTION = "section" ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/utility/PackageItemResolver.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/utility/PackageItemResolver.kt b/app/src/main/kotlin/com/leos/droidify/utility/PackageItemResolver.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/utility/PackageItemResolver.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,153 @@ ++package com.leos.droidify.utility ++ ++import android.Manifest ++import android.content.Context ++import android.content.pm.PackageItemInfo ++import android.content.pm.PermissionInfo ++import android.content.res.Resources ++import android.os.Build ++import com.leos.core.common.SdkCheck ++import java.util.Locale ++ ++object PackageItemResolver { ++ class LocalCache { ++ internal val resources = mutableMapOf() ++ } ++ ++ private data class CacheKey(val locales: List, val packageName: String, val resId: Int) ++ ++ private val cache = mutableMapOf() ++ ++ private fun load( ++ context: Context, ++ localCache: LocalCache, ++ packageName: String, ++ nonLocalized: CharSequence?, ++ resId: Int ++ ): CharSequence? { ++ return when { ++ nonLocalized != null -> { ++ nonLocalized ++ } ++ ++ resId != 0 -> { ++ val locales = if (SdkCheck.isNougat) { ++ val localesList = context.resources.configuration.locales ++ (0 until localesList.size()).map(localesList::get) ++ } else { ++ @Suppress("DEPRECATION") ++ listOf(context.resources.configuration.locale) ++ } ++ val cacheKey = CacheKey(locales, packageName, resId) ++ if (cache.containsKey(cacheKey)) { ++ cache[cacheKey] ++ } else { ++ val resources = localCache.resources[packageName] ?: run { ++ val resources = try { ++ val resources = ++ context.packageManager.getResourcesForApplication(packageName) ++ @Suppress("DEPRECATION") ++ resources.updateConfiguration(context.resources.configuration, null) ++ resources ++ } catch (e: Exception) { ++ null ++ } ++ resources?.let { localCache.resources[packageName] = it } ++ resources ++ } ++ val label = resources?.getString(resId) ++ cache[cacheKey] = label ++ label ++ } ++ } ++ ++ else -> { ++ null ++ } ++ } ++ } ++ ++ fun loadLabel( ++ context: Context, ++ localCache: LocalCache, ++ packageItemInfo: PackageItemInfo ++ ): CharSequence? { ++ return load( ++ context, ++ localCache, ++ packageItemInfo.packageName, ++ packageItemInfo.nonLocalizedLabel, ++ packageItemInfo.labelRes ++ ) ++ } ++ ++ fun loadDescription( ++ context: Context, ++ localCache: LocalCache, ++ permissionInfo: PermissionInfo ++ ): CharSequence? { ++ return load( ++ context, ++ localCache, ++ permissionInfo.packageName, ++ permissionInfo.nonLocalizedDescription, ++ permissionInfo.descriptionRes ++ ) ++ } ++ ++ fun getPermissionGroup(permissionInfo: PermissionInfo): String? = ++ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { ++ when (permissionInfo.name) { ++ Manifest.permission.READ_CONTACTS, ++ Manifest.permission.WRITE_CONTACTS, ++ Manifest.permission.GET_ACCOUNTS ++ -> Manifest.permission_group.CONTACTS ++ ++ Manifest.permission.READ_CALENDAR, ++ Manifest.permission.WRITE_CALENDAR ++ -> Manifest.permission_group.CALENDAR ++ ++ Manifest.permission.SEND_SMS, ++ Manifest.permission.RECEIVE_SMS, ++ Manifest.permission.READ_SMS, ++ Manifest.permission.RECEIVE_MMS, ++ Manifest.permission.RECEIVE_WAP_PUSH, ++ "android.permission.READ_CELL_BROADCASTS" ++ -> Manifest.permission_group.SMS ++ ++ Manifest.permission.READ_EXTERNAL_STORAGE, ++ Manifest.permission.WRITE_EXTERNAL_STORAGE, ++ Manifest.permission.ACCESS_MEDIA_LOCATION ++ -> Manifest.permission_group.STORAGE ++ ++ Manifest.permission.ACCESS_FINE_LOCATION, ++ Manifest.permission.ACCESS_COARSE_LOCATION, ++ Manifest.permission.ACCESS_BACKGROUND_LOCATION ++ -> Manifest.permission_group.LOCATION ++ ++ Manifest.permission.READ_CALL_LOG, ++ Manifest.permission.WRITE_CALL_LOG, ++ @Suppress("DEPRECATION") ++ Manifest.permission.PROCESS_OUTGOING_CALLS ++ -> Manifest.permission_group.CALL_LOG ++ ++ Manifest.permission.READ_PHONE_STATE, ++ Manifest.permission.READ_PHONE_NUMBERS, ++ Manifest.permission.CALL_PHONE, ++ Manifest.permission.ADD_VOICEMAIL, ++ Manifest.permission.USE_SIP, ++ Manifest.permission.ANSWER_PHONE_CALLS, ++ Manifest.permission.ACCEPT_HANDOVER ++ -> Manifest.permission_group.PHONE ++ ++ Manifest.permission.RECORD_AUDIO -> Manifest.permission_group.MICROPHONE ++ Manifest.permission.ACTIVITY_RECOGNITION -> ++ Manifest.permission_group.ACTIVITY_RECOGNITION ++ Manifest.permission.CAMERA -> Manifest.permission_group.CAMERA ++ Manifest.permission.BODY_SENSORS -> Manifest.permission_group.SENSORS ++ else -> null ++ } ++ } else { ++ permissionInfo.group ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/utility/ProgressInputStream.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/utility/ProgressInputStream.kt b/app/src/main/kotlin/com/leos/droidify/utility/ProgressInputStream.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/utility/ProgressInputStream.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,36 @@ ++package com.leos.droidify.utility ++ ++import java.io.InputStream ++ ++fun InputStream.getProgress(callback: (Long) -> Unit): InputStream = ++ ProgressInputStream(this, callback) ++ ++private class ProgressInputStream( ++ private val inputStream: InputStream, ++ private val callback: (Long) -> Unit ++) : InputStream() { ++ private var count = 0L ++ ++ private inline fun notify(one: Boolean, read: () -> T): T { ++ val result = read() ++ count += if (one) 1L else result.toLong() ++ callback(count) ++ return result ++ } ++ ++ override fun read(): Int = notify(true) { inputStream.read() } ++ override fun read(b: ByteArray): Int = notify(false) { inputStream.read(b) } ++ override fun read(b: ByteArray, off: Int, len: Int): Int = ++ notify(false) { inputStream.read(b, off, len) } ++ ++ override fun skip(n: Long): Long = notify(false) { inputStream.skip(n) } ++ ++ override fun available(): Int { ++ return inputStream.available() ++ } ++ ++ override fun close() { ++ inputStream.close() ++ super.close() ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/utility/extension/Android.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/utility/extension/Android.kt b/app/src/main/kotlin/com/leos/droidify/utility/extension/Android.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/utility/extension/Android.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,14 @@ ++@file:Suppress("PackageDirectoryMismatch") ++ ++package com.leos.droidify.utility.extension.android ++ ++import android.os.Build ++ ++object Android { ++ val name: String = "Android ${Build.VERSION.RELEASE}" ++ ++ val platforms = Build.SUPPORTED_ABIS.toSet() ++ ++ val primaryPlatform: String? = Build.SUPPORTED_64_BIT_ABIS?.firstOrNull() ++ ?: Build.SUPPORTED_32_BIT_ABIS?.firstOrNull() ++} +Index: app/src/main/kotlin/com/leos/droidify/utility/extension/Connection.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/utility/extension/Connection.kt b/app/src/main/kotlin/com/leos/droidify/utility/extension/Connection.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/utility/extension/Connection.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,37 @@ ++package com.leos.droidify.utility.extension ++ ++import com.leos.core.domain.InstalledItem ++import com.leos.core.domain.Product ++import com.leos.core.domain.Repository ++import com.leos.core.domain.findSuggested ++import com.leos.droidify.service.Connection ++import com.leos.droidify.service.DownloadService ++import com.leos.droidify.utility.extension.android.Android ++ ++fun Connection.startUpdate( ++ packageName: String, ++ installedItem: InstalledItem?, ++ products: List> ++) { ++ if (binder == null || products.isEmpty()) return ++ ++ val (product, repository) = products.findSuggested(installedItem) ?: return ++ ++ val compatibleReleases = product.selectedReleases ++ .filter { installedItem == null || installedItem.signature == it.signature } ++ .ifEmpty { return } ++ ++ val selectedRelease = compatibleReleases.singleOrNull() ?: compatibleReleases.run { ++ filter { Android.primaryPlatform in it.platforms }.minByOrNull { it.platforms.size } ++ ?: minByOrNull { it.platforms.size } ++ ?: firstOrNull() ++ } ?: return ++ ++ requireNotNull(binder).enqueue( ++ packageName = packageName, ++ name = product.name, ++ repository = repository, ++ release = selectedRelease, ++ isUpdate = installedItem != null ++ ) ++} +Index: app/src/main/kotlin/com/leos/droidify/utility/extension/Fragment.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/utility/extension/Fragment.kt b/app/src/main/kotlin/com/leos/droidify/utility/extension/Fragment.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/utility/extension/Fragment.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,7 @@ ++package com.leos.droidify.utility.extension ++ ++import androidx.fragment.app.Fragment ++import com.leos.droidify.ScreenActivity ++ ++inline val Fragment.screenActivity: ScreenActivity ++ get() = requireActivity() as ScreenActivity +Index: app/src/main/kotlin/com/leos/droidify/utility/extension/ImageUtils.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/utility/extension/ImageUtils.kt b/app/src/main/kotlin/com/leos/droidify/utility/extension/ImageUtils.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/utility/extension/ImageUtils.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,46 @@ ++package com.leos.droidify.utility.extension ++ ++import android.view.View ++import com.leos.core.common.Singleton ++import com.leos.core.common.extension.dpi ++import com.leos.core.domain.Product ++import com.leos.core.domain.ProductItem ++import com.leos.core.domain.Repository ++ ++object ImageUtils { ++ private val SUPPORTED_DPI = listOf(120, 160, 240, 320, 480, 640) ++ private var DeviceDpi = Singleton() ++ ++ fun Product.Screenshot.url( ++ repository: Repository, ++ packageName: String ++ ): String { ++ val phoneType = when (type) { ++ Product.Screenshot.Type.PHONE -> "phoneScreenshots" ++ Product.Screenshot.Type.SMALL_TABLET -> "sevenInchScreenshots" ++ Product.Screenshot.Type.LARGE_TABLET -> "tenInchScreenshots" ++ } ++ return "${repository.address}/$packageName/$locale/$phoneType/$path" ++ } ++ ++ fun ProductItem.icon( ++ view: View, ++ repository: Repository ++ ): String? { ++ if (packageName.isBlank()) return null ++ if (icon.isBlank() && metadataIcon.isBlank()) return null ++ if (repository.version < 11 && icon.isNotBlank()) { ++ return "${repository.address}/icons/$icon" ++ } ++ if (icon.isNotBlank()) { ++ val deviceDpi = DeviceDpi.getOrUpdate { ++ (SUPPORTED_DPI.find { it >= view.dpi } ?: SUPPORTED_DPI.last()).toString() ++ } ++ return "${repository.address}/icons-$deviceDpi/$icon" ++ } ++ if (metadataIcon.isNotBlank()) { ++ return "${repository.address}/$packageName/$metadataIcon" ++ } ++ return null ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/utility/extension/PackageInfo.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/utility/extension/PackageInfo.kt b/app/src/main/kotlin/com/leos/droidify/utility/extension/PackageInfo.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/utility/extension/PackageInfo.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,17 @@ ++package com.leos.droidify.utility.extension ++ ++import android.content.pm.PackageInfo ++import com.leos.core.common.extension.calculateHash ++import com.leos.core.common.extension.singleSignature ++import com.leos.core.common.extension.versionCodeCompat ++import com.leos.core.domain.InstalledItem ++ ++fun PackageInfo.toInstalledItem(): InstalledItem { ++ val signatureString = singleSignature?.calculateHash().orEmpty() ++ return InstalledItem( ++ packageName, ++ versionName.orEmpty(), ++ versionCodeCompat, ++ signatureString ++ ) ++} +Index: app/src/main/kotlin/com/leos/droidify/utility/extension/Resources.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/utility/extension/Resources.kt b/app/src/main/kotlin/com/leos/droidify/utility/extension/Resources.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/utility/extension/Resources.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,16 @@ ++@file:Suppress("PackageDirectoryMismatch") ++ ++package com.leos.droidify.utility.extension.resources ++ ++import android.content.res.Resources ++import android.graphics.Typeface ++import kotlin.math.roundToInt ++ ++object TypefaceExtra { ++ val medium = Typeface.create("sans-serif-medium", Typeface.NORMAL)!! ++ val light = Typeface.create("sans-serif-light", Typeface.NORMAL)!! ++} ++ ++fun Resources.sizeScaled(size: Int): Int { ++ return (size * displayMetrics.density).roundToInt() ++} +Index: app/src/main/kotlin/com/leos/droidify/utility/serialization/ProductItemSerialization.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/utility/serialization/ProductItemSerialization.kt b/app/src/main/kotlin/com/leos/droidify/utility/serialization/ProductItemSerialization.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/utility/serialization/ProductItemSerialization.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,55 @@ ++package com.leos.droidify.utility.serialization ++ ++import com.fasterxml.jackson.core.JsonGenerator ++import com.fasterxml.jackson.core.JsonParser ++import com.leos.core.common.extension.forEachKey ++import com.leos.core.domain.ProductItem ++ ++fun ProductItem.serialize(generator: JsonGenerator) { ++ generator.writeNumberField("serialVersion", 1) ++ generator.writeNumberField("repositoryId", repositoryId) ++ generator.writeStringField("packageName", packageName) ++ generator.writeStringField("name", name) ++ generator.writeStringField("summary", summary) ++ generator.writeStringField("icon", icon) ++ generator.writeStringField("metadataIcon", metadataIcon) ++ generator.writeStringField("version", version) ++ generator.writeStringField("installedVersion", installedVersion) ++ generator.writeBooleanField("compatible", compatible) ++ generator.writeBooleanField("canUpdate", canUpdate) ++ generator.writeNumberField("matchRank", matchRank) ++} ++ ++fun JsonParser.productItem(): ProductItem { ++ var repositoryId = 0L ++ var packageName = "" ++ var name = "" ++ var summary = "" ++ var icon = "" ++ var metadataIcon = "" ++ var version = "" ++ var installedVersion = "" ++ var compatible = false ++ var canUpdate = false ++ var matchRank = 0 ++ forEachKey { ++ when { ++ it.number("repositoryId") -> repositoryId = valueAsLong ++ it.string("packageName") -> packageName = valueAsString ++ it.string("name") -> name = valueAsString ++ it.string("summary") -> summary = valueAsString ++ it.string("icon") -> icon = valueAsString ++ it.string("metadataIcon") -> metadataIcon = valueAsString ++ it.string("version") -> version = valueAsString ++ it.string("installedVersion") -> installedVersion = valueAsString ++ it.boolean("compatible") -> compatible = valueAsBoolean ++ it.boolean("canUpdate") -> canUpdate = valueAsBoolean ++ it.number("matchRank") -> matchRank = valueAsInt ++ else -> skipChildren() ++ } ++ } ++ return ProductItem( ++ repositoryId, packageName, name, summary, icon, metadataIcon, ++ version, installedVersion, compatible, canUpdate, matchRank ++ ) ++} +Index: app/src/main/kotlin/com/leos/droidify/utility/serialization/ProductPreferenceSerialization.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/utility/serialization/ProductPreferenceSerialization.kt b/app/src/main/kotlin/com/leos/droidify/utility/serialization/ProductPreferenceSerialization.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/utility/serialization/ProductPreferenceSerialization.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,24 @@ ++package com.leos.droidify.utility.serialization ++ ++import com.fasterxml.jackson.core.JsonGenerator ++import com.fasterxml.jackson.core.JsonParser ++import com.leos.core.common.extension.forEachKey ++import com.leos.core.domain.ProductPreference ++ ++fun ProductPreference.serialize(generator: JsonGenerator) { ++ generator.writeBooleanField("ignoreUpdates", ignoreUpdates) ++ generator.writeNumberField("ignoreVersionCode", ignoreVersionCode) ++} ++ ++fun JsonParser.productPreference(): ProductPreference { ++ var ignoreUpdates = false ++ var ignoreVersionCode = 0L ++ forEachKey { ++ when { ++ it.boolean("ignoreUpdates") -> ignoreUpdates = valueAsBoolean ++ it.number("ignoreVersionCode") -> ignoreVersionCode = valueAsLong ++ else -> skipChildren() ++ } ++ } ++ return ProductPreference(ignoreUpdates, ignoreVersionCode) ++} +Index: app/src/main/kotlin/com/leos/droidify/utility/serialization/ProductSerialization.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/utility/serialization/ProductSerialization.kt b/app/src/main/kotlin/com/leos/droidify/utility/serialization/ProductSerialization.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/utility/serialization/ProductSerialization.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,208 @@ ++package com.leos.droidify.utility.serialization ++ ++import com.fasterxml.jackson.core.JsonGenerator ++import com.fasterxml.jackson.core.JsonParser ++import com.fasterxml.jackson.core.JsonToken ++import com.leos.core.common.extension.collectNotNull ++import com.leos.core.common.extension.collectNotNullStrings ++import com.leos.core.common.extension.forEachKey ++import com.leos.core.common.extension.writeArray ++import com.leos.core.common.extension.writeDictionary ++import com.leos.core.domain.Product ++import com.leos.core.domain.Release ++ ++fun Product.serialize(generator: JsonGenerator) { ++ generator.writeNumberField("repositoryId", repositoryId) ++ generator.writeNumberField("serialVersion", 1) ++ generator.writeStringField("packageName", packageName) ++ generator.writeStringField("name", name) ++ generator.writeStringField("summary", summary) ++ generator.writeStringField("description", description) ++ generator.writeStringField("whatsNew", whatsNew) ++ generator.writeStringField("icon", icon) ++ generator.writeStringField("metadataIcon", metadataIcon) ++ generator.writeStringField("authorName", author.name) ++ generator.writeStringField("authorEmail", author.email) ++ generator.writeStringField("authorWeb", author.web) ++ generator.writeStringField("source", source) ++ generator.writeStringField("changelog", changelog) ++ generator.writeStringField("web", web) ++ generator.writeStringField("tracker", tracker) ++ generator.writeNumberField("added", added) ++ generator.writeNumberField("updated", updated) ++ generator.writeNumberField("suggestedVersionCode", suggestedVersionCode) ++ generator.writeArray("categories") { categories.forEach(::writeString) } ++ generator.writeArray("antiFeatures") { antiFeatures.forEach(::writeString) } ++ generator.writeArray("licenses") { licenses.forEach(::writeString) } ++ generator.writeArray("donates") { ++ donates.forEach { ++ writeDictionary { ++ when (it) { ++ is Product.Donate.Regular -> { ++ writeStringField("type", "") ++ writeStringField("url", it.url) ++ } ++ ++ is Product.Donate.Bitcoin -> { ++ writeStringField("type", "bitcoin") ++ writeStringField("address", it.address) ++ } ++ ++ is Product.Donate.Litecoin -> { ++ writeStringField("type", "litecoin") ++ writeStringField("address", it.address) ++ } ++ ++ is Product.Donate.Flattr -> { ++ writeStringField("type", "flattr") ++ writeStringField("id", it.id) ++ } ++ ++ is Product.Donate.Liberapay -> { ++ writeStringField("type", "liberapay") ++ writeStringField("id", it.id) ++ } ++ ++ is Product.Donate.OpenCollective -> { ++ writeStringField("type", "openCollective") ++ writeStringField("id", it.id) ++ } ++ }::class ++ } ++ } ++ } ++ generator.writeArray("screenshots") { ++ screenshots.forEach { ++ writeDictionary { ++ writeStringField("locale", it.locale) ++ writeStringField("type", it.type.jsonName) ++ writeStringField("path", it.path) ++ } ++ } ++ } ++ generator.writeArray("releases") { releases.forEach { writeDictionary { it.serialize(this) } } } ++} ++ ++fun JsonParser.product(): Product { ++ var repositoryId = 0L ++ var packageName = "" ++ var name = "" ++ var summary = "" ++ var description = "" ++ var whatsNew = "" ++ var icon = "" ++ var metadataIcon = "" ++ var authorName = "" ++ var authorEmail = "" ++ var authorWeb = "" ++ var source = "" ++ var changelog = "" ++ var web = "" ++ var tracker = "" ++ var added = 0L ++ var updated = 0L ++ var suggestedVersionCode = 0L ++ var categories = emptyList() ++ var antiFeatures = emptyList() ++ var licenses = emptyList() ++ var donates = emptyList() ++ var screenshots = emptyList() ++ var releases = emptyList() ++ forEachKey { it -> ++ when { ++ it.string("repositoryId") -> repositoryId = valueAsLong ++ it.string("packageName") -> packageName = valueAsString ++ it.string("name") -> name = valueAsString ++ it.string("summary") -> summary = valueAsString ++ it.string("description") -> description = valueAsString ++ it.string("whatsNew") -> whatsNew = valueAsString ++ it.string("icon") -> icon = valueAsString ++ it.string("metadataIcon") -> metadataIcon = valueAsString ++ it.string("authorName") -> authorName = valueAsString ++ it.string("authorEmail") -> authorEmail = valueAsString ++ it.string("authorWeb") -> authorWeb = valueAsString ++ it.string("source") -> source = valueAsString ++ it.string("changelog") -> changelog = valueAsString ++ it.string("web") -> web = valueAsString ++ it.string("tracker") -> tracker = valueAsString ++ it.number("added") -> added = valueAsLong ++ it.number("updated") -> updated = valueAsLong ++ it.number("suggestedVersionCode") -> suggestedVersionCode = valueAsLong ++ it.array("categories") -> categories = collectNotNullStrings() ++ it.array("antiFeatures") -> antiFeatures = collectNotNullStrings() ++ it.array("licenses") -> licenses = collectNotNullStrings() ++ it.array("donates") -> donates = collectNotNull(JsonToken.START_OBJECT) { ++ var type = "" ++ var url = "" ++ var address = "" ++ var id = "" ++ forEachKey { ++ when { ++ it.string("type") -> type = valueAsString ++ it.string("url") -> url = valueAsString ++ it.string("address") -> address = valueAsString ++ it.string("id") -> id = valueAsString ++ else -> skipChildren() ++ } ++ } ++ when (type) { ++ "" -> Product.Donate.Regular(url) ++ "bitcoin" -> Product.Donate.Bitcoin(address) ++ "litecoin" -> Product.Donate.Litecoin(address) ++ "flattr" -> Product.Donate.Flattr(id) ++ "liberapay" -> Product.Donate.Liberapay(id) ++ "openCollective" -> Product.Donate.OpenCollective(id) ++ else -> null ++ } ++ } ++ ++ it.array("screenshots") -> ++ screenshots = ++ collectNotNull(JsonToken.START_OBJECT) { ++ var locale = "" ++ var type = "" ++ var path = "" ++ forEachKey { ++ when { ++ it.string("locale") -> locale = valueAsString ++ it.string("type") -> type = valueAsString ++ it.string("path") -> path = valueAsString ++ else -> skipChildren() ++ } ++ } ++ Product.Screenshot.Type.entries.find { it.jsonName == type } ++ ?.let { Product.Screenshot(locale, it, path) } ++ } ++ ++ it.array("releases") -> ++ releases = ++ collectNotNull(JsonToken.START_OBJECT) { release() } ++ ++ else -> skipChildren() ++ } ++ } ++ return Product( ++ repositoryId, ++ packageName, ++ name, ++ summary, ++ description, ++ whatsNew, ++ icon, ++ metadataIcon, ++ Product.Author(authorName, authorEmail, authorWeb), ++ source, ++ changelog, ++ web, ++ tracker, ++ added, ++ updated, ++ suggestedVersionCode, ++ categories, ++ antiFeatures, ++ licenses, ++ donates, ++ screenshots, ++ releases ++ ) ++} +Index: app/src/main/kotlin/com/leos/droidify/utility/serialization/ReleaseSerialization.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/utility/serialization/ReleaseSerialization.kt b/app/src/main/kotlin/com/leos/droidify/utility/serialization/ReleaseSerialization.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/utility/serialization/ReleaseSerialization.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,160 @@ ++package com.leos.droidify.utility.serialization ++ ++import com.fasterxml.jackson.core.JsonGenerator ++import com.fasterxml.jackson.core.JsonParser ++import com.fasterxml.jackson.core.JsonToken ++import com.leos.core.common.extension.collectNotNull ++import com.leos.core.common.extension.collectNotNullStrings ++import com.leos.core.common.extension.forEachKey ++import com.leos.core.common.extension.writeArray ++import com.leos.core.common.extension.writeDictionary ++import com.leos.core.domain.Release ++ ++fun Release.serialize(generator: JsonGenerator) { ++ generator.writeNumberField("serialVersion", 1) ++ generator.writeBooleanField("selected", selected) ++ generator.writeStringField("version", version) ++ generator.writeNumberField("versionCode", versionCode) ++ generator.writeNumberField("added", added) ++ generator.writeNumberField("size", size) ++ generator.writeNumberField("minSdkVersion", minSdkVersion) ++ generator.writeNumberField("targetSdkVersion", targetSdkVersion) ++ generator.writeNumberField("maxSdkVersion", maxSdkVersion) ++ generator.writeStringField("source", source) ++ generator.writeStringField("release", release) ++ generator.writeStringField("hash", hash) ++ generator.writeStringField("hashType", hashType) ++ generator.writeStringField("signature", signature) ++ generator.writeStringField("obbMain", obbMain) ++ generator.writeStringField("obbMainHash", obbMainHash) ++ generator.writeStringField("obbMainHashType", obbMainHashType) ++ generator.writeStringField("obbPatch", obbPatch) ++ generator.writeStringField("obbPatchHash", obbPatchHash) ++ generator.writeStringField("obbPatchHashType", obbPatchHashType) ++ generator.writeArray("permissions") { permissions.forEach { writeString(it) } } ++ generator.writeArray("features") { features.forEach { writeString(it) } } ++ generator.writeArray("platforms") { platforms.forEach { writeString(it) } } ++ generator.writeArray("incompatibilities") { ++ incompatibilities.forEach { ++ writeDictionary { ++ when (it) { ++ is Release.Incompatibility.MinSdk -> { ++ writeStringField("type", "minSdk") ++ } ++ ++ is Release.Incompatibility.MaxSdk -> { ++ writeStringField("type", "maxSdk") ++ } ++ ++ is Release.Incompatibility.Platform -> { ++ writeStringField("type", "platform") ++ } ++ ++ is Release.Incompatibility.Feature -> { ++ writeStringField("type", "feature") ++ writeStringField("feature", it.feature) ++ } ++ }::class ++ } ++ } ++ } ++} ++ ++fun JsonParser.release(): Release { ++ var selected = false ++ var version = "" ++ var versionCode = 0L ++ var added = 0L ++ var size = 0L ++ var minSdkVersion = 0 ++ var targetSdkVersion = 0 ++ var maxSdkVersion = 0 ++ var source = "" ++ var release = "" ++ var hash = "" ++ var hashType = "" ++ var signature = "" ++ var obbMain = "" ++ var obbMainHash = "" ++ var obbMainHashType = "" ++ var obbPatch = "" ++ var obbPatchHash = "" ++ var obbPatchHashType = "" ++ var permissions = emptyList() ++ var features = emptyList() ++ var platforms = emptyList() ++ var incompatibilities = emptyList() ++ forEachKey { it -> ++ when { ++ it.boolean("selected") -> selected = valueAsBoolean ++ it.string("version") -> version = valueAsString ++ it.number("versionCode") -> versionCode = valueAsLong ++ it.number("added") -> added = valueAsLong ++ it.number("size") -> size = valueAsLong ++ it.number("minSdkVersion") -> minSdkVersion = valueAsInt ++ it.number("targetSdkVersion") -> targetSdkVersion = valueAsInt ++ it.number("maxSdkVersion") -> maxSdkVersion = valueAsInt ++ it.string("source") -> source = valueAsString ++ it.string("release") -> release = valueAsString ++ it.string("hash") -> hash = valueAsString ++ it.string("hashType") -> hashType = valueAsString ++ it.string("signature") -> signature = valueAsString ++ it.string("obbMain") -> obbMain = valueAsString ++ it.string("obbMainHash") -> obbMainHash = valueAsString ++ it.string("obbMainHashType") -> obbMainHashType = valueAsString ++ it.string("obbPatch") -> obbPatch = valueAsString ++ it.string("obbPatchHash") -> obbPatchHash = valueAsString ++ it.string("obbPatchHashType") -> obbPatchHashType = valueAsString ++ it.array("permissions") -> permissions = collectNotNullStrings() ++ it.array("features") -> features = collectNotNullStrings() ++ it.array("platforms") -> platforms = collectNotNullStrings() ++ it.array("incompatibilities") -> ++ incompatibilities = ++ collectNotNull(JsonToken.START_OBJECT) { ++ var type = "" ++ var feature = "" ++ forEachKey { ++ when { ++ it.string("type") -> type = valueAsString ++ it.string("feature") -> feature = valueAsString ++ else -> skipChildren() ++ } ++ } ++ when (type) { ++ "minSdk" -> Release.Incompatibility.MinSdk ++ "maxSdk" -> Release.Incompatibility.MaxSdk ++ "platform" -> Release.Incompatibility.Platform ++ "feature" -> Release.Incompatibility.Feature(feature) ++ else -> null ++ } ++ } ++ ++ else -> skipChildren() ++ } ++ } ++ return Release( ++ selected, ++ version, ++ versionCode, ++ added, ++ size, ++ minSdkVersion, ++ targetSdkVersion, ++ maxSdkVersion, ++ source, ++ release, ++ hash, ++ hashType, ++ signature, ++ obbMain, ++ obbMainHash, ++ obbMainHashType, ++ obbPatch, ++ obbPatchHash, ++ obbPatchHashType, ++ permissions, ++ features, ++ platforms, ++ incompatibilities ++ ) ++} +Index: app/src/main/kotlin/com/leos/droidify/utility/serialization/RepositorySerialization.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/utility/serialization/RepositorySerialization.kt b/app/src/main/kotlin/com/leos/droidify/utility/serialization/RepositorySerialization.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/utility/serialization/RepositorySerialization.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,63 @@ ++package com.leos.droidify.utility.serialization ++ ++import com.fasterxml.jackson.core.JsonGenerator ++import com.fasterxml.jackson.core.JsonParser ++import com.leos.core.common.extension.collectNotNullStrings ++import com.leos.core.common.extension.forEachKey ++import com.leos.core.common.extension.writeArray ++import com.leos.core.domain.Repository ++ ++fun Repository.serialize(generator: JsonGenerator) { ++ generator.writeNumberField("serialVersion", 1) ++ generator.writeNumberField("id", id) ++ generator.writeStringField("address", address) ++ generator.writeArray("mirrors") { mirrors.forEach { writeString(it) } } ++ generator.writeStringField("name", name) ++ generator.writeStringField("description", description) ++ generator.writeNumberField("version", version) ++ generator.writeBooleanField("enabled", enabled) ++ generator.writeStringField("fingerprint", fingerprint) ++ generator.writeStringField("lastModified", lastModified) ++ generator.writeStringField("entityTag", entityTag) ++ generator.writeNumberField("updated", updated) ++ generator.writeNumberField("timestamp", timestamp) ++ generator.writeStringField("authentication", authentication) ++} ++ ++fun JsonParser.repository(): Repository { ++ var id = -1L ++ var address = "" ++ var mirrors = emptyList() ++ var name = "" ++ var description = "" ++ var version = 0 ++ var enabled = false ++ var fingerprint = "" ++ var lastModified = "" ++ var entityTag = "" ++ var updated = 0L ++ var timestamp = 0L ++ var authentication = "" ++ forEachKey { ++ when { ++ it.string("id") -> id = valueAsLong ++ it.string("address") -> address = valueAsString ++ it.array("mirrors") -> mirrors = collectNotNullStrings() ++ it.string("name") -> name = valueAsString ++ it.string("description") -> description = valueAsString ++ it.number("version") -> version = valueAsInt ++ it.boolean("enabled") -> enabled = valueAsBoolean ++ it.string("fingerprint") -> fingerprint = valueAsString ++ it.string("lastModified") -> lastModified = valueAsString ++ it.string("entityTag") -> entityTag = valueAsString ++ it.number("updated") -> updated = valueAsLong ++ it.number("timestamp") -> timestamp = valueAsLong ++ it.string("authentication") -> authentication = valueAsString ++ else -> skipChildren() ++ } ++ } ++ return Repository( ++ id, address, mirrors, name, description, version, enabled, fingerprint, ++ lastModified, entityTag, updated, timestamp, authentication ++ ) ++} +Index: app/src/main/kotlin/com/leos/droidify/widget/CursorRecyclerAdapter.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/widget/CursorRecyclerAdapter.kt b/app/src/main/kotlin/com/leos/droidify/widget/CursorRecyclerAdapter.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/widget/CursorRecyclerAdapter.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,36 @@ ++package com.leos.droidify.widget ++ ++import android.database.Cursor ++import androidx.recyclerview.widget.RecyclerView ++ ++abstract class CursorRecyclerAdapter, VH : RecyclerView.ViewHolder> : ++ EnumRecyclerAdapter() { ++ init { ++ super.setHasStableIds(true) ++ } ++ ++ private var rowIdIndex = 0 ++ ++ var cursor: Cursor? = null ++ set(value) { ++ if (field != value) { ++ field?.close() ++ field = value ++ rowIdIndex = value?.getColumnIndexOrThrow("_id") ?: 0 ++ notifyDataSetChanged() ++ } ++ } ++ ++ final override fun setHasStableIds(hasStableIds: Boolean) { ++ throw UnsupportedOperationException() ++ } ++ ++ override fun getItemCount(): Int = cursor?.count ?: 0 ++ override fun getItemId(position: Int): Long = moveTo(position).getLong(rowIdIndex) ++ ++ fun moveTo(position: Int): Cursor { ++ val cursor = cursor!! ++ cursor.moveToPosition(position) ++ return cursor ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/widget/DividerItemDecoration.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/widget/DividerItemDecoration.kt b/app/src/main/kotlin/com/leos/droidify/widget/DividerItemDecoration.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/widget/DividerItemDecoration.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,139 @@ ++package com.leos.droidify.widget ++ ++import android.content.Context ++import android.graphics.Canvas ++import android.graphics.Rect ++import android.view.View ++import androidx.recyclerview.widget.RecyclerView ++import com.leos.core.common.extension.divider ++import com.leos.droidify.R ++import kotlin.math.roundToInt ++ ++fun RecyclerView.addDivider( ++ configure: ( ++ context: Context, ++ position: Int, ++ configuration: DividerConfiguration ++ ) -> Unit ++) { ++ addItemDecoration( ++ DividerItemDecoration( ++ context = context, ++ configure = configure ++ ) ++ ) ++} ++ ++fun interface DividerConfiguration { ++ fun set(needDivider: Boolean, toTop: Boolean, paddingStart: Int, paddingEnd: Int) ++} ++ ++private class DividerItemDecoration( ++ context: Context, ++ private val configure: ( ++ context: Context, ++ position: Int, ++ configuration: DividerConfiguration ++ ) -> Unit ++) : RecyclerView.ItemDecoration() { ++ ++ private class ConfigurationHolder : DividerConfiguration { ++ var needDivider = false ++ var toTop = false ++ var paddingStart = 0 ++ var paddingEnd = 0 ++ ++ override fun set(needDivider: Boolean, toTop: Boolean, paddingStart: Int, paddingEnd: Int) { ++ this.needDivider = needDivider ++ this.toTop = toTop ++ this.paddingStart = paddingStart ++ this.paddingEnd = paddingEnd ++ } ++ } ++ ++ private val View.configuration: ConfigurationHolder ++ get() = getTag(R.id.divider_configuration) as? ConfigurationHolder ?: run { ++ val configuration = ConfigurationHolder() ++ setTag(R.id.divider_configuration, configuration) ++ configuration ++ } ++ ++ private val divider = context.divider ++ private val bounds = Rect() ++ ++ private fun draw( ++ c: Canvas, ++ configuration: ConfigurationHolder, ++ view: View, ++ top: Int, ++ width: Int, ++ rtl: Boolean ++ ) { ++ val divider = divider ++ val left = if (rtl) configuration.paddingEnd else configuration.paddingStart ++ val right = width - (if (rtl) configuration.paddingStart else configuration.paddingEnd) ++ val translatedTop = top + view.translationY.roundToInt() ++ divider.alpha = (view.alpha * 0xff).toInt() ++ divider.setBounds(left, translatedTop, right, translatedTop + divider.intrinsicHeight) ++ divider.draw(c) ++ } ++ ++ override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { ++ val divider = divider ++ val bounds = bounds ++ val rtl = parent.layoutDirection == View.LAYOUT_DIRECTION_RTL ++ for (i in 0 until parent.childCount) { ++ val view = parent.getChildAt(i) ++ val configuration = view.configuration ++ if (configuration.needDivider) { ++ val position = parent.getChildAdapterPosition(view) ++ if (position == parent.adapter!!.itemCount - 1) { ++ parent.getDecoratedBoundsWithMargins(view, bounds) ++ draw(c, configuration, view, bounds.bottom, parent.width, rtl) ++ } else { ++ val toTopView = if (configuration.toTop && position >= 0) { ++ parent.findViewHolderForAdapterPosition(position + 1)?.itemView ++ } else { ++ null ++ } ++ if (toTopView != null) { ++ parent.getDecoratedBoundsWithMargins(toTopView, bounds) ++ draw( ++ c, ++ configuration, ++ toTopView, ++ bounds.top - divider.intrinsicHeight, ++ parent.width, ++ rtl ++ ) ++ } else { ++ parent.getDecoratedBoundsWithMargins(view, bounds) ++ draw( ++ c, ++ configuration, ++ view, ++ bounds.bottom - divider.intrinsicHeight, ++ parent.width, ++ rtl ++ ) ++ } ++ } ++ } ++ } ++ } ++ ++ override fun getItemOffsets( ++ outRect: Rect, ++ view: View, ++ parent: RecyclerView, ++ state: RecyclerView.State ++ ) { ++ val configuration = view.configuration ++ val position = parent.getChildAdapterPosition(view) ++ if (position >= 0) { ++ configure(view.context, position, configuration) ++ } ++ val needDivider = position < parent.adapter!!.itemCount - 1 && configuration.needDivider ++ outRect.set(0, 0, 0, if (needDivider) divider.intrinsicHeight else 0) ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/widget/EnumRecyclerAdapter.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/widget/EnumRecyclerAdapter.kt b/app/src/main/kotlin/com/leos/droidify/widget/EnumRecyclerAdapter.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/widget/EnumRecyclerAdapter.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,29 @@ ++package com.leos.droidify.widget ++ ++import android.util.SparseArray ++import android.view.ViewGroup ++import androidx.recyclerview.widget.RecyclerView ++ ++abstract class EnumRecyclerAdapter, VH : RecyclerView.ViewHolder> : ++ RecyclerView.Adapter() { ++ abstract val viewTypeClass: Class ++ ++ private val names = SparseArray() ++ ++ private fun getViewType(viewType: Int): VT { ++ return java.lang.Enum.valueOf(viewTypeClass, names.get(viewType)) ++ } ++ ++ final override fun getItemViewType(position: Int): Int { ++ val enum = getItemEnumViewType(position) ++ names.put(enum.ordinal, enum.name) ++ return enum.ordinal ++ } ++ ++ final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { ++ return onCreateViewHolder(parent, getViewType(viewType)) ++ } ++ ++ abstract fun getItemEnumViewType(position: Int): VT ++ abstract fun onCreateViewHolder(parent: ViewGroup, viewType: VT): VH ++} +Index: app/src/main/kotlin/com/leos/droidify/widget/FocusSearchView.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/widget/FocusSearchView.kt b/app/src/main/kotlin/com/leos/droidify/widget/FocusSearchView.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/widget/FocusSearchView.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,39 @@ ++package com.leos.droidify.widget ++ ++import android.content.Context ++import android.util.AttributeSet ++import android.view.KeyEvent ++import androidx.appcompat.widget.SearchView ++ ++class FocusSearchView : SearchView { ++ constructor(context: Context) : super(context) ++ constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) ++ constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( ++ context, ++ attrs, ++ defStyleAttr ++ ) ++ ++ var allowFocus = true ++ ++ override fun dispatchKeyEventPreIme(event: KeyEvent): Boolean { ++ // Always clear focus on back press ++ return if (hasFocus() && event.keyCode == KeyEvent.KEYCODE_BACK) { ++ if (event.action == KeyEvent.ACTION_UP) { ++ clearFocus() ++ } ++ true ++ } else { ++ super.dispatchKeyEventPreIme(event) ++ } ++ } ++ ++ override fun setIconified(iconify: Boolean) { ++ super.setIconified(iconify) ++ ++ // Don't focus view and raise keyboard unless allowed ++ if (!iconify && !allowFocus) { ++ clearFocus() ++ } ++ } ++} +Index: app/src/main/kotlin/com/leos/droidify/widget/StableRecyclerAdapter.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/widget/StableRecyclerAdapter.kt b/app/src/main/kotlin/com/leos/droidify/widget/StableRecyclerAdapter.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/widget/StableRecyclerAdapter.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,28 @@ ++package com.leos.droidify.widget ++ ++import androidx.recyclerview.widget.RecyclerView ++ ++abstract class StableRecyclerAdapter, VH : RecyclerView.ViewHolder> : ++ EnumRecyclerAdapter() { ++ private var nextId = 1L ++ private val descriptorToId = mutableMapOf() ++ ++ init { ++ super.setHasStableIds(true) ++ } ++ ++ final override fun setHasStableIds(hasStableIds: Boolean) { ++ throw UnsupportedOperationException() ++ } ++ ++ override fun getItemId(position: Int): Long { ++ val descriptor = getItemDescriptor(position) ++ return descriptorToId[descriptor] ?: run { ++ val id = nextId++ ++ descriptorToId[descriptor] = id ++ id ++ } ++ } ++ ++ abstract fun getItemDescriptor(position: Int): String ++} +Index: app/src/main/kotlin/com/leos/droidify/work/CleanUpWorker.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/kotlin/com/leos/droidify/work/CleanUpWorker.kt b/app/src/main/kotlin/com/leos/droidify/work/CleanUpWorker.kt +new file mode 100644 +--- /dev/null (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) ++++ b/app/src/main/kotlin/com/leos/droidify/work/CleanUpWorker.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -0,0 +1,74 @@ ++package com.leos.droidify.work ++ ++import android.content.Context ++import android.util.Log ++import androidx.hilt.work.HiltWorker ++import androidx.work.CoroutineWorker ++import androidx.work.ExistingPeriodicWorkPolicy ++import androidx.work.ExistingWorkPolicy ++import androidx.work.OneTimeWorkRequestBuilder ++import androidx.work.PeriodicWorkRequestBuilder ++import androidx.work.WorkManager ++import androidx.work.WorkerParameters ++import com.leos.core.common.cache.Cache ++import com.leos.core.datastore.SettingsRepository ++import dagger.assisted.Assisted ++import dagger.assisted.AssistedInject ++import kotlin.time.Duration ++import kotlin.time.toJavaDuration ++import kotlinx.coroutines.Dispatchers ++import kotlinx.coroutines.withContext ++ ++@HiltWorker ++class CleanUpWorker @AssistedInject constructor( ++ @Assisted context: Context, ++ @Assisted workerParams: WorkerParameters, ++ private val settingsRepository: SettingsRepository ++) : CoroutineWorker(context, workerParams) { ++ companion object { ++ private const val TAG = "CleanUpWorker" ++ ++ fun removeAllSchedules(context: Context) { ++ val workManager = WorkManager.getInstance(context) ++ workManager.cancelUniqueWork(TAG) ++ } ++ ++ fun scheduleCleanup(context: Context, duration: Duration) { ++ val workManager = WorkManager.getInstance(context) ++ val cleanup = PeriodicWorkRequestBuilder(duration.toJavaDuration()) ++ .build() ++ ++ workManager.enqueueUniquePeriodicWork( ++ TAG, ++ ExistingPeriodicWorkPolicy.UPDATE, ++ cleanup ++ ) ++ Log.i(TAG, "Periodic work enqueued with duration: $duration") ++ } ++ ++ fun force(context: Context) { ++ val cleanup = OneTimeWorkRequestBuilder() ++ .build() ++ ++ val workManager = WorkManager.getInstance(context) ++ workManager.enqueueUniqueWork( ++ "$TAG.force", ++ ExistingWorkPolicy.KEEP, ++ cleanup ++ ) ++ Log.i(TAG, "Forced cleanup enqueued") ++ } ++ } ++ ++ override suspend fun doWork(): Result = withContext(Dispatchers.IO) { ++ try { ++ Log.i(TAG, "doWork: Started Cleanup") ++ settingsRepository.setCleanupInstant() ++ Cache.cleanup(applicationContext) ++ Result.success() ++ } catch (e: Exception) { ++ Log.i(TAG, "doWork: Failed to clean up", e) ++ Result.failure() ++ } ++ } ++} +Index: app/src/main/res/layout/settings_page.xml +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/app/src/main/res/layout/settings_page.xml b/app/src/main/res/layout/settings_page.xml +--- a/app/src/main/res/layout/settings_page.xml (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/app/src/main/res/layout/settings_page.xml (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -5,7 +5,7 @@ + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="?colorSurface" +- tools:context="com.looker.droidify.ui.settings.SettingsFragment"> ++ tools:context="com.leos.droidify.ui.settings.SettingsFragment"> + + UTF-8 +=================================================================== +diff --git a/build-logic/structure/src/main/kotlin/AndroidApplicationPlugin.kt b/build-logic/structure/src/main/kotlin/AndroidApplicationPlugin.kt +--- a/build-logic/structure/src/main/kotlin/AndroidApplicationPlugin.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/build-logic/structure/src/main/kotlin/AndroidApplicationPlugin.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,5 +1,5 @@ + import com.android.build.api.dsl.ApplicationExtension +-import com.looker.droidify.configureKotlinAndroid ++import com.leos.droidify.configureKotlinAndroid + import org.gradle.api.Plugin + import org.gradle.api.Project + import org.gradle.kotlin.dsl.configure +Index: build-logic/structure/src/main/kotlin/AndroidHiltPlugin.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/build-logic/structure/src/main/kotlin/AndroidHiltPlugin.kt b/build-logic/structure/src/main/kotlin/AndroidHiltPlugin.kt +--- a/build-logic/structure/src/main/kotlin/AndroidHiltPlugin.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/build-logic/structure/src/main/kotlin/AndroidHiltPlugin.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,5 +1,5 @@ +-import com.looker.droidify.getLibrary +-import com.looker.droidify.libs ++import com.leos.droidify.getLibrary ++import com.leos.droidify.libs + import org.gradle.api.Plugin + import org.gradle.api.Project + import org.gradle.kotlin.dsl.dependencies +Index: build-logic/structure/src/main/kotlin/AndroidHiltWorkerPlugin.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/build-logic/structure/src/main/kotlin/AndroidHiltWorkerPlugin.kt b/build-logic/structure/src/main/kotlin/AndroidHiltWorkerPlugin.kt +--- a/build-logic/structure/src/main/kotlin/AndroidHiltWorkerPlugin.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/build-logic/structure/src/main/kotlin/AndroidHiltWorkerPlugin.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,5 +1,5 @@ +-import com.looker.droidify.getLibrary +-import com.looker.droidify.libs ++import com.leos.droidify.getLibrary ++import com.leos.droidify.libs + import org.gradle.api.Plugin + import org.gradle.api.Project + import org.gradle.kotlin.dsl.dependencies +Index: build-logic/structure/src/main/kotlin/AndroidLibraryPlugin.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/build-logic/structure/src/main/kotlin/AndroidLibraryPlugin.kt b/build-logic/structure/src/main/kotlin/AndroidLibraryPlugin.kt +--- a/build-logic/structure/src/main/kotlin/AndroidLibraryPlugin.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/build-logic/structure/src/main/kotlin/AndroidLibraryPlugin.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,6 +1,6 @@ + import com.android.build.api.variant.LibraryAndroidComponentsExtension + import com.android.build.gradle.LibraryExtension +-import com.looker.droidify.configureKotlinAndroid ++import com.leos.droidify.configureKotlinAndroid + import org.gradle.api.Plugin + import org.gradle.api.Project + import org.gradle.kotlin.dsl.configure +Index: build-logic/structure/src/main/kotlin/AndroidRoomPlugin.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/build-logic/structure/src/main/kotlin/AndroidRoomPlugin.kt b/build-logic/structure/src/main/kotlin/AndroidRoomPlugin.kt +--- a/build-logic/structure/src/main/kotlin/AndroidRoomPlugin.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/build-logic/structure/src/main/kotlin/AndroidRoomPlugin.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,6 +1,6 @@ + import com.google.devtools.ksp.gradle.KspExtension +-import com.looker.droidify.getLibrary +-import com.looker.droidify.libs ++import com.leos.droidify.getLibrary ++import com.leos.droidify.libs + import org.gradle.api.Plugin + import org.gradle.api.Project + import org.gradle.api.tasks.InputDirectory +Index: build-logic/structure/src/main/kotlin/AndroidSerializationPlugin.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/build-logic/structure/src/main/kotlin/AndroidSerializationPlugin.kt b/build-logic/structure/src/main/kotlin/AndroidSerializationPlugin.kt +--- a/build-logic/structure/src/main/kotlin/AndroidSerializationPlugin.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/build-logic/structure/src/main/kotlin/AndroidSerializationPlugin.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,5 +1,5 @@ +-import com.looker.droidify.getLibrary +-import com.looker.droidify.libs ++import com.leos.droidify.getLibrary ++import com.leos.droidify.libs + import org.gradle.api.Plugin + import org.gradle.api.Project + import org.gradle.kotlin.dsl.dependencies +Index: build-logic/structure/src/main/kotlin/DefaultConfig.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/build-logic/structure/src/main/kotlin/DefaultConfig.kt b/build-logic/structure/src/main/kotlin/DefaultConfig.kt +--- a/build-logic/structure/src/main/kotlin/DefaultConfig.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/build-logic/structure/src/main/kotlin/DefaultConfig.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,7 +1,7 @@ + object DefaultConfig { + // Update [release_build.yml] along with this + const val buildTools: String = "34.0.0" +- const val appId = "com.looker.droidify" ++ const val appId = "com.leos.droidify" + const val compileSdk = 34 + const val minSdk = 23 + const val versionCode = 595 +Index: build-logic/structure/src/main/kotlin/com/looker/droidify/KotlinAndroid.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/build-logic/structure/src/main/kotlin/com/looker/droidify/KotlinAndroid.kt b/build-logic/structure/src/main/kotlin/com/looker/droidify/KotlinAndroid.kt +--- a/build-logic/structure/src/main/kotlin/com/looker/droidify/KotlinAndroid.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/build-logic/structure/src/main/kotlin/com/looker/droidify/KotlinAndroid.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.droidify ++package com.leos.droidify + + import DefaultConfig + import com.android.build.api.dsl.CommonExtension +Index: build-logic/structure/src/main/kotlin/com/looker/droidify/ProjectExtensions.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/build-logic/structure/src/main/kotlin/com/looker/droidify/ProjectExtensions.kt b/build-logic/structure/src/main/kotlin/com/looker/droidify/ProjectExtensions.kt +--- a/build-logic/structure/src/main/kotlin/com/looker/droidify/ProjectExtensions.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/build-logic/structure/src/main/kotlin/com/looker/droidify/ProjectExtensions.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.droidify ++package com.leos.droidify + + import org.gradle.api.Project + import org.gradle.api.artifacts.MinimalExternalModuleDependency +Index: core/common/build.gradle.kts +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts +--- a/core/common/build.gradle.kts (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/build.gradle.kts (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -6,7 +6,7 @@ + } + + android { +- namespace = "com.looker.core.common" ++ namespace = "com.leos.core.common" + defaultConfig { + vectorDrawables.useSupportLibrary = true + } +Index: core/common/src/main/java/com/looker/core/common/Constants.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/Constants.kt b/core/common/src/main/java/com/looker/core/common/Constants.kt +--- a/core/common/src/main/java/com/looker/core/common/Constants.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/Constants.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.common ++package com.leos.core.common + + object Constants { + const val NOTIFICATION_CHANNEL_SYNCING = "syncing" +Index: core/common/src/main/java/com/looker/core/common/DataSize.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/DataSize.kt b/core/common/src/main/java/com/looker/core/common/DataSize.kt +--- a/core/common/src/main/java/com/looker/core/common/DataSize.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/DataSize.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.common ++package com.leos.core.common + + import java.util.Locale + +Index: core/common/src/main/java/com/looker/core/common/Deeplinks.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/Deeplinks.kt b/core/common/src/main/java/com/looker/core/common/Deeplinks.kt +--- a/core/common/src/main/java/com/looker/core/common/Deeplinks.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/Deeplinks.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,7 +1,7 @@ +-package com.looker.core.common ++package com.leos.core.common + + import android.content.Intent +-import com.looker.core.common.extension.get ++import com.leos.core.common.extension.get + + private const val PERSONAL_HOST = "droidify.eu.org" + +Index: core/common/src/main/java/com/looker/core/common/Exporter.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/Exporter.kt b/core/common/src/main/java/com/looker/core/common/Exporter.kt +--- a/core/common/src/main/java/com/looker/core/common/Exporter.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/Exporter.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.common ++package com.leos.core.common + + import android.net.Uri + +Index: core/common/src/main/java/com/looker/core/common/PackageName.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/PackageName.kt b/core/common/src/main/java/com/looker/core/common/PackageName.kt +--- a/core/common/src/main/java/com/looker/core/common/PackageName.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/PackageName.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.common ++package com.leos.core.common + + @JvmInline + value class PackageName(val name: String) +Index: core/common/src/main/java/com/looker/core/common/SdkCheck.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/SdkCheck.kt b/core/common/src/main/java/com/looker/core/common/SdkCheck.kt +--- a/core/common/src/main/java/com/looker/core/common/SdkCheck.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/SdkCheck.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.common ++package com.leos.core.common + + import android.os.Build + import androidx.annotation.ChecksSdkIntAtLeast +Index: core/common/src/main/java/com/looker/core/common/Singleton.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/Singleton.kt b/core/common/src/main/java/com/looker/core/common/Singleton.kt +--- a/core/common/src/main/java/com/looker/core/common/Singleton.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/Singleton.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.common ++package com.leos.core.common + + class Singleton { + private var value: T? = null +Index: core/common/src/main/java/com/looker/core/common/Text.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/Text.kt b/core/common/src/main/java/com/looker/core/common/Text.kt +--- a/core/common/src/main/java/com/looker/core/common/Text.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/Text.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.common ++package com.leos.core.common + + import android.util.Log + import java.util.Locale +Index: core/common/src/main/java/com/looker/core/common/cache/Cache.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/cache/Cache.kt b/core/common/src/main/java/com/looker/core/common/cache/Cache.kt +--- a/core/common/src/main/java/com/looker/core/common/cache/Cache.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/cache/Cache.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.common.cache ++package com.leos.core.common.cache + + import android.content.ContentProvider + import android.content.ContentValues +@@ -11,8 +11,8 @@ + import android.os.ParcelFileDescriptor + import android.provider.OpenableColumns + import android.system.Os +-import com.looker.core.common.SdkCheck +-import com.looker.core.common.sdkAbove ++import com.leos.core.common.SdkCheck ++import com.leos.core.common.sdkAbove + import java.io.File + import java.util.UUID + import kotlin.concurrent.thread +Index: core/common/src/main/java/com/looker/core/common/device/Huawei.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/device/Huawei.kt b/core/common/src/main/java/com/looker/core/common/device/Huawei.kt +--- a/core/common/src/main/java/com/looker/core/common/device/Huawei.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/device/Huawei.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.common.device ++package com.leos.core.common.device + + object Huawei { + val isHuaweiEmui: Boolean +Index: core/common/src/main/java/com/looker/core/common/device/Miui.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/device/Miui.kt b/core/common/src/main/java/com/looker/core/common/device/Miui.kt +--- a/core/common/src/main/java/com/looker/core/common/device/Miui.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/device/Miui.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.common.device ++package com.leos.core.common.device + + import android.annotation.SuppressLint + import android.util.Log +Index: core/common/src/main/java/com/looker/core/common/extension/Collections.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/extension/Collections.kt b/core/common/src/main/java/com/looker/core/common/extension/Collections.kt +--- a/core/common/src/main/java/com/looker/core/common/extension/Collections.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/extension/Collections.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.common.extension ++package com.leos.core.common.extension + + inline fun Map.updateAsMutable(block: MutableMap.() -> Unit): Map { + return toMutableMap().apply(block) +Index: core/common/src/main/java/com/looker/core/common/extension/Context.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/extension/Context.kt b/core/common/src/main/java/com/looker/core/common/extension/Context.kt +--- a/core/common/src/main/java/com/looker/core/common/extension/Context.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/extension/Context.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.common.extension ++package com.leos.core.common.extension + + import android.app.NotificationManager + import android.app.job.JobScheduler +@@ -14,7 +14,7 @@ + import androidx.appcompat.content.res.AppCompatResources + import androidx.core.content.ContextCompat + import androidx.core.content.getSystemService +-import com.looker.core.common.R ++import com.leos.core.common.R + + inline val Context.clipboardManager: ClipboardManager? + get() = getSystemService() +Index: core/common/src/main/java/com/looker/core/common/extension/Cursor.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/extension/Cursor.kt b/core/common/src/main/java/com/looker/core/common/extension/Cursor.kt +--- a/core/common/src/main/java/com/looker/core/common/extension/Cursor.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/extension/Cursor.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.common.extension ++package com.leos.core.common.extension + + import android.database.Cursor + +Index: core/common/src/main/java/com/looker/core/common/extension/DateTime.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/extension/DateTime.kt b/core/common/src/main/java/com/looker/core/common/extension/DateTime.kt +--- a/core/common/src/main/java/com/looker/core/common/extension/DateTime.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/extension/DateTime.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.common.extension ++package com.leos.core.common.extension + + import java.text.SimpleDateFormat + import java.util.Date +Index: core/common/src/main/java/com/looker/core/common/extension/Exception.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/extension/Exception.kt b/core/common/src/main/java/com/looker/core/common/extension/Exception.kt +--- a/core/common/src/main/java/com/looker/core/common/extension/Exception.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/extension/Exception.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.common.extension ++package com.leos.core.common.extension + + import kotlinx.coroutines.CancellationException + +Index: core/common/src/main/java/com/looker/core/common/extension/File.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/extension/File.kt b/core/common/src/main/java/com/looker/core/common/extension/File.kt +--- a/core/common/src/main/java/com/looker/core/common/extension/File.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/extension/File.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.common.extension ++package com.leos.core.common.extension + + import java.io.File + import java.io.InputStream +Index: core/common/src/main/java/com/looker/core/common/extension/Fingerprint.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/extension/Fingerprint.kt b/core/common/src/main/java/com/looker/core/common/extension/Fingerprint.kt +--- a/core/common/src/main/java/com/looker/core/common/extension/Fingerprint.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/extension/Fingerprint.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,6 +1,6 @@ +-package com.looker.core.common.extension ++package com.leos.core.common.extension + +-import com.looker.core.common.hex ++import com.leos.core.common.hex + import java.security.MessageDigest + import java.security.cert.Certificate + import java.security.cert.CertificateEncodingException +Index: core/common/src/main/java/com/looker/core/common/extension/Flow.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/extension/Flow.kt b/core/common/src/main/java/com/looker/core/common/extension/Flow.kt +--- a/core/common/src/main/java/com/looker/core/common/extension/Flow.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/extension/Flow.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.common.extension ++package com.leos.core.common.extension + + import androidx.lifecycle.ViewModel + import androidx.lifecycle.viewModelScope +Index: core/common/src/main/java/com/looker/core/common/extension/Insets.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/extension/Insets.kt b/core/common/src/main/java/com/looker/core/common/extension/Insets.kt +--- a/core/common/src/main/java/com/looker/core/common/extension/Insets.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/extension/Insets.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.common.extension ++package com.leos.core.common.extension + + import android.view.View + import android.view.ViewGroup +@@ -10,11 +10,11 @@ + import androidx.core.view.updatePadding + import androidx.core.widget.NestedScrollView + import androidx.recyclerview.widget.RecyclerView +-import com.looker.core.common.SdkCheck +-import com.looker.core.common.extension.InsetSides.BOTTOM +-import com.looker.core.common.extension.InsetSides.LEFT +-import com.looker.core.common.extension.InsetSides.RIGHT +-import com.looker.core.common.extension.InsetSides.TOP ++import com.leos.core.common.SdkCheck ++import com.leos.core.common.extension.InsetSides.BOTTOM ++import com.leos.core.common.extension.InsetSides.LEFT ++import com.leos.core.common.extension.InsetSides.RIGHT ++import com.leos.core.common.extension.InsetSides.TOP + + fun View.systemBarsMargin( + persistentPadding: Int, +Index: core/common/src/main/java/com/looker/core/common/extension/Intent.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/extension/Intent.kt b/core/common/src/main/java/com/looker/core/common/extension/Intent.kt +--- a/core/common/src/main/java/com/looker/core/common/extension/Intent.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/extension/Intent.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,11 +1,11 @@ +-package com.looker.core.common.extension ++package com.leos.core.common.extension + + import android.app.PendingIntent + import android.content.Context + import android.content.Intent + import android.net.Uri + import androidx.core.app.TaskStackBuilder +-import com.looker.core.common.SdkCheck ++import com.leos.core.common.SdkCheck + + inline val intentFlagCompat + get() = if (SdkCheck.isSnowCake) { +Index: core/common/src/main/java/com/looker/core/common/extension/JarFile.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/extension/JarFile.kt b/core/common/src/main/java/com/looker/core/common/extension/JarFile.kt +--- a/core/common/src/main/java/com/looker/core/common/extension/JarFile.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/extension/JarFile.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.common.extension ++package com.leos.core.common.extension + + import java.io.File + import java.security.CodeSigner +Index: core/common/src/main/java/com/looker/core/common/extension/Json.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/extension/Json.kt b/core/common/src/main/java/com/looker/core/common/extension/Json.kt +--- a/core/common/src/main/java/com/looker/core/common/extension/Json.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/extension/Json.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.common.extension ++package com.leos.core.common.extension + + import com.fasterxml.jackson.core.JsonFactory + import com.fasterxml.jackson.core.JsonGenerator +Index: core/common/src/main/java/com/looker/core/common/extension/Locale.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/extension/Locale.kt b/core/common/src/main/java/com/looker/core/common/extension/Locale.kt +--- a/core/common/src/main/java/com/looker/core/common/extension/Locale.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/extension/Locale.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.common.extension ++package com.leos.core.common.extension + + import java.util.Locale + +Index: core/common/src/main/java/com/looker/core/common/extension/Network.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/extension/Network.kt b/core/common/src/main/java/com/looker/core/common/extension/Network.kt +--- a/core/common/src/main/java/com/looker/core/common/extension/Network.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/extension/Network.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.common.extension ++package com.leos.core.common.extension + + import androidx.core.net.toUri + +Index: core/common/src/main/java/com/looker/core/common/extension/Number.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/extension/Number.kt b/core/common/src/main/java/com/looker/core/common/extension/Number.kt +--- a/core/common/src/main/java/com/looker/core/common/extension/Number.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/extension/Number.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,9 +1,9 @@ +-package com.looker.core.common.extension ++package com.leos.core.common.extension + + import android.content.res.Resources + import android.util.TypedValue + import android.view.View +-import com.looker.core.common.DataSize ++import com.leos.core.common.DataSize + import kotlin.math.roundToInt + + infix fun Long.percentBy(denominator: Long?): Int { +Index: core/common/src/main/java/com/looker/core/common/extension/PackageInfo.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/extension/PackageInfo.kt b/core/common/src/main/java/com/looker/core/common/extension/PackageInfo.kt +--- a/core/common/src/main/java/com/looker/core/common/extension/PackageInfo.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/extension/PackageInfo.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,9 +1,9 @@ +-package com.looker.core.common.extension ++package com.leos.core.common.extension + + import android.content.Intent + import android.content.pm.* +-import com.looker.core.common.SdkCheck +-import com.looker.core.common.hex ++import com.leos.core.common.SdkCheck ++import com.leos.core.common.hex + import java.security.MessageDigest + + val PackageInfo.singleSignature: Signature? +Index: core/common/src/main/java/com/looker/core/common/extension/SQLiteDatabase.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/extension/SQLiteDatabase.kt b/core/common/src/main/java/com/looker/core/common/extension/SQLiteDatabase.kt +--- a/core/common/src/main/java/com/looker/core/common/extension/SQLiteDatabase.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/extension/SQLiteDatabase.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.common.extension ++package com.leos.core.common.extension + + import android.database.sqlite.SQLiteDatabase + +Index: core/common/src/main/java/com/looker/core/common/extension/Service.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/extension/Service.kt b/core/common/src/main/java/com/looker/core/common/extension/Service.kt +--- a/core/common/src/main/java/com/looker/core/common/extension/Service.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/extension/Service.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,8 +1,8 @@ +-package com.looker.core.common.extension ++package com.leos.core.common.extension + + import android.app.Service + import android.content.Intent +-import com.looker.core.common.SdkCheck ++import com.leos.core.common.SdkCheck + + fun Service.startSelf() { + val intent = Intent(this, this::class.java) +Index: core/common/src/main/java/com/looker/core/common/extension/View.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/extension/View.kt b/core/common/src/main/java/com/looker/core/common/extension/View.kt +--- a/core/common/src/main/java/com/looker/core/common/extension/View.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/extension/View.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.common.extension ++package com.leos.core.common.extension + + import android.util.TypedValue + import android.view.LayoutInflater +Index: core/common/src/main/java/com/looker/core/common/result/Result.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/result/Result.kt b/core/common/src/main/java/com/looker/core/common/result/Result.kt +--- a/core/common/src/main/java/com/looker/core/common/result/Result.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/result/Result.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.common.result ++package com.leos.core.common.result + + sealed interface Result { + data class Success(val data: T) : Result +Index: core/common/src/main/java/com/looker/core/common/signature/FileValidator.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/signature/FileValidator.kt b/core/common/src/main/java/com/looker/core/common/signature/FileValidator.kt +--- a/core/common/src/main/java/com/looker/core/common/signature/FileValidator.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/signature/FileValidator.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.common.signature ++package com.leos.core.common.signature + + import java.io.File + +Index: core/common/src/main/java/com/looker/core/common/signature/HashChecker.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/main/java/com/looker/core/common/signature/HashChecker.kt b/core/common/src/main/java/com/looker/core/common/signature/HashChecker.kt +--- a/core/common/src/main/java/com/looker/core/common/signature/HashChecker.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/main/java/com/looker/core/common/signature/HashChecker.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,7 +1,7 @@ +-package com.looker.core.common.signature ++package com.leos.core.common.signature + +-import com.looker.core.common.extension.exceptCancellation +-import com.looker.core.common.hex ++import com.leos.core.common.extension.exceptCancellation ++import com.leos.core.common.hex + import java.io.File + import java.security.MessageDigest + import kotlinx.coroutines.* +Index: core/common/src/test/java/com/looker/core/common/signature/HashCheckerTest.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/common/src/test/java/com/looker/core/common/signature/HashCheckerTest.kt b/core/common/src/test/java/com/looker/core/common/signature/HashCheckerTest.kt +--- a/core/common/src/test/java/com/looker/core/common/signature/HashCheckerTest.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/common/src/test/java/com/looker/core/common/signature/HashCheckerTest.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.common.signature ++package com.leos.core.common.signature + + import java.io.File + import kotlin.test.Test +Index: core/data/build.gradle.kts +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts +--- a/core/data/build.gradle.kts (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/data/build.gradle.kts (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -5,7 +5,7 @@ + } + + android { +- namespace = "com.looker.core.data" ++ namespace = "com.leos.core.data" + + buildTypes { + release { +Index: core/data/src/main/java/com/looker/core/data/di/DataModule.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/data/src/main/java/com/looker/core/data/di/DataModule.kt b/core/data/src/main/java/com/looker/core/data/di/DataModule.kt +--- a/core/data/src/main/java/com/looker/core/data/di/DataModule.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/data/src/main/java/com/looker/core/data/di/DataModule.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,11 +1,11 @@ +-package com.looker.core.data.di ++package com.leos.core.data.di + +-import com.looker.core.data.fdroid.repository.AppRepository +-import com.looker.core.data.fdroid.repository.RepoRepository +-import com.looker.core.data.fdroid.repository.offline.OfflineFirstAppRepository +-import com.looker.core.data.fdroid.repository.offline.OfflineFirstRepoRepository +-import com.looker.core.data.fdroid.sync.IndexDownloader +-import com.looker.core.data.fdroid.sync.IndexDownloaderImpl ++import com.leos.core.data.fdroid.repository.AppRepository ++import com.leos.core.data.fdroid.repository.RepoRepository ++import com.leos.core.data.fdroid.repository.offline.OfflineFirstAppRepository ++import com.leos.core.data.fdroid.repository.offline.OfflineFirstRepoRepository ++import com.leos.core.data.fdroid.sync.IndexDownloader ++import com.leos.core.data.fdroid.sync.IndexDownloaderImpl + import dagger.Binds + import dagger.Module + import dagger.hilt.InstallIn +Index: core/data/src/main/java/com/looker/core/data/di/DataModuleSingleton.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/data/src/main/java/com/looker/core/data/di/DataModuleSingleton.kt b/core/data/src/main/java/com/looker/core/data/di/DataModuleSingleton.kt +--- a/core/data/src/main/java/com/looker/core/data/di/DataModuleSingleton.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/data/src/main/java/com/looker/core/data/di/DataModuleSingleton.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,7 +1,7 @@ +-package com.looker.core.data.di ++package com.leos.core.data.di + +-import com.looker.core.data.fdroid.sync.IndexDownloader +-import com.looker.core.data.fdroid.sync.IndexManager ++import com.leos.core.data.fdroid.sync.IndexDownloader ++import com.leos.core.data.fdroid.sync.IndexManager + import dagger.Module + import dagger.Provides + import dagger.hilt.InstallIn +Index: core/data/src/main/java/com/looker/core/data/fdroid/Mapper.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/data/src/main/java/com/looker/core/data/fdroid/Mapper.kt b/core/data/src/main/java/com/looker/core/data/fdroid/Mapper.kt +--- a/core/data/src/main/java/com/looker/core/data/fdroid/Mapper.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/data/src/main/java/com/looker/core/data/fdroid/Mapper.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,11 +1,11 @@ +-package com.looker.core.data.fdroid ++package com.leos.core.data.fdroid + +-import com.looker.core.database.model.AntiFeatureEntity +-import com.looker.core.database.model.AppEntity +-import com.looker.core.database.model.CategoryEntity +-import com.looker.core.database.model.PackageEntity +-import com.looker.core.database.model.PermissionEntity +-import com.looker.core.database.model.RepoEntity ++import com.leos.core.database.model.AntiFeatureEntity ++import com.leos.core.database.model.AppEntity ++import com.leos.core.database.model.CategoryEntity ++import com.leos.core.database.model.PackageEntity ++import com.leos.core.database.model.PermissionEntity ++import com.leos.core.database.model.RepoEntity + import org.fdroid.index.v2.PackageV2 + import org.fdroid.index.v2.PackageVersionV2 + import org.fdroid.index.v2.RepoV2 +Index: core/data/src/main/java/com/looker/core/data/fdroid/repository/AppRepository.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/data/src/main/java/com/looker/core/data/fdroid/repository/AppRepository.kt b/core/data/src/main/java/com/looker/core/data/fdroid/repository/AppRepository.kt +--- a/core/data/src/main/java/com/looker/core/data/fdroid/repository/AppRepository.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/data/src/main/java/com/looker/core/data/fdroid/repository/AppRepository.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,9 +1,9 @@ +-package com.looker.core.data.fdroid.repository ++package com.leos.core.data.fdroid.repository + +-import com.looker.core.common.PackageName +-import com.looker.core.domain.newer.App +-import com.looker.core.domain.newer.Author +-import com.looker.core.domain.newer.Package ++import com.leos.core.common.PackageName ++import com.leos.core.domain.newer.App ++import com.leos.core.domain.newer.Author ++import com.leos.core.domain.newer.Package + import kotlinx.coroutines.flow.Flow + + interface AppRepository { +Index: core/data/src/main/java/com/looker/core/data/fdroid/repository/RepoRepository.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/data/src/main/java/com/looker/core/data/fdroid/repository/RepoRepository.kt b/core/data/src/main/java/com/looker/core/data/fdroid/repository/RepoRepository.kt +--- a/core/data/src/main/java/com/looker/core/data/fdroid/repository/RepoRepository.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/data/src/main/java/com/looker/core/data/fdroid/repository/RepoRepository.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,6 +1,6 @@ +-package com.looker.core.data.fdroid.repository ++package com.leos.core.data.fdroid.repository + +-import com.looker.core.domain.newer.Repo ++import com.leos.core.domain.newer.Repo + import kotlinx.coroutines.flow.Flow + + interface RepoRepository { +Index: core/data/src/main/java/com/looker/core/data/fdroid/repository/offline/OfflineFirstAppRepository.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/data/src/main/java/com/looker/core/data/fdroid/repository/offline/OfflineFirstAppRepository.kt b/core/data/src/main/java/com/looker/core/data/fdroid/repository/offline/OfflineFirstAppRepository.kt +--- a/core/data/src/main/java/com/looker/core/data/fdroid/repository/offline/OfflineFirstAppRepository.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/data/src/main/java/com/looker/core/data/fdroid/repository/offline/OfflineFirstAppRepository.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,18 +1,18 @@ +-package com.looker.core.data.fdroid.repository.offline ++package com.leos.core.data.fdroid.repository.offline + +-import com.looker.core.common.PackageName +-import com.looker.core.data.fdroid.repository.AppRepository +-import com.looker.core.database.dao.AppDao +-import com.looker.core.database.dao.InstalledDao +-import com.looker.core.database.model.AppEntity +-import com.looker.core.database.model.InstalledEntity +-import com.looker.core.database.model.PackageEntity +-import com.looker.core.database.model.toExternal +-import com.looker.core.datastore.SettingsRepository +-import com.looker.core.datastore.get +-import com.looker.core.domain.newer.App +-import com.looker.core.domain.newer.Author +-import com.looker.core.domain.newer.Package ++import com.leos.core.common.PackageName ++import com.leos.core.data.fdroid.repository.AppRepository ++import com.leos.core.database.dao.AppDao ++import com.leos.core.database.dao.InstalledDao ++import com.leos.core.database.model.AppEntity ++import com.leos.core.database.model.InstalledEntity ++import com.leos.core.database.model.PackageEntity ++import com.leos.core.database.model.toExternal ++import com.leos.core.datastore.SettingsRepository ++import com.leos.core.datastore.get ++import com.leos.core.domain.newer.App ++import com.leos.core.domain.newer.Author ++import com.leos.core.domain.newer.Package + import javax.inject.Inject + import kotlinx.coroutines.async + import kotlinx.coroutines.coroutineScope +Index: core/data/src/main/java/com/looker/core/data/fdroid/repository/offline/OfflineFirstRepoRepository.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/data/src/main/java/com/looker/core/data/fdroid/repository/offline/OfflineFirstRepoRepository.kt b/core/data/src/main/java/com/looker/core/data/fdroid/repository/offline/OfflineFirstRepoRepository.kt +--- a/core/data/src/main/java/com/looker/core/data/fdroid/repository/offline/OfflineFirstRepoRepository.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/data/src/main/java/com/looker/core/data/fdroid/repository/offline/OfflineFirstRepoRepository.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,17 +1,17 @@ +-package com.looker.core.data.fdroid.repository.offline ++package com.leos.core.data.fdroid.repository.offline + +-import com.looker.core.common.extension.exceptCancellation +-import com.looker.core.data.fdroid.repository.RepoRepository +-import com.looker.core.data.fdroid.sync.IndexManager +-import com.looker.core.data.fdroid.toEntity +-import com.looker.core.database.dao.AppDao +-import com.looker.core.database.dao.RepoDao +-import com.looker.core.database.model.toExternal +-import com.looker.core.database.model.update +-import com.looker.core.datastore.SettingsRepository +-import com.looker.core.di.ApplicationScope +-import com.looker.core.di.DefaultDispatcher +-import com.looker.core.domain.newer.Repo ++import com.leos.core.common.extension.exceptCancellation ++import com.leos.core.data.fdroid.repository.RepoRepository ++import com.leos.core.data.fdroid.sync.IndexManager ++import com.leos.core.data.fdroid.toEntity ++import com.leos.core.database.dao.AppDao ++import com.leos.core.database.dao.RepoDao ++import com.leos.core.database.model.toExternal ++import com.leos.core.database.model.update ++import com.leos.core.datastore.SettingsRepository ++import com.leos.core.di.ApplicationScope ++import com.leos.core.di.DefaultDispatcher ++import com.leos.core.domain.newer.Repo + import javax.inject.Inject + import kotlinx.coroutines.CoroutineDispatcher + import kotlinx.coroutines.CoroutineScope +Index: core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexDownloader.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexDownloader.kt b/core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexDownloader.kt +--- a/core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexDownloader.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexDownloader.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,6 +1,6 @@ +-package com.looker.core.data.fdroid.sync ++package com.leos.core.data.fdroid.sync + +-import com.looker.core.domain.newer.Repo ++import com.leos.core.domain.newer.Repo + import org.fdroid.index.v1.IndexV1 + import org.fdroid.index.v2.Entry + import org.fdroid.index.v2.IndexV2 +Index: core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexDownloaderImpl.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexDownloaderImpl.kt b/core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexDownloaderImpl.kt +--- a/core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexDownloaderImpl.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexDownloaderImpl.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,11 +1,11 @@ +-package com.looker.core.data.fdroid.sync ++package com.leos.core.data.fdroid.sync + +-import com.looker.core.common.signature.FileValidator +-import com.looker.core.data.fdroid.sync.signature.EntryValidator +-import com.looker.core.data.fdroid.sync.signature.IndexValidator +-import com.looker.core.domain.newer.Repo +-import com.looker.network.Downloader +-import com.looker.network.NetworkResponse ++import com.leos.core.common.signature.FileValidator ++import com.leos.core.data.fdroid.sync.signature.EntryValidator ++import com.leos.core.data.fdroid.sync.signature.IndexValidator ++import com.leos.core.domain.newer.Repo ++import com.leos.network.Downloader ++import com.leos.network.NetworkResponse + import java.io.File + import java.io.InputStream + import java.util.Date +Index: core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexManager.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexManager.kt b/core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexManager.kt +--- a/core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexManager.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexManager.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,6 +1,6 @@ +-package com.looker.core.data.fdroid.sync ++package com.leos.core.data.fdroid.sync + +-import com.looker.core.domain.newer.Repo ++import com.leos.core.domain.newer.Repo + import kotlinx.coroutines.Dispatchers + import kotlinx.coroutines.withContext + import org.fdroid.index.IndexConverter +Index: core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexType.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexType.kt b/core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexType.kt +--- a/core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexType.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexType.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.data.fdroid.sync ++package com.leos.core.data.fdroid.sync + + enum class IndexType { + INDEX_V1, +Index: core/data/src/main/java/com/looker/core/data/fdroid/sync/signature/EntryValidator.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/data/src/main/java/com/looker/core/data/fdroid/sync/signature/EntryValidator.kt b/core/data/src/main/java/com/looker/core/data/fdroid/sync/signature/EntryValidator.kt +--- a/core/data/src/main/java/com/looker/core/data/fdroid/sync/signature/EntryValidator.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/data/src/main/java/com/looker/core/data/fdroid/sync/signature/EntryValidator.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,12 +1,12 @@ +-package com.looker.core.data.fdroid.sync.signature ++package com.leos.core.data.fdroid.sync.signature + +-import com.looker.core.common.extension.certificate +-import com.looker.core.common.extension.codeSigner +-import com.looker.core.common.extension.fingerprint +-import com.looker.core.common.extension.toJarFile +-import com.looker.core.common.signature.FileValidator +-import com.looker.core.common.signature.ValidationException +-import com.looker.core.domain.newer.Repo ++import com.leos.core.common.extension.certificate ++import com.leos.core.common.extension.codeSigner ++import com.leos.core.common.extension.fingerprint ++import com.leos.core.common.extension.toJarFile ++import com.leos.core.common.signature.FileValidator ++import com.leos.core.common.signature.ValidationException ++import com.leos.core.domain.newer.Repo + import kotlinx.coroutines.Dispatchers + import kotlinx.coroutines.withContext + import org.fdroid.index.IndexParser +Index: core/data/src/main/java/com/looker/core/data/fdroid/sync/signature/IndexValidator.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/data/src/main/java/com/looker/core/data/fdroid/sync/signature/IndexValidator.kt b/core/data/src/main/java/com/looker/core/data/fdroid/sync/signature/IndexValidator.kt +--- a/core/data/src/main/java/com/looker/core/data/fdroid/sync/signature/IndexValidator.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/data/src/main/java/com/looker/core/data/fdroid/sync/signature/IndexValidator.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,12 +1,12 @@ +-package com.looker.core.data.fdroid.sync.signature ++package com.leos.core.data.fdroid.sync.signature + +-import com.looker.core.common.extension.certificate +-import com.looker.core.common.extension.codeSigner +-import com.looker.core.common.extension.fingerprint +-import com.looker.core.common.extension.toJarFile +-import com.looker.core.common.signature.FileValidator +-import com.looker.core.common.signature.ValidationException +-import com.looker.core.domain.newer.Repo ++import com.leos.core.common.extension.certificate ++import com.leos.core.common.extension.codeSigner ++import com.leos.core.common.extension.fingerprint ++import com.leos.core.common.extension.toJarFile ++import com.leos.core.common.signature.FileValidator ++import com.leos.core.common.signature.ValidationException ++import com.leos.core.domain.newer.Repo + import kotlinx.coroutines.Dispatchers + import kotlinx.coroutines.withContext + import org.fdroid.index.IndexParser +Index: core/data/src/main/java/com/looker/core/data/fdroid/sync/workers/DelegatingWorker.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/data/src/main/java/com/looker/core/data/fdroid/sync/workers/DelegatingWorker.kt b/core/data/src/main/java/com/looker/core/data/fdroid/sync/workers/DelegatingWorker.kt +--- a/core/data/src/main/java/com/looker/core/data/fdroid/sync/workers/DelegatingWorker.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/data/src/main/java/com/looker/core/data/fdroid/sync/workers/DelegatingWorker.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.data.fdroid.sync.workers ++package com.leos.core.data.fdroid.sync.workers + + import android.content.Context + import androidx.hilt.work.HiltWorkerFactory +Index: core/data/src/main/java/com/looker/core/data/fdroid/sync/workers/SyncWorkHelper.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/data/src/main/java/com/looker/core/data/fdroid/sync/workers/SyncWorkHelper.kt b/core/data/src/main/java/com/looker/core/data/fdroid/sync/workers/SyncWorkHelper.kt +--- a/core/data/src/main/java/com/looker/core/data/fdroid/sync/workers/SyncWorkHelper.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/data/src/main/java/com/looker/core/data/fdroid/sync/workers/SyncWorkHelper.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.data.fdroid.sync.workers ++package com.leos.core.data.fdroid.sync.workers + + import android.app.Notification + import android.app.NotificationChannel +@@ -6,9 +6,9 @@ + import android.content.Context + import androidx.core.app.NotificationCompat + import androidx.work.ForegroundInfo +-import com.looker.core.common.R as CommonR +-import com.looker.core.common.SdkCheck +-import com.looker.core.common.extension.notificationManager ++import com.leos.core.common.R as CommonR ++import com.leos.core.common.SdkCheck ++import com.leos.core.common.extension.notificationManager + + private const val SyncNotificationID = 12 + private const val SyncNotificationChannelID = "SyncNotificationChannelID" +Index: core/data/src/main/java/com/looker/core/data/fdroid/sync/workers/SyncWorker.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/data/src/main/java/com/looker/core/data/fdroid/sync/workers/SyncWorker.kt b/core/data/src/main/java/com/looker/core/data/fdroid/sync/workers/SyncWorker.kt +--- a/core/data/src/main/java/com/looker/core/data/fdroid/sync/workers/SyncWorker.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/data/src/main/java/com/looker/core/data/fdroid/sync/workers/SyncWorker.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.data.fdroid.sync.workers ++package com.leos.core.data.fdroid.sync.workers + + import android.content.Context + import android.util.Log +@@ -14,7 +14,7 @@ + import androidx.work.PeriodicWorkRequestBuilder + import androidx.work.WorkManager + import androidx.work.WorkerParameters +-import com.looker.core.data.fdroid.repository.RepoRepository ++import com.leos.core.data.fdroid.repository.RepoRepository + import dagger.assisted.Assisted + import dagger.assisted.AssistedInject + import java.util.concurrent.TimeUnit +Index: core/data/src/main/java/com/looker/core/data/utils/ConnectivityManagerNetworkMonitor.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/data/src/main/java/com/looker/core/data/utils/ConnectivityManagerNetworkMonitor.kt b/core/data/src/main/java/com/looker/core/data/utils/ConnectivityManagerNetworkMonitor.kt +--- a/core/data/src/main/java/com/looker/core/data/utils/ConnectivityManagerNetworkMonitor.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/data/src/main/java/com/looker/core/data/utils/ConnectivityManagerNetworkMonitor.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,11 +1,11 @@ +-package com.looker.core.data.utils ++package com.leos.core.data.utils + + import android.content.Context + import android.net.ConnectivityManager + import android.net.Network + import android.net.NetworkCapabilities + import android.net.NetworkRequest +-import com.looker.core.common.extension.connectivityManager ++import com.leos.core.common.extension.connectivityManager + import dagger.hilt.android.qualifiers.ApplicationContext + import javax.inject.Inject + import kotlinx.coroutines.channels.awaitClose +Index: core/data/src/main/java/com/looker/core/data/utils/NetworkMonitor.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/data/src/main/java/com/looker/core/data/utils/NetworkMonitor.kt b/core/data/src/main/java/com/looker/core/data/utils/NetworkMonitor.kt +--- a/core/data/src/main/java/com/looker/core/data/utils/NetworkMonitor.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/data/src/main/java/com/looker/core/data/utils/NetworkMonitor.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.data.utils ++package com.leos.core.data.utils + + import kotlinx.coroutines.flow.Flow + +Index: core/data/src/main/java/com/looker/core/data/utils/SyncStatusMonitor.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/data/src/main/java/com/looker/core/data/utils/SyncStatusMonitor.kt b/core/data/src/main/java/com/looker/core/data/utils/SyncStatusMonitor.kt +--- a/core/data/src/main/java/com/looker/core/data/utils/SyncStatusMonitor.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/data/src/main/java/com/looker/core/data/utils/SyncStatusMonitor.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.data.utils ++package com.leos.core.data.utils + + import kotlinx.coroutines.flow.Flow + +Index: core/database/build.gradle.kts +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts +--- a/core/database/build.gradle.kts (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/database/build.gradle.kts (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -6,7 +6,7 @@ + } + + android { +- namespace = "com.looker.core.database" ++ namespace = "com.leos.core.database" + + buildTypes { + release { +Index: core/database/src/main/java/com/looker/core/database/Converters.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/database/src/main/java/com/looker/core/database/Converters.kt b/core/database/src/main/java/com/looker/core/database/Converters.kt +--- a/core/database/src/main/java/com/looker/core/database/Converters.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/database/src/main/java/com/looker/core/database/Converters.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,11 +1,11 @@ +-package com.looker.core.database ++package com.leos.core.database + + import androidx.room.TypeConverter +-import com.looker.core.database.model.AntiFeatureEntity +-import com.looker.core.database.model.CategoryEntity +-import com.looker.core.database.model.LocalizedList +-import com.looker.core.database.model.LocalizedString +-import com.looker.core.database.model.PackageEntity ++import com.leos.core.database.model.AntiFeatureEntity ++import com.leos.core.database.model.CategoryEntity ++import com.leos.core.database.model.LocalizedList ++import com.leos.core.database.model.LocalizedString ++import com.leos.core.database.model.PackageEntity + import kotlinx.serialization.builtins.ListSerializer + import kotlinx.serialization.builtins.MapSerializer + import kotlinx.serialization.builtins.serializer +Index: core/database/src/main/java/com/looker/core/database/DroidifyDatabase.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/database/src/main/java/com/looker/core/database/DroidifyDatabase.kt b/core/database/src/main/java/com/looker/core/database/DroidifyDatabase.kt +--- a/core/database/src/main/java/com/looker/core/database/DroidifyDatabase.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/database/src/main/java/com/looker/core/database/DroidifyDatabase.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,14 +1,14 @@ +-package com.looker.core.database ++package com.leos.core.database + + import androidx.room.Database + import androidx.room.RoomDatabase + import androidx.room.TypeConverters +-import com.looker.core.database.dao.AppDao +-import com.looker.core.database.dao.InstalledDao +-import com.looker.core.database.dao.RepoDao +-import com.looker.core.database.model.AppEntity +-import com.looker.core.database.model.InstalledEntity +-import com.looker.core.database.model.RepoEntity ++import com.leos.core.database.dao.AppDao ++import com.leos.core.database.dao.InstalledDao ++import com.leos.core.database.dao.RepoDao ++import com.leos.core.database.model.AppEntity ++import com.leos.core.database.model.InstalledEntity ++import com.leos.core.database.model.RepoEntity + + @Database( + version = 1, +Index: core/database/src/main/java/com/looker/core/database/dao/AppDao.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/database/src/main/java/com/looker/core/database/dao/AppDao.kt b/core/database/src/main/java/com/looker/core/database/dao/AppDao.kt +--- a/core/database/src/main/java/com/looker/core/database/dao/AppDao.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/database/src/main/java/com/looker/core/database/dao/AppDao.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,12 +1,12 @@ +-package com.looker.core.database.dao ++package com.leos.core.database.dao + + import androidx.room.Dao + import androidx.room.Insert + import androidx.room.OnConflictStrategy + import androidx.room.Query + import androidx.room.Upsert +-import com.looker.core.database.model.AppEntity +-import com.looker.core.database.model.PackageEntity ++import com.leos.core.database.model.AppEntity ++import com.leos.core.database.model.PackageEntity + import kotlinx.coroutines.flow.Flow + + @Dao +Index: core/database/src/main/java/com/looker/core/database/dao/InstalledDao.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/database/src/main/java/com/looker/core/database/dao/InstalledDao.kt b/core/database/src/main/java/com/looker/core/database/dao/InstalledDao.kt +--- a/core/database/src/main/java/com/looker/core/database/dao/InstalledDao.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/database/src/main/java/com/looker/core/database/dao/InstalledDao.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,7 +1,7 @@ +-package com.looker.core.database.dao ++package com.leos.core.database.dao + + import androidx.room.* +-import com.looker.core.database.model.InstalledEntity ++import com.leos.core.database.model.InstalledEntity + import kotlinx.coroutines.flow.Flow + + @Dao +Index: core/database/src/main/java/com/looker/core/database/dao/RepoDao.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/database/src/main/java/com/looker/core/database/dao/RepoDao.kt b/core/database/src/main/java/com/looker/core/database/dao/RepoDao.kt +--- a/core/database/src/main/java/com/looker/core/database/dao/RepoDao.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/database/src/main/java/com/looker/core/database/dao/RepoDao.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,9 +1,9 @@ +-package com.looker.core.database.dao ++package com.leos.core.database.dao + + import androidx.room.Dao + import androidx.room.Query + import androidx.room.Upsert +-import com.looker.core.database.model.RepoEntity ++import com.leos.core.database.model.RepoEntity + import kotlinx.coroutines.flow.Flow + + @Dao +Index: core/database/src/main/java/com/looker/core/database/di/DaoModule.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/database/src/main/java/com/looker/core/database/di/DaoModule.kt b/core/database/src/main/java/com/looker/core/database/di/DaoModule.kt +--- a/core/database/src/main/java/com/looker/core/database/di/DaoModule.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/database/src/main/java/com/looker/core/database/di/DaoModule.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,9 +1,9 @@ +-package com.looker.core.database.di ++package com.leos.core.database.di + +-import com.looker.core.database.DroidifyDatabase +-import com.looker.core.database.dao.AppDao +-import com.looker.core.database.dao.InstalledDao +-import com.looker.core.database.dao.RepoDao ++import com.leos.core.database.DroidifyDatabase ++import com.leos.core.database.dao.AppDao ++import com.leos.core.database.dao.InstalledDao ++import com.leos.core.database.dao.RepoDao + import dagger.Module + import dagger.Provides + import dagger.hilt.InstallIn +Index: core/database/src/main/java/com/looker/core/database/di/DatabaseModule.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/database/src/main/java/com/looker/core/database/di/DatabaseModule.kt b/core/database/src/main/java/com/looker/core/database/di/DatabaseModule.kt +--- a/core/database/src/main/java/com/looker/core/database/di/DatabaseModule.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/database/src/main/java/com/looker/core/database/di/DatabaseModule.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,8 +1,8 @@ +-package com.looker.core.database.di ++package com.leos.core.database.di + + import android.content.Context + import androidx.room.Room +-import com.looker.core.database.DroidifyDatabase ++import com.leos.core.database.DroidifyDatabase + import dagger.Module + import dagger.Provides + import dagger.hilt.InstallIn +Index: core/database/src/main/java/com/looker/core/database/model/AppEntity.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/database/src/main/java/com/looker/core/database/model/AppEntity.kt b/core/database/src/main/java/com/looker/core/database/model/AppEntity.kt +--- a/core/database/src/main/java/com/looker/core/database/model/AppEntity.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/database/src/main/java/com/looker/core/database/model/AppEntity.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,17 +1,17 @@ +-package com.looker.core.database.model ++package com.leos.core.database.model + + import androidx.room.ColumnInfo + import androidx.room.Entity +-import com.looker.core.common.nullIfEmpty +-import com.looker.core.common.toPackageName +-import com.looker.core.database.utils.localizedValue +-import com.looker.core.domain.newer.App +-import com.looker.core.domain.newer.Author +-import com.looker.core.domain.newer.Donation +-import com.looker.core.domain.newer.Graphics +-import com.looker.core.domain.newer.Links +-import com.looker.core.domain.newer.Metadata +-import com.looker.core.domain.newer.Screenshots ++import com.leos.core.common.nullIfEmpty ++import com.leos.core.common.toPackageName ++import com.leos.core.database.utils.localizedValue ++import com.leos.core.domain.newer.App ++import com.leos.core.domain.newer.Author ++import com.leos.core.domain.newer.Donation ++import com.leos.core.domain.newer.Graphics ++import com.leos.core.domain.newer.Links ++import com.leos.core.domain.newer.Metadata ++import com.leos.core.domain.newer.Screenshots + + internal typealias LocalizedString = Map + internal typealias LocalizedList = Map> +Index: core/database/src/main/java/com/looker/core/database/model/InstalledEntity.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/database/src/main/java/com/looker/core/database/model/InstalledEntity.kt b/core/database/src/main/java/com/looker/core/database/model/InstalledEntity.kt +--- a/core/database/src/main/java/com/looker/core/database/model/InstalledEntity.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/database/src/main/java/com/looker/core/database/model/InstalledEntity.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.database.model ++package com.leos.core.database.model + + import androidx.room.Entity + import androidx.room.PrimaryKey +Index: core/database/src/main/java/com/looker/core/database/model/PackageEntity.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/database/src/main/java/com/looker/core/database/model/PackageEntity.kt b/core/database/src/main/java/com/looker/core/database/model/PackageEntity.kt +--- a/core/database/src/main/java/com/looker/core/database/model/PackageEntity.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/database/src/main/java/com/looker/core/database/model/PackageEntity.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,12 +1,12 @@ +-package com.looker.core.database.model ++package com.leos.core.database.model + +-import com.looker.core.database.utils.localizedValue +-import com.looker.core.domain.newer.ApkFile +-import com.looker.core.domain.newer.Manifest +-import com.looker.core.domain.newer.Package +-import com.looker.core.domain.newer.Permission +-import com.looker.core.domain.newer.Platforms +-import com.looker.core.domain.newer.SDKs ++import com.leos.core.database.utils.localizedValue ++import com.leos.core.domain.newer.ApkFile ++import com.leos.core.domain.newer.Manifest ++import com.leos.core.domain.newer.Package ++import com.leos.core.domain.newer.Permission ++import com.leos.core.domain.newer.Platforms ++import com.leos.core.domain.newer.SDKs + import kotlinx.serialization.Serializable + + @Serializable +Index: core/database/src/main/java/com/looker/core/database/model/RepoEntity.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/database/src/main/java/com/looker/core/database/model/RepoEntity.kt b/core/database/src/main/java/com/looker/core/database/model/RepoEntity.kt +--- a/core/database/src/main/java/com/looker/core/database/model/RepoEntity.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/database/src/main/java/com/looker/core/database/model/RepoEntity.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,13 +1,13 @@ +-package com.looker.core.database.model ++package com.leos.core.database.model + + import androidx.room.Entity + import androidx.room.PrimaryKey +-import com.looker.core.database.utils.localizedValue +-import com.looker.core.domain.newer.AntiFeature +-import com.looker.core.domain.newer.Authentication +-import com.looker.core.domain.newer.Category +-import com.looker.core.domain.newer.Repo +-import com.looker.core.domain.newer.VersionInfo ++import com.leos.core.database.utils.localizedValue ++import com.leos.core.domain.newer.AntiFeature ++import com.leos.core.domain.newer.Authentication ++import com.leos.core.domain.newer.Category ++import com.leos.core.domain.newer.Repo ++import com.leos.core.domain.newer.VersionInfo + import kotlinx.serialization.Serializable + + @Entity(tableName = "repos") +Index: core/database/src/main/java/com/looker/core/database/utils/Localization.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/database/src/main/java/com/looker/core/database/utils/Localization.kt b/core/database/src/main/java/com/looker/core/database/utils/Localization.kt +--- a/core/database/src/main/java/com/looker/core/database/utils/Localization.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/database/src/main/java/com/looker/core/database/utils/Localization.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,7 +1,7 @@ +-package com.looker.core.database.utils ++package com.leos.core.database.utils + + import androidx.core.os.LocaleListCompat +-import com.looker.core.common.stripBetween ++import com.leos.core.common.stripBetween + import java.util.Locale + + internal fun localeListCompat(tag: String): LocaleListCompat = +Index: core/database/src/test/java/com/looker/core/database/LocalizationTest.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/database/src/test/java/com/looker/core/database/LocalizationTest.kt b/core/database/src/test/java/com/looker/core/database/LocalizationTest.kt +--- a/core/database/src/test/java/com/looker/core/database/LocalizationTest.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/database/src/test/java/com/looker/core/database/LocalizationTest.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,11 +1,11 @@ +-package com.looker.core.database ++package com.leos.core.database + + import androidx.core.os.LocaleListCompat + import androidx.core.os.LocaleListCompat.getEmptyLocaleList +-import com.looker.core.database.utils.localeListCompat +-import com.looker.core.database.utils.localizedValue +-import com.looker.core.database.utils.suitableLocale +-import com.looker.core.database.utils.suitableTag ++import com.leos.core.database.utils.localeListCompat ++import com.leos.core.database.utils.localizedValue ++import com.leos.core.database.utils.suitableLocale ++import com.leos.core.database.utils.suitableTag + import org.junit.Test + import java.util.Locale + import kotlin.test.assertEquals +Index: core/datastore/build.gradle.kts +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts +--- a/core/datastore/build.gradle.kts (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/datastore/build.gradle.kts (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -6,7 +6,7 @@ + } + + android { +- namespace = "com.looker.core.datastore" ++ namespace = "com.leos.core.datastore" + + buildTypes { + release { +Index: core/datastore/src/main/java/com/looker/core/datastore/DataStoreSettingsRepository.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/datastore/src/main/java/com/looker/core/datastore/DataStoreSettingsRepository.kt b/core/datastore/src/main/java/com/looker/core/datastore/DataStoreSettingsRepository.kt +--- a/core/datastore/src/main/java/com/looker/core/datastore/DataStoreSettingsRepository.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/datastore/src/main/java/com/looker/core/datastore/DataStoreSettingsRepository.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,15 +1,15 @@ +-package com.looker.core.datastore ++package com.leos.core.datastore + + import android.net.Uri + import android.util.Log + import androidx.datastore.core.DataStore +-import com.looker.core.common.Exporter +-import com.looker.core.common.extension.updateAsMutable +-import com.looker.core.datastore.model.AutoSync +-import com.looker.core.datastore.model.InstallerType +-import com.looker.core.datastore.model.ProxyType +-import com.looker.core.datastore.model.SortOrder +-import com.looker.core.datastore.model.Theme ++import com.leos.core.common.Exporter ++import com.leos.core.common.extension.updateAsMutable ++import com.leos.core.datastore.model.AutoSync ++import com.leos.core.datastore.model.InstallerType ++import com.leos.core.datastore.model.ProxyType ++import com.leos.core.datastore.model.SortOrder ++import com.leos.core.datastore.model.Theme + import kotlinx.coroutines.flow.Flow + import kotlinx.coroutines.flow.catch + import kotlinx.coroutines.flow.first +Index: core/datastore/src/main/java/com/looker/core/datastore/Settings.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/datastore/src/main/java/com/looker/core/datastore/Settings.kt b/core/datastore/src/main/java/com/looker/core/datastore/Settings.kt +--- a/core/datastore/src/main/java/com/looker/core/datastore/Settings.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/datastore/src/main/java/com/looker/core/datastore/Settings.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,11 +1,11 @@ +-package com.looker.core.datastore ++package com.leos.core.datastore + + import androidx.datastore.core.Serializer +-import com.looker.core.datastore.model.AutoSync +-import com.looker.core.datastore.model.InstallerType +-import com.looker.core.datastore.model.ProxyPreference +-import com.looker.core.datastore.model.SortOrder +-import com.looker.core.datastore.model.Theme ++import com.leos.core.datastore.model.AutoSync ++import com.leos.core.datastore.model.InstallerType ++import com.leos.core.datastore.model.ProxyPreference ++import com.leos.core.datastore.model.SortOrder ++import com.leos.core.datastore.model.Theme + import java.io.IOException + import java.io.InputStream + import java.io.OutputStream +Index: core/datastore/src/main/java/com/looker/core/datastore/SettingsRepository.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/datastore/src/main/java/com/looker/core/datastore/SettingsRepository.kt b/core/datastore/src/main/java/com/looker/core/datastore/SettingsRepository.kt +--- a/core/datastore/src/main/java/com/looker/core/datastore/SettingsRepository.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/datastore/src/main/java/com/looker/core/datastore/SettingsRepository.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,11 +1,11 @@ +-package com.looker.core.datastore ++package com.leos.core.datastore + + import android.net.Uri +-import com.looker.core.datastore.model.AutoSync +-import com.looker.core.datastore.model.InstallerType +-import com.looker.core.datastore.model.ProxyType +-import com.looker.core.datastore.model.SortOrder +-import com.looker.core.datastore.model.Theme ++import com.leos.core.datastore.model.AutoSync ++import com.leos.core.datastore.model.InstallerType ++import com.leos.core.datastore.model.ProxyType ++import com.leos.core.datastore.model.SortOrder ++import com.leos.core.datastore.model.Theme + import kotlinx.coroutines.flow.Flow + import kotlinx.coroutines.flow.distinctUntilChanged + import kotlinx.coroutines.flow.map +Index: core/datastore/src/main/java/com/looker/core/datastore/di/DatastoreModule.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/datastore/src/main/java/com/looker/core/datastore/di/DatastoreModule.kt b/core/datastore/src/main/java/com/looker/core/datastore/di/DatastoreModule.kt +--- a/core/datastore/src/main/java/com/looker/core/datastore/di/DatastoreModule.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/datastore/src/main/java/com/looker/core/datastore/di/DatastoreModule.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.datastore.di ++package com.leos.core.datastore.di + + import android.content.Context + import androidx.datastore.core.DataStore +@@ -7,15 +7,15 @@ + import androidx.datastore.preferences.core.PreferenceDataStoreFactory + import androidx.datastore.preferences.core.Preferences + import androidx.datastore.preferences.preferencesDataStoreFile +-import com.looker.core.common.Exporter +-import com.looker.core.datastore.DataStoreSettingsRepository +-import com.looker.core.datastore.Settings +-import com.looker.core.datastore.SettingsRepository +-import com.looker.core.datastore.SettingsSerializer +-import com.looker.core.datastore.exporter.SettingsExporter +-import com.looker.core.datastore.migration.ProtoDataStoreMigration +-import com.looker.core.di.ApplicationScope +-import com.looker.core.di.IoDispatcher ++import com.leos.core.common.Exporter ++import com.leos.core.datastore.DataStoreSettingsRepository ++import com.leos.core.datastore.Settings ++import com.leos.core.datastore.SettingsRepository ++import com.leos.core.datastore.SettingsSerializer ++import com.leos.core.datastore.exporter.SettingsExporter ++import com.leos.core.datastore.migration.ProtoDataStoreMigration ++import com.leos.core.di.ApplicationScope ++import com.leos.core.di.IoDispatcher + import dagger.Module + import dagger.Provides + import dagger.hilt.InstallIn +Index: core/datastore/src/main/java/com/looker/core/datastore/exporter/SettingsExporter.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/datastore/src/main/java/com/looker/core/datastore/exporter/SettingsExporter.kt b/core/datastore/src/main/java/com/looker/core/datastore/exporter/SettingsExporter.kt +--- a/core/datastore/src/main/java/com/looker/core/datastore/exporter/SettingsExporter.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/datastore/src/main/java/com/looker/core/datastore/exporter/SettingsExporter.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,9 +1,9 @@ +-package com.looker.core.datastore.exporter ++package com.leos.core.datastore.exporter + + import android.content.Context + import android.net.Uri +-import com.looker.core.common.Exporter +-import com.looker.core.datastore.Settings ++import com.leos.core.common.Exporter ++import com.leos.core.datastore.Settings + import kotlinx.coroutines.CoroutineDispatcher + import kotlinx.coroutines.CoroutineScope + import kotlinx.coroutines.cancel +Index: core/datastore/src/main/java/com/looker/core/datastore/extension/Preferences.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/datastore/src/main/java/com/looker/core/datastore/extension/Preferences.kt b/core/datastore/src/main/java/com/looker/core/datastore/extension/Preferences.kt +--- a/core/datastore/src/main/java/com/looker/core/datastore/extension/Preferences.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/datastore/src/main/java/com/looker/core/datastore/extension/Preferences.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,16 +1,16 @@ +-package com.looker.core.datastore.extension ++package com.leos.core.datastore.extension + + import android.content.Context + import android.content.res.Configuration +-import com.looker.core.common.R +-import com.looker.core.common.R.string as stringRes +-import com.looker.core.common.R.style as styleRes +-import com.looker.core.common.SdkCheck +-import com.looker.core.datastore.model.AutoSync +-import com.looker.core.datastore.model.InstallerType +-import com.looker.core.datastore.model.ProxyType +-import com.looker.core.datastore.model.SortOrder +-import com.looker.core.datastore.model.Theme ++import com.leos.core.common.R ++import com.leos.core.common.R.string as stringRes ++import com.leos.core.common.R.style as styleRes ++import com.leos.core.common.SdkCheck ++import com.leos.core.datastore.model.AutoSync ++import com.leos.core.datastore.model.InstallerType ++import com.leos.core.datastore.model.ProxyType ++import com.leos.core.datastore.model.SortOrder ++import com.leos.core.datastore.model.Theme + import kotlin.time.Duration + + fun Configuration.getThemeRes(theme: Theme, dynamicTheme: Boolean) = when (theme) { +Index: core/datastore/src/main/java/com/looker/core/datastore/migration/ProtoDataStoreMigration.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/datastore/src/main/java/com/looker/core/datastore/migration/ProtoDataStoreMigration.kt b/core/datastore/src/main/java/com/looker/core/datastore/migration/ProtoDataStoreMigration.kt +--- a/core/datastore/src/main/java/com/looker/core/datastore/migration/ProtoDataStoreMigration.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/datastore/src/main/java/com/looker/core/datastore/migration/ProtoDataStoreMigration.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.datastore.migration ++package com.leos.core.datastore.migration + + import androidx.datastore.core.DataMigration + import androidx.datastore.core.DataStore +@@ -9,13 +9,13 @@ + import androidx.datastore.preferences.core.longPreferencesKey + import androidx.datastore.preferences.core.stringPreferencesKey + import androidx.datastore.preferences.core.stringSetPreferencesKey +-import com.looker.core.datastore.Settings +-import com.looker.core.datastore.model.AutoSync +-import com.looker.core.datastore.model.InstallerType +-import com.looker.core.datastore.model.ProxyPreference +-import com.looker.core.datastore.model.ProxyType +-import com.looker.core.datastore.model.SortOrder +-import com.looker.core.datastore.model.Theme ++import com.leos.core.datastore.Settings ++import com.leos.core.datastore.model.AutoSync ++import com.leos.core.datastore.model.InstallerType ++import com.leos.core.datastore.model.ProxyPreference ++import com.leos.core.datastore.model.ProxyType ++import com.leos.core.datastore.model.SortOrder ++import com.leos.core.datastore.model.Theme + import kotlin.time.Duration.Companion.hours + import kotlinx.coroutines.flow.first + import kotlinx.datetime.Instant +Index: core/datastore/src/main/java/com/looker/core/datastore/model/AutoSync.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/datastore/src/main/java/com/looker/core/datastore/model/AutoSync.kt b/core/datastore/src/main/java/com/looker/core/datastore/model/AutoSync.kt +--- a/core/datastore/src/main/java/com/looker/core/datastore/model/AutoSync.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/datastore/src/main/java/com/looker/core/datastore/model/AutoSync.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.datastore.model ++package com.leos.core.datastore.model + + enum class AutoSync { + ALWAYS, +Index: core/datastore/src/main/java/com/looker/core/datastore/model/InstallerType.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/datastore/src/main/java/com/looker/core/datastore/model/InstallerType.kt b/core/datastore/src/main/java/com/looker/core/datastore/model/InstallerType.kt +--- a/core/datastore/src/main/java/com/looker/core/datastore/model/InstallerType.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/datastore/src/main/java/com/looker/core/datastore/model/InstallerType.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,6 +1,6 @@ +-package com.looker.core.datastore.model ++package com.leos.core.datastore.model + +-import com.looker.core.common.device.Miui ++import com.leos.core.common.device.Miui + + enum class InstallerType { + LEGACY, +Index: core/datastore/src/main/java/com/looker/core/datastore/model/ProxyPreference.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/datastore/src/main/java/com/looker/core/datastore/model/ProxyPreference.kt b/core/datastore/src/main/java/com/looker/core/datastore/model/ProxyPreference.kt +--- a/core/datastore/src/main/java/com/looker/core/datastore/model/ProxyPreference.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/datastore/src/main/java/com/looker/core/datastore/model/ProxyPreference.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.datastore.model ++package com.leos.core.datastore.model + + import kotlinx.serialization.Serializable + +Index: core/datastore/src/main/java/com/looker/core/datastore/model/ProxyType.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/datastore/src/main/java/com/looker/core/datastore/model/ProxyType.kt b/core/datastore/src/main/java/com/looker/core/datastore/model/ProxyType.kt +--- a/core/datastore/src/main/java/com/looker/core/datastore/model/ProxyType.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/datastore/src/main/java/com/looker/core/datastore/model/ProxyType.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.datastore.model ++package com.leos.core.datastore.model + + enum class ProxyType { + DIRECT, +Index: core/datastore/src/main/java/com/looker/core/datastore/model/SortOrder.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/datastore/src/main/java/com/looker/core/datastore/model/SortOrder.kt b/core/datastore/src/main/java/com/looker/core/datastore/model/SortOrder.kt +--- a/core/datastore/src/main/java/com/looker/core/datastore/model/SortOrder.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/datastore/src/main/java/com/looker/core/datastore/model/SortOrder.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.datastore.model ++package com.leos.core.datastore.model + + // todo: Add Support for sorting by size + enum class SortOrder { +Index: core/datastore/src/main/java/com/looker/core/datastore/model/Theme.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/datastore/src/main/java/com/looker/core/datastore/model/Theme.kt b/core/datastore/src/main/java/com/looker/core/datastore/model/Theme.kt +--- a/core/datastore/src/main/java/com/looker/core/datastore/model/Theme.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/datastore/src/main/java/com/looker/core/datastore/model/Theme.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.datastore.model ++package com.leos.core.datastore.model + + enum class Theme { + SYSTEM, +Index: core/di/build.gradle.kts +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/di/build.gradle.kts b/core/di/build.gradle.kts +--- a/core/di/build.gradle.kts (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/di/build.gradle.kts (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -5,7 +5,7 @@ + } + + android { +- namespace = "com.looker.core.di" ++ namespace = "com.leos.core.di" + + buildTypes { + release { +Index: core/di/src/main/kotlin/com/looker/core/di/CoroutinesModule.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/di/src/main/kotlin/com/looker/core/di/CoroutinesModule.kt b/core/di/src/main/kotlin/com/looker/core/di/CoroutinesModule.kt +--- a/core/di/src/main/kotlin/com/looker/core/di/CoroutinesModule.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/di/src/main/kotlin/com/looker/core/di/CoroutinesModule.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.di ++package com.leos.core.di + + import dagger.Module + import dagger.Provides +Index: core/domain/build.gradle.kts +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts +--- a/core/domain/build.gradle.kts (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/domain/build.gradle.kts (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -5,7 +5,7 @@ + } + + android { +- namespace = "com.looker.core.domain" ++ namespace = "com.leos.core.domain" + + buildTypes { + release { +Index: core/domain/src/main/kotlin/com/looker/core/domain/InstalledItem.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/domain/src/main/kotlin/com/looker/core/domain/InstalledItem.kt b/core/domain/src/main/kotlin/com/looker/core/domain/InstalledItem.kt +--- a/core/domain/src/main/kotlin/com/looker/core/domain/InstalledItem.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/domain/src/main/kotlin/com/looker/core/domain/InstalledItem.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.domain ++package com.leos.core.domain + + class InstalledItem( + val packageName: String, +Index: core/domain/src/main/kotlin/com/looker/core/domain/Product.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/domain/src/main/kotlin/com/looker/core/domain/Product.kt b/core/domain/src/main/kotlin/com/looker/core/domain/Product.kt +--- a/core/domain/src/main/kotlin/com/looker/core/domain/Product.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/domain/src/main/kotlin/com/looker/core/domain/Product.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.domain ++package com.leos.core.domain + + data class Product( + var repositoryId: Long, +Index: core/domain/src/main/kotlin/com/looker/core/domain/ProductItem.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/domain/src/main/kotlin/com/looker/core/domain/ProductItem.kt b/core/domain/src/main/kotlin/com/looker/core/domain/ProductItem.kt +--- a/core/domain/src/main/kotlin/com/looker/core/domain/ProductItem.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/domain/src/main/kotlin/com/looker/core/domain/ProductItem.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.domain ++package com.leos.core.domain + + import android.os.Parcelable + import kotlinx.parcelize.Parcelize +Index: core/domain/src/main/kotlin/com/looker/core/domain/ProductPreference.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/domain/src/main/kotlin/com/looker/core/domain/ProductPreference.kt b/core/domain/src/main/kotlin/com/looker/core/domain/ProductPreference.kt +--- a/core/domain/src/main/kotlin/com/looker/core/domain/ProductPreference.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/domain/src/main/kotlin/com/looker/core/domain/ProductPreference.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.domain ++package com.leos.core.domain + + data class ProductPreference(val ignoreUpdates: Boolean, val ignoreVersionCode: Long) { + fun shouldIgnoreUpdate(versionCode: Long): Boolean { +Index: core/domain/src/main/kotlin/com/looker/core/domain/Release.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/domain/src/main/kotlin/com/looker/core/domain/Release.kt b/core/domain/src/main/kotlin/com/looker/core/domain/Release.kt +--- a/core/domain/src/main/kotlin/com/looker/core/domain/Release.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/domain/src/main/kotlin/com/looker/core/domain/Release.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.domain ++package com.leos.core.domain + + import android.net.Uri + +Index: core/domain/src/main/kotlin/com/looker/core/domain/Repository.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/domain/src/main/kotlin/com/looker/core/domain/Repository.kt b/core/domain/src/main/kotlin/com/looker/core/domain/Repository.kt +--- a/core/domain/src/main/kotlin/com/looker/core/domain/Repository.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/domain/src/main/kotlin/com/looker/core/domain/Repository.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,6 +1,6 @@ +-package com.looker.core.domain ++package com.leos.core.domain + +-import com.looker.core.domain.newer.isOnion ++import com.leos.core.domain.newer.isOnion + import java.net.URL + + data class Repository( +Index: core/domain/src/main/kotlin/com/looker/core/domain/Syncable.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/domain/src/main/kotlin/com/looker/core/domain/Syncable.kt b/core/domain/src/main/kotlin/com/looker/core/domain/Syncable.kt +--- a/core/domain/src/main/kotlin/com/looker/core/domain/Syncable.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/domain/src/main/kotlin/com/looker/core/domain/Syncable.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,7 +1,7 @@ +-package com.looker.core.domain ++package com.leos.core.domain + +-import com.looker.core.domain.newer.App +-import com.looker.core.domain.newer.Repo ++import com.leos.core.domain.newer.App ++import com.leos.core.domain.newer.Repo + + interface Syncable { + +Index: core/domain/src/main/kotlin/com/looker/core/domain/newer/App.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/domain/src/main/kotlin/com/looker/core/domain/newer/App.kt b/core/domain/src/main/kotlin/com/looker/core/domain/newer/App.kt +--- a/core/domain/src/main/kotlin/com/looker/core/domain/newer/App.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/domain/src/main/kotlin/com/looker/core/domain/newer/App.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,6 +1,6 @@ +-package com.looker.core.domain.newer ++package com.leos.core.domain.newer + +-import com.looker.core.common.PackageName ++import com.leos.core.common.PackageName + + data class App( + val repoId: Long, +Index: core/domain/src/main/kotlin/com/looker/core/domain/newer/DataFile.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/domain/src/main/kotlin/com/looker/core/domain/newer/DataFile.kt b/core/domain/src/main/kotlin/com/looker/core/domain/newer/DataFile.kt +--- a/core/domain/src/main/kotlin/com/looker/core/domain/newer/DataFile.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/domain/src/main/kotlin/com/looker/core/domain/newer/DataFile.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.domain.newer ++package com.leos.core.domain.newer + + interface DataFile { + val name: String +Index: core/domain/src/main/kotlin/com/looker/core/domain/newer/Package.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/domain/src/main/kotlin/com/looker/core/domain/newer/Package.kt b/core/domain/src/main/kotlin/com/looker/core/domain/newer/Package.kt +--- a/core/domain/src/main/kotlin/com/looker/core/domain/newer/Package.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/domain/src/main/kotlin/com/looker/core/domain/newer/Package.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.domain.newer ++package com.leos.core.domain.newer + + data class Package( + val installed: Boolean, +Index: core/domain/src/main/kotlin/com/looker/core/domain/newer/Repo.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/domain/src/main/kotlin/com/looker/core/domain/newer/Repo.kt b/core/domain/src/main/kotlin/com/looker/core/domain/newer/Repo.kt +--- a/core/domain/src/main/kotlin/com/looker/core/domain/newer/Repo.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/domain/src/main/kotlin/com/looker/core/domain/newer/Repo.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.core.domain.newer ++package com.leos.core.domain.newer + + data class Repo( + val id: Long, +Index: core/network/build.gradle.kts +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts +--- a/core/network/build.gradle.kts (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/network/build.gradle.kts (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -5,7 +5,7 @@ + } + + android { +- namespace = "com.looker.network" ++ namespace = "com.leos.network" + + buildTypes { + release { +Index: core/network/src/main/java/com/looker/network/Downloader.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/network/src/main/java/com/looker/network/Downloader.kt b/core/network/src/main/java/com/looker/network/Downloader.kt +--- a/core/network/src/main/java/com/looker/network/Downloader.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/network/src/main/java/com/looker/network/Downloader.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,8 +1,8 @@ +-package com.looker.network ++package com.leos.network + +-import com.looker.core.common.DataSize +-import com.looker.core.common.signature.FileValidator +-import com.looker.network.header.HeadersBuilder ++import com.leos.core.common.DataSize ++import com.leos.core.common.signature.FileValidator ++import com.leos.network.header.HeadersBuilder + import java.io.File + import java.net.Proxy + +Index: core/network/src/main/java/com/looker/network/KtorDownloader.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/network/src/main/java/com/looker/network/KtorDownloader.kt b/core/network/src/main/java/com/looker/network/KtorDownloader.kt +--- a/core/network/src/main/java/com/looker/network/KtorDownloader.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/network/src/main/java/com/looker/network/KtorDownloader.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,14 +1,14 @@ +-package com.looker.network ++package com.leos.network + +-import com.looker.core.common.DataSize +-import com.looker.core.common.extension.exceptCancellation +-import com.looker.core.common.extension.size +-import com.looker.core.common.signature.FileValidator +-import com.looker.core.common.signature.ValidationException +-import com.looker.network.Downloader.Companion.CONNECTION_TIMEOUT +-import com.looker.network.Downloader.Companion.SOCKET_TIMEOUT +-import com.looker.network.header.HeadersBuilder +-import com.looker.network.header.KtorHeadersBuilder ++import com.leos.core.common.DataSize ++import com.leos.core.common.extension.exceptCancellation ++import com.leos.core.common.extension.size ++import com.leos.core.common.signature.FileValidator ++import com.leos.core.common.signature.ValidationException ++import com.leos.network.Downloader.Companion.CONNECTION_TIMEOUT ++import com.leos.network.Downloader.Companion.SOCKET_TIMEOUT ++import com.leos.network.header.HeadersBuilder ++import com.leos.network.header.KtorHeadersBuilder + import io.ktor.client.HttpClient + import io.ktor.client.HttpClientConfig + import io.ktor.client.engine.okhttp.OkHttp +Index: core/network/src/main/java/com/looker/network/NetworkResponse.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/network/src/main/java/com/looker/network/NetworkResponse.kt b/core/network/src/main/java/com/looker/network/NetworkResponse.kt +--- a/core/network/src/main/java/com/looker/network/NetworkResponse.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/network/src/main/java/com/looker/network/NetworkResponse.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,6 +1,6 @@ +-package com.looker.network ++package com.leos.network + +-import com.looker.core.common.signature.ValidationException ++import com.leos.core.common.signature.ValidationException + import java.util.Date + + sealed interface NetworkResponse { +Index: core/network/src/main/java/com/looker/network/di/NetworkModule.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/network/src/main/java/com/looker/network/di/NetworkModule.kt b/core/network/src/main/java/com/looker/network/di/NetworkModule.kt +--- a/core/network/src/main/java/com/looker/network/di/NetworkModule.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/network/src/main/java/com/looker/network/di/NetworkModule.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,7 +1,7 @@ +-package com.looker.network.di ++package com.leos.network.di + +-import com.looker.network.Downloader +-import com.looker.network.KtorDownloader ++import com.leos.network.Downloader ++import com.leos.network.KtorDownloader + import dagger.Module + import dagger.Provides + import dagger.hilt.InstallIn +Index: core/network/src/main/java/com/looker/network/header/HeadersBuilder.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/network/src/main/java/com/looker/network/header/HeadersBuilder.kt b/core/network/src/main/java/com/looker/network/header/HeadersBuilder.kt +--- a/core/network/src/main/java/com/looker/network/header/HeadersBuilder.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/network/src/main/java/com/looker/network/header/HeadersBuilder.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.network.header ++package com.leos.network.header + + import java.util.Date + +Index: core/network/src/main/java/com/looker/network/header/KtorHeadersBuilder.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/core/network/src/main/java/com/looker/network/header/KtorHeadersBuilder.kt b/core/network/src/main/java/com/looker/network/header/KtorHeadersBuilder.kt +--- a/core/network/src/main/java/com/looker/network/header/KtorHeadersBuilder.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/core/network/src/main/java/com/looker/network/header/KtorHeadersBuilder.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,6 +1,6 @@ +-package com.looker.network.header ++package com.leos.network.header + +-import com.looker.core.common.extension.toFormattedString ++import com.leos.core.common.extension.toFormattedString + import io.ktor.http.HttpHeaders + import io.ktor.util.encodeBase64 + import java.util.Date +Index: installer/build.gradle.kts +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/installer/build.gradle.kts b/installer/build.gradle.kts +--- a/installer/build.gradle.kts (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/installer/build.gradle.kts (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -5,7 +5,7 @@ + } + + android { +- namespace = "com.looker.installer" ++ namespace = "com.leos.installer" + + buildTypes { + release { +Index: installer/src/main/java/com/looker/installer/InstallManager.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/installer/src/main/java/com/looker/installer/InstallManager.kt b/installer/src/main/java/com/looker/installer/InstallManager.kt +--- a/installer/src/main/java/com/looker/installer/InstallManager.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/installer/src/main/java/com/looker/installer/InstallManager.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,22 +1,22 @@ +-package com.looker.installer ++package com.leos.installer + + import android.content.Context +-import com.looker.core.common.Constants +-import com.looker.core.common.PackageName +-import com.looker.core.common.extension.addAndCompute +-import com.looker.core.common.extension.filter +-import com.looker.core.common.extension.notificationManager +-import com.looker.core.common.extension.updateAsMutable +-import com.looker.core.datastore.SettingsRepository +-import com.looker.core.datastore.get +-import com.looker.core.datastore.model.InstallerType +-import com.looker.installer.installers.Installer +-import com.looker.installer.installers.LegacyInstaller +-import com.looker.installer.installers.root.RootInstaller +-import com.looker.installer.installers.session.SessionInstaller +-import com.looker.installer.installers.shizuku.ShizukuInstaller +-import com.looker.installer.model.InstallItem +-import com.looker.installer.model.InstallState ++import com.leos.core.common.Constants ++import com.leos.core.common.PackageName ++import com.leos.core.common.extension.addAndCompute ++import com.leos.core.common.extension.filter ++import com.leos.core.common.extension.notificationManager ++import com.leos.core.common.extension.updateAsMutable ++import com.leos.core.datastore.SettingsRepository ++import com.leos.core.datastore.get ++import com.leos.core.datastore.model.InstallerType ++import com.leos.installer.installers.Installer ++import com.leos.installer.installers.LegacyInstaller ++import com.leos.installer.installers.root.RootInstaller ++import com.leos.installer.installers.session.SessionInstaller ++import com.leos.installer.installers.shizuku.ShizukuInstaller ++import com.leos.installer.model.InstallItem ++import com.leos.installer.model.InstallState + import kotlinx.coroutines.CoroutineScope + import kotlinx.coroutines.channels.Channel + import kotlinx.coroutines.channels.consumeEach +Index: installer/src/main/java/com/looker/installer/InstallModule.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/installer/src/main/java/com/looker/installer/InstallModule.kt b/installer/src/main/java/com/looker/installer/InstallModule.kt +--- a/installer/src/main/java/com/looker/installer/InstallModule.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/installer/src/main/java/com/looker/installer/InstallModule.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,9 +1,9 @@ +-package com.looker.installer ++package com.leos.installer + + import android.content.Context +-import com.looker.core.datastore.SettingsRepository +-import com.looker.installer.installers.root.RootPermissionHandler +-import com.looker.installer.installers.shizuku.ShizukuPermissionHandler ++import com.leos.core.datastore.SettingsRepository ++import com.leos.installer.installers.root.RootPermissionHandler ++import com.leos.installer.installers.shizuku.ShizukuPermissionHandler + import dagger.Module + import dagger.Provides + import dagger.hilt.InstallIn +Index: installer/src/main/java/com/looker/installer/installers/Installer.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/installer/src/main/java/com/looker/installer/installers/Installer.kt b/installer/src/main/java/com/looker/installer/installers/Installer.kt +--- a/installer/src/main/java/com/looker/installer/installers/Installer.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/installer/src/main/java/com/looker/installer/installers/Installer.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,8 +1,8 @@ +-package com.looker.installer.installers ++package com.leos.installer.installers + +-import com.looker.core.common.PackageName +-import com.looker.installer.model.InstallItem +-import com.looker.installer.model.InstallState ++import com.leos.core.common.PackageName ++import com.leos.installer.model.InstallItem ++import com.leos.installer.model.InstallState + + interface Installer { + +Index: installer/src/main/java/com/looker/installer/installers/LegacyInstaller.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/installer/src/main/java/com/looker/installer/installers/LegacyInstaller.kt b/installer/src/main/java/com/looker/installer/installers/LegacyInstaller.kt +--- a/installer/src/main/java/com/looker/installer/installers/LegacyInstaller.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/installer/src/main/java/com/looker/installer/installers/LegacyInstaller.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,14 +1,14 @@ +-package com.looker.installer.installers ++package com.leos.installer.installers + + import android.content.Context + import android.content.Intent + import android.util.AndroidRuntimeException + import androidx.core.net.toUri +-import com.looker.core.common.PackageName +-import com.looker.core.common.SdkCheck +-import com.looker.core.common.cache.Cache +-import com.looker.installer.model.InstallItem +-import com.looker.installer.model.InstallState ++import com.leos.core.common.PackageName ++import com.leos.core.common.SdkCheck ++import com.leos.core.common.cache.Cache ++import com.leos.installer.model.InstallItem ++import com.leos.installer.model.InstallState + import kotlin.coroutines.resume + import kotlinx.coroutines.suspendCancellableCoroutine + +Index: installer/src/main/java/com/looker/installer/installers/root/RootInstaller.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/installer/src/main/java/com/looker/installer/installers/root/RootInstaller.kt b/installer/src/main/java/com/looker/installer/installers/root/RootInstaller.kt +--- a/installer/src/main/java/com/looker/installer/installers/root/RootInstaller.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/installer/src/main/java/com/looker/installer/installers/root/RootInstaller.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,13 +1,13 @@ +-package com.looker.installer.installers.root ++package com.leos.installer.installers.root + + import android.content.Context +-import com.looker.core.common.PackageName +-import com.looker.core.common.SdkCheck +-import com.looker.core.common.cache.Cache +-import com.looker.installer.installers.Installer +-import com.looker.installer.installers.uninstallPackage +-import com.looker.installer.model.InstallItem +-import com.looker.installer.model.InstallState ++import com.leos.core.common.PackageName ++import com.leos.core.common.SdkCheck ++import com.leos.core.common.cache.Cache ++import com.leos.installer.installers.Installer ++import com.leos.installer.installers.uninstallPackage ++import com.leos.installer.model.InstallItem ++import com.leos.installer.model.InstallState + import com.topjohnwu.superuser.Shell + import kotlinx.coroutines.suspendCancellableCoroutine + import java.io.File +Index: installer/src/main/java/com/looker/installer/installers/root/RootPermissionHandler.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/installer/src/main/java/com/looker/installer/installers/root/RootPermissionHandler.kt b/installer/src/main/java/com/looker/installer/installers/root/RootPermissionHandler.kt +--- a/installer/src/main/java/com/looker/installer/installers/root/RootPermissionHandler.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/installer/src/main/java/com/looker/installer/installers/root/RootPermissionHandler.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.installer.installers.root ++package com.leos.installer.installers.root + + import com.topjohnwu.superuser.Shell + +Index: installer/src/main/java/com/looker/installer/installers/session/SessionInstaller.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/installer/src/main/java/com/looker/installer/installers/session/SessionInstaller.kt b/installer/src/main/java/com/looker/installer/installers/session/SessionInstaller.kt +--- a/installer/src/main/java/com/looker/installer/installers/session/SessionInstaller.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/installer/src/main/java/com/looker/installer/installers/session/SessionInstaller.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.installer.installers.session ++package com.leos.installer.installers.session + + import android.annotation.SuppressLint + import android.app.PendingIntent +@@ -9,14 +9,14 @@ + import android.os.Handler + import android.os.Looper + import android.util.Log +-import com.looker.core.common.PackageName +-import com.looker.core.common.SdkCheck +-import com.looker.core.common.cache.Cache +-import com.looker.core.common.log +-import com.looker.core.common.sdkAbove +-import com.looker.installer.installers.Installer +-import com.looker.installer.model.InstallItem +-import com.looker.installer.model.InstallState ++import com.leos.core.common.PackageName ++import com.leos.core.common.SdkCheck ++import com.leos.core.common.cache.Cache ++import com.leos.core.common.log ++import com.leos.core.common.sdkAbove ++import com.leos.installer.installers.Installer ++import com.leos.installer.model.InstallItem ++import com.leos.installer.model.InstallState + import kotlinx.coroutines.suspendCancellableCoroutine + import kotlin.coroutines.resume + +Index: installer/src/main/java/com/looker/installer/installers/session/SessionInstallerService.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/installer/src/main/java/com/looker/installer/installers/session/SessionInstallerService.kt b/installer/src/main/java/com/looker/installer/installers/session/SessionInstallerService.kt +--- a/installer/src/main/java/com/looker/installer/installers/session/SessionInstallerService.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/installer/src/main/java/com/looker/installer/installers/session/SessionInstallerService.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.installer.installers.session ++package com.leos.installer.installers.session + + import android.app.Service + import android.content.Intent +@@ -7,10 +7,10 @@ + import android.os.IBinder + import android.view.ContextThemeWrapper + import androidx.core.app.NotificationCompat +-import com.looker.core.common.Constants.NOTIFICATION_CHANNEL_DOWNLOADING +-import com.looker.core.common.Constants.NOTIFICATION_ID_DOWNLOADING +-import com.looker.core.common.R as CommonR +-import com.looker.core.common.extension.notificationManager ++import com.leos.core.common.Constants.NOTIFICATION_CHANNEL_DOWNLOADING ++import com.leos.core.common.Constants.NOTIFICATION_ID_DOWNLOADING ++import com.leos.core.common.R as CommonR ++import com.leos.core.common.extension.notificationManager + + class SessionInstallerService : Service() { + companion object { +Index: installer/src/main/java/com/looker/installer/installers/shizuku/ShizukuInstaller.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/installer/src/main/java/com/looker/installer/installers/shizuku/ShizukuInstaller.kt b/installer/src/main/java/com/looker/installer/installers/shizuku/ShizukuInstaller.kt +--- a/installer/src/main/java/com/looker/installer/installers/shizuku/ShizukuInstaller.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/installer/src/main/java/com/looker/installer/installers/shizuku/ShizukuInstaller.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,13 +1,13 @@ +-package com.looker.installer.installers.shizuku ++package com.leos.installer.installers.shizuku + + import android.content.Context +-import com.looker.core.common.PackageName +-import com.looker.core.common.SdkCheck +-import com.looker.core.common.cache.Cache +-import com.looker.installer.installers.Installer +-import com.looker.installer.installers.uninstallPackage +-import com.looker.installer.model.InstallItem +-import com.looker.installer.model.InstallState ++import com.leos.core.common.PackageName ++import com.leos.core.common.SdkCheck ++import com.leos.core.common.cache.Cache ++import com.leos.installer.installers.Installer ++import com.leos.installer.installers.uninstallPackage ++import com.leos.installer.model.InstallItem ++import com.leos.installer.model.InstallState + import java.io.BufferedReader + import java.io.InputStream + import kotlin.coroutines.resume +Index: installer/src/main/java/com/looker/installer/installers/shizuku/ShizukuPermissionHandler.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/installer/src/main/java/com/looker/installer/installers/shizuku/ShizukuPermissionHandler.kt b/installer/src/main/java/com/looker/installer/installers/shizuku/ShizukuPermissionHandler.kt +--- a/installer/src/main/java/com/looker/installer/installers/shizuku/ShizukuPermissionHandler.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/installer/src/main/java/com/looker/installer/installers/shizuku/ShizukuPermissionHandler.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,8 +1,8 @@ +-package com.looker.installer.installers.shizuku ++package com.leos.installer.installers.shizuku + + import android.content.Context + import android.content.pm.PackageManager +-import com.looker.core.common.extension.getPackageInfoCompat ++import com.leos.core.common.extension.getPackageInfoCompat + import kotlinx.coroutines.Dispatchers + import kotlinx.coroutines.channels.awaitClose + import kotlinx.coroutines.flow.Flow +Index: installer/src/main/java/com/looker/installer/model/InstallItem.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/installer/src/main/java/com/looker/installer/model/InstallItem.kt b/installer/src/main/java/com/looker/installer/model/InstallItem.kt +--- a/installer/src/main/java/com/looker/installer/model/InstallItem.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/installer/src/main/java/com/looker/installer/model/InstallItem.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,7 +1,7 @@ +-package com.looker.installer.model ++package com.leos.installer.model + +-import com.looker.core.common.PackageName +-import com.looker.core.common.toPackageName ++import com.leos.core.common.PackageName ++import com.leos.core.common.toPackageName + + data class InstallItem( + val packageName: PackageName, +Index: installer/src/main/java/com/looker/installer/model/InstallState.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/installer/src/main/java/com/looker/installer/model/InstallState.kt b/installer/src/main/java/com/looker/installer/model/InstallState.kt +--- a/installer/src/main/java/com/looker/installer/model/InstallState.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/installer/src/main/java/com/looker/installer/model/InstallState.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.installer.model ++package com.leos.installer.model + + enum class InstallState { Failed, Pending, Installing, Installed } + +Index: sync/fdroid/build.gradle.kts +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/sync/fdroid/build.gradle.kts b/sync/fdroid/build.gradle.kts +--- a/sync/fdroid/build.gradle.kts (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/sync/fdroid/build.gradle.kts (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -5,7 +5,7 @@ + } + + android { +- namespace = "com.looker.sync.fdroid" ++ namespace = "com.leos.sync.fdroid" + + buildTypes { + release { +Index: sync/fdroid/src/androidTest/kotlin/com/looker/sync/fdroid/ExampleInstrumentedTest.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/sync/fdroid/src/androidTest/kotlin/com/looker/sync/fdroid/ExampleInstrumentedTest.kt b/sync/fdroid/src/androidTest/kotlin/com/looker/sync/fdroid/ExampleInstrumentedTest.kt +--- a/sync/fdroid/src/androidTest/kotlin/com/looker/sync/fdroid/ExampleInstrumentedTest.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/sync/fdroid/src/androidTest/kotlin/com/looker/sync/fdroid/ExampleInstrumentedTest.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.sync.fdroid ++package com.leos.sync.fdroid + + import androidx.test.platform.app.InstrumentationRegistry + import androidx.test.ext.junit.runners.AndroidJUnit4 +@@ -19,6 +19,6 @@ + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext +- assertEquals("com.looker.sync.fdroid.test", appContext.packageName) ++ assertEquals("com.leos.sync.fdroid.test", appContext.packageName) + } + } +Index: sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/FdroidSyncable.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/FdroidSyncable.kt b/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/FdroidSyncable.kt +--- a/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/FdroidSyncable.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/FdroidSyncable.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,8 +1,8 @@ +-package com.looker.sync.fdroid ++package com.leos.sync.fdroid + +-import com.looker.core.domain.Syncable +-import com.looker.core.domain.newer.App +-import com.looker.core.domain.newer.Repo ++import com.leos.core.domain.Syncable ++import com.leos.core.domain.newer.App ++import com.leos.core.domain.newer.Repo + + class FdroidSyncable(override val repo: Repo) : Syncable { + +Index: sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/IndexValidator.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/IndexValidator.kt b/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/IndexValidator.kt +--- a/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/IndexValidator.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/IndexValidator.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,12 +1,12 @@ +-package com.looker.sync.fdroid ++package com.leos.sync.fdroid + +-import com.looker.core.common.extension.certificate +-import com.looker.core.common.extension.codeSigner +-import com.looker.core.common.extension.fingerprint +-import com.looker.core.common.extension.toJarFile +-import com.looker.core.common.signature.FileValidator +-import com.looker.core.common.signature.ValidationException +-import com.looker.core.domain.newer.Repo ++import com.leos.core.common.extension.certificate ++import com.leos.core.common.extension.codeSigner ++import com.leos.core.common.extension.fingerprint ++import com.leos.core.common.extension.toJarFile ++import com.leos.core.common.signature.FileValidator ++import com.leos.core.common.signature.ValidationException ++import com.leos.core.domain.newer.Repo + import kotlinx.coroutines.Dispatchers + import kotlinx.coroutines.withContext + import org.fdroid.index.IndexParser +Index: sync/fdroid/src/test/kotlin/com/looker/sync/fdroid/ExampleUnitTest.kt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/sync/fdroid/src/test/kotlin/com/looker/sync/fdroid/ExampleUnitTest.kt b/sync/fdroid/src/test/kotlin/com/looker/sync/fdroid/ExampleUnitTest.kt +--- a/sync/fdroid/src/test/kotlin/com/looker/sync/fdroid/ExampleUnitTest.kt (revision 0458da45767012c818054339c035c122795f8f19) ++++ b/sync/fdroid/src/test/kotlin/com/looker/sync/fdroid/ExampleUnitTest.kt (revision 7930ca9b65b0a8066e31a8214c9ce883d08022ec) +@@ -1,4 +1,4 @@ +-package com.looker.sync.fdroid ++package com.leos.sync.fdroid + + import org.junit.Test + +diff --git a/xx.git/HEAD b/xx.git/HEAD +deleted file mode 100644 +index b870d82622c1a9ca6bcaf5df639680424a1904b0..0000000000000000000000000000000000000000 +GIT binary patch +literal 0 +Hc$@UTF-8 +=================================================================== +diff --git a/build-logic/structure/src/main/kotlin/DefaultConfig.kt b/build-logic/structure/src/main/kotlin/DefaultConfig.kt +--- a/build-logic/structure/src/main/kotlin/DefaultConfig.kt (revision 9efd9725d03549d0f467d59c5a45df7996ab6af5) ++++ b/build-logic/structure/src/main/kotlin/DefaultConfig.kt (revision 82f8ffcc4719b25ce01b7d7fc7943ffc01dc618e) +@@ -4,6 +4,6 @@ + const val appId = "com.leos.droidify" + const val compileSdk = 34 + const val minSdk = 23 +- const val versionCode = 595 +- const val versionName = "0.5.9 Patch 5" ++ const val versionCode = 666 ++ const val versionName = "v01.6.4" + } diff --git a/LICENSE b/LICENSE index 06d3450..f288702 100644 --- a/LICENSE +++ b/LICENSE @@ -1,232 +1,674 @@ -GNU GENERAL PUBLIC LICENSE -Version 3, 29 June 2007 - -Copyright © 2007 Free Software Foundation, Inc. - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -Preamble - -The GNU General Public License is a free, copyleft license for software and other kinds of works. - -The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. - -When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. - -To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. - -For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. - -Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. - -For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. - -Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. - -Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. - -The precise terms and conditions for copying, distribution and modification follow. - -TERMS AND CONDITIONS - -0. Definitions. - -“This License” refers to version 3 of the GNU General Public License. - -“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. - -“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations. - -To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work. - -A “covered work” means either the unmodified Program or a work based on the Program. - -To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. - -To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. - -An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. - -1. Source Code. -The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work. - -A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. - -The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. - -The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. - -The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. - -The Corresponding Source for a work in source code form is that same work. - -2. Basic Permissions. -All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. - -You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. - -Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. - -3. Protecting Users' Legal Rights From Anti-Circumvention Law. -No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. - -When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. - -4. Conveying Verbatim Copies. -You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. - -You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. - -5. Conveying Modified Source Versions. -You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”. - - c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. - -A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. - -6. Conveying Non-Source Forms. -You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: - - a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. - - d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. - -A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. - -A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. - -“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. - -If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). - -The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. - -Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. - -7. Additional Terms. -“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. - -When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. - -Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or authors of the material; or - - e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. - -All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. - -If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. - -Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. - -8. Termination. -You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). - -However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. - -Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. - -Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. - -9. Acceptance Not Required for Having Copies. -You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. - -10. Automatic Licensing of Downstream Recipients. -Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. - -An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. - -You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. - -11. Patents. -A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”. - -A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. - -Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. - -In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. - -If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. - -If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. - -A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. - -Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. - -12. No Surrender of Others' Freedom. -If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. - -13. Use with the GNU Affero General Public License. -Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. - -14. Revised Versions of this License. -The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. - -If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. - -Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. - -15. Disclaimer of Warranty. -THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. Limitation of Liability. -IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -17. Interpretation of Sections 15 and 16. -If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Programs - -If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. - -To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found. - - LeOS-Droid - Copyright (C) 2023 JoJo - - This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. - - This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with this program. If not, see . + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. -If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: - LeOS-Droid Copyright (C) 2023 JoJo - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. -The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”. +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". -You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. -The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md index 39da1bb..32c4a8b 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,23 @@ +
+ +Droid-ify + # LeOS-Droid -# LeOS-Droid - -### A quick material F-Droid client. - -
- -## :book: Features - -* Material F-Droid style -* No cards or inappropriate animations -* Fast repository syncing -* Standard Android components and minimal dependencies -* share option \ No newline at end of file +### A quick material F-Droid client. + +
+ +## :book: Features + +* Material F-Droid style +* No cards or inappropriate animations +* Fast repository syncing +* Standard Android components and minimal dependencies +* share option + + +## :scroll: License + +Licensed GPLv3+. \ + diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 0000000..fe93a5f --- /dev/null +++ b/STATUS.md @@ -0,0 +1,15 @@ +_17-Aug-2023_ +# Project Status + +I was holding back releases, because I was re-writing the whole structure of the app. But I think this is taking too long. +It is only natural that users will grow impatient for an update, thats why I will be releasing new versions soon. + +## Why the delay: +- I had exams and submissions in my college +- I am really unwell mentally +- I was not able to create a solid structure for the new backend + +## What now? +- Next release will be on **19-Aug**, and will contain many bug fixes and stability improvements. +- All the [progress](https://github.com/Droid-ify/client/pull/309) made in past months is still here and will help in future. +- We will be missing on the new index format introduced by fdroid for some future releases. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..dbdaf67 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,152 @@ +plugins { + alias(libs.plugins.looker.android.application) + alias(libs.plugins.looker.hilt.work) + alias(libs.plugins.looker.lint) + id("kotlin-parcelize") +} + +android { + namespace = "com.leos.droidify" + defaultConfig { + vectorDrawables.useSupportLibrary = true + + resourceConfigurations += mutableListOf( + /* locale list begin */ + "ar", + "az", + "be", + "bg", + "bn", + "ca", + "cs", + "de", + "el", + "eo", + "es", + "fa", + "fi", + "fr", + "gl", + "hi", + "hr", + "hu", + "ia", + "in", + "it", + "iw", + "ja", + "kn", + "ko", + "lt", + "lv", + "ml", + "nb-rNO", + "nl", + "nn", + "or", + "pa", + "pl", + "pt", + "pt-rBR", + "ro", + "ru", + "ryu", + "si", + "sl", + "sr", + "sv", + "tl", + "tr", + "uk", + "ur", + "vi", + "zh-rCN", + "zh-rTW" + /* locale list end */ + ) + } + + sourceSets.forEach { source -> + val javaDir = source.java.srcDirs.find { it.name == "java" } + source.java { + srcDir(File(javaDir?.parentFile, "kotlin")) + } + } + + buildTypes { + getByName("debug") { + applicationIdSuffix = ".debug" + resValue("string", "application_name", "LeOS-Droid-Debug") + } + getByName("release") { + isMinifyEnabled = true + isShrinkResources = true + resValue("string", "application_name", "LeOS-Droid") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard.pro" + ) + } + create("alpha") { + initWith(getByName("debug")) + applicationIdSuffix = ".alpha" + resValue("string", "application_name", "LeOS-Droid Alpha") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard.pro" + ) + isDebuggable = true + isMinifyEnabled = true + } + all { + buildConfigField( + type = "String", + name = "VERSION_NAME", + value = "\"v1.6.4\"" + ) + } + } + packaging { + resources { + excludes += listOf( + "/DebugProbesKt.bin", + "/kotlin/**.kotlin_builtins", + "/kotlin/**.kotlin_metadata", + "/META-INF/**.kotlin_module", + "/META-INF/**.pro", + "/META-INF/**.version", + "/META-INF/versions/9/previous-**.bin" + ) + } + } + buildFeatures { + viewBinding = true + buildConfig = true + } +} + +dependencies { + + modules( + Modules.coreDomain, + Modules.coreCommon, + Modules.coreNetwork, + Modules.coreDatastore, + Modules.coreDI, + Modules.installer + ) + + implementation(libs.android.material) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.fragment.ktx) + implementation(libs.androidx.lifecycle.viewModel.ktx) + implementation(libs.androidx.recyclerview) + implementation(libs.androidx.sqlite.ktx) + implementation(libs.coil.kt) + implementation(libs.kotlinx.datetime) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.jackson.core) + implementation(libs.image.viewer) +} diff --git a/app/proguard.pro b/app/proguard.pro new file mode 100644 index 0000000..950af2e --- /dev/null +++ b/app/proguard.pro @@ -0,0 +1,9 @@ +-dontobfuscate + +# Disable ServiceLoader reproducibility-breaking optimizations +-keep class kotlinx.coroutines.CoroutineExceptionHandler +-keep class kotlinx.coroutines.internal.MainDispatcherFactory + +-dontwarn kotlinx.serialization.KSerializer +-dontwarn kotlinx.serialization.Serializable +-dontwarn org.slf4j.impl.StaticLoggerBinder \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d8e510d --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,184 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..db2ed4c Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/kotlin/com/leos/droidify/MainActivity.kt b/app/src/main/kotlin/com/leos/droidify/MainActivity.kt new file mode 100644 index 0000000..8320f76 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/MainActivity.kt @@ -0,0 +1,29 @@ +package com.leos.droidify + +import android.content.Intent +import com.leos.core.common.getInstallPackageName +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ScreenActivity() { + companion object { + const val ACTION_UPDATES = "${BuildConfig.APPLICATION_ID}.intent.action.UPDATES" + const val ACTION_INSTALL = "${BuildConfig.APPLICATION_ID}.intent.action.INSTALL" + const val EXTRA_CACHE_FILE_NAME = + "${BuildConfig.APPLICATION_ID}.intent.extra.CACHE_FILE_NAME" + } + + override fun handleIntent(intent: Intent?) { + when (intent?.action) { + ACTION_UPDATES -> handleSpecialIntent(SpecialIntent.Updates) + ACTION_INSTALL -> handleSpecialIntent( + SpecialIntent.Install( + intent.getInstallPackageName, + intent.getStringExtra(EXTRA_CACHE_FILE_NAME) + ) + ) + + else -> super.handleIntent(intent) + } + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/MainApplication.kt b/app/src/main/kotlin/com/leos/droidify/MainApplication.kt new file mode 100644 index 0000000..1839001 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/MainApplication.kt @@ -0,0 +1,276 @@ +package com.leos.droidify + +import android.annotation.SuppressLint +import android.app.Application +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.appcompat.app.AppCompatDelegate +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration +import androidx.work.NetworkType +import coil.ImageLoader +import coil.ImageLoaderFactory +import coil.disk.DiskCache +import coil.memory.MemoryCache +import com.leos.core.common.Constants +import com.leos.core.common.cache.Cache +import com.leos.core.common.extension.getInstalledPackagesCompat +import com.leos.core.common.extension.jobScheduler +import com.leos.core.common.log +import com.leos.core.datastore.SettingsRepository +import com.leos.core.datastore.get +import com.leos.core.datastore.model.AutoSync +import com.leos.core.datastore.model.InstallerType +import com.leos.core.datastore.model.ProxyPreference +import com.leos.core.datastore.model.ProxyType +import com.leos.droidify.content.ProductPreferences +import com.leos.droidify.database.Database +import com.leos.droidify.index.RepositoryUpdater +import com.leos.droidify.receivers.InstalledAppReceiver +import com.leos.droidify.service.Connection +import com.leos.droidify.service.SyncService +import com.leos.droidify.sync.SyncPreference +import com.leos.droidify.sync.toJobNetworkType +import com.leos.droidify.utility.extension.toInstalledItem +import com.leos.droidify.work.CleanUpWorker +import com.leos.installer.InstallManager +import com.leos.installer.installers.root.RootPermissionHandler +import com.leos.installer.installers.shizuku.ShizukuPermissionHandler +import com.leos.network.Downloader +import dagger.hilt.android.HiltAndroidApp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collectIndexed +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch +import java.net.InetSocketAddress +import java.net.Proxy +import javax.inject.Inject +import kotlin.time.Duration.Companion.INFINITE +import kotlin.time.Duration.Companion.hours +import com.leos.core.common.R as CommonR + +@HiltAndroidApp +class MainApplication : Application(), ImageLoaderFactory, Configuration.Provider { + + private val parentJob = SupervisorJob() + private val appScope = CoroutineScope(Dispatchers.Default + parentJob) + + @Inject + lateinit var settingsRepository: SettingsRepository + + @Inject + lateinit var installer: InstallManager + + @Inject + lateinit var downloader: Downloader + + @Inject + lateinit var shizukuPermissionHandler: ShizukuPermissionHandler + + @Inject + lateinit var rootPermissionHandler: RootPermissionHandler + + @Inject + lateinit var workerFactory: HiltWorkerFactory + + override fun onCreate() { + super.onCreate() + + val databaseUpdated = Database.init(this) + ProductPreferences.init(this, appScope) + RepositoryUpdater.init(appScope, downloader) + listenApplications() + checkLanguage() + updatePreference() + setupInstaller() + + if (databaseUpdated) forceSyncAll() + } + + override fun onTerminate() { + super.onTerminate() + appScope.cancel("Application Terminated") + installer.close() + } + + private fun setupInstaller() { + appScope.launch { + launch { + settingsRepository.get { installerType }.collect { + if (it == InstallerType.SHIZUKU) handleShizukuInstaller() + if (it == InstallerType.ROOT) { + if (!rootPermissionHandler.isGranted) { + settingsRepository.setInstallerType(InstallerType.Default) + } + } + } + } + installer() + } + } + + private fun CoroutineScope.handleShizukuInstaller() = launch { + shizukuPermissionHandler.state.collect { (isGranted, isAlive, _) -> + if (isAlive && isGranted) { + settingsRepository.setInstallerType(InstallerType.SHIZUKU) + return@collect + } + if (isAlive) { + settingsRepository.setInstallerType(InstallerType.Default) + shizukuPermissionHandler.requestPermission() + return@collect + } + settingsRepository.setInstallerType(InstallerType.Default) + } + } + + private fun listenApplications() { + registerReceiver( + InstalledAppReceiver(packageManager), + IntentFilter().apply { + addAction(Intent.ACTION_PACKAGE_ADDED) + addAction(Intent.ACTION_PACKAGE_REMOVED) + addDataScheme("package") + } + ) + val installedItems = + packageManager.getInstalledPackagesCompat() + ?.map { it.toInstalledItem() } + ?: return + Database.InstalledAdapter.putAll(installedItems) + } + + private fun checkLanguage() { + appScope.launch { + val lastSetLanguage = settingsRepository.getInitial().language + val systemSetLanguage = AppCompatDelegate.getApplicationLocales().toLanguageTags() + if (systemSetLanguage != lastSetLanguage && lastSetLanguage != "system") { + settingsRepository.setLanguage(systemSetLanguage) + } + } + } + + private fun updatePreference() { + appScope.launch { + launch { + settingsRepository.get { unstableUpdate }.drop(1).collect { + forceSyncAll() + } + } + launch { + settingsRepository.get { autoSync }.collectIndexed { index, syncMode -> + // Don't update sync job on initial collect + updateSyncJob(index > 0, syncMode) + } + } + launch { + settingsRepository.get { cleanUpInterval }.drop(1).collect { + if (it == INFINITE) { + CleanUpWorker.removeAllSchedules(applicationContext) + } else { + CleanUpWorker.scheduleCleanup(applicationContext, it) + } + } + } + launch { + settingsRepository.get { proxy }.collect(::updateProxy) + } + } + } + + private fun updateProxy(proxyPreference: ProxyPreference) { + val type = proxyPreference.type + val host = proxyPreference.host + val port = proxyPreference.port + val socketAddress = when (type) { + ProxyType.DIRECT -> null + ProxyType.HTTP, ProxyType.SOCKS -> { + try { + InetSocketAddress.createUnresolved(host, port) + } catch (e: IllegalArgumentException) { + log(e) + null + } + } + } + val androidProxyType = when (type) { + ProxyType.DIRECT -> Proxy.Type.DIRECT + ProxyType.HTTP -> Proxy.Type.HTTP + ProxyType.SOCKS -> Proxy.Type.SOCKS + } + val determinedProxy = socketAddress?.let { Proxy(androidProxyType, it) } ?: Proxy.NO_PROXY + downloader.setProxy(determinedProxy) + } + + private fun updateSyncJob(force: Boolean, autoSync: AutoSync) { + if (autoSync == AutoSync.NEVER) { + jobScheduler?.cancel(Constants.JOB_ID_SYNC) + return + } + val jobScheduler = jobScheduler + val syncConditions = when (autoSync) { + AutoSync.ALWAYS -> SyncPreference(NetworkType.CONNECTED) + AutoSync.WIFI_ONLY -> SyncPreference(NetworkType.UNMETERED) + AutoSync.WIFI_PLUGGED_IN -> SyncPreference(NetworkType.UNMETERED, pluggedIn = true) + else -> null + } + val isPreviousJobPending = jobScheduler?.allPendingJobs + ?.any { it.id == Constants.JOB_ID_SYNC } == false + if ((force || !isPreviousJobPending) && syncConditions != null) { + val period = 12.hours.inWholeMilliseconds + val job = SyncService.Job.create( + context = this, + periodMillis = period, + networkType = syncConditions.toJobNetworkType(), + isCharging = syncConditions.pluggedIn, + isBatteryLow = syncConditions.batteryNotLow + ) + jobScheduler?.schedule(job) + } + } + + private fun forceSyncAll() { + Database.RepositoryAdapter.getAll().forEach { + if (it.lastModified.isNotEmpty() || it.entityTag.isNotEmpty()) { + Database.RepositoryAdapter.put(it.copy(lastModified = "", entityTag = "")) + } + } + Connection(SyncService::class.java, onBind = { connection, binder -> + binder.sync(SyncService.SyncRequest.FORCE) + connection.unbind(this) + }).bind(this) + } + + class BootReceiver : BroadcastReceiver() { + @SuppressLint("UnsafeProtectedBroadcastReceiver") + override fun onReceive(context: Context, intent: Intent) = Unit + } + + override fun newImageLoader(): ImageLoader { + val memoryCache = MemoryCache.Builder(this) + .maxSizePercent(0.25) + .build() + + val diskCache = DiskCache.Builder() + .directory(Cache.getImagesDir(this)) + .maxSizePercent(0.05) + .build() + + return ImageLoader.Builder(this) + .memoryCache(memoryCache) + .diskCache(diskCache) + .error(CommonR.drawable.ic_cannot_load) + .crossfade(350) + .build() + } + + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() + .setWorkerFactory(workerFactory) + .build() +} diff --git a/app/src/main/kotlin/com/leos/droidify/ScreenActivity.kt b/app/src/main/kotlin/com/leos/droidify/ScreenActivity.kt new file mode 100644 index 0000000..f2f1bc5 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/ScreenActivity.kt @@ -0,0 +1,311 @@ +package com.leos.droidify + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.Toolbar +import androidx.core.content.ContextCompat +import androidx.core.view.WindowCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.commit +import androidx.lifecycle.lifecycleScope +import com.leos.core.common.* +import com.leos.core.common.extension.* +import com.leos.core.datastore.SettingsRepository +import com.leos.core.datastore.extension.getThemeRes +import com.leos.core.datastore.get +import com.leos.droidify.database.CursorOwner +import com.leos.droidify.ui.ScreenFragment +import com.leos.droidify.ui.appDetail.AppDetailFragment +import com.leos.droidify.ui.favourites.FavouritesFragment +import com.leos.droidify.ui.repository.EditRepositoryFragment +import com.leos.droidify.ui.repository.RepositoriesFragment +import com.leos.droidify.ui.repository.RepositoryFragment +import com.leos.droidify.ui.settings.SettingsFragment +import com.leos.droidify.ui.tabsFragment.TabsFragment +import com.leos.installer.InstallManager +import com.leos.installer.model.installFrom +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +@AndroidEntryPoint +abstract class ScreenActivity : AppCompatActivity() { + companion object { + private const val STATE_FRAGMENT_STACK = "fragmentStack" + } + + sealed interface SpecialIntent { + data object Updates : SpecialIntent + class Install(val packageName: String?, val cacheFileName: String?) : SpecialIntent + } + + private val notificationPermission = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { } + + @Inject + lateinit var installer: InstallManager + + @Parcelize + private class FragmentStackItem( + val className: String, + val arguments: Bundle?, + val savedState: Fragment.SavedState? + ) : Parcelable + + lateinit var cursorOwner: CursorOwner + private set + + private val fragmentStack = mutableListOf() + + private val currentFragment: Fragment? + get() { + supportFragmentManager.executePendingTransactions() + return supportFragmentManager.findFragmentById(R.id.main_content) + } + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface CustomUserRepositoryInjector { + fun settingsRepository(): SettingsRepository + } + + private fun collectChange() { + val hiltEntryPoint = + EntryPointAccessors.fromApplication( + this, + CustomUserRepositoryInjector::class.java + ) + val newSettings = hiltEntryPoint.settingsRepository() + .get { theme to dynamicTheme } + runBlocking { + val theme = newSettings.first() + setTheme( + resources.configuration.getThemeRes( + theme = theme.first, + dynamicTheme = theme.second + ) + ) + } + lifecycleScope.launch { + newSettings.drop(1).collect { themeAndDynamic -> + setTheme( + resources.configuration.getThemeRes( + theme = themeAndDynamic.first, + dynamicTheme = themeAndDynamic.second + ) + ) + recreate() + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + collectChange() + super.onCreate(savedInstanceState) + val rootView = FrameLayout(this).apply { id = R.id.main_content } + addContentView( + rootView, + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + ) + + when { + ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED -> { + } + + shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) -> { + sdkAbove(Build.VERSION_CODES.TIRAMISU) { + notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + + else -> { + sdkAbove(Build.VERSION_CODES.TIRAMISU) { + notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + } + + supportFragmentManager.addFragmentOnAttachListener { _, _ -> + hideKeyboard() + } + + if (savedInstanceState == null) { + cursorOwner = CursorOwner() + supportFragmentManager.commit { + add(cursorOwner, CursorOwner::class.java.name) + } + } else { + cursorOwner = supportFragmentManager + .findFragmentByTag(CursorOwner::class.java.name) as CursorOwner + } + + savedInstanceState?.getParcelableArrayList(STATE_FRAGMENT_STACK) + ?.let { fragmentStack += it } + if (savedInstanceState == null) { + replaceFragment(TabsFragment(), null) + if ((intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) == 0) { + handleIntent(intent) + } + } + if (SdkCheck.isR) { + window.statusBarColor = resources.getColor(android.R.color.transparent, theme) + window.navigationBarColor = resources.getColor(android.R.color.transparent, theme) + WindowCompat.setDecorFitsSystemWindows(window, false) + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putParcelableArrayList(STATE_FRAGMENT_STACK, ArrayList(fragmentStack)) + } + + override fun onBackPressed() { + val currentFragment = currentFragment + if (!(currentFragment is ScreenFragment && currentFragment.onBackPressed())) { + hideKeyboard() + if (!popFragment()) { + super.onBackPressed() + } + } + } + + private fun replaceFragment(fragment: Fragment, open: Boolean?) { + if (open != null) { + currentFragment?.view?.translationZ = + (if (open) Int.MIN_VALUE else Int.MAX_VALUE).toFloat() + } + supportFragmentManager.commit { + if (open != null) { + setCustomAnimations( + if (open) R.animator.slide_in else 0, + if (open) R.animator.slide_in_keep else R.animator.slide_out + ) + } + setReorderingAllowed(true) + replace(R.id.main_content, fragment) + } + } + + private fun pushFragment(fragment: Fragment) { + currentFragment?.let { + fragmentStack.add( + FragmentStackItem( + it::class.java.name, + it.arguments, + supportFragmentManager.saveFragmentInstanceState(it) + ) + ) + } + replaceFragment(fragment, true) + } + + private fun popFragment(): Boolean { + return fragmentStack.isNotEmpty() && run { + val stackItem = fragmentStack.removeAt(fragmentStack.size - 1) + val fragment = Class.forName(stackItem.className).newInstance() as Fragment + stackItem.arguments?.let(fragment::setArguments) + stackItem.savedState?.let(fragment::setInitialSavedState) + replaceFragment(fragment, false) + true + } + } + + private fun hideKeyboard() { + inputManager?.hideSoftInputFromWindow((currentFocus ?: window.decorView).windowToken, 0) + } + + internal fun onToolbarCreated(toolbar: Toolbar) { + if (fragmentStack.isNotEmpty()) { + toolbar.navigationIcon = toolbar.context.homeAsUp + toolbar.setNavigationOnClickListener { onBackPressed() } + } + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + handleIntent(intent) + } + + protected fun handleSpecialIntent(specialIntent: SpecialIntent) { + when (specialIntent) { + is SpecialIntent.Updates -> { + if (currentFragment !is TabsFragment) { + fragmentStack.clear() + replaceFragment(TabsFragment(), true) + } + val tabsFragment = currentFragment as TabsFragment + tabsFragment.selectUpdates() + } + + is SpecialIntent.Install -> { + val packageName = specialIntent.packageName + if (!packageName.isNullOrEmpty()) { + navigateProduct(packageName) + specialIntent.cacheFileName?.also { cacheFile -> + val installItem = packageName installFrom cacheFile + lifecycleScope.launch { installer install installItem } + } + } + Unit + } + }::class + } + + open fun handleIntent(intent: Intent?) { + when (intent?.action) { + Intent.ACTION_VIEW -> { + when (val deeplink = intent.deeplinkType) { + is DeeplinkType.AppDetail -> { + val fragment = currentFragment + if (fragment !is AppDetailFragment) { + navigateProduct(deeplink.packageName, deeplink.repoAddress) + } + } + + is DeeplinkType.AddRepository -> { + navigateAddRepository(repoAddress = deeplink.address) + } + + null -> {} + } + } + } + } + + internal fun navigateFavourites() = pushFragment(FavouritesFragment()) + internal fun navigateProduct(packageName: String, repoAddress: String? = null) = + pushFragment(AppDetailFragment(packageName, repoAddress)) + + internal fun navigateRepositories() = pushFragment(RepositoriesFragment()) + internal fun navigatePreferences() = pushFragment(SettingsFragment.newInstance()) + internal fun navigateAddRepository(repoAddress: String? = null) = + pushFragment(EditRepositoryFragment(null, repoAddress)) + + internal fun navigateRepository(repositoryId: Long) = + pushFragment(RepositoryFragment(repositoryId)) + + internal fun navigateEditRepository(repositoryId: Long) = + pushFragment(EditRepositoryFragment(repositoryId, null)) +} diff --git a/app/src/main/kotlin/com/leos/droidify/content/ProductPreferences.kt b/app/src/main/kotlin/com/leos/droidify/content/ProductPreferences.kt new file mode 100644 index 0000000..f7b5569 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/content/ProductPreferences.kt @@ -0,0 +1,79 @@ +package com.leos.droidify.content + +import android.content.Context +import android.content.SharedPreferences +import com.leos.core.common.extension.Json +import com.leos.core.common.extension.parseDictionary +import com.leos.core.common.extension.writeDictionary +import com.leos.core.domain.ProductPreference +import com.leos.droidify.database.Database +import com.leos.droidify.utility.serialization.productPreference +import com.leos.droidify.utility.serialization.serialize +import java.io.ByteArrayOutputStream +import java.nio.charset.Charset +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch + +object ProductPreferences { + private val defaultProductPreference = ProductPreference(false, 0L) + private lateinit var preferences: SharedPreferences + private val mutableSubject = MutableSharedFlow>() + private val subject = mutableSubject.asSharedFlow() + + fun init(context: Context, scope: CoroutineScope) { + preferences = context.getSharedPreferences("product_preferences", Context.MODE_PRIVATE) + Database.LockAdapter.putAll( + preferences.all.keys.mapNotNull { packageName -> + this[packageName].databaseVersionCode?.let { Pair(packageName, it) } + } + ) + scope.launch { + subject.collect { (packageName, versionCode) -> + if (versionCode != null) { + Database.LockAdapter.put(Pair(packageName, versionCode)) + } else { + Database.LockAdapter.delete(packageName) + } + } + } + } + + private val ProductPreference.databaseVersionCode: Long? + get() = when { + ignoreUpdates -> 0L + ignoreVersionCode > 0L -> ignoreVersionCode + else -> null + } + + operator fun get(packageName: String): ProductPreference { + return if (preferences.contains(packageName)) { + try { + Json.factory.createParser(preferences.getString(packageName, "{}")) + .use { it.parseDictionary { productPreference() } } + } catch (e: Exception) { + e.printStackTrace() + defaultProductPreference + } + } else { + defaultProductPreference + } + } + + operator fun set(packageName: String, productPreference: ProductPreference) { + val oldProductPreference = this[packageName] + preferences.edit().putString( + packageName, + ByteArrayOutputStream().apply { + Json.factory.createGenerator(this) + .use { it.writeDictionary(productPreference::serialize) } + }.toByteArray().toString(Charset.defaultCharset()) + ).apply() + if (oldProductPreference.ignoreUpdates != productPreference.ignoreUpdates || + oldProductPreference.ignoreVersionCode != productPreference.ignoreVersionCode + ) { + mutableSubject.tryEmit(Pair(packageName, productPreference.databaseVersionCode)) + } + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/database/CursorOwner.kt b/app/src/main/kotlin/com/leos/droidify/database/CursorOwner.kt new file mode 100644 index 0000000..3e583bd --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/database/CursorOwner.kt @@ -0,0 +1,143 @@ +package com.leos.droidify.database + +import android.database.Cursor +import android.os.Bundle +import androidx.fragment.app.Fragment +import androidx.loader.app.LoaderManager +import androidx.loader.content.Loader +import com.leos.core.datastore.model.SortOrder +import com.leos.core.domain.ProductItem + +class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks { + sealed class Request { + internal abstract val id: Int + + data class ProductsAvailable( + val searchQuery: String, + val section: ProductItem.Section, + val order: SortOrder + ) : Request() { + override val id: Int + get() = 1 + } + + data class ProductsInstalled( + val searchQuery: String, + val section: ProductItem.Section, + val order: SortOrder + ) : Request() { + override val id: Int + get() = 2 + } + + data class ProductsUpdates( + val searchQuery: String, + val section: ProductItem.Section, + val order: SortOrder + ) : Request() { + override val id: Int + get() = 3 + } + + object Repositories : Request() { + override val id: Int + get() = 4 + } + } + + interface Callback { + fun onCursorData(request: Request, cursor: Cursor?) + } + + private data class ActiveRequest( + val request: Request, + val callback: Callback?, + val cursor: Cursor? + ) + + init { + retainInstance = true + } + + private val activeRequests = mutableMapOf() + + fun attach(callback: Callback, request: Request) { + val oldActiveRequest = activeRequests[request.id] + if (oldActiveRequest?.callback != null && + oldActiveRequest.callback != callback && oldActiveRequest.cursor != null + ) { + oldActiveRequest.callback.onCursorData(oldActiveRequest.request, null) + } + val cursor = if (oldActiveRequest?.request == request && oldActiveRequest.cursor != null) { + callback.onCursorData(request, oldActiveRequest.cursor) + oldActiveRequest.cursor + } else { + null + } + activeRequests[request.id] = ActiveRequest(request, callback, cursor) + if (cursor == null) { + LoaderManager.getInstance(this).restartLoader(request.id, null, this) + } + } + + fun detach(callback: Callback) { + for (id in activeRequests.keys) { + val activeRequest = activeRequests[id]!! + if (activeRequest.callback == callback) { + activeRequests[id] = activeRequest.copy(callback = null) + } + } + } + + override fun onCreateLoader(id: Int, args: Bundle?): Loader { + val request = activeRequests[id]!!.request + return QueryLoader(requireContext()) { + when (request) { + is Request.ProductsAvailable -> + Database.ProductAdapter + .query( + installed = false, + updates = false, + searchQuery = request.searchQuery, + section = request.section, + order = request.order, + signal = it + ) + + is Request.ProductsInstalled -> + Database.ProductAdapter + .query( + installed = true, + updates = false, + searchQuery = request.searchQuery, + section = request.section, + order = request.order, + signal = it + ) + + is Request.ProductsUpdates -> + Database.ProductAdapter + .query( + installed = true, + updates = true, + searchQuery = request.searchQuery, + section = request.section, + order = request.order, + signal = it + ) + + is Request.Repositories -> Database.RepositoryAdapter.query(it) + } + } + } + + override fun onLoadFinished(loader: Loader, data: Cursor?) { + val activeRequest = activeRequests[loader.id] + if (activeRequest != null) { + activeRequests[loader.id] = activeRequest.copy(cursor = data) + activeRequest.callback?.onCursorData(activeRequest.request, data) + } + } + + override fun onLoaderReset(loader: Loader) = onLoadFinished(loader, null) +} diff --git a/app/src/main/kotlin/com/leos/droidify/database/Database.kt b/app/src/main/kotlin/com/leos/droidify/database/Database.kt new file mode 100644 index 0000000..6b1f492 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/database/Database.kt @@ -0,0 +1,964 @@ +package com.leos.droidify.database + +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import android.os.CancellationSignal +import androidx.core.database.sqlite.transaction +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.leos.core.common.extension.Json +import com.leos.core.common.extension.asSequence +import com.leos.core.common.extension.firstOrNull +import com.leos.core.common.extension.parseDictionary +import com.leos.core.common.extension.writeDictionary +import com.leos.core.common.log +import com.leos.core.datastore.model.SortOrder +import com.leos.core.domain.InstalledItem +import com.leos.core.domain.Product +import com.leos.core.domain.ProductItem +import com.leos.core.domain.Repository +import com.leos.droidify.BuildConfig +import com.leos.droidify.utility.serialization.product +import com.leos.droidify.utility.serialization.productItem +import com.leos.droidify.utility.serialization.repository +import com.leos.droidify.utility.serialization.serialize +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.set + +object Database { + fun init(context: Context): Boolean { + val helper = Helper(context) + db = helper.writableDatabase + if (helper.created) { + for (repository in Repository.defaultRepositories) { + RepositoryAdapter.put(repository) + } + } + RepositoryAdapter.removeDuplicates() + return helper.created || helper.updated + } + + private lateinit var db: SQLiteDatabase + + private interface Table { + val memory: Boolean + val innerName: String + val createTable: String + val createIndex: String? + get() = null + + val databasePrefix: String + get() = if (memory) "memory." else "" + + val name: String + get() = "$databasePrefix$innerName" + + fun formatCreateTable(name: String): String { + return "CREATE TABLE $name (${QueryBuilder.trimQuery(createTable)})" + } + + val createIndexPairFormatted: Pair? + get() = createIndex?.let { + Pair( + "CREATE INDEX ${innerName}_index ON $innerName ($it)", + "CREATE INDEX ${name}_index ON $innerName ($it)" + ) + } + } + + private object Schema { + object Repository : Table { + const val ROW_ID = "_id" + const val ROW_ENABLED = "enabled" + const val ROW_DELETED = "deleted" + const val ROW_DATA = "data" + + override val memory = false + override val innerName = "repository" + override val createTable = """ + $ROW_ID INTEGER PRIMARY KEY AUTOINCREMENT, + $ROW_ENABLED INTEGER NOT NULL, + $ROW_DELETED INTEGER NOT NULL, + $ROW_DATA BLOB NOT NULL + """ + } + + object Product : Table { + const val ROW_REPOSITORY_ID = "repository_id" + const val ROW_PACKAGE_NAME = "package_name" + const val ROW_NAME = "name" + const val ROW_SUMMARY = "summary" + const val ROW_DESCRIPTION = "description" + const val ROW_ADDED = "added" + const val ROW_UPDATED = "updated" + const val ROW_VERSION_CODE = "version_code" + const val ROW_SIGNATURES = "signatures" + const val ROW_COMPATIBLE = "compatible" + const val ROW_DATA = "data" + const val ROW_DATA_ITEM = "data_item" + + override val memory = false + override val innerName = "product" + override val createTable = """ + $ROW_REPOSITORY_ID INTEGER NOT NULL, + $ROW_PACKAGE_NAME TEXT NOT NULL, + $ROW_NAME TEXT NOT NULL, + $ROW_SUMMARY TEXT NOT NULL, + $ROW_DESCRIPTION TEXT NOT NULL, + $ROW_ADDED INTEGER NOT NULL, + $ROW_UPDATED INTEGER NOT NULL, + $ROW_VERSION_CODE INTEGER NOT NULL, + $ROW_SIGNATURES TEXT NOT NULL, + $ROW_COMPATIBLE INTEGER NOT NULL, + $ROW_DATA BLOB NOT NULL, + $ROW_DATA_ITEM BLOB NOT NULL, + PRIMARY KEY ($ROW_REPOSITORY_ID, $ROW_PACKAGE_NAME) + """ + override val createIndex = ROW_PACKAGE_NAME + } + + object Category : Table { + const val ROW_REPOSITORY_ID = "repository_id" + const val ROW_PACKAGE_NAME = "package_name" + const val ROW_NAME = "name" + + override val memory = false + override val innerName = "category" + override val createTable = """ + $ROW_REPOSITORY_ID INTEGER NOT NULL, + $ROW_PACKAGE_NAME TEXT NOT NULL, + $ROW_NAME TEXT NOT NULL, + PRIMARY KEY ($ROW_REPOSITORY_ID, $ROW_PACKAGE_NAME, $ROW_NAME) + """ + override val createIndex = "$ROW_PACKAGE_NAME, $ROW_NAME" + } + + object Installed : Table { + const val ROW_PACKAGE_NAME = "package_name" + const val ROW_VERSION = "version" + const val ROW_VERSION_CODE = "version_code" + const val ROW_SIGNATURE = "signature" + + override val memory = true + override val innerName = "installed" + override val createTable = """ + $ROW_PACKAGE_NAME TEXT PRIMARY KEY, + $ROW_VERSION TEXT NOT NULL, + $ROW_VERSION_CODE INTEGER NOT NULL, + $ROW_SIGNATURE TEXT NOT NULL + """ + } + + object Lock : Table { + const val ROW_PACKAGE_NAME = "package_name" + const val ROW_VERSION_CODE = "version_code" + + override val memory = true + override val innerName = "lock" + override val createTable = """ + $ROW_PACKAGE_NAME TEXT PRIMARY KEY, + $ROW_VERSION_CODE INTEGER NOT NULL + """ + } + + object Synthetic { + const val ROW_CAN_UPDATE = "can_update" + const val ROW_MATCH_RANK = "match_rank" + } + } + + private class Helper(context: Context) : SQLiteOpenHelper(context, "droidify", null, 2) { + var created = false + private set + var updated = false + private set + + override fun onCreate(db: SQLiteDatabase) = Unit + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = + onVersionChange(db) + + override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = + onVersionChange(db) + + private fun onVersionChange(db: SQLiteDatabase) { + handleTables(db, true, Schema.Product, Schema.Category) + addRepos(db, Repository.newlyAdded) + this.updated = true + } + + override fun onOpen(db: SQLiteDatabase) { + val create = handleTables(db, false, Schema.Repository) + val updated = handleTables(db, create, Schema.Product, Schema.Category) + db.execSQL("ATTACH DATABASE ':memory:' AS memory") + handleTables(db, false, Schema.Installed, Schema.Lock) + handleIndexes( + db, + Schema.Repository, + Schema.Product, + Schema.Category, + Schema.Installed, + Schema.Lock + ) + dropOldTables(db, Schema.Repository, Schema.Product, Schema.Category) + this.created = this.created || create + this.updated = this.updated || create || updated + } + } + + private fun handleTables(db: SQLiteDatabase, recreate: Boolean, vararg tables: Table): Boolean { + val shouldRecreate = recreate || tables.any { table -> + val sql = db.query( + "${table.databasePrefix}sqlite_master", + columns = arrayOf("sql"), + selection = Pair("type = ? AND name = ?", arrayOf("table", table.innerName)) + ).use { it.firstOrNull()?.getString(0) }.orEmpty() + table.formatCreateTable(table.innerName) != sql + } + return shouldRecreate && run { + val shouldVacuum = tables.map { + db.execSQL("DROP TABLE IF EXISTS ${it.name}") + db.execSQL(it.formatCreateTable(it.name)) + !it.memory + } + if (shouldVacuum.any { it } && !db.inTransaction()) { + db.execSQL("VACUUM") + } + true + } + } + + private fun addRepos(db: SQLiteDatabase, repos: List) { + if (BuildConfig.DEBUG) { + log("Add Repos: $repos", "RepositoryAdapter") + } + if (repos.isEmpty()) return + db.transaction { + repos.forEach { + RepositoryAdapter.put(it) + } + } + } + + private fun handleIndexes(db: SQLiteDatabase, vararg tables: Table) { + val shouldVacuum = tables.map { table -> + val sqls = db.query( + "${table.databasePrefix}sqlite_master", + columns = arrayOf("name", "sql"), + selection = Pair("type = ? AND tbl_name = ?", arrayOf("index", table.innerName)) + ) + .use { cursor -> + cursor.asSequence() + .mapNotNull { it.getString(1)?.let { sql -> Pair(it.getString(0), sql) } } + .toList() + } + .filter { !it.first.startsWith("sqlite_") } + val createIndexes = table.createIndexPairFormatted?.let { listOf(it) }.orEmpty() + createIndexes.map { it.first } != sqls.map { it.second } && run { + for (name in sqls.map { it.first }) { + db.execSQL("DROP INDEX IF EXISTS $name") + } + for (createIndexPair in createIndexes) { + db.execSQL(createIndexPair.second) + } + !table.memory + } + } + if (shouldVacuum.any { it } && !db.inTransaction()) { + db.execSQL("VACUUM") + } + } + + private fun dropOldTables(db: SQLiteDatabase, vararg neededTables: Table) { + val tables = db.query( + "sqlite_master", + columns = arrayOf("name"), + selection = Pair("type = ?", arrayOf("table")) + ) + .use { cursor -> cursor.asSequence().mapNotNull { it.getString(0) }.toList() } + .filter { !it.startsWith("sqlite_") && !it.startsWith("android_") } + .toSet() - neededTables.mapNotNull { if (it.memory) null else it.name }.toSet() + if (tables.isNotEmpty()) { + for (table in tables) { + db.execSQL("DROP TABLE IF EXISTS $table") + } + if (!db.inTransaction()) { + db.execSQL("VACUUM") + } + } + } + + sealed class Subject { + data object Repositories : Subject() + data class Repository(val id: Long) : Subject() + data object Products : Subject() + } + + private val observers = mutableMapOf Unit>>() + + private fun dataObservable(subject: Subject): (Boolean, () -> Unit) -> Unit = + { register, observer -> + synchronized(observers) { + val set = observers[subject] ?: run { + val set = mutableSetOf<() -> Unit>() + observers[subject] = set + set + } + if (register) { + set += observer + } else { + set -= observer + } + } + } + + fun flowCollection(subject: Subject): Flow = callbackFlow { + val callback: () -> Unit = { trySend(Unit) } + val dataObservable = dataObservable(subject) + dataObservable(true, callback) + + awaitClose { dataObservable(false, callback) } + }.flowOn(Dispatchers.IO) + + private fun notifyChanged(vararg subjects: Subject) { + synchronized(observers) { + subjects.asSequence().mapNotNull { observers[it] }.flatten().forEach { it() } + } + } + + private fun SQLiteDatabase.insertOrReplace( + replace: Boolean, + table: String, + contentValues: ContentValues + ): Long { + return if (replace) { + replace(table, null, contentValues) + } else { + insert( + table, + null, + contentValues + ) + } + } + + private fun SQLiteDatabase.query( + table: String, + columns: Array? = null, + selection: Pair>? = null, + orderBy: String? = null, + signal: CancellationSignal? = null + ): Cursor { + return query( + false, + table, + columns, + selection?.first, + selection?.second, + null, + null, + orderBy, + null, + signal + ) + } + + private fun Cursor.observable(subject: Subject): ObservableCursor { + return ObservableCursor(this, dataObservable(subject)) + } + + fun ByteArray.jsonParse(callback: (JsonParser) -> T): T { + return Json.factory.createParser(this).use { it.parseDictionary(callback) } + } + + fun jsonGenerate(callback: (JsonGenerator) -> Unit): ByteArray { + val outputStream = ByteArrayOutputStream() + Json.factory.createGenerator(outputStream).use { it.writeDictionary(callback) } + return outputStream.toByteArray() + } + + object RepositoryAdapter { + internal fun putWithoutNotification(repository: Repository, shouldReplace: Boolean): Long { + return db.insertOrReplace( + shouldReplace, + Schema.Repository.name, + ContentValues().apply { + if (shouldReplace) { + put(Schema.Repository.ROW_ID, repository.id) + } + put(Schema.Repository.ROW_ENABLED, if (repository.enabled) 1 else 0) + put(Schema.Repository.ROW_DELETED, 0) + put(Schema.Repository.ROW_DATA, jsonGenerate(repository::serialize)) + } + ) + } + + fun put(repository: Repository): Repository { + val shouldReplace = repository.id >= 0L + val newId = putWithoutNotification(repository, shouldReplace) + val id = if (shouldReplace) repository.id else newId + notifyChanged(Subject.Repositories, Subject.Repository(id), Subject.Products) + return if (newId != repository.id) repository.copy(id = newId) else repository + } + + fun removeDuplicates() { + db.transaction { + val all = getAll() + val different = all.distinctBy { it.address } + val duplicates = all - different.toSet() + duplicates.forEach { + markAsDeleted(it.id) + } + } + } + + fun getStream(id: Long): Flow = flowOf(Unit) + .onCompletion { if (it == null) emitAll(flowCollection(Subject.Repositories)) } + .map { get(id) } + .flowOn(Dispatchers.IO) + + fun get(id: Long): Repository? { + return db.query( + Schema.Repository.name, + selection = Pair( + "${Schema.Repository.ROW_ID} = ? AND ${Schema.Repository.ROW_DELETED} == 0", + arrayOf(id.toString()) + ) + ).use { it.firstOrNull()?.let(::transform) } + } + + fun getAllStream(): Flow> = flowOf(Unit) + .onCompletion { if (it == null) emitAll(flowCollection(Subject.Repositories)) } + .map { getAll() } + .flowOn(Dispatchers.IO) + + fun getEnabledStream(): Flow> = flowOf(Unit) + .onCompletion { if (it == null) emitAll(flowCollection(Subject.Repositories)) } + .map { getEnabled() } + .flowOn(Dispatchers.IO) + + private suspend fun getEnabled(): List = withContext(Dispatchers.IO) { + db.query( + Schema.Repository.name, + selection = Pair( + "${Schema.Repository.ROW_ENABLED} != 0 AND " + + "${Schema.Repository.ROW_DELETED} == 0", + emptyArray() + ), + signal = null + ).use { it.asSequence().map(::transform).toList() } + } + + fun getAll(): List { + return db.query( + Schema.Repository.name, + selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()), + signal = null + ).use { it.asSequence().map(::transform).toList() } + } + + fun getAllRemovedStream(): Flow> = flowOf(Unit) + .onCompletion { if (it == null) emitAll(flowCollection(Subject.Repositories)) } + .map { getAllDisabledDeleted() } + .flowOn(Dispatchers.IO) + + private fun getAllDisabledDeleted(): Map { + return db.query( + Schema.Repository.name, + columns = arrayOf(Schema.Repository.ROW_ID, Schema.Repository.ROW_DELETED), + selection = Pair( + "${Schema.Repository.ROW_ENABLED} == 0 OR " + + "${Schema.Repository.ROW_DELETED} != 0", + emptyArray() + ), + signal = null + ).use { parentCursor -> + parentCursor.asSequence().associate { + val idIndex = it.getColumnIndexOrThrow(Schema.Repository.ROW_ID) + val isDeletedIndex = it.getColumnIndexOrThrow(Schema.Repository.ROW_DELETED) + it.getLong(idIndex) to (it.getInt(isDeletedIndex) != 0) + } + } + } + + fun markAsDeleted(id: Long) { + db.update( + Schema.Repository.name, + ContentValues().apply { + put(Schema.Repository.ROW_DELETED, 1) + }, + "${Schema.Repository.ROW_ID} = ?", + arrayOf(id.toString()) + ) + notifyChanged(Subject.Repositories, Subject.Repository(id), Subject.Products) + } + + fun cleanup(removedRepos: Map) { + val result = removedRepos.map { (id, isDeleted) -> + val idsString = id.toString() + val productsCount = db.delete( + Schema.Product.name, + "${Schema.Product.ROW_REPOSITORY_ID} IN ($idsString)", + null + ) + val categoriesCount = db.delete( + Schema.Category.name, + "${Schema.Category.ROW_REPOSITORY_ID} IN ($idsString)", + null + ) + if (isDeleted) { + db.delete( + Schema.Repository.name, + "${Schema.Repository.ROW_ID} IN ($id)", + null + ) + } + productsCount != 0 || categoriesCount != 0 + } + if (result.any { it }) { + notifyChanged(Subject.Products) + } + } + + fun importRepos(list: List) { + db.transaction { + val currentAddresses = getAll().map { it.address } + val newRepos = list + .filter { it.address !in currentAddresses } + newRepos.forEach { put(it) } + removeDuplicates() + } + } + + fun query(signal: CancellationSignal?): Cursor { + return db.query( + Schema.Repository.name, + selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()), + orderBy = "${Schema.Repository.ROW_ENABLED} DESC", + signal = signal + ).observable(Subject.Repositories) + } + + fun transform(cursor: Cursor): Repository { + return cursor.getBlob(cursor.getColumnIndexOrThrow(Schema.Repository.ROW_DATA)) + .jsonParse { + it.repository().apply { + this.id = + cursor.getLong(cursor.getColumnIndexOrThrow(Schema.Repository.ROW_ID)) + } + } + } + } + + object ProductAdapter { + + fun getStream(packageName: String): Flow> = flowOf(Unit) + .onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) } + .map { get(packageName, null) } + .flowOn(Dispatchers.IO) + + suspend fun getUpdates(): List = withContext(Dispatchers.IO) { + query( + installed = true, + updates = true, + searchQuery = "", + section = ProductItem.Section.All, + order = SortOrder.NAME, + signal = null + ).use { + it.asSequence() + .map(ProductAdapter::transformItem) + .toList() + } + } + + fun getUpdatesStream(): Flow> = flowOf(Unit) + .onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) } + // Crashes due to immediate retrieval of data? + .onEach { delay(50) } + .map { getUpdates() } + .flowOn(Dispatchers.IO) + + fun get(packageName: String, signal: CancellationSignal?): List { + return db.query( + Schema.Product.name, + columns = arrayOf( + Schema.Product.ROW_REPOSITORY_ID, + Schema.Product.ROW_DESCRIPTION, + Schema.Product.ROW_DATA + ), + selection = Pair("${Schema.Product.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)), + signal = signal + ).use { it.asSequence().map(::transform).toList() } + } + + fun getCountStream(repositoryId: Long): Flow = flowOf(Unit) + .onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) } + .map { getCount(repositoryId) } + .flowOn(Dispatchers.IO) + + private fun getCount(repositoryId: Long): Int { + return db.query( + Schema.Product.name, + columns = arrayOf("COUNT (*)"), + selection = Pair( + "${Schema.Product.ROW_REPOSITORY_ID} = ?", + arrayOf(repositoryId.toString()) + ) + ).use { it.firstOrNull()?.getInt(0) ?: 0 } + } + + fun query( + installed: Boolean, + updates: Boolean, + searchQuery: String, + section: ProductItem.Section, + order: SortOrder, + signal: CancellationSignal? + ): Cursor { + val builder = QueryBuilder() + + val signatureMatches = """installed.${Schema.Installed.ROW_SIGNATURE} IS NOT NULL AND + product.${Schema.Product.ROW_SIGNATURES} LIKE ('%.' || installed.${Schema.Installed.ROW_SIGNATURE} || '.%') AND + product.${Schema.Product.ROW_SIGNATURES} != ''""" + + builder += """SELECT product.rowid AS _id, product.${Schema.Product.ROW_REPOSITORY_ID}, + product.${Schema.Product.ROW_PACKAGE_NAME}, product.${Schema.Product.ROW_NAME}, + product.${Schema.Product.ROW_SUMMARY}, installed.${Schema.Installed.ROW_VERSION}, + (COALESCE(lock.${Schema.Lock.ROW_VERSION_CODE}, -1) NOT IN (0, product.${Schema.Product.ROW_VERSION_CODE}) AND + product.${Schema.Product.ROW_COMPATIBLE} != 0 AND product.${Schema.Product.ROW_VERSION_CODE} > + COALESCE(installed.${Schema.Installed.ROW_VERSION_CODE}, 0xffffffff) AND $signatureMatches) + AS ${Schema.Synthetic.ROW_CAN_UPDATE}, product.${Schema.Product.ROW_COMPATIBLE}, + product.${Schema.Product.ROW_DATA_ITEM},""" + + if (searchQuery.isNotEmpty()) { + builder += """(((product.${Schema.Product.ROW_NAME} LIKE ? OR + product.${Schema.Product.ROW_SUMMARY} LIKE ?) * 7) | + ((product.${Schema.Product.ROW_PACKAGE_NAME} LIKE ?) * 3) | + (product.${Schema.Product.ROW_DESCRIPTION} LIKE ?)) AS ${Schema.Synthetic.ROW_MATCH_RANK},""" + builder %= List(4) { "%$searchQuery%" } + } else { + builder += "0 AS ${Schema.Synthetic.ROW_MATCH_RANK}," + } + + builder += """MAX((product.${Schema.Product.ROW_COMPATIBLE} AND + (installed.${Schema.Installed.ROW_SIGNATURE} IS NULL OR $signatureMatches)) || + PRINTF('%016X', product.${Schema.Product.ROW_VERSION_CODE})) FROM ${Schema.Product.name} AS product""" + builder += """JOIN ${Schema.Repository.name} AS repository + ON product.${Schema.Product.ROW_REPOSITORY_ID} = repository.${Schema.Repository.ROW_ID}""" + builder += """LEFT JOIN ${Schema.Lock.name} AS lock + ON product.${Schema.Product.ROW_PACKAGE_NAME} = lock.${Schema.Lock.ROW_PACKAGE_NAME}""" + + if (!installed && !updates) { + builder += "LEFT" + } + builder += """JOIN ${Schema.Installed.name} AS installed + ON product.${Schema.Product.ROW_PACKAGE_NAME} = installed.${Schema.Installed.ROW_PACKAGE_NAME}""" + + if (section is ProductItem.Section.Category) { + builder += """JOIN ${Schema.Category.name} AS category + ON product.${Schema.Product.ROW_PACKAGE_NAME} = category.${Schema.Product.ROW_PACKAGE_NAME}""" + } + + builder += """WHERE repository.${Schema.Repository.ROW_ENABLED} != 0 AND + repository.${Schema.Repository.ROW_DELETED} == 0""" + + if (section is ProductItem.Section.Category) { + builder += "AND category.${Schema.Category.ROW_NAME} = ?" + builder %= section.name + } else if (section is ProductItem.Section.Repository) { + builder += "AND product.${Schema.Product.ROW_REPOSITORY_ID} = ?" + builder %= section.id.toString() + } + + if (searchQuery.isNotEmpty()) { + builder += """AND ${Schema.Synthetic.ROW_MATCH_RANK} > 0""" + } + + builder += "GROUP BY product.${Schema.Product.ROW_PACKAGE_NAME} HAVING 1" + + if (updates) { + builder += "AND ${Schema.Synthetic.ROW_CAN_UPDATE}" + } + builder += "ORDER BY" + + if (searchQuery.isNotEmpty()) { + builder += """${Schema.Synthetic.ROW_MATCH_RANK} DESC,""" + } + + when (order) { + SortOrder.UPDATED -> builder += "product.${Schema.Product.ROW_UPDATED} DESC," + SortOrder.ADDED -> builder += "product.${Schema.Product.ROW_ADDED} DESC," + SortOrder.NAME -> Unit + }::class + builder += "product.${Schema.Product.ROW_NAME} COLLATE LOCALIZED ASC" + + return builder.query(db, signal).observable(Subject.Products) + } + + private fun transform(cursor: Cursor): Product { + return cursor.getBlob(cursor.getColumnIndexOrThrow(Schema.Product.ROW_DATA)) + .jsonParse { + it.product().apply { + this.repositoryId = cursor + .getLong(cursor.getColumnIndexOrThrow(Schema.Product.ROW_REPOSITORY_ID)) + this.description = cursor + .getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_DESCRIPTION)) + } + } + } + + fun transformItem(cursor: Cursor): ProductItem { + return cursor.getBlob(cursor.getColumnIndexOrThrow(Schema.Product.ROW_DATA_ITEM)) + .jsonParse { + it.productItem().apply { + this.repositoryId = cursor + .getLong(cursor.getColumnIndexOrThrow(Schema.Product.ROW_REPOSITORY_ID)) + this.packageName = cursor + .getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_PACKAGE_NAME)) + this.name = cursor + .getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_NAME)) + this.summary = cursor + .getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_SUMMARY)) + this.installedVersion = cursor + .getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION)) + .orEmpty() + this.compatible = cursor + .getInt(cursor.getColumnIndexOrThrow(Schema.Product.ROW_COMPATIBLE)) != 0 + this.canUpdate = cursor + .getInt(cursor.getColumnIndexOrThrow(Schema.Synthetic.ROW_CAN_UPDATE)) != 0 + this.matchRank = cursor + .getInt(cursor.getColumnIndexOrThrow(Schema.Synthetic.ROW_MATCH_RANK)) + } + } + } + } + + object CategoryAdapter { + + fun getAllStream(): Flow> = flowOf(Unit) + .onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) } + .map { getAll() } + .flowOn(Dispatchers.IO) + + private suspend fun getAll(): Set = withContext(Dispatchers.IO) { + val builder = QueryBuilder() + + builder += """SELECT DISTINCT category.${Schema.Category.ROW_NAME} + FROM ${Schema.Category.name} AS category + JOIN ${Schema.Repository.name} AS repository + ON category.${Schema.Category.ROW_REPOSITORY_ID} = repository.${Schema.Repository.ROW_ID} + WHERE repository.${Schema.Repository.ROW_ENABLED} != 0 AND + repository.${Schema.Repository.ROW_DELETED} == 0""" + + builder.query(db, null).use { cursor -> + cursor.asSequence().map { + it.getString(it.getColumnIndexOrThrow(Schema.Category.ROW_NAME)) + }.toSet() + } + } + } + + object InstalledAdapter { + + fun getStream(packageName: String): Flow = flowOf(Unit) + .onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) } + .map { get(packageName, null) } + .flowOn(Dispatchers.IO) + + fun get(packageName: String, signal: CancellationSignal?): InstalledItem? { + return db.query( + Schema.Installed.name, + columns = arrayOf( + Schema.Installed.ROW_PACKAGE_NAME, + Schema.Installed.ROW_VERSION, + Schema.Installed.ROW_VERSION_CODE, + Schema.Installed.ROW_SIGNATURE + ), + selection = Pair("${Schema.Installed.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)), + signal = signal + ).use { it.firstOrNull()?.let(::transform) } + } + + private fun put(installedItem: InstalledItem, notify: Boolean) { + db.insertOrReplace( + true, + Schema.Installed.name, + ContentValues().apply { + put(Schema.Installed.ROW_PACKAGE_NAME, installedItem.packageName) + put(Schema.Installed.ROW_VERSION, installedItem.version) + put(Schema.Installed.ROW_VERSION_CODE, installedItem.versionCode) + put(Schema.Installed.ROW_SIGNATURE, installedItem.signature) + } + ) + if (notify) { + notifyChanged(Subject.Products) + } + } + + fun put(installedItem: InstalledItem) = put(installedItem, true) + + fun putAll(installedItems: List) { + db.transaction { + db.delete(Schema.Installed.name, null, null) + installedItems.forEach { put(it, false) } + } + } + + fun delete(packageName: String) { + val count = db.delete( + Schema.Installed.name, + "${Schema.Installed.ROW_PACKAGE_NAME} = ?", + arrayOf(packageName) + ) + if (count > 0) { + notifyChanged(Subject.Products) + } + } + + private fun transform(cursor: Cursor): InstalledItem { + return InstalledItem( + cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_PACKAGE_NAME)), + cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION)), + cursor.getLong(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION_CODE)), + cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_SIGNATURE)) + ) + } + } + + object LockAdapter { + private fun put(lock: Pair, notify: Boolean) { + db.insertOrReplace( + true, + Schema.Lock.name, + ContentValues().apply { + put(Schema.Lock.ROW_PACKAGE_NAME, lock.first) + put(Schema.Lock.ROW_VERSION_CODE, lock.second) + } + ) + if (notify) { + notifyChanged(Subject.Products) + } + } + + fun put(lock: Pair) = put(lock, true) + + fun putAll(locks: List>) { + db.transaction { + db.delete(Schema.Lock.name, null, null) + locks.forEach { put(it, false) } + } + } + + fun delete(packageName: String) { + db.delete(Schema.Lock.name, "${Schema.Lock.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)) + notifyChanged(Subject.Products) + } + } + + object UpdaterAdapter { + private val Table.temporaryName: String + get() = "${name}_temporary" + + fun createTemporaryTable() { + db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}") + db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}") + db.execSQL(Schema.Product.formatCreateTable(Schema.Product.temporaryName)) + db.execSQL(Schema.Category.formatCreateTable(Schema.Category.temporaryName)) + } + + fun putTemporary(products: List) { + db.transaction { + for (product in products) { + // Format signatures like ".signature1.signature2." for easier select + val signatures = product.signatures.joinToString { ".$it" } + .let { if (it.isNotEmpty()) "$it." else "" } + db.insertOrReplace( + true, + Schema.Product.temporaryName, + ContentValues().apply { + put(Schema.Product.ROW_REPOSITORY_ID, product.repositoryId) + put(Schema.Product.ROW_PACKAGE_NAME, product.packageName) + put(Schema.Product.ROW_NAME, product.name) + put(Schema.Product.ROW_SUMMARY, product.summary) + put(Schema.Product.ROW_DESCRIPTION, product.description) + put(Schema.Product.ROW_ADDED, product.added) + put(Schema.Product.ROW_UPDATED, product.updated) + put(Schema.Product.ROW_VERSION_CODE, product.versionCode) + put(Schema.Product.ROW_SIGNATURES, signatures) + put(Schema.Product.ROW_COMPATIBLE, if (product.compatible) 1 else 0) + put(Schema.Product.ROW_DATA, jsonGenerate(product::serialize)) + put( + Schema.Product.ROW_DATA_ITEM, + jsonGenerate(product.item()::serialize) + ) + } + ) + for (category in product.categories) { + db.insertOrReplace( + true, + Schema.Category.temporaryName, + ContentValues().apply { + put(Schema.Category.ROW_REPOSITORY_ID, product.repositoryId) + put(Schema.Category.ROW_PACKAGE_NAME, product.packageName) + put(Schema.Category.ROW_NAME, category) + } + ) + } + } + } + } + + fun finishTemporary(repository: Repository, success: Boolean) { + if (success) { + db.transaction { + db.delete( + Schema.Product.name, + "${Schema.Product.ROW_REPOSITORY_ID} = ?", + arrayOf(repository.id.toString()) + ) + db.delete( + Schema.Category.name, + "${Schema.Category.ROW_REPOSITORY_ID} = ?", + arrayOf(repository.id.toString()) + ) + db.execSQL( + "INSERT INTO ${Schema.Product.name} SELECT * " + + "FROM ${Schema.Product.temporaryName}" + ) + db.execSQL( + "INSERT INTO ${Schema.Category.name} SELECT * " + + "FROM ${Schema.Category.temporaryName}" + ) + RepositoryAdapter.putWithoutNotification(repository, true) + db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}") + db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}") + } + notifyChanged( + Subject.Repositories, + Subject.Repository(repository.id), + Subject.Products + ) + } else { + db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}") + db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}") + } + } + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/database/ObservableCursor.kt b/app/src/main/kotlin/com/leos/droidify/database/ObservableCursor.kt new file mode 100644 index 0000000..ffdf1c5 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/database/ObservableCursor.kt @@ -0,0 +1,64 @@ +package com.leos.droidify.database + +import android.database.ContentObservable +import android.database.ContentObserver +import android.database.Cursor +import android.database.CursorWrapper + +class ObservableCursor( + cursor: Cursor, + private val observable: ( + register: Boolean, + observer: () -> Unit + ) -> Unit +) : CursorWrapper(cursor) { + private var registered = false + private val contentObservable = ContentObservable() + + private val onChange: () -> Unit = { + contentObservable.dispatchChange(false, null) + } + + init { + observable(true, onChange) + registered = true + } + + override fun registerContentObserver(observer: ContentObserver) { + super.registerContentObserver(observer) + contentObservable.registerObserver(observer) + } + + override fun unregisterContentObserver(observer: ContentObserver) { + super.unregisterContentObserver(observer) + contentObservable.unregisterObserver(observer) + } + + @Deprecated("Deprecated in Java") + @Suppress("DEPRECATION") + override fun requery(): Boolean { + if (!registered) { + observable(true, onChange) + registered = true + } + return super.requery() + } + + @Deprecated("Deprecated in Java") + @Suppress("DEPRECATION") + override fun deactivate() { + super.deactivate() + deactivateOrClose() + } + + override fun close() { + super.close() + contentObservable.unregisterAll() + deactivateOrClose() + } + + private fun deactivateOrClose() { + observable(false, onChange) + registered = false + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/database/QueryBuilder.kt b/app/src/main/kotlin/com/leos/droidify/database/QueryBuilder.kt new file mode 100644 index 0000000..cb777ba --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/database/QueryBuilder.kt @@ -0,0 +1,50 @@ +package com.leos.droidify.database + +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import android.os.CancellationSignal +import com.leos.core.common.extension.asSequence +import com.leos.core.common.log +import com.leos.droidify.BuildConfig + +class QueryBuilder { + companion object { + fun trimQuery(query: String): String { + return query.lines().map { it.trim() }.filter { it.isNotEmpty() } + .joinToString(separator = " ") + } + } + + private val builder = StringBuilder() + private val arguments = mutableListOf() + + operator fun plusAssign(query: String) { + if (builder.isNotEmpty()) { + builder.append(" ") + } + builder.append(trimQuery(query)) + } + + operator fun remAssign(argument: String) { + this.arguments += argument + } + + operator fun remAssign(arguments: List) { + this.arguments += arguments + } + + fun query(db: SQLiteDatabase, signal: CancellationSignal?): Cursor { + val query = builder.toString() + val arguments = arguments.toTypedArray() + if (BuildConfig.DEBUG) { + synchronized(QueryBuilder::class.java) { + log(query) + db.rawQuery("EXPLAIN QUERY PLAN $query", arguments).use { + it.asSequence() + .forEach { log(":: ${it.getString(it.getColumnIndex("detail"))}") } + } + } + } + return db.rawQuery(query, arguments, signal) + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/database/QueryLoader.kt b/app/src/main/kotlin/com/leos/droidify/database/QueryLoader.kt new file mode 100644 index 0000000..38648f6 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/database/QueryLoader.kt @@ -0,0 +1,94 @@ +package com.leos.droidify.database + +import android.content.Context +import android.database.Cursor +import android.os.CancellationSignal +import android.os.OperationCanceledException +import androidx.loader.content.AsyncTaskLoader + +class QueryLoader(context: Context, private val query: (CancellationSignal) -> Cursor?) : + AsyncTaskLoader(context) { + private val observer = ForceLoadContentObserver() + private var cancellationSignal: CancellationSignal? = null + private var cursor: Cursor? = null + + override fun loadInBackground(): Cursor? { + val cancellationSignal = synchronized(this) { + if (isLoadInBackgroundCanceled) { + throw OperationCanceledException() + } + val cancellationSignal = CancellationSignal() + this.cancellationSignal = cancellationSignal + cancellationSignal + } + try { + val cursor = query(cancellationSignal) + if (cursor != null) { + try { + cursor.count // Ensure the cursor window is filled + cursor.registerContentObserver(observer) + } catch (e: Exception) { + cursor.close() + throw e + } + } + return cursor + } finally { + synchronized(this) { + this.cancellationSignal = null + } + } + } + + override fun cancelLoadInBackground() { + super.cancelLoadInBackground() + + synchronized(this) { + cancellationSignal?.cancel() + } + } + + override fun deliverResult(data: Cursor?) { + if (isReset) { + data?.close() + } else { + val oldCursor = cursor + cursor = data + if (isStarted) { + super.deliverResult(data) + } + if (oldCursor != data) { + oldCursor.closeIfNeeded() + } + } + } + + override fun onStartLoading() { + cursor?.let(this::deliverResult) + if (takeContentChanged() || cursor == null) { + forceLoad() + } + } + + override fun onStopLoading() { + cancelLoad() + } + + override fun onCanceled(data: Cursor?) { + data.closeIfNeeded() + } + + override fun onReset() { + super.onReset() + + stopLoading() + cursor.closeIfNeeded() + cursor = null + } + + private fun Cursor?.closeIfNeeded() { + if (this != null && !isClosed) { + close() + } + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/database/RepositoryExporter.kt b/app/src/main/kotlin/com/leos/droidify/database/RepositoryExporter.kt new file mode 100644 index 0000000..bf69004 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/database/RepositoryExporter.kt @@ -0,0 +1,73 @@ +package com.leos.droidify.database + +import android.content.Context +import android.net.Uri +import com.fasterxml.jackson.core.JsonToken +import com.leos.core.common.Exporter +import com.leos.core.common.extension.Json +import com.leos.core.common.extension.forEach +import com.leos.core.common.extension.forEachKey +import com.leos.core.common.extension.parseDictionary +import com.leos.core.common.extension.writeArray +import com.leos.core.common.extension.writeDictionary +import com.leos.core.di.ApplicationScope +import com.leos.core.di.IoDispatcher +import com.leos.core.domain.Repository +import com.leos.droidify.utility.serialization.repository +import com.leos.droidify.utility.serialization.serialize +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Singleton +class RepositoryExporter @Inject constructor( + @ApplicationContext private val context: Context, + @ApplicationScope private val scope: CoroutineScope, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher +) : Exporter> { + override suspend fun export(item: List, target: Uri) { + scope.launch(ioDispatcher) { + val stream = context.contentResolver.openOutputStream(target) + Json.factory.createGenerator(stream).use { generator -> + generator.writeDictionary { + writeArray("repositories") { + item.map { + it.copy( + id = -1, + mirrors = if (it.enabled) it.mirrors else emptyList(), + lastModified = "", + entityTag = "" + ) + }.forEach { repo -> + writeDictionary { + repo.serialize(this) + } + } + } + } + } + } + } + + override suspend fun import(target: Uri): List = withContext(ioDispatcher) { + val list = mutableListOf() + val stream = context.contentResolver.openInputStream(target) + Json.factory.createParser(stream).use { generator -> + generator?.parseDictionary { + forEachKey { + if (it.array("repositories")) { + forEach(JsonToken.START_OBJECT) { + val repo = repository() + list.add(repo) + } + } + } + } + } + list + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/graphics/DrawableWrapper.kt b/app/src/main/kotlin/com/leos/droidify/graphics/DrawableWrapper.kt new file mode 100644 index 0000000..b305897 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/graphics/DrawableWrapper.kt @@ -0,0 +1,57 @@ +package com.leos.droidify.graphics + +import android.graphics.Canvas +import android.graphics.ColorFilter +import android.graphics.Rect +import android.graphics.drawable.Drawable + +open class DrawableWrapper(val drawable: Drawable) : Drawable() { + init { + drawable.callback = object : Callback { + override fun invalidateDrawable(who: Drawable) { + callback?.invalidateDrawable(who) + } + + override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) { + callback?.scheduleDrawable(who, what, `when`) + } + + override fun unscheduleDrawable(who: Drawable, what: Runnable) { + callback?.unscheduleDrawable(who, what) + } + } + } + + override fun onBoundsChange(bounds: Rect) { + drawable.bounds = bounds + } + + override fun getIntrinsicWidth(): Int = drawable.intrinsicWidth + override fun getIntrinsicHeight(): Int = drawable.intrinsicHeight + override fun getMinimumWidth(): Int = drawable.minimumWidth + override fun getMinimumHeight(): Int = drawable.minimumHeight + + override fun draw(canvas: Canvas) { + drawable.draw(canvas) + } + + override fun getAlpha(): Int { + return drawable.alpha + } + + override fun setAlpha(alpha: Int) { + drawable.alpha = alpha + } + + override fun getColorFilter(): ColorFilter? { + return drawable.colorFilter + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + drawable.colorFilter = colorFilter + } + + @Deprecated("Deprecated in Java") + @Suppress("DEPRECATION") + override fun getOpacity(): Int = drawable.opacity +} diff --git a/app/src/main/kotlin/com/leos/droidify/graphics/PaddingDrawable.kt b/app/src/main/kotlin/com/leos/droidify/graphics/PaddingDrawable.kt new file mode 100644 index 0000000..28b080f --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/graphics/PaddingDrawable.kt @@ -0,0 +1,30 @@ +package com.leos.droidify.graphics + +import android.graphics.Rect +import android.graphics.drawable.Drawable +import kotlin.math.roundToInt + +class PaddingDrawable( + drawable: Drawable, + private val horizontalFactor: Float, + private val aspectRatio: Float = 16f / 9f +) : DrawableWrapper(drawable) { + override fun getIntrinsicWidth(): Int = + (horizontalFactor * super.getIntrinsicWidth()).roundToInt() + + override fun getIntrinsicHeight(): Int = + ((horizontalFactor * aspectRatio) * super.getIntrinsicHeight()).roundToInt() + + override fun onBoundsChange(bounds: Rect) { + val width = (bounds.width() / horizontalFactor).roundToInt() + val height = (bounds.height() / (horizontalFactor * aspectRatio)).roundToInt() + val left = (bounds.width() - width) / 2 + val top = (bounds.height() - height) / 2 + drawable.setBounds( + bounds.left + left, + bounds.top + top, + bounds.left + left + width, + bounds.top + top + height + ) + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/index/IndexMerger.kt b/app/src/main/kotlin/com/leos/droidify/index/IndexMerger.kt new file mode 100644 index 0000000..6115cc4 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/index/IndexMerger.kt @@ -0,0 +1,115 @@ +package com.leos.droidify.index + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import com.fasterxml.jackson.core.JsonToken +import com.leos.core.common.extension.Json +import com.leos.core.common.extension.asSequence +import com.leos.core.common.extension.collectNotNull +import com.leos.core.common.extension.execWithResult +import com.leos.core.common.extension.writeDictionary +import com.leos.core.domain.Product +import com.leos.core.domain.Release +import com.leos.droidify.utility.serialization.product +import com.leos.droidify.utility.serialization.release +import com.leos.droidify.utility.serialization.serialize +import java.io.ByteArrayOutputStream +import java.io.Closeable +import java.io.File + +class IndexMerger(file: File) : Closeable { + private val db = SQLiteDatabase.openOrCreateDatabase(file, null) + + init { + db.execWithResult("PRAGMA synchronous = OFF") + db.execWithResult("PRAGMA journal_mode = OFF") + db.execSQL( + "CREATE TABLE product (" + + "package_name TEXT PRIMARY KEY," + + "description TEXT NOT NULL, " + + "data BLOB NOT NULL)" + ) + db.execSQL("CREATE TABLE releases (package_name TEXT PRIMARY KEY, data BLOB NOT NULL)") + db.beginTransaction() + } + + fun addProducts(products: List) { + for (product in products) { + val outputStream = ByteArrayOutputStream() + Json.factory.createGenerator(outputStream) + .use { it.writeDictionary(product::serialize) } + db.insert( + "product", + null, + ContentValues().apply { + put("package_name", product.packageName) + put("description", product.description) + put("data", outputStream.toByteArray()) + } + ) + } + } + + fun addReleases(pairs: List>>) { + for (pair in pairs) { + val (packageName, releases) = pair + val outputStream = ByteArrayOutputStream() + Json.factory.createGenerator(outputStream).use { + it.writeStartArray() + for (release in releases) { + it.writeDictionary(release::serialize) + } + it.writeEndArray() + } + db.insert( + "releases", + null, + ContentValues().apply { + put("package_name", packageName) + put("data", outputStream.toByteArray()) + } + ) + } + } + + private fun closeTransaction() { + if (db.inTransaction()) { + db.setTransactionSuccessful() + db.endTransaction() + } + } + + fun forEach(repositoryId: Long, windowSize: Int, callback: (List, Int) -> Unit) { + closeTransaction() + db.rawQuery( + """SELECT product.description, product.data AS pd, releases.data AS rd FROM product + LEFT JOIN releases ON product.package_name = releases.package_name""", + null + )?.use { cursor -> + cursor.asSequence().map { currentCursor -> + val description = currentCursor.getString(0) + val product = Json.factory.createParser(currentCursor.getBlob(1)).use { + it.nextToken() + it.product().apply { + this.repositoryId = repositoryId + this.description = description + } + } + val releases = currentCursor.getBlob(2)?.let { bytes -> + Json.factory.createParser(bytes).use { + it.nextToken() + it.collectNotNull( + JsonToken.START_OBJECT + ) { release() } + } + }.orEmpty() + product.copy(releases = releases) + }.windowed(windowSize, windowSize, true) + .forEach { products -> callback(products, cursor.count) } + } + } + + override fun close() { + db.use { closeTransaction() } + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/index/IndexV1Parser.kt b/app/src/main/kotlin/com/leos/droidify/index/IndexV1Parser.kt new file mode 100644 index 0000000..69815e5 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/index/IndexV1Parser.kt @@ -0,0 +1,489 @@ +package com.leos.droidify.index + +import android.content.res.Resources +import androidx.core.os.ConfigurationCompat.getLocales +import androidx.core.os.LocaleListCompat +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.JsonToken +import com.leos.core.common.SdkCheck +import com.leos.core.common.extension.Json +import com.leos.core.common.extension.collectDistinctNotEmptyStrings +import com.leos.core.common.extension.collectNotNull +import com.leos.core.common.extension.forEach +import com.leos.core.common.extension.forEachKey +import com.leos.core.common.extension.illegal +import com.leos.core.common.nullIfEmpty +import com.leos.core.domain.Product +import com.leos.core.domain.Release +import java.io.InputStream + +object IndexV1Parser { + interface Callback { + fun onRepository( + mirrors: List, + name: String, + description: String, + version: Int, + timestamp: Long + ) + + fun onProduct(product: Product) + fun onReleases(packageName: String, releases: List) + } + + private class Screenshots( + val phone: List, + val smallTablet: List, + val largeTablet: List + ) + + private class Localized( + val name: String, + val summary: String, + val description: String, + val whatsNew: String, + val metadataIcon: String, + val screenshots: Screenshots? + ) + + private fun Map.getAndCall( + key: String, + callback: (String, Localized) -> T? + ): T? { + return this[key]?.let { callback(key, it) } + } + + /** + * Gets the best localization for the given [localeList] + * from collections. + */ + private fun Map?.getBestLocale(localeList: LocaleListCompat): T? { + if (isNullOrEmpty()) return null + val firstMatch = localeList.getFirstMatch(keys.toTypedArray()) ?: return null + val tag = firstMatch.toLanguageTag() + // try first matched tag first (usually has region tag, e.g. de-DE) + return get(tag) ?: run { + // split away stuff like script and try language and region only + val langCountryTag = "${firstMatch.language}-${firstMatch.country}" + getOrStartsWith(langCountryTag) ?: run { + // split away region tag and try language only + val langTag = firstMatch.language + // try language, then English and then just take the first of the list + getOrStartsWith(langTag) ?: get("en-US") ?: get("en") ?: values.first() + } + } + } + + /** + * Returns the value from the map with the given key or if that key is not contained in the map, + * tries the first map key that starts with the given key. + * If nothing matches, null is returned. + * + * This is useful when looking for a language tag like `fr_CH` and falling back to `fr` + * in a map that has `fr_FR` as a key. + */ + private fun Map.getOrStartsWith(s: String): T? = get(s) ?: run { + entries.forEach { (key, value) -> + if (key.startsWith(s)) return value + } + return null + } + + private fun Map.find(callback: (String, Localized) -> T?): T? { + return getAndCall("en-US", callback) ?: getAndCall("en_US", callback) ?: getAndCall( + "en", + callback + ) + } + + private fun Map.findLocalized(callback: (Localized) -> T?): T? { + return getBestLocale(getLocales(Resources.getSystem().configuration))?.let { callback(it) } + } + + private fun Map.findString( + fallback: String, + callback: (Localized) -> String + ): String { + return (find { _, localized -> callback(localized).nullIfEmpty() } ?: fallback).trim() + } + + private fun Map.findLocalizedString( + fallback: String, + callback: (Localized) -> String + ): String { + // @BLumia: it's possible a key of a certain Localized object is empty, so we still need a fallback + return ( + findLocalized { localized -> callback(localized).trim().nullIfEmpty() } ?: findString( + fallback, + callback + ) + ).trim() + } + + internal object DonateComparator : Comparator { + private val classes = listOf( + Product.Donate.Regular::class, + Product.Donate.Bitcoin::class, + Product.Donate.Litecoin::class, + Product.Donate.Flattr::class, + Product.Donate.Liberapay::class, + Product.Donate.OpenCollective::class + ) + + override fun compare(donate1: Product.Donate, donate2: Product.Donate): Int { + val index1 = classes.indexOf(donate1::class) + val index2 = classes.indexOf(donate2::class) + return when { + index1 >= 0 && index2 == -1 -> -1 + index2 >= 0 && index1 == -1 -> 1 + else -> index1.compareTo(index2) + } + } + } + + fun parse(repositoryId: Long, inputStream: InputStream, callback: Callback) { + val jsonParser = Json.factory.createParser(inputStream) + if (jsonParser.nextToken() != JsonToken.START_OBJECT) { + jsonParser.illegal() + } else { + jsonParser.forEachKey { it -> + when { + it.dictionary("repo") -> { + var address = "" + var mirrors = emptyList() + var name = "" + var description = "" + var version = 0 + var timestamp = 0L + forEachKey { + when { + it.string("address") -> address = valueAsString + it.array("mirrors") -> mirrors = collectDistinctNotEmptyStrings() + it.string("name") -> name = valueAsString + it.string("description") -> description = valueAsString + it.number("version") -> version = valueAsInt + it.number("timestamp") -> timestamp = valueAsLong + else -> skipChildren() + } + } + val realMirrors = ( + if (address.isNotEmpty()) { + listOf(address) + } else { + emptyList() + } + ) + mirrors + callback.onRepository( + mirrors = realMirrors.distinct(), + name = name, + description = description, + version = version, + timestamp = timestamp + ) + } + + it.array("apps") -> forEach(JsonToken.START_OBJECT) { + val product = parseProduct(repositoryId) + callback.onProduct(product) + } + + it.dictionary("packages") -> forEachKey { + if (it.token == JsonToken.START_ARRAY) { + val packageName = it.key + val releases = collectNotNull(JsonToken.START_OBJECT) { parseRelease() } + callback.onReleases(packageName, releases) + } else { + skipChildren() + } + } + + else -> skipChildren() + } + } + } + } + + private fun JsonParser.parseProduct(repositoryId: Long): Product { + var packageName = "" + var nameFallback = "" + var summaryFallback = "" + var descriptionFallback = "" + var icon = "" + var authorName = "" + var authorEmail = "" + var authorWeb = "" + var source = "" + var changelog = "" + var web = "" + var tracker = "" + var added = 0L + var updated = 0L + var suggestedVersionCode = 0L + var categories = emptyList() + var antiFeatures = emptyList() + val licenses = mutableListOf() + val donates = mutableListOf() + val localizedMap = mutableMapOf() + forEachKey { it -> + when { + it.string("packageName") -> packageName = valueAsString + it.string("name") -> nameFallback = valueAsString + it.string("summary") -> summaryFallback = valueAsString + it.string("description") -> descriptionFallback = valueAsString + it.string("icon") -> icon = validateIcon(valueAsString) + it.string("authorName") -> authorName = valueAsString + it.string("authorEmail") -> authorEmail = valueAsString + it.string("authorWebSite") -> authorWeb = valueAsString + it.string("sourceCode") -> source = valueAsString + it.string("changelog") -> changelog = valueAsString + it.string("webSite") -> web = valueAsString + it.string("issueTracker") -> tracker = valueAsString + it.number("added") -> added = valueAsLong + it.number("lastUpdated") -> updated = valueAsLong + it.string("suggestedVersionCode") -> + suggestedVersionCode = + valueAsString.toLongOrNull() ?: 0L + + it.array("categories") -> categories = collectDistinctNotEmptyStrings() + it.array("antiFeatures") -> antiFeatures = collectDistinctNotEmptyStrings() + it.string("license") -> licenses += valueAsString.split(',') + .filter { it.isNotEmpty() } + + it.string("donate") -> donates += Product.Donate.Regular(valueAsString) + it.string("bitcoin") -> donates += Product.Donate.Bitcoin(valueAsString) + it.string("flattrID") -> donates += Product.Donate.Flattr(valueAsString) + it.string("liberapayID") -> donates += Product.Donate.Liberapay(valueAsString) + it.string("openCollective") -> donates += Product.Donate.OpenCollective( + valueAsString + ) + + it.dictionary("localized") -> forEachKey { it -> + if (it.token == JsonToken.START_OBJECT) { + val locale = it.key + var name = "" + var summary = "" + var description = "" + var whatsNew = "" + var metadataIcon = "" + var phone = emptyList() + var smallTablet = emptyList() + var largeTablet = emptyList() + forEachKey { + when { + it.string("name") -> name = valueAsString + it.string("summary") -> summary = valueAsString + it.string("description") -> description = valueAsString + it.string("whatsNew") -> whatsNew = valueAsString + it.string("icon") -> metadataIcon = valueAsString + it.array("phoneScreenshots") -> + phone = + collectDistinctNotEmptyStrings() + + it.array("sevenInchScreenshots") -> + smallTablet = + collectDistinctNotEmptyStrings() + + it.array("tenInchScreenshots") -> + largeTablet = + collectDistinctNotEmptyStrings() + + else -> skipChildren() + } + } + val screenshots = + if (sequenceOf( + phone, + smallTablet, + largeTablet + ).any { it.isNotEmpty() } + ) { + Screenshots(phone, smallTablet, largeTablet) + } else { + null + } + localizedMap[locale] = Localized( + name, + summary, + description, + whatsNew, + metadataIcon.nullIfEmpty()?.let { "$locale/$it" }.orEmpty(), + screenshots + ) + } else { + skipChildren() + } + } + + else -> skipChildren() + } + } + val name = localizedMap.findLocalizedString(nameFallback) { it.name } + val summary = localizedMap.findLocalizedString(summaryFallback) { it.summary } + val description = + localizedMap.findLocalizedString(descriptionFallback) { it.description }.replace( + "\n", + "
" + ) + val whatsNew = localizedMap.findLocalizedString("") { it.whatsNew }.replace("\n", "
") + val metadataIcon = localizedMap.findLocalizedString("") { it.metadataIcon }.ifEmpty { + localizedMap.firstNotNullOfOrNull { it.value.metadataIcon }.orEmpty() + } + val screenshotPairs = + localizedMap.find { key, localized -> localized.screenshots?.let { Pair(key, it) } } + val screenshots = screenshotPairs + ?.let { (key, screenshots) -> + screenshots.phone.asSequence() + .map { Product.Screenshot(key, Product.Screenshot.Type.PHONE, it) } + + screenshots.smallTablet.asSequence() + .map { + Product.Screenshot( + key, + Product.Screenshot.Type.SMALL_TABLET, + it + ) + } + + screenshots.largeTablet.asSequence() + .map { + Product.Screenshot( + key, + Product.Screenshot.Type.LARGE_TABLET, + it + ) + } + } + .orEmpty().toList() + return Product( + repositoryId, + packageName, + name, + summary, + description, + whatsNew, + icon, + metadataIcon, + Product.Author(authorName, authorEmail, authorWeb), + source, + changelog, + web, + tracker, + added, + updated, + suggestedVersionCode, + categories, + antiFeatures, + licenses, + donates.sortedWith(DonateComparator), + screenshots, + emptyList() + ) + } + + private fun JsonParser.parseRelease(): Release { + var version = "" + var versionCode = 0L + var added = 0L + var size = 0L + var minSdkVersion = 0 + var targetSdkVersion = 0 + var maxSdkVersion = 0 + var source = "" + var release = "" + var hash = "" + var hashTypeCandidate = "" + var signature = "" + var obbMain = "" + var obbMainHash = "" + var obbPatch = "" + var obbPatchHash = "" + val permissions = linkedSetOf() + var features = emptyList() + var platforms = emptyList() + forEachKey { + when { + it.string("versionName") -> version = valueAsString + it.number("versionCode") -> versionCode = valueAsLong + it.number("added") -> added = valueAsLong + it.number("size") -> size = valueAsLong + it.number("minSdkVersion") -> minSdkVersion = valueAsInt + it.number("targetSdkVersion") -> targetSdkVersion = valueAsInt + it.number("maxSdkVersion") -> maxSdkVersion = valueAsInt + it.string("srcname") -> source = valueAsString + it.string("apkName") -> release = valueAsString + it.string("hash") -> hash = valueAsString + it.string("hashType") -> hashTypeCandidate = valueAsString + it.string("sig") -> signature = valueAsString + it.string("obbMainFile") -> obbMain = valueAsString + it.string("obbMainFileSha256") -> obbMainHash = valueAsString + it.string("obbPatchFile") -> obbPatch = valueAsString + it.string("obbPatchFileSha256") -> obbPatchHash = valueAsString + it.array("uses-permission") -> collectPermissions(permissions, 0) + it.array("uses-permission-sdk-23") -> collectPermissions(permissions, 23) + it.array("features") -> features = collectDistinctNotEmptyStrings() + it.array("nativecode") -> platforms = collectDistinctNotEmptyStrings() + else -> skipChildren() + } + } + val hashType = + if (hash.isNotEmpty() && hashTypeCandidate.isEmpty()) "sha256" else hashTypeCandidate + val obbMainHashType = if (obbMainHash.isNotEmpty()) "sha256" else "" + val obbPatchHashType = if (obbPatchHash.isNotEmpty()) "sha256" else "" + return Release( + false, + version, + versionCode, + added, + size, + minSdkVersion, + targetSdkVersion, + maxSdkVersion, + source, + release, + hash, + hashType, + signature, + obbMain, + obbMainHash, + obbMainHashType, + obbPatch, + obbPatchHash, + obbPatchHashType, + permissions.toList(), + features, + platforms, + emptyList() + ) + } + + private fun JsonParser.collectPermissions(permissions: LinkedHashSet, minSdk: Int) { + forEach(JsonToken.START_ARRAY) { + val firstToken = nextToken() + val permission = if (firstToken == JsonToken.VALUE_STRING) valueAsString else "" + if (firstToken != JsonToken.END_ARRAY) { + val secondToken = nextToken() + val maxSdk = if (secondToken == JsonToken.VALUE_NUMBER_INT) valueAsInt else 0 + if (permission.isNotEmpty() && + SdkCheck.sdk >= minSdk && ( + maxSdk <= 0 || + SdkCheck.sdk <= maxSdk + ) + ) { + permissions.add(permission) + } + if (secondToken != JsonToken.END_ARRAY) { + while (true) { + val token = nextToken() + if (token == JsonToken.END_ARRAY) { + break + } else if (token.isStructStart) { + skipChildren() + } + } + } + } + } + } + + private fun validateIcon(icon: String): String { + return if (icon.endsWith(".xml")) "" else icon + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/index/RepositoryUpdater.kt b/app/src/main/kotlin/com/leos/droidify/index/RepositoryUpdater.kt new file mode 100644 index 0000000..269858f --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/index/RepositoryUpdater.kt @@ -0,0 +1,460 @@ +package com.leos.droidify.index + +import android.content.Context +import android.net.Uri +import com.leos.core.common.SdkCheck +import com.leos.core.common.cache.Cache +import com.leos.core.common.extension.fingerprint +import com.leos.core.common.extension.toFormattedString +import com.leos.core.common.result.Result +import com.leos.core.domain.Product +import com.leos.core.domain.Release +import com.leos.core.domain.Repository +import com.leos.droidify.database.Database +import com.leos.droidify.utility.extension.android.Android +import com.leos.droidify.utility.getProgress +import com.leos.network.Downloader +import com.leos.network.NetworkResponse +import java.io.File +import java.security.CodeSigner +import java.security.cert.Certificate +import java.util.jar.JarEntry +import java.util.jar.JarFile +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map + +object RepositoryUpdater { + enum class Stage { + DOWNLOAD, PROCESS, MERGE, COMMIT + } + + // TODO Add support for Index-V2 and also cleanup everything here + private enum class IndexType( + val jarName: String, + val contentName: String + ) { + INDEX_V1("index-v1.jar", "index-v1.json") + } + + enum class ErrorType { + NETWORK, HTTP, VALIDATION, PARSING + } + + class UpdateException : Exception { + val errorType: ErrorType + + constructor(errorType: ErrorType, message: String) : super(message) { + this.errorType = errorType + } + + constructor(errorType: ErrorType, message: String, cause: Exception) : super( + message, + cause + ) { + this.errorType = errorType + } + } + + private val updaterLock = Any() + private val cleanupLock = Any() + + private lateinit var downloader: Downloader + + fun init(scope: CoroutineScope, downloader: Downloader) { + this.downloader = downloader + scope.launch { + // No need of mutex because it is in same coroutine scope + var lastDisabled = emptyMap() + Database.RepositoryAdapter + .getAllRemovedStream() + .map { deletedRepos -> + deletedRepos + .filterNot { it.key in lastDisabled.keys } + .also { lastDisabled = deletedRepos } + } + // To not perform complete cleanup on startup + .drop(1) + .filter { it.isNotEmpty() } + .collect(Database.RepositoryAdapter::cleanup) + } + } + + fun await() { + synchronized(updaterLock) { } + } + + suspend fun update( + context: Context, + repository: Repository, + unstable: Boolean, + callback: (Stage, Long, Long?) -> Unit + ) = update( + context = context, + repository = repository, + unstable = unstable, + indexTypes = listOf(IndexType.INDEX_V1), + callback = callback + ) + + private suspend fun update( + context: Context, + repository: Repository, + unstable: Boolean, + indexTypes: List, + callback: (Stage, Long, Long?) -> Unit + ): Result = withContext(Dispatchers.IO) { + val indexType = indexTypes[0] + when (val request = downloadIndex(context, repository, indexType, callback)) { + is Result.Error -> { + val result = request.data + ?: return@withContext Result.Error(request.exception, false) + + val file = request.data?.file + ?: return@withContext Result.Error(request.exception, false) + file.delete() + if (result.statusCode == 404 && indexTypes.isNotEmpty()) { + update( + context = context, + repository = repository, + indexTypes = indexTypes.subList(1, indexTypes.size), + unstable = unstable, + callback = callback + ) + } else { + Result.Error( + UpdateException( + ErrorType.HTTP, + "Invalid response: HTTP ${result.statusCode}" + ) + ) + } + } + + is Result.Success -> { + if (request.data.isUnmodified) { + request.data.file.delete() + Result.Success(false) + } else { + try { + val isFileParsedSuccessfully = processFile( + context = context, + repository = repository, + indexType = indexType, + unstable = unstable, + file = request.data.file, + lastModified = request.data.lastModified, + entityTag = request.data.entityTag, + callback = callback + ) + Result.Success(isFileParsedSuccessfully) + } catch (e: UpdateException) { + Result.Error(e) + } + } + } + } + } + + private suspend fun downloadIndex( + context: Context, + repository: Repository, + indexType: IndexType, + callback: (Stage, Long, Long?) -> Unit + ): Result = withContext(Dispatchers.IO) { + val file = Cache.getTemporaryFile(context) + val result = downloader.downloadToFile( + url = Uri.parse(repository.address).buildUpon() + .appendPath(indexType.jarName).build().toString(), + target = file, + headers = { + ifModifiedSince(repository.lastModified) + etag(repository.entityTag) + authentication(repository.authentication) + } + ) { read, total -> + callback(Stage.DOWNLOAD, read.value, total.value) + } + + when (result) { + is NetworkResponse.Success -> { + Result.Success( + IndexFile( + isUnmodified = result.statusCode == 304, + lastModified = result.lastModified?.toFormattedString() ?: "", + entityTag = result.etag ?: "", + statusCode = result.statusCode, + file = file + ) + ) + } + + is NetworkResponse.Error -> { + file.delete() + when (result) { + is NetworkResponse.Error.Http -> { + val errorType = if (result.statusCode in 400..499) { + ErrorType.HTTP + } else { + ErrorType.NETWORK + } + + Result.Error( + UpdateException( + errorType = errorType, + message = "Failed with Status: ${result.statusCode}" + ) + ) + } + + is NetworkResponse.Error.ConnectionTimeout -> Result.Error(result.exception) + is NetworkResponse.Error.IO -> Result.Error(result.exception) + is NetworkResponse.Error.SocketTimeout -> Result.Error(result.exception) + is NetworkResponse.Error.Unknown -> Result.Error(result.exception) + // TODO: Add Validator + is NetworkResponse.Error.Validation -> Result.Error() + } + } + } + } + + private fun processFile( + context: Context, + repository: Repository, + indexType: IndexType, + unstable: Boolean, + file: File, + lastModified: String, + entityTag: String, + callback: (Stage, Long, Long?) -> Unit + ): Boolean { + var rollback = true + return synchronized(updaterLock) { + try { + val jarFile = JarFile(file, true) + val indexEntry = jarFile.getEntry(indexType.contentName) as JarEntry + val total = indexEntry.size + Database.UpdaterAdapter.createTemporaryTable() + val features = context.packageManager.systemAvailableFeatures + .asSequence().map { it.name }.toSet() + setOf("android.hardware.touchscreen") + + var changedRepository: Repository? = null + + val mergerFile = Cache.getTemporaryFile(context) + try { + val unmergedProducts = mutableListOf() + val unmergedReleases = mutableListOf>>() + IndexMerger(mergerFile).use { indexMerger -> + jarFile.getInputStream(indexEntry).getProgress { + callback(Stage.PROCESS, it, total) + }.use { entryStream -> + IndexV1Parser.parse( + repository.id, + entryStream, + object : IndexV1Parser.Callback { + override fun onRepository( + mirrors: List, + name: String, + description: String, + version: Int, + timestamp: Long + ) { + changedRepository = repository.update( + mirrors, + name, + description, + version, + lastModified, + entityTag, + timestamp + ) + } + + override fun onProduct(product: Product) { + if (Thread.interrupted()) { + throw InterruptedException() + } + unmergedProducts += product + if (unmergedProducts.size >= 50) { + indexMerger.addProducts(unmergedProducts) + unmergedProducts.clear() + } + } + + override fun onReleases( + packageName: String, + releases: List + ) { + if (Thread.interrupted()) { + throw InterruptedException() + } + unmergedReleases += Pair(packageName, releases) + if (unmergedReleases.size >= 50) { + indexMerger.addReleases(unmergedReleases) + unmergedReleases.clear() + } + } + } + ) + + if (Thread.interrupted()) { + throw InterruptedException() + } + if (unmergedProducts.isNotEmpty()) { + indexMerger.addProducts(unmergedProducts) + unmergedProducts.clear() + } + if (unmergedReleases.isNotEmpty()) { + indexMerger.addReleases(unmergedReleases) + unmergedReleases.clear() + } + var progress = 0 + indexMerger.forEach(repository.id, 50) { products, totalCount -> + if (Thread.interrupted()) { + throw InterruptedException() + } + progress += products.size + callback( + Stage.MERGE, + progress.toLong(), + totalCount.toLong() + ) + Database.UpdaterAdapter.putTemporary( + products + .map { transformProduct(it, features, unstable) } + ) + } + } + } + } finally { + mergerFile.delete() + } + + val workRepository = changedRepository ?: repository + if (workRepository.timestamp < repository.timestamp) { + throw UpdateException( + ErrorType.VALIDATION, + "New index is older than current index:" + + " ${workRepository.timestamp} < ${repository.timestamp}" + ) + } + + val fingerprint = indexEntry + .codeSigner + .certificate + .fingerprint() + .uppercase() + + val commitRepository = if (!workRepository.fingerprint.equals( + fingerprint, + ignoreCase = true + ) + ) { + if (workRepository.fingerprint.isNotEmpty()) { + throw UpdateException( + ErrorType.VALIDATION, + "Certificate fingerprints do not match" + ) + } + + workRepository.copy(fingerprint = fingerprint) + } else { + workRepository + } + if (Thread.interrupted()) { + throw InterruptedException() + } + callback(Stage.COMMIT, 0, null) + synchronized(cleanupLock) { + Database.UpdaterAdapter.finishTemporary(commitRepository, true) + } + rollback = false + true + } catch (e: Exception) { + throw when (e) { + is UpdateException, is InterruptedException -> e + else -> UpdateException(ErrorType.PARSING, "Error parsing index", e) + } + } finally { + file.delete() + if (rollback) { + Database.UpdaterAdapter.finishTemporary(repository, false) + } + } + } + } + + @get:Throws(UpdateException::class) + private val JarEntry.codeSigner: CodeSigner + get() = codeSigners?.singleOrNull() + ?: throw UpdateException( + ErrorType.VALIDATION, + "index.jar must be signed by a single code signer" + ) + + @get:Throws(UpdateException::class) + private val CodeSigner.certificate: Certificate + get() = signerCertPath?.certificates?.singleOrNull() + ?: throw UpdateException( + ErrorType.VALIDATION, + "index.jar code signer should have only one certificate" + ) + + private fun transformProduct( + product: Product, + features: Set, + unstable: Boolean + ): Product { + val releasePairs = product.releases + .distinctBy { it.identifier } + .sortedByDescending { it.versionCode } + .map { release -> + val incompatibilities = mutableListOf() + if (release.minSdkVersion > 0 && SdkCheck.sdk < release.minSdkVersion) { + incompatibilities += Release.Incompatibility.MinSdk + } + if (release.maxSdkVersion > 0 && SdkCheck.sdk > release.maxSdkVersion) { + incompatibilities += Release.Incompatibility.MaxSdk + } + if (release.platforms.isNotEmpty() && + (release.platforms intersect Android.platforms).isEmpty() + ) { + incompatibilities += Release.Incompatibility.Platform + } + incompatibilities += (release.features - features).sorted() + .map { Release.Incompatibility.Feature(it) } + Pair(release, incompatibilities.toList()) + } + + val predicate: (Release) -> Boolean = { + unstable || + product.suggestedVersionCode <= 0 || + it.versionCode <= product.suggestedVersionCode + } + + val firstSelected = + releasePairs.firstOrNull { it.second.isEmpty() && predicate(it.first) } + ?: releasePairs.firstOrNull { predicate(it.first) } + + val releases = releasePairs + .map { (release, incompatibilities) -> + release.copy( + incompatibilities = incompatibilities, + selected = firstSelected?.let { + it.first.versionCode == release.versionCode && + it.second == incompatibilities + } ?: false + ) + } + return product.copy(releases = releases) + } +} + +data class IndexFile( + val isUnmodified: Boolean, + val lastModified: String, + val entityTag: String, + val statusCode: Int, + val file: File +) diff --git a/app/src/main/kotlin/com/leos/droidify/receivers/InstalledAppReceiver.kt b/app/src/main/kotlin/com/leos/droidify/receivers/InstalledAppReceiver.kt new file mode 100644 index 0000000..e437afe --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/receivers/InstalledAppReceiver.kt @@ -0,0 +1,30 @@ +package com.leos.droidify.receivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import com.leos.core.common.extension.getPackageInfoCompat +import com.leos.droidify.database.Database +import com.leos.droidify.utility.extension.toInstalledItem + +class InstalledAppReceiver(private val packageManager: PackageManager) : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val packageName = + intent.data?.let { if (it.scheme == "package") it.schemeSpecificPart else null } + if (packageName != null) { + when (intent.action.orEmpty()) { + Intent.ACTION_PACKAGE_ADDED, + Intent.ACTION_PACKAGE_REMOVED + -> { + val packageInfo = packageManager.getPackageInfoCompat(packageName) + if (packageInfo != null) { + Database.InstalledAdapter.put(packageInfo.toInstalledItem()) + } else { + Database.InstalledAdapter.delete(packageName) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/service/Connection.kt b/app/src/main/kotlin/com/leos/droidify/service/Connection.kt new file mode 100644 index 0000000..d20f42b --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/service/Connection.kt @@ -0,0 +1,43 @@ +package com.leos.droidify.service + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder + +class Connection>( + private val serviceClass: Class, + private val onBind: ((Connection, B) -> Unit)? = null, + private val onUnbind: ((Connection, B) -> Unit)? = null +) : ServiceConnection { + var binder: B? = null + private set + + private fun handleUnbind() { + binder?.let { + binder = null + onUnbind?.invoke(this, it) + } + } + + override fun onServiceConnected(componentName: ComponentName, binder: IBinder) { + @Suppress("UNCHECKED_CAST") + binder as B + this.binder = binder + onBind?.invoke(this, binder) + } + + override fun onServiceDisconnected(componentName: ComponentName) { + handleUnbind() + } + + fun bind(context: Context) { + context.bindService(Intent(context, serviceClass), this, Context.BIND_AUTO_CREATE) + } + + fun unbind(context: Context) { + context.unbindService(this) + handleUnbind() + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/service/ConnectionService.kt b/app/src/main/kotlin/com/leos/droidify/service/ConnectionService.kt new file mode 100644 index 0000000..1f41bac --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/service/ConnectionService.kt @@ -0,0 +1,22 @@ +package com.leos.droidify.service + +import android.app.Service +import android.content.Intent +import android.os.IBinder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel + +abstract class ConnectionService : Service() { + + private val supervisorJob = SupervisorJob() + val lifecycleScope = CoroutineScope(Dispatchers.Main + supervisorJob) + + abstract override fun onBind(intent: Intent): T + + override fun onDestroy() { + super.onDestroy() + lifecycleScope.cancel() + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/service/DownloadService.kt b/app/src/main/kotlin/com/leos/droidify/service/DownloadService.kt new file mode 100644 index 0000000..31eb7ec --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/service/DownloadService.kt @@ -0,0 +1,487 @@ +package com.leos.droidify.service + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.util.Log +import android.view.ContextThemeWrapper +import androidx.core.app.NotificationCompat +import com.leos.core.common.Constants +import com.leos.core.common.DataSize +import com.leos.core.common.R as CommonR +import com.leos.core.common.R.string as stringRes +import com.leos.core.common.R.style as styleRes +import com.leos.core.common.SdkCheck +import com.leos.core.common.cache.Cache +import com.leos.core.common.extension.notificationManager +import com.leos.core.common.extension.percentBy +import com.leos.core.common.extension.startSelf +import com.leos.core.common.extension.stopForegroundCompat +import com.leos.core.common.extension.toPendingIntent +import com.leos.core.common.extension.updateAsMutable +import com.leos.core.common.log +import com.leos.core.common.sdkAbove +import com.leos.core.common.signature.ValidationException +import com.leos.core.datastore.SettingsRepository +import com.leos.core.datastore.get +import com.leos.core.datastore.model.InstallerType +import com.leos.core.domain.Release +import com.leos.core.domain.Repository +import com.leos.droidify.BuildConfig +import com.leos.droidify.MainActivity +import com.leos.installer.InstallManager +import com.leos.installer.model.installFrom +import com.leos.network.Downloader +import com.leos.network.NetworkResponse +import dagger.hilt.android.AndroidEntryPoint +import java.io.File +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.yield + +@AndroidEntryPoint +class DownloadService : ConnectionService() { + companion object { + private const val ACTION_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL" + } + + @Inject + lateinit var settingsRepository: SettingsRepository + + @Inject + lateinit var downloader: Downloader + + private val installerType + get() = settingsRepository.get { installerType } + + @Inject + lateinit var installer: InstallManager + + sealed class State(val packageName: String) { + data object Idle : State("") + data class Connecting(val name: String) : State(name) + data class Downloading(val name: String, val read: DataSize, val total: DataSize?) : State( + name + ) + + data class Error(val name: String) : State(name) + data class Cancel(val name: String) : State(name) + data class Success(val name: String, val release: Release) : State(name) + } + + data class DownloadState( + val currentItem: State = State.Idle, + val queue: List = emptyList() + ) { + infix fun isDownloading(packageName: String): Boolean = + currentItem.packageName == packageName && ( + currentItem is State.Connecting || currentItem is State.Downloading + ) + + infix fun isComplete(packageName: String): Boolean = + currentItem.packageName == packageName && ( + currentItem is State.Error || + currentItem is State.Cancel || + currentItem is State.Success || + currentItem is State.Idle + ) + } + + private val _downloadState = MutableStateFlow(DownloadState()) + + private class Task( + val packageName: String, + val name: String, + val release: Release, + val url: String, + val authentication: String, + val isUpdate: Boolean = false + ) { + val notificationTag: String + get() = "download-$packageName" + } + + private data class CurrentTask(val task: Task, val job: Job, val lastState: State) + + private var started = false + private val tasks = mutableListOf() + private var currentTask: CurrentTask? = null + + private val lock = Mutex() + + inner class Binder : android.os.Binder() { + val downloadState = _downloadState.asStateFlow() + fun enqueue( + packageName: String, + name: String, + repository: Repository, + release: Release, + isUpdate: Boolean = false + ) { + val task = Task( + packageName = packageName, + name = name, + release = release, + url = release.getDownloadUrl(repository), + authentication = repository.authentication, + isUpdate = isUpdate + ) + if (Cache.getReleaseFile(this@DownloadService, release.cacheFileName).exists()) { + lifecycleScope.launch { publishSuccess(task) } + return + } + cancelTasks(packageName) + cancelCurrentTask(packageName) + notificationManager?.cancel( + task.notificationTag, + Constants.NOTIFICATION_ID_DOWNLOADING + ) + tasks += task + if (currentTask == null) { + handleDownload() + } else { + updateCurrentQueue { add(packageName) } + } + } + + fun cancel(packageName: String) { + cancelTasks(packageName) + cancelCurrentTask(packageName) + } + } + + private val binder = Binder() + override fun onBind(intent: Intent): Binder = binder + + override fun onCreate() { + super.onCreate() + + sdkAbove(Build.VERSION_CODES.O) { + NotificationChannel( + Constants.NOTIFICATION_CHANNEL_DOWNLOADING, + getString(stringRes.downloading), + NotificationManager.IMPORTANCE_LOW + ).apply { setShowBadge(false) } + .let { + notificationManager?.createNotificationChannel(it) + } + } + + lifecycleScope.launch { + _downloadState + .filter { currentTask != null } + .sample(400) + .collectLatest { + publishForegroundState(false, it.currentItem) + } + } + } + + override fun onDestroy() { + super.onDestroy() + cancelTasks(null) + cancelCurrentTask(null) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_CANCEL) { + currentTask?.let { binder.cancel(it.task.packageName) } + } + return START_NOT_STICKY + } + + private fun cancelTasks(packageName: String?) { + tasks.removeAll { + (packageName == null || it.packageName == packageName) && run { + updateCurrentState(State.Cancel(it.packageName)) + true + } + } + } + + private fun cancelCurrentTask(packageName: String?) { + currentTask?.let { + if (packageName == null || it.task.packageName == packageName) { + it.job.cancel() + currentTask = null + updateCurrentState(State.Cancel(it.task.packageName)) + } + } + } + + private sealed interface ErrorType { + data object IO : ErrorType + data object Http : ErrorType + data object SocketTimeout : ErrorType + data object ConnectionTimeout : ErrorType + class Validation(val exception: ValidationException) : ErrorType + } + + private fun showNotificationError(task: Task, errorType: ErrorType) { + val intent = Intent(this, MainActivity::class.java) + .setAction(Intent.ACTION_VIEW) + .setData(Uri.parse("package:${task.packageName}")) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + .toPendingIntent(this) + notificationManager?.notify( + task.notificationTag, + Constants.NOTIFICATION_ID_DOWNLOADING, + NotificationCompat + .Builder(this, Constants.NOTIFICATION_CHANNEL_DOWNLOADING) + .setAutoCancel(true) + .setSmallIcon(android.R.drawable.stat_notify_error) + .setColor( + ContextThemeWrapper(this, styleRes.Theme_Main_Light) + .getColor(CommonR.color.md_theme_dark_errorContainer) + ) + .setOnlyAlertOnce(true) + .setContentIntent(intent) + .errorNotificationContent(task, errorType) + .build() + ) + } + + private fun NotificationCompat.Builder.errorNotificationContent( + task: Task, + errorType: ErrorType + ): NotificationCompat.Builder { + val title = if (errorType is ErrorType.Validation) { + stringRes.could_not_validate_FORMAT + } else { + stringRes.could_not_download_FORMAT + } + val description = when (errorType) { + ErrorType.ConnectionTimeout -> getString(stringRes.connection_error_DESC) + ErrorType.Http -> getString(stringRes.http_error_DESC) + ErrorType.IO -> getString(stringRes.io_error_DESC) + ErrorType.SocketTimeout -> getString(stringRes.socket_error_DESC) + is ErrorType.Validation -> errorType.exception.message + } + setContentTitle(getString(title, task.name)) + return setContentText(description) + } + + private fun showNotificationInstall(task: Task) { + val intent = Intent(this, MainActivity::class.java) + .setAction(MainActivity.ACTION_INSTALL) + .setData(Uri.parse("package:${task.packageName}")) + .putExtra(MainActivity.EXTRA_CACHE_FILE_NAME, task.release.cacheFileName) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + .toPendingIntent(this) + notificationManager?.notify( + task.notificationTag, + Constants.NOTIFICATION_ID_DOWNLOADING, + NotificationCompat + .Builder(this, Constants.NOTIFICATION_CHANNEL_DOWNLOADING) + .setAutoCancel(true) + .setOngoing(false) + .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setColor( + ContextThemeWrapper(this, styleRes.Theme_Main_Light) + .getColor(CommonR.color.md_theme_dark_primaryContainer) + ) + .setOnlyAlertOnce(true) + .setContentIntent(intent) + .setContentTitle(getString(stringRes.downloaded_FORMAT, task.name)) + .setContentText(getString(stringRes.tap_to_install_DESC)) + .build() + ) + } + + private suspend fun publishSuccess(task: Task) { + val currentInstaller = installerType.first() + updateCurrentQueue { add("") } + updateCurrentState(State.Success(task.packageName, task.release)) + val autoInstallWithSessionInstaller = + SdkCheck.canAutoInstall(task.release.targetSdkVersion) && + currentInstaller == InstallerType.SESSION && + task.isUpdate + + showNotificationInstall(task) + if (currentInstaller == InstallerType.ROOT || + currentInstaller == InstallerType.SHIZUKU || + autoInstallWithSessionInstaller + ) { + val installItem = task.packageName installFrom task.release.cacheFileName + installer install installItem + } + } + + private val stateNotificationBuilder by lazy { + NotificationCompat + .Builder(this, Constants.NOTIFICATION_CHANNEL_DOWNLOADING) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setColor( + ContextThemeWrapper(this, styleRes.Theme_Main_Light) + .getColor(CommonR.color.md_theme_dark_primaryContainer) + ) + .addAction( + 0, + getString(stringRes.cancel), + PendingIntent.getService( + this, + 0, + Intent(this, this::class.java).setAction(ACTION_CANCEL), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + ) + } + + private fun publishForegroundState(force: Boolean, state: State) { + if (!force && currentTask == null) return + currentTask = currentTask!!.copy(lastState = state) + stateNotificationBuilder.downloadingNotificationContent(state) + ?.let { notification -> + startForeground( + Constants.NOTIFICATION_ID_DOWNLOADING, + notification.build() + ) + } ?: run { + log("Invalid Download State: $state", "DownloadService", Log.ERROR) + } + } + + private fun NotificationCompat.Builder.downloadingNotificationContent( + state: State + ): NotificationCompat.Builder? { + return when (state) { + is State.Connecting -> { + setContentTitle(getString(stringRes.downloading_FORMAT, currentTask!!.task.name)) + setContentText(getString(stringRes.connecting)) + setProgress(1, 0, true) + } + + is State.Downloading -> { + setContentTitle(getString(stringRes.downloading_FORMAT, currentTask!!.task.name)) + if (state.total != null) { + setContentText("${state.read} / ${state.total}") + setProgress(100, state.read.value percentBy state.total.value, false) + } else { + setContentText(state.read.toString()) + setProgress(0, 0, true) + } + } + + else -> null + } + } + + private fun handleDownload() { + if (currentTask != null) return + if (tasks.isEmpty() && started) { + started = false + stopForegroundCompat() + return + } + if (!started) { + started = true + startSelf() + } + val task = tasks.removeFirstOrNull() ?: return + with(stateNotificationBuilder) { + setWhen(System.currentTimeMillis()) + setContentIntent(createNotificationIntent(task.packageName)) + } + val connectionState = State.Connecting(task.packageName) + val partialReleaseFile = + Cache.getPartialReleaseFile(this, task.release.cacheFileName) + val job = lifecycleScope.downloadFile(task, partialReleaseFile) + currentTask = CurrentTask(task, job, connectionState) + publishForegroundState(true, connectionState) + updateCurrentState(State.Connecting(task.packageName)) + } + + private fun createNotificationIntent(packageName: String): PendingIntent? = + Intent(this, MainActivity::class.java) + .setAction(Intent.ACTION_VIEW) + .setData(Uri.parse("package:$packageName")) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .toPendingIntent(this) + + private fun CoroutineScope.downloadFile( + task: Task, + target: File + ) = launch { + try { + val releaseValidator = ReleaseFileValidator( + context = this@DownloadService, + packageName = task.packageName, + release = task.release + ) + val response = downloader.downloadToFile( + url = task.url, + target = target, + validator = releaseValidator, + headers = { authentication(task.authentication) } + ) { read, total -> + yield() + updateCurrentState(State.Downloading(task.packageName, read, total)) + } + + when (response) { + is NetworkResponse.Success -> { + val releaseFile = Cache.getReleaseFile( + this@DownloadService, + task.release.cacheFileName + ) + target.renameTo(releaseFile) + publishSuccess(task) + } + + is NetworkResponse.Error -> { + updateCurrentState(State.Error(task.packageName)) + val errorType = when (response) { + is NetworkResponse.Error.ConnectionTimeout -> ErrorType.ConnectionTimeout + is NetworkResponse.Error.IO -> ErrorType.IO + is NetworkResponse.Error.SocketTimeout -> ErrorType.SocketTimeout + is NetworkResponse.Error.Validation -> ErrorType.Validation( + response.exception + ) + + else -> ErrorType.Http + } + showNotificationError(task, errorType) + } + } + } finally { + lock.withLock { currentTask = null } + handleDownload() + } + } + + private fun updateCurrentState(state: State) { + _downloadState.update { + val newQueue = + if (state.packageName in it.queue) { + it.queue.updateAsMutable { + removeAll { name -> name == "" } + remove(state.packageName) + } + } else { + it.queue + } + it.copy(currentItem = state, queue = newQueue) + } + } + + private fun updateCurrentQueue(block: MutableList.() -> Unit) { + _downloadState.update { state -> + state.copy(queue = state.queue.updateAsMutable(block)) + } + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/service/ReleaseFileValidator.kt b/app/src/main/kotlin/com/leos/droidify/service/ReleaseFileValidator.kt new file mode 100644 index 0000000..5b92156 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/service/ReleaseFileValidator.kt @@ -0,0 +1,53 @@ +package com.leos.droidify.service + +import android.content.Context +import androidx.annotation.StringRes +import com.leos.core.common.R.string as strings +import com.leos.core.common.extension.calculateHash +import com.leos.core.common.extension.getPackageArchiveInfoCompat +import com.leos.core.common.extension.singleSignature +import com.leos.core.common.extension.versionCodeCompat +import com.leos.core.common.signature.FileValidator +import com.leos.core.common.signature.Hash +import com.leos.core.common.signature.ValidationException +import com.leos.core.common.signature.verifyHash +import com.leos.core.domain.Release +import java.io.File + +class ReleaseFileValidator( + private val context: Context, + private val packageName: String, + private val release: Release +) : FileValidator { + + override suspend fun validate(file: File) { + val hash = Hash(release.hashType, release.hash) + if (!file.verifyHash(hash)) { + throw ValidationException( + getString(strings.integrity_check_error_DESC) + ) + } + val packageInfo = context.packageManager.getPackageArchiveInfoCompat(file.path) + ?: throw ValidationException(getString(strings.file_format_error_DESC)) + if (packageInfo.packageName != packageName || + packageInfo.versionCodeCompat != release.versionCode + ) { + throw ValidationException(getString(strings.invalid_metadata_error_DESC)) + } + + packageInfo.singleSignature + ?.calculateHash() + ?.takeIf { it.isNotBlank() || it == release.signature } + ?: throw ValidationException(getString(strings.invalid_signature_error_DESC)) + + packageInfo.permissions + ?.asSequence() + .orEmpty() + .map { it.name } + .toSet() + .takeIf { release.permissions.containsAll(it) } + ?: throw ValidationException(getString(strings.invalid_permissions_error_DESC)) + } + + private fun getString(@StringRes id: Int): String = context.getString(id) +} diff --git a/app/src/main/kotlin/com/leos/droidify/service/SyncService.kt b/app/src/main/kotlin/com/leos/droidify/service/SyncService.kt new file mode 100644 index 0000000..1aba090 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/service/SyncService.kt @@ -0,0 +1,636 @@ +package com.leos.droidify.service + +import android.annotation.SuppressLint +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.job.JobInfo +import android.app.job.JobParameters +import android.app.job.JobService +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.os.Build +import android.text.SpannableStringBuilder +import android.text.style.ForegroundColorSpan +import android.view.ContextThemeWrapper +import androidx.core.app.NotificationCompat +import androidx.fragment.app.Fragment +import com.leos.core.common.Constants +import com.leos.core.common.DataSize +import com.leos.core.common.SdkCheck +import com.leos.core.common.extension.getColorFromAttr +import com.leos.core.common.extension.notificationManager +import com.leos.core.common.extension.percentBy +import com.leos.core.common.extension.startSelf +import com.leos.core.common.extension.stopForegroundCompat +import com.leos.core.common.result.Result +import com.leos.core.common.sdkAbove +import com.leos.core.datastore.SettingsRepository +import com.leos.core.domain.ProductItem +import com.leos.core.domain.Repository +import com.leos.droidify.BuildConfig +import com.leos.droidify.MainActivity +import com.leos.droidify.database.Database +import com.leos.droidify.index.RepositoryUpdater +import com.leos.droidify.utility.extension.startUpdate +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.lang.ref.WeakReference +import javax.inject.Inject +import com.leos.core.common.R as CommonR +import com.leos.core.common.R.string as stringRes +import com.leos.core.common.R.style as styleRes +import kotlinx.coroutines.Job as CoroutinesJob + +@AndroidEntryPoint +class SyncService : ConnectionService() { + + companion object { + private const val MAX_PROGRESS = 100 + + private const val NOTIFICATION_UPDATE_SAMPLING = 400L + + private const val MAX_UPDATE_NOTIFICATION = 5 + private const val ACTION_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL" + + private val syncState = MutableSharedFlow() + private val onFinishState = MutableSharedFlow() + } + + @Inject + lateinit var settingsRepository: SettingsRepository + + private sealed class State(val name: String) { + data class Connecting(val appName: String) : State(appName) + data class Syncing( + val appName: String, + val stage: RepositoryUpdater.Stage, + val read: DataSize, + val total: DataSize? + ) : State(appName) + } + + private class Task(val repositoryId: Long, val manual: Boolean) + private data class CurrentTask( + val task: Task?, + val job: CoroutinesJob, + val hasUpdates: Boolean, + val lastState: State + ) + + private enum class Started { NO, AUTO, MANUAL } + + private var started = Started.NO + private val tasks = mutableListOf() + private var currentTask: CurrentTask? = null + + private var updateNotificationBlockerFragment: WeakReference? = null + + private val downloadConnection = Connection(DownloadService::class.java) + private val lock = Mutex() + + enum class SyncRequest { AUTO, MANUAL, FORCE } + + inner class Binder : android.os.Binder() { + + val onFinish: SharedFlow + get() = onFinishState.asSharedFlow() + + private fun sync(ids: List, request: SyncRequest) { + val cancelledTask = + cancelCurrentTask { request == SyncRequest.FORCE && it.task?.repositoryId in ids } + cancelTasks { !it.manual && it.repositoryId in ids } + val currentIds = tasks.asSequence().map { it.repositoryId }.toSet() + val manual = request != SyncRequest.AUTO + tasks += ids.asSequence().filter { + it !in currentIds && + it != currentTask?.task?.repositoryId + }.map { Task(it, manual) } + handleNextTask(cancelledTask?.hasUpdates == true) + if (request != SyncRequest.AUTO && started == Started.AUTO) { + started = Started.MANUAL + startSelf() + handleSetStarted() + currentTask?.lastState?.let { publishForegroundState(true, it) } + } + } + + fun sync(request: SyncRequest) { + val ids = Database.RepositoryAdapter.getAll() + .asSequence().filter { it.enabled }.map { it.id }.toList() + sync(ids, request) + } + + fun sync(repository: Repository) { + if (repository.enabled) { + sync(listOf(repository.id), SyncRequest.FORCE) + } + } + + suspend fun updateAllApps() { + updateAllAppsInternal() + } + + fun setUpdateNotificationBlocker(fragment: Fragment?) { + updateNotificationBlockerFragment = fragment?.let(::WeakReference) + if (fragment != null) { + notificationManager?.cancel(Constants.NOTIFICATION_ID_UPDATES) + } + } + + fun setEnabled(repository: Repository, enabled: Boolean): Boolean { + Database.RepositoryAdapter.put(repository.enable(enabled)) + if (enabled) { + val isRepoInTasks = repository.id != currentTask?.task?.repositoryId && + !tasks.any { it.repositoryId == repository.id } + if (isRepoInTasks) { + tasks += Task(repository.id, true) + handleNextTask(false) + } + } else { + cancelTasks { it.repositoryId == repository.id } + val cancelledTask = cancelCurrentTask { + it.task?.repositoryId == repository.id + } + handleNextTask(cancelledTask?.hasUpdates == true) + } + return true + } + + fun isCurrentlySyncing(repositoryId: Long): Boolean { + return currentTask?.task?.repositoryId == repositoryId + } + + fun deleteRepository(repositoryId: Long): Boolean { + val repository = Database.RepositoryAdapter.get(repositoryId) + return repository != null && run { + setEnabled(repository, false) + Database.RepositoryAdapter.markAsDeleted(repository.id) + true + } + } + + fun cancelAuto(): Boolean { + val removed = cancelTasks { !it.manual } + val currentTask = cancelCurrentTask { it.task?.manual == false } + handleNextTask(currentTask?.hasUpdates == true) + return removed || currentTask != null + } + } + + private val binder = Binder() + override fun onBind(intent: Intent): Binder = binder + + override fun onCreate() { + super.onCreate() + + sdkAbove(Build.VERSION_CODES.O) { + val channels = listOf( + NotificationChannel( + Constants.NOTIFICATION_CHANNEL_SYNCING, + getString(stringRes.syncing), + NotificationManager.IMPORTANCE_LOW + ).apply { setShowBadge(false) }, + NotificationChannel( + Constants.NOTIFICATION_CHANNEL_UPDATES, + getString(stringRes.updates), + NotificationManager.IMPORTANCE_LOW + ) + ) + notificationManager?.createNotificationChannels(channels) + } + downloadConnection.bind(this) + lifecycleScope.launch { + syncState + .sample(NOTIFICATION_UPDATE_SAMPLING) + .collectLatest { + publishForegroundState(false, it) + } + } + } + + override fun onDestroy() { + super.onDestroy() + downloadConnection.unbind(this) + cancelTasks { true } + cancelCurrentTask { true } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_CANCEL) { + tasks.clear() + val cancelledTask = cancelCurrentTask { it.task != null } + handleNextTask(cancelledTask?.hasUpdates == true) + } + return START_NOT_STICKY + } + + private fun cancelTasks(condition: (Task) -> Boolean): Boolean { + return tasks.removeAll(condition) + } + + private fun cancelCurrentTask(condition: ((CurrentTask) -> Boolean)): CurrentTask? { + return currentTask?.let { + if (condition(it)) { + currentTask = null + it.job.cancel() + RepositoryUpdater.await() + it + } else { + null + } + } + } + + private fun showNotificationError(repository: Repository, exception: Exception) { + val description = getString( + when (exception) { + is RepositoryUpdater.UpdateException -> when (exception.errorType) { + RepositoryUpdater.ErrorType.NETWORK -> stringRes.network_error_DESC + RepositoryUpdater.ErrorType.HTTP -> stringRes.http_error_DESC + RepositoryUpdater.ErrorType.VALIDATION -> stringRes.validation_index_error_DESC + RepositoryUpdater.ErrorType.PARSING -> stringRes.parsing_index_error_DESC + } + + else -> stringRes.unknown_error_DESC + } + ) + notificationManager?.notify( + "repository-${repository.id}", + Constants.NOTIFICATION_ID_SYNCING, + NotificationCompat + .Builder(this, Constants.NOTIFICATION_CHANNEL_SYNCING) + .setSmallIcon(android.R.drawable.stat_sys_warning) + .setColor( + ContextThemeWrapper(this, styleRes.Theme_Main_Light) + .getColorFromAttr(android.R.attr.colorPrimary).defaultColor + ) + .setContentTitle(getString(stringRes.could_not_sync_FORMAT, repository.name)) + .setContentText(description) + .build() + ) + } + + private val stateNotificationBuilder by lazy { + NotificationCompat + .Builder(this, Constants.NOTIFICATION_CHANNEL_SYNCING) + .setSmallIcon(CommonR.drawable.ic_sync) + .setColor( + ContextThemeWrapper(this, styleRes.Theme_Main_Light) + .getColorFromAttr(android.R.attr.colorPrimary).defaultColor + ) + .addAction( + 0, + getString(stringRes.cancel), + PendingIntent.getService( + this, + 0, + Intent(this, this::class.java).setAction(ACTION_CANCEL), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + ) + } + + private fun publishForegroundState(force: Boolean, state: State) { + if (force || currentTask?.lastState != state) { + currentTask = currentTask?.copy(lastState = state) + if (started == Started.MANUAL) { + startForeground( + Constants.NOTIFICATION_ID_SYNCING, + stateNotificationBuilder.apply { + setContentTitle(getString(stringRes.syncing_FORMAT, state.name)) + when (state) { + is State.Connecting -> { + setContentText(getString(stringRes.connecting)) + setProgress(0, 0, true) + } + + is State.Syncing -> { + when (state.stage) { + RepositoryUpdater.Stage.DOWNLOAD -> { + if (state.total != null) { + setContentText("${state.read} / ${state.total}") + setProgress( + MAX_PROGRESS, + state.read percentBy state.total, + false + ) + } else { + setContentText(state.read.toString()) + setProgress(0, 0, true) + } + } + + RepositoryUpdater.Stage.PROCESS -> { + val progress = (state.read percentBy state.total) + .takeIf { it != -1 } + setContentText( + getString( + stringRes.processing_FORMAT, + "${progress ?: 0}%" + ) + ) + setProgress(MAX_PROGRESS, progress ?: 0, progress == null) + } + + RepositoryUpdater.Stage.MERGE -> { + val progress = (state.read percentBy state.total) + setContentText( + getString( + stringRes.merging_FORMAT, + "${state.read.value} / ${state.total?.value ?: state.read.value}" + ) + ) + setProgress(MAX_PROGRESS, progress, false) + } + + RepositoryUpdater.Stage.COMMIT -> { + setContentText(getString(stringRes.saving_details)) + setProgress(0, 0, true) + } + } + } + }::class + }.build() + ) + } + } + } + + private fun handleSetStarted() { + stateNotificationBuilder.setWhen(System.currentTimeMillis()) + } + + private fun handleNextTask(hasUpdates: Boolean) { + if (currentTask != null) return + if (tasks.isEmpty()) { + if (started != Started.NO) { + lifecycleScope.launch { + val setting = settingsRepository.getInitial() + handleUpdates( + hasUpdates = hasUpdates, + notifyUpdates = setting.notifyUpdate, + autoUpdate = setting.autoUpdate + ) + } + } + return + } + val task = tasks.removeFirst() + val repository = Database.RepositoryAdapter.get(task.repositoryId) + if (repository == null || !repository.enabled) handleNextTask(hasUpdates) + val lastStarted = started + val newStarted = if (task.manual || lastStarted == Started.MANUAL) { + Started.MANUAL + } else { + Started.AUTO + } + started = newStarted + if (newStarted == Started.MANUAL && lastStarted != Started.MANUAL) { + startSelf() + handleSetStarted() + } + val initialState = State.Connecting(repository!!.name) + publishForegroundState(true, initialState) + lifecycleScope.launch { + val unstableUpdates = + settingsRepository.getInitial().unstableUpdate + val downloadJob = downloadFile( + task = task, + repository = repository, + hasUpdates = hasUpdates, + unstableUpdates = unstableUpdates + ) + currentTask = CurrentTask(task, downloadJob, hasUpdates, initialState) + } + } + + private fun CoroutineScope.downloadFile( + task: Task, + repository: Repository, + hasUpdates: Boolean, + unstableUpdates: Boolean + ): CoroutinesJob = launch(Dispatchers.Default) { + var passedHasUpdates = hasUpdates + try { + val response = RepositoryUpdater.update( + this@SyncService, + repository, + unstableUpdates + ) { stage, progress, total -> + launch { + syncState.emit( + State.Syncing( + appName = repository.name, + stage = stage, + read = DataSize(progress), + total = total?.let { DataSize(it) } + ) + ) + } + } + passedHasUpdates = when (response) { + is Result.Error -> { + response.exception?.let { + it.printStackTrace() + if (task.manual) showNotificationError(repository, it as Exception) + } + response.data == true || hasUpdates + } + + is Result.Success -> response.data || hasUpdates + } + } finally { + withContext(NonCancellable) { + lock.withLock { currentTask = null } + handleNextTask(passedHasUpdates) + } + } + } + + private suspend fun handleUpdates( + hasUpdates: Boolean, + notifyUpdates: Boolean, + autoUpdate: Boolean + ) { + try { + if (!hasUpdates || !notifyUpdates) { + onFinishState.emit(Unit) + val needStop = started == Started.MANUAL + started = Started.NO + if (needStop) stopForegroundCompat() + return + } + val blocked = updateNotificationBlockerFragment?.get()?.isAdded == true + val updates = Database.ProductAdapter.getUpdates() + if (!blocked && updates.isNotEmpty()) { + displayUpdatesNotification(updates) + if (autoUpdate) updateAllAppsInternal() + } + handleUpdates(hasUpdates = false, notifyUpdates = true, autoUpdate = autoUpdate) + } finally { + withContext(NonCancellable) { + lock.withLock { currentTask = null } + handleNextTask(false) + } + } + } + + private suspend fun updateAllAppsInternal() { + Database.ProductAdapter + .getUpdates() + // Update LeOS-Doid the last + .sortedBy { if (it.packageName == packageName) 1 else -1 } + .map { + Database.InstalledAdapter.get(it.packageName, null) to + Database.RepositoryAdapter.get(it.repositoryId) + } + .filter { it.first != null && it.second != null } + .forEach { (installItem, repo) -> + val productRepo = Database.ProductAdapter.get(installItem!!.packageName, null) + .filter { it.repositoryId == repo!!.id } + .map { it to repo!! } + downloadConnection.startUpdate( + installItem.packageName, + installItem, + productRepo + ) + } + } + + private fun displayUpdatesNotification(productItems: List) { + fun T.applyHack(callback: T.() -> Unit): T = apply(callback) + notificationManager?.notify( + Constants.NOTIFICATION_ID_UPDATES, + NotificationCompat + .Builder(this, Constants.NOTIFICATION_CHANNEL_UPDATES) + .setSmallIcon(CommonR.drawable.ic_new_releases) + .setContentTitle(getString(stringRes.new_updates_available)) + .setContentText( + resources.getQuantityString( + CommonR.plurals.new_updates_DESC_FORMAT, + productItems.size, + productItems.size + ) + ) + .setColor( + ContextThemeWrapper(this, styleRes.Theme_Main_Light) + .getColorFromAttr(android.R.attr.colorPrimary).defaultColor + ) + .setContentIntent( + PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java) + .setAction(MainActivity.ACTION_UPDATES), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + ) + .setStyle( + NotificationCompat.InboxStyle().applyHack { + for (productItem in productItems.take(MAX_UPDATE_NOTIFICATION)) { + val builder = SpannableStringBuilder(productItem.name) + builder.setSpan( + ForegroundColorSpan(Color.BLACK), + 0, + builder.length, + SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE + ) + builder.append(' ').append(productItem.version) + addLine(builder) + } + if (productItems.size > MAX_UPDATE_NOTIFICATION) { + val summary = + getString( + stringRes.plus_more_FORMAT, + productItems.size - MAX_UPDATE_NOTIFICATION + ) + if (SdkCheck.isNougat) addLine(summary) else setSummaryText(summary) + } + } + ) + .build() + ) + } + + @SuppressLint("SpecifyJobSchedulerIdRange") + class Job : JobService() { + private val jobScope = CoroutineScope(Dispatchers.Default) + private var syncParams: JobParameters? = null + private val syncConnection = + Connection(SyncService::class.java, onBind = { connection, binder -> + jobScope.launch { + binder.onFinish.collect { + val params = syncParams + if (params != null) { + syncParams = null + connection.unbind(this@Job) + jobFinished(params, false) + } + } + } + binder.sync(SyncRequest.AUTO) + }, onUnbind = { _, binder -> + binder.cancelAuto() + jobScope.cancel() + val params = syncParams + if (params != null) { + syncParams = null + jobFinished(params, true) + } + }) + + override fun onStartJob(params: JobParameters): Boolean { + syncParams = params + syncConnection.bind(this) + return true + } + + override fun onStopJob(params: JobParameters): Boolean { + syncParams = null + jobScope.cancel() + val reschedule = syncConnection.binder?.cancelAuto() == true + syncConnection.unbind(this) + return reschedule + } + + companion object { + fun create( + context: Context, + periodMillis: Long, + networkType: Int, + isCharging: Boolean, + isBatteryLow: Boolean + ): JobInfo = JobInfo.Builder( + Constants.JOB_ID_SYNC, + ComponentName(context, Job::class.java) + ).apply { + setRequiredNetworkType(networkType) + sdkAbove(sdk = Build.VERSION_CODES.O) { + setRequiresCharging(isCharging) + setRequiresBatteryNotLow(isBatteryLow) + setRequiresStorageNotLow(true) + } + if (SdkCheck.isNougat) { + setPeriodic(periodMillis, JobInfo.getMinFlexMillis()) + } else { + setPeriodic(periodMillis) + } + }.build() + } + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/sync/SyncPreference.kt b/app/src/main/kotlin/com/leos/droidify/sync/SyncPreference.kt new file mode 100644 index 0000000..6378f99 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/sync/SyncPreference.kt @@ -0,0 +1,23 @@ +package com.leos.droidify.sync + +import android.app.job.JobInfo +import androidx.work.Constraints +import androidx.work.NetworkType + +data class SyncPreference( + val networkType: NetworkType, + val pluggedIn: Boolean = false, + val batteryNotLow: Boolean = true, +) + +fun SyncPreference.toJobNetworkType() = when (networkType) { + NetworkType.NOT_REQUIRED -> JobInfo.NETWORK_TYPE_NONE + NetworkType.UNMETERED -> JobInfo.NETWORK_TYPE_UNMETERED + else -> JobInfo.NETWORK_TYPE_ANY +} + +fun SyncPreference.toWorkConstraints(): Constraints = Constraints( + requiredNetworkType = networkType, + requiresCharging = pluggedIn, + requiresBatteryNotLow = batteryNotLow +) diff --git a/app/src/main/kotlin/com/leos/droidify/ui/MessageDialog.kt b/app/src/main/kotlin/com/leos/droidify/ui/MessageDialog.kt new file mode 100644 index 0000000..6c68dfb --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/ui/MessageDialog.kt @@ -0,0 +1,265 @@ +package com.leos.droidify.ui + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.os.Parcel +import android.os.Parcelable +import androidx.appcompat.app.AlertDialog +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.leos.core.common.SdkCheck +import com.leos.core.common.nullIfEmpty +import com.leos.core.domain.Release +import com.leos.droidify.ui.repository.RepositoryFragment +import com.leos.droidify.utility.PackageItemResolver +import com.leos.droidify.utility.extension.android.Android +import kotlinx.parcelize.Parceler +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.TypeParceler +import com.leos.core.common.R.string as stringRes + +class MessageDialog() : DialogFragment() { + companion object { + private const val EXTRA_MESSAGE = "message" + } + + constructor(message: Message) : this() { + arguments = bundleOf(EXTRA_MESSAGE to message) + } + + fun show(fragmentManager: FragmentManager) { + show(fragmentManager, this::class.java.name) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog { + val dialog = MaterialAlertDialogBuilder(requireContext()) + val message = if (SdkCheck.isTiramisu) { + arguments?.getParcelable(EXTRA_MESSAGE, Message::class.java)!! + } else { + arguments?.getParcelable(EXTRA_MESSAGE)!! + } + when (message) { + is Message.DeleteRepositoryConfirm -> { + dialog.setTitle(stringRes.confirmation) + dialog.setMessage(stringRes.delete_repository_DESC) + dialog.setPositiveButton(stringRes.delete) { _, _ -> + (parentFragment as RepositoryFragment).onDeleteConfirm() + } + dialog.setNegativeButton(stringRes.cancel, null) + } + + is Message.CantEditSyncing -> { + dialog.setTitle(stringRes.action_failed) + dialog.setMessage(stringRes.cant_edit_sync_DESC) + dialog.setPositiveButton(stringRes.ok, null) + } + + is Message.Link -> { + dialog.setTitle(stringRes.confirmation) + dialog.setMessage(getString(stringRes.open_DESC_FORMAT, message.uri.toString())) + dialog.setPositiveButton(stringRes.ok) { _, _ -> + try { + startActivity(Intent(Intent.ACTION_VIEW, message.uri)) + } catch (e: ActivityNotFoundException) { + e.printStackTrace() + } + } + dialog.setNegativeButton(stringRes.cancel, null) + } + + is Message.Permissions -> { + val packageManager = requireContext().packageManager + val builder = StringBuilder() + val localCache = PackageItemResolver.LocalCache() + val title = if (message.group != null) { + val name = try { + val permissionGroupInfo = + packageManager.getPermissionGroupInfo(message.group, 0) + PackageItemResolver.loadLabel( + requireContext(), + localCache, + permissionGroupInfo + )?.nullIfEmpty()?.let { if (it == message.group) null else it } + } catch (e: Exception) { + null + } + name ?: getString(stringRes.unknown) + } else { + getString(stringRes.other) + } + for (permission in message.permissions) { + kotlin.runCatching { + val permissionInfo = packageManager.getPermissionInfo(permission, 0) + PackageItemResolver.loadDescription( + requireContext(), + localCache, + permissionInfo + )?.nullIfEmpty()?.let { if (it == permission) null else it } + ?: error("Invalid Permission Description") + }.onSuccess { + builder.append(it).append("\n\n") + } + } + if (builder.isNotEmpty()) { + builder.delete(builder.length - 2, builder.length) + } else { + builder.append(getString(stringRes.no_description_available_DESC)) + } + dialog.setTitle(title) + dialog.setMessage(builder) + dialog.setPositiveButton(stringRes.ok, null) + } + + is Message.ReleaseIncompatible -> { + val builder = StringBuilder() + val minSdkVersion = + if (Release.Incompatibility.MinSdk in message.incompatibilities) { + message.minSdkVersion + } else { + null + } + val maxSdkVersion = + if (Release.Incompatibility.MaxSdk in message.incompatibilities) { + message.maxSdkVersion + } else { + null + } + if (minSdkVersion != null || maxSdkVersion != null) { + val versionMessage = minSdkVersion?.let { + getString( + stringRes.incompatible_api_min_DESC_FORMAT, + it + ) + } + ?: maxSdkVersion?.let { + getString( + stringRes.incompatible_api_max_DESC_FORMAT, + it + ) + } + builder.append( + getString( + stringRes.incompatible_api_DESC_FORMAT, + Android.name, + SdkCheck.sdk, + versionMessage.orEmpty() + ) + ).append("\n\n") + } + if (Release.Incompatibility.Platform in message.incompatibilities) { + builder.append( + getString( + stringRes.incompatible_platforms_DESC_FORMAT, + Android.primaryPlatform ?: getString(stringRes.unknown), + message.platforms.joinToString(separator = ", ") + ) + ).append("\n\n") + } + val features = + message.incompatibilities.mapNotNull { it as? Release.Incompatibility.Feature } + if (features.isNotEmpty()) { + builder.append(getString(stringRes.incompatible_features_DESC)) + for (feature in features) { + builder.append("\n\u2022 ").append(feature.feature) + } + builder.append("\n\n") + } + if (builder.isNotEmpty()) { + builder.delete(builder.length - 2, builder.length) + } + dialog.setTitle(stringRes.incompatible_version) + dialog.setMessage(builder) + dialog.setPositiveButton(stringRes.ok, null) + } + + is Message.ReleaseOlder -> { + dialog.setTitle(stringRes.incompatible_version) + dialog.setMessage(stringRes.incompatible_older_DESC) + dialog.setPositiveButton(stringRes.ok, null) + } + + is Message.ReleaseSignatureMismatch -> { + dialog.setTitle(stringRes.incompatible_version) + dialog.setMessage(stringRes.incompatible_signature_DESC) + dialog.setPositiveButton(stringRes.ok, null) + } + }::class + return dialog.create() + } +} + +@Parcelize +sealed interface Message : Parcelable { + @Parcelize + data object DeleteRepositoryConfirm : Message + + @Parcelize + data object CantEditSyncing : Message + + @Parcelize + class Link(val uri: Uri) : Message + + @Parcelize + class Permissions(val group: String?, val permissions: List) : Message + + @Parcelize + @TypeParceler + class ReleaseIncompatible( + val incompatibilities: List, + val platforms: List, + val minSdkVersion: Int, + val maxSdkVersion: Int + ) : Message + + @Parcelize + data object ReleaseOlder : Message + + @Parcelize + data object ReleaseSignatureMismatch : Message +} + +class ReleaseIncompatibilityParceler : Parceler { + + private companion object { + // Incompatibility indices in `Parcel` + const val MIN_SDK_INDEX = 0 + const val MAX_SDK_INDEX = 1 + const val PLATFORM_INDEX = 2 + const val FEATURE_INDEX = 3 + } + + override fun create(parcel: Parcel): Release.Incompatibility { + return when (parcel.readInt()) { + MIN_SDK_INDEX -> Release.Incompatibility.MinSdk + MAX_SDK_INDEX -> Release.Incompatibility.MaxSdk + PLATFORM_INDEX -> Release.Incompatibility.Platform + FEATURE_INDEX -> Release.Incompatibility.Feature(requireNotNull(parcel.readString())) + else -> error("Invalid Index for Incompatibility") + } + } + + override fun Release.Incompatibility.write(parcel: Parcel, flags: Int) { + when (this) { + is Release.Incompatibility.MinSdk -> { + parcel.writeInt(MIN_SDK_INDEX) + } + + is Release.Incompatibility.MaxSdk -> { + parcel.writeInt(MAX_SDK_INDEX) + } + + is Release.Incompatibility.Platform -> { + parcel.writeInt(PLATFORM_INDEX) + } + + is Release.Incompatibility.Feature -> { + parcel.writeInt(FEATURE_INDEX) + parcel.writeString(feature) + } + } + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/ui/ScreenFragment.kt b/app/src/main/kotlin/com/leos/droidify/ui/ScreenFragment.kt new file mode 100644 index 0000000..1d3a0f6 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/ui/ScreenFragment.kt @@ -0,0 +1,33 @@ +package com.leos.droidify.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.google.android.material.appbar.MaterialToolbar +import com.leos.droidify.databinding.FragmentBinding + +open class ScreenFragment : Fragment() { + private var _fragmentBinding: FragmentBinding? = null + val fragmentBinding get() = _fragmentBinding!! + val toolbar: MaterialToolbar get() = fragmentBinding.toolbar + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + _fragmentBinding = FragmentBinding.inflate(layoutInflater) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = fragmentBinding.root + + open fun onBackPressed(): Boolean = false + + override fun onDestroyView() { + super.onDestroyView() + _fragmentBinding = null + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/ui/appDetail/AppDetailAdapter.kt b/app/src/main/kotlin/com/leos/droidify/ui/appDetail/AppDetailAdapter.kt new file mode 100644 index 0000000..a0f071a --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/ui/appDetail/AppDetailAdapter.kt @@ -0,0 +1,1808 @@ +package com.leos.droidify.ui.appDetail + +import android.annotation.SuppressLint +import android.content.* +import android.content.pm.PermissionGroupInfo +import android.content.pm.PermissionInfo +import android.content.res.Resources +import android.graphics.* +import android.net.Uri +import android.os.Parcelable +import android.text.SpannableStringBuilder +import android.text.format.DateFormat +import android.text.method.LinkMovementMethod +import android.text.style.* +import android.text.util.Linkify +import android.view.* +import android.widget.* +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.net.toUri +import androidx.core.text.HtmlCompat +import androidx.core.text.bold +import androidx.core.text.buildSpannedString +import androidx.core.text.util.LinkifyCompat +import androidx.core.view.isVisible +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import coil.load +import com.google.android.material.button.MaterialButton +import com.google.android.material.card.MaterialCardView +import com.google.android.material.imageview.ShapeableImageView +import com.google.android.material.materialswitch.MaterialSwitch +import com.google.android.material.progressindicator.LinearProgressIndicator +import com.google.android.material.snackbar.Snackbar +import com.leos.core.common.DataSize +import com.leos.core.common.extension.* +import com.leos.core.common.formatSize +import com.leos.core.common.nullIfEmpty +import com.leos.core.domain.* +import com.leos.droidify.R +import com.leos.droidify.content.ProductPreferences +import com.leos.droidify.utility.PackageItemResolver +import com.leos.droidify.utility.extension.ImageUtils.icon +import com.leos.droidify.utility.extension.android.Android +import com.leos.droidify.utility.extension.resources.TypefaceExtra +import com.leos.droidify.utility.extension.resources.sizeScaled +import com.leos.droidify.widget.StableRecyclerAdapter +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toJavaLocalDateTime +import kotlinx.datetime.toLocalDateTime +import kotlinx.parcelize.Parcelize +import java.lang.ref.WeakReference +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale +import kotlin.math.PI +import kotlin.math.roundToInt +import kotlin.math.sin +import com.google.android.material.R as MaterialR +import com.leos.core.common.R.drawable as drawableRes +import com.leos.core.common.R.string as stringRes + +class AppDetailAdapter(private val callbacks: Callbacks) : + StableRecyclerAdapter() { + + companion object { + private const val MAX_RELEASE_ITEMS = 5 + } + + interface Callbacks { + fun onActionClick(action: Action) + fun onFavouriteClicked() + fun onPreferenceChanged(preference: ProductPreference) + fun onPermissionsClick(group: String?, permissions: List) + fun onScreenshotClick(screenshot: Product.Screenshot, parentView: ImageView) + fun onReleaseClick(release: Release) + fun onRequestAddRepository(address: String) + fun onUriClick(uri: Uri, shouldConfirm: Boolean): Boolean + } + + enum class Action(@StringRes val titleResId: Int, @DrawableRes val iconResId: Int) { + INSTALL(stringRes.install, drawableRes.ic_download), + UPDATE(stringRes.update, drawableRes.ic_download), + LAUNCH(stringRes.launch, drawableRes.ic_launch), + DETAILS(stringRes.details, drawableRes.ic_tune), + UNINSTALL(stringRes.uninstall, drawableRes.ic_delete), + CANCEL(stringRes.cancel, drawableRes.ic_cancel), + SHARE(stringRes.share, drawableRes.ic_share) + } + + sealed interface Status { + data object Idle : Status + data object Pending : Status + data object Connecting : Status + data class Downloading(val read: DataSize, val total: DataSize?) : Status + data object PendingInstall : Status + data object Installing : Status + } + + enum class ViewType { + APP_INFO, + DOWNLOAD_STATUS, + INSTALL_BUTTON, + SCREENSHOT, + SWITCH, + SECTION, + EXPAND, + TEXT, + LINK, + PERMISSIONS, + RELEASE, + EMPTY + } + + private enum class SwitchType(val titleResId: Int) { + IGNORE_ALL_UPDATES(stringRes.ignore_all_updates), + IGNORE_THIS_UPDATE(stringRes.ignore_this_update) + } + + private enum class SectionType( + val titleResId: Int, + val colorAttrResId: Int = MaterialR.attr.colorPrimary + ) { + ANTI_FEATURES(stringRes.anti_features, MaterialR.attr.colorError), + CHANGES(stringRes.changes), + LINKS(stringRes.links), + DONATE(stringRes.donate), + PERMISSIONS(stringRes.permissions), + VERSIONS(stringRes.versions) + } + + internal enum class ExpandType { + NOTHING, DESCRIPTION, CHANGES, + LINKS, DONATES, PERMISSIONS, VERSIONS + } + + private enum class TextType { DESCRIPTION, ANTI_FEATURES, CHANGES } + + private enum class LinkType( + val iconResId: Int, + val titleResId: Int, + val format: ((Context, String) -> String)? = null + ) { + SOURCE(drawableRes.ic_code, stringRes.source_code), + AUTHOR(drawableRes.ic_person, stringRes.author_website), + EMAIL(drawableRes.ic_email, stringRes.author_email), + LICENSE( + drawableRes.ic_copyright, + stringRes.license, + format = { context, text -> context.getString(stringRes.license_FORMAT, text) } + ), + TRACKER(drawableRes.ic_bug_report, stringRes.bug_tracker), + CHANGELOG(drawableRes.ic_history, stringRes.changelog), + WEB(drawableRes.ic_public, stringRes.project_website) + } + + private sealed class Item { + abstract val descriptor: String + abstract val viewType: ViewType + + class AppInfoItem( + val repository: Repository, + val product: Product + ) : Item() { + override val descriptor: String + get() = "app_info.${product.name}" + + override val viewType: ViewType + get() = ViewType.APP_INFO + } + + data object DownloadStatusItem : Item() { + override val descriptor: String + get() = "download_status" + override val viewType: ViewType + get() = ViewType.DOWNLOAD_STATUS + } + + data object InstallButtonItem : Item() { + override val descriptor: String + get() = "install_button" + override val viewType: ViewType + get() = ViewType.INSTALL_BUTTON + } + + class ScreenshotItem( + val screenshots: List, + val packageName: String, + val repository: Repository + ) : Item() { + override val descriptor: String + get() = "screenshot.${screenshots.size}" + override val viewType: ViewType + get() = ViewType.SCREENSHOT + } + + class SwitchItem( + val switchType: SwitchType, + val packageName: String, + val versionCode: Long + ) : Item() { + override val descriptor: String + get() = "switch.${switchType.name}" + + override val viewType: ViewType + get() = ViewType.SWITCH + } + + class SectionItem( + val sectionType: SectionType, + val expandType: ExpandType, + val items: List, + val collapseCount: Int + ) : Item() { + constructor(sectionType: SectionType) : this( + sectionType, + ExpandType.NOTHING, + emptyList(), + 0 + ) + + override val descriptor: String + get() = "section.${sectionType.name}" + + override val viewType: ViewType + get() = ViewType.SECTION + } + + class ExpandItem( + val expandType: ExpandType, + val replace: Boolean, + val items: List + ) : Item() { + override val descriptor: String + get() = "expand.${expandType.name}" + + override val viewType: ViewType + get() = ViewType.EXPAND + } + + class TextItem(val textType: TextType, val text: CharSequence) : Item() { + override val descriptor: String + get() = "text.${textType.name}" + + override val viewType: ViewType + get() = ViewType.TEXT + } + + sealed class LinkItem : Item() { + override val viewType: ViewType + get() = ViewType.LINK + + abstract val iconResId: Int + abstract fun getTitle(context: Context): String + abstract val uri: Uri? + + val displayLink: String? + get() = uri?.schemeSpecificPart?.nullIfEmpty() + ?.let { if (it.startsWith("//")) null else it } ?: uri?.toString() + + class Typed( + val linkType: LinkType, + val text: String, + override val uri: Uri? + ) : LinkItem() { + override val descriptor: String + get() = "link.typed.${linkType.name}" + + override val iconResId: Int + get() = linkType.iconResId + + override fun getTitle(context: Context): String { + return text.nullIfEmpty()?.let { linkType.format?.invoke(context, it) ?: it } + ?: context.getString(linkType.titleResId) + } + } + + class Donate(val donate: Product.Donate) : LinkItem() { + override val descriptor: String + get() = "link.donate.$donate" + + override val iconResId: Int + get() = when (donate) { + is Product.Donate.Regular -> drawableRes.ic_donate + is Product.Donate.Bitcoin -> drawableRes.ic_donate_bitcoin + is Product.Donate.Litecoin -> drawableRes.ic_donate_litecoin + is Product.Donate.Flattr -> drawableRes.ic_donate_flattr + is Product.Donate.Liberapay -> drawableRes.ic_donate_liberapay + is Product.Donate.OpenCollective -> drawableRes.ic_donate_opencollective + } + + override fun getTitle(context: Context): String = when (donate) { + is Product.Donate.Regular -> context.getString(stringRes.website) + is Product.Donate.Bitcoin -> "Bitcoin" + is Product.Donate.Litecoin -> "Litecoin" + is Product.Donate.Flattr -> "Flattr" + is Product.Donate.Liberapay -> "Liberapay" + is Product.Donate.OpenCollective -> "Open Collective" + } + + override val uri: Uri? = when (donate) { + is Product.Donate.Regular -> Uri.parse(donate.url) + is Product.Donate.Bitcoin -> Uri.parse("bitcoin:${donate.address}") + is Product.Donate.Litecoin -> Uri.parse("litecoin:${donate.address}") + is Product.Donate.Flattr -> Uri.parse( + "https://flattr.com/thing/${donate.id}" + ) + + is Product.Donate.Liberapay -> Uri.parse( + "https://liberapay.com/~${donate.id}" + ) + + is Product.Donate.OpenCollective -> Uri.parse( + "https://opencollective.com/${donate.id}" + ) + } + } + } + + class PermissionsItem( + val group: PermissionGroupInfo?, + val permissions: List + ) : Item() { + override val descriptor: String + get() = "permissions.${group?.name}" + + ".${permissions.joinToString(separator = ".") { it.name }}" + + override val viewType: ViewType + get() = ViewType.PERMISSIONS + } + + class ReleaseItem( + val repository: Repository, + val release: Release, + val selectedRepository: Boolean, + val showSignature: Boolean + ) : Item() { + override val descriptor: String + get() = "release.${repository.id}.${release.identifier}" + + override val viewType: ViewType + get() = ViewType.RELEASE + } + + class EmptyItem(val packageName: String, val repoAddress: String?) : Item() { + override val descriptor: String + get() = "empty" + + override val viewType: ViewType + get() = ViewType.EMPTY + } + } + + private class Measurement { + private var density = 0f + private var scaledDensity = 0f + private lateinit var metric: T + + fun measure(view: View) { + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + .let { view.measure(it, it) } + } + + fun invalidate(resources: Resources, callback: () -> T): T { + val (density, scaledDensity) = resources.displayMetrics.let { + Pair( + it.density, + it.scaledDensity + ) + } + if (this.density != density || this.scaledDensity != scaledDensity) { + this.density = density + this.scaledDensity = scaledDensity + metric = callback() + } + return metric + } + } + + @Volatile + private var isFavourite: Boolean = false + + private class AppInfoViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val icon = itemView.findViewById(R.id.app_icon)!! + val name = itemView.findViewById(R.id.app_name)!! + val authorName = itemView.findViewById(R.id.author_name)!! + val packageName = itemView.findViewById(R.id.package_name)!! + val textSwitcher = itemView.findViewById(R.id.author_package_name)!! + + init { + textSwitcher.setInAnimation(itemView.context!!, R.anim.slide_right_fade_in) + textSwitcher.setOutAnimation(itemView.context!!, R.anim.slide_right_fade_out) + } + + val version = itemView.findViewById(R.id.version)!! + val size = itemView.findViewById(R.id.size)!! + val dev = itemView.findViewById(R.id.dev_block)!! + + val favouriteButton = itemView.findViewById(R.id.favourite)!! + } + + private class DownloadStatusViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val statusText = itemView.findViewById(R.id.status)!! + val progress = itemView.findViewById(R.id.progress)!! + } + + private class InstallButtonViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val button = itemView.findViewById(R.id.action)!! + + val actionTintNormal = button.context.getColorFromAttr(MaterialR.attr.colorPrimary) + val actionTintOnNormal = button.context.getColorFromAttr(MaterialR.attr.colorOnPrimary) + val actionTintCancel = button.context.getColorFromAttr(MaterialR.attr.colorError) + val actionTintOnCancel = button.context.getColorFromAttr(MaterialR.attr.colorOnError) + val actionTintDisabled = button.context.getColorFromAttr(MaterialR.attr.colorOutline) + val actionTintOnDisabled = button.context.getColorFromAttr(android.R.attr.colorBackground) + + init { + button.height = itemView.resources.sizeScaled(48) + } + } + + private class ScreenShotViewHolder(context: Context) : + RecyclerView.ViewHolder(RecyclerView(context)) { + + val screenshotsRecycler: RecyclerView + get() = itemView as RecyclerView + } + + private class SwitchViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val switch = itemView.findViewById(R.id.update_state_switch)!! + + val statefulViews: Sequence + get() = sequenceOf(itemView, switch) + } + + private class SectionViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val title = itemView.findViewById(R.id.title)!! + val icon = itemView.findViewById(R.id.icon)!! + } + + private class ExpandViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val button = itemView.findViewById(R.id.expand_view_button)!! + } + + private class TextViewHolder(context: Context) : + RecyclerView.ViewHolder(TextView(context)) { + val text: TextView + get() = itemView as TextView + + init { + with(itemView as TextView) { + setTextIsSelectable(true) + setTextSizeScaled(15) + isFocusable = false + 16.dp.let { itemView.setPadding(it, it, it, it) } + movementMethod = LinkMovementMethod() + layoutParams = RecyclerView.LayoutParams( + RecyclerView.LayoutParams.MATCH_PARENT, + RecyclerView.LayoutParams.WRAP_CONTENT + ) + } + } + } + + @SuppressLint("ClickableViewAccessibility") + private open class OverlappingViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + init { + // Block touch events if touched above negative margin + itemView.setOnTouchListener { _, event -> + event.action == MotionEvent.ACTION_DOWN && run { + val top = (itemView.layoutParams as ViewGroup.MarginLayoutParams).topMargin + top < 0 && event.y < -top + } + } + } + } + + private class LinkViewHolder(itemView: View) : OverlappingViewHolder(itemView) { + companion object { + private val measurement = Measurement() + } + + val icon = itemView.findViewById(R.id.icon)!! + val text = itemView.findViewById(R.id.text)!! + val link = itemView.findViewById(R.id.link)!! + + init { + text.typeface = TypefaceExtra.medium + val margin = measurement.invalidate(itemView.resources) { + @SuppressLint("SetTextI18n") + text.text = "measure" + link.visibility = View.GONE + measurement.measure(itemView) + ((itemView.measuredHeight - icon.measuredHeight) / 2f).roundToInt() + } + (icon.layoutParams as ViewGroup.MarginLayoutParams).apply { + topMargin += margin + bottomMargin += margin + } + } + } + + private class PermissionsViewHolder(itemView: View) : OverlappingViewHolder(itemView) { + companion object { + private val measurement = Measurement() + } + + val icon = itemView.findViewById(R.id.icon)!! + val text = itemView.findViewById(R.id.text)!! + + init { + val margin = measurement.invalidate(itemView.resources) { + @SuppressLint("SetTextI18n") + text.text = "measure" + measurement.measure(itemView) + ((itemView.measuredHeight - icon.measuredHeight) / 2f).roundToInt() + } + (icon.layoutParams as ViewGroup.MarginLayoutParams).apply { + topMargin += margin + bottomMargin += margin + } + } + } + + private class ReleaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val dateFormat = DateFormat.getDateFormat(itemView.context)!! + + val version = itemView.findViewById(R.id.version)!! + val status = itemView.findViewById(R.id.installation_status)!! + val source = itemView.findViewById(R.id.source)!! + val added = itemView.findViewById(R.id.added)!! + val size = itemView.findViewById(R.id.size)!! + val signature = itemView.findViewById(R.id.signature)!! + val compatibility = itemView.findViewById(R.id.compatibility)!! + + val statefulViews: Sequence + get() = sequenceOf( + itemView, + version, + status, + source, + added, + size, + signature, + compatibility + ) + } + + private class EmptyViewHolder(context: Context) : + RecyclerView.ViewHolder(LinearLayout(context)) { + val packageName = TextView(context) + val repoTitle = TextView(context) + val repoAddress = TextView(context) + val copyRepoAddress = MaterialButton(context) + + init { + with(itemView as LinearLayout) { + layoutParams = RecyclerView.LayoutParams( + RecyclerView.LayoutParams.MATCH_PARENT, + RecyclerView.LayoutParams.MATCH_PARENT + ) + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + setPadding(20.dp, 20.dp, 20.dp, 20.dp) + val imageView = ImageView(context) + val bitmap = Bitmap.createBitmap( + 64.dp.px.roundToInt(), + 32.dp.px.roundToInt(), + Bitmap.Config.ARGB_8888 + ) + val canvas = Canvas(bitmap) + val title = TextView(context) + with(title) { + gravity = Gravity.CENTER + typeface = TypefaceExtra.medium + setTextColor(context.getColorFromAttr(MaterialR.attr.colorPrimary)) + setTextSizeScaled(20) + setText(stringRes.application_not_found) + setPadding(0, 12.dp, 0, 12.dp) + } + with(packageName) { + gravity = Gravity.CENTER + setTextColor(context.getColorFromAttr(MaterialR.attr.colorOutline)) + typeface = Typeface.DEFAULT_BOLD + setTextSizeScaled(16) + background = context.corneredBackground + setPadding(0, 12.dp, 0, 12.dp) + } + val waveHeight = 2.dp.px + val waveWidth = 12.dp.px + with(canvas) { + val linePaint = Paint().apply { + color = context.getColorFromAttr(MaterialR.attr.colorOutline).defaultColor + strokeWidth = 8f + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND + } + for (x in 12..(width - 12)) { + val yValue = + ( + ( + sin(x * (2f * PI / waveWidth)) * + (waveHeight / (2)) + + (waveHeight / 2) + ).toFloat() + + (0 - (waveHeight / 2)) + ) + height / 2 + drawPoint(x.toFloat(), yValue, linePaint) + } + } + imageView.load(bitmap) + with(repoTitle) { + gravity = Gravity.CENTER + typeface = TypefaceExtra.medium + setTextColor(context.getColorFromAttr(MaterialR.attr.colorPrimary)) + setTextSizeScaled(20) + setPadding(0, 0, 0, 12.dp) + } + with(repoAddress) { + gravity = Gravity.CENTER + setTextColor(context.getColorFromAttr(MaterialR.attr.colorOutline)) + typeface = Typeface.DEFAULT_BOLD + setTextSizeScaled(16) + background = context.corneredBackground + setPadding(0, 12.dp, 0, 12.dp) + } + with(copyRepoAddress) { + icon = context.open + setText(stringRes.add_repository) + setBackgroundColor(context.getColor(android.R.color.transparent)) + setTextColor(context.getColorFromAttr(MaterialR.attr.colorPrimary)) + iconTint = context.getColorFromAttr(MaterialR.attr.colorPrimary) + } + addView( + title, + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + addView( + packageName, + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + addView( + imageView, + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + addView( + repoTitle, + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + addView( + repoAddress, + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + addView( + copyRepoAddress, + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + } + } + } + + private val items = mutableListOf() + private val expanded = mutableSetOf() + private var product: Product? = null + private var installedItem: InstalledItem? = null + + fun setProducts( + context: Context, + packageName: String, + suggestedRepo: String? = null, + products: List>, + installedItem: InstalledItem?, + isFavourite: Boolean, + allowIncompatibleVersion: Boolean + ) { + items.clear() + val productRepository = products.findSuggested(installedItem) ?: run { + items += Item.EmptyItem(packageName, suggestedRepo) + notifyDataSetChanged() + return + } + + this.product = productRepository.first + this.installedItem = installedItem + this.isFavourite = isFavourite + + items += Item.AppInfoItem( + productRepository.second, + productRepository.first + ) + + items += Item.DownloadStatusItem + items += Item.InstallButtonItem + + if (productRepository.first.screenshots.isNotEmpty()) { + val screenShotItem = mutableListOf() + screenShotItem += Item.ScreenshotItem( + productRepository.first.screenshots, + packageName, + productRepository.second + ) + items += screenShotItem + } + + if (installedItem != null) { + items.add( + Item.SwitchItem( + SwitchType.IGNORE_ALL_UPDATES, + packageName, + productRepository.first.versionCode + ) + ) + if (productRepository.first.canUpdate(installedItem)) { + items.add( + Item.SwitchItem( + SwitchType.IGNORE_THIS_UPDATE, + packageName, + productRepository.first.versionCode + ) + ) + } + } + + val textViewHolder = TextViewHolder(context) + val textViewWidthSpec = context.resources.displayMetrics.widthPixels + .let { View.MeasureSpec.makeMeasureSpec(it, View.MeasureSpec.EXACTLY) } + val textViewHeightSpec = + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + + fun CharSequence.lineCropped(maxLines: Int, cropLines: Int): CharSequence? { + assert(cropLines <= maxLines) + textViewHolder.text.text = this + textViewHolder.text.measure(textViewWidthSpec, textViewHeightSpec) + textViewHolder.text.layout( + 0, + 0, + textViewHolder.text.measuredWidth, + textViewHolder.text.measuredHeight + ) + val layout = textViewHolder.text.layout + val cropLineOffset = + if (layout.lineCount <= maxLines) -1 else layout.getLineEnd(cropLines - 1) + val paragraphEndIndex = if (cropLineOffset < 0) { + -1 + } else { + indexOf("\n\n", cropLineOffset).let { if (it >= 0) it else length } + } + val paragraphEndLine = if (paragraphEndIndex < 0) { + -1 + } else { + layout.getLineForOffset(paragraphEndIndex).apply { assert(this >= 0) } + } + val end = when { + cropLineOffset < 0 -> -1 + paragraphEndLine >= 0 && paragraphEndLine - (cropLines - 1) <= 3 -> + if (paragraphEndIndex < length) paragraphEndIndex else -1 + + else -> cropLineOffset + } + val length = if (end < 0) { + -1 + } else { + asSequence().take(end) + .indexOfLast { it != '\n' }.let { if (it >= 0) it + 1 else end } + } + return if (length >= 0) subSequence(0, length) else null + } + + val description = formatHtml(productRepository.first.description).apply { + if (productRepository.first.let { it.summary.isNotEmpty() && it.name != it.summary }) { + if (isNotEmpty()) { + insert(0, "\n\n") + } + insert(0, productRepository.first.summary) + if (isNotEmpty()) { + setSpan( + TypefaceSpan("sans-serif-medium"), + 0, + productRepository.first.summary.length, + SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } + } + if (description.isNotEmpty()) { + val cropped = if (ExpandType.DESCRIPTION !in expanded) { + description.lineCropped( + 12, + 10 + ) + } else { + null + } + val item = Item.TextItem(TextType.DESCRIPTION, description) + if (cropped != null) { + val croppedItem = Item.TextItem(TextType.DESCRIPTION, cropped) + items += listOf( + croppedItem, + Item.ExpandItem(ExpandType.DESCRIPTION, true, listOf(item, croppedItem)) + ) + } else { + items += item + } + } + + val antiFeatures = productRepository.first.antiFeatures.map { + when (it) { + "Ads" -> context.getString(stringRes.has_advertising) + "ApplicationDebuggable" -> context.getString(stringRes.compiled_for_debugging) + "DisabledAlgorithm" -> context.getString(stringRes.signed_using_unsafe_algorithm) + "KnownVuln" -> context.getString(stringRes.has_security_vulnerabilities) + "NoSourceSince" -> context.getString(stringRes.source_code_no_longer_available) + "NonFreeAdd" -> context.getString(stringRes.promotes_non_free_software) + "NonFreeAssets" -> context.getString(stringRes.contains_non_free_media) + "NonFreeDep" -> context.getString(stringRes.has_non_free_dependencies) + "NonFreeNet" -> context.getString(stringRes.promotes_non_free_network_services) + "NSFW" -> context.getString(stringRes.contains_nsfw) + "Tracking" -> context.getString(stringRes.tracks_or_reports_your_activity) + "UpstreamNonFree" -> context.getString(stringRes.upstream_source_code_is_not_free) + // special tag(https://floss.social/@IzzyOnDroid/110815951568369581) + // apps include non-free libraries + "NonFreeComp" -> context.getString(stringRes.has_non_free_components) + else -> context.getString(stringRes.unknown_FORMAT, it) + } + }.joinToString(separator = "\n") { "\u2022 $it" } + if (antiFeatures.isNotEmpty()) { + items += Item.SectionItem(SectionType.ANTI_FEATURES) + items += Item.TextItem(TextType.ANTI_FEATURES, antiFeatures) + } + + val changes = formatHtml(productRepository.first.whatsNew) + if (changes.isNotEmpty()) { + items += Item.SectionItem(SectionType.CHANGES) + val cropped = + if (ExpandType.CHANGES !in expanded) { + changes.lineCropped(12, 10) + } else { + null + } + val item = Item.TextItem(TextType.CHANGES, changes) + if (cropped != null) { + val croppedItem = Item.TextItem(TextType.CHANGES, cropped) + items += listOf( + croppedItem, + Item.ExpandItem(ExpandType.CHANGES, true, listOf(item, croppedItem)) + ) + } else { + items += item + } + } + + val linkItems = mutableListOf() + with(productRepository.first) { + source.let { link -> + if (link.isNotEmpty()) { + linkItems += Item.LinkItem.Typed( + LinkType.SOURCE, + "", + link.toUri() + ) + } + } + + if (author.name.isNotEmpty() || author.web.isNotEmpty()) { + linkItems += Item.LinkItem.Typed( + LinkType.AUTHOR, + author.name, + author.web.nullIfEmpty()?.let(Uri::parse) + ) + } + author.email.nullIfEmpty()?.let { + linkItems += Item.LinkItem.Typed(LinkType.EMAIL, "", Uri.parse("mailto:$it")) + } + linkItems += licenses.asSequence().map { + Item.LinkItem.Typed( + LinkType.LICENSE, + it, + Uri.parse("https://spdx.org/licenses/$it.html") + ) + } + tracker.nullIfEmpty() + ?.let { linkItems += Item.LinkItem.Typed(LinkType.TRACKER, "", Uri.parse(it)) } + changelog.nullIfEmpty()?.let { + linkItems += Item.LinkItem.Typed( + LinkType.CHANGELOG, + "", + Uri.parse(it) + ) + } + web.nullIfEmpty() + ?.let { linkItems += Item.LinkItem.Typed(LinkType.WEB, "", Uri.parse(it)) } + } + if (linkItems.isNotEmpty()) { + if (ExpandType.LINKS in expanded) { + items += Item.SectionItem( + SectionType.LINKS, + ExpandType.LINKS, + emptyList(), + linkItems.size + ) + items += linkItems + } else { + items += Item.SectionItem(SectionType.LINKS, ExpandType.LINKS, linkItems, 0) + } + } + + val donateItems = productRepository.first.donates.map(Item.LinkItem::Donate) + if (donateItems.isNotEmpty()) { + if (ExpandType.DONATES in expanded) { + items += Item.SectionItem( + SectionType.DONATE, + ExpandType.DONATES, + emptyList(), + donateItems.size + ) + items += donateItems + } else { + items += Item.SectionItem( + SectionType.DONATE, + ExpandType.DONATES, + donateItems, + 0 + ) + } + } + + val release = productRepository.first.displayRelease + if (release != null) { + val packageManager = context.packageManager + val permissions = release.permissions + .asSequence().mapNotNull { + try { + packageManager.getPermissionInfo(it, 0) + } catch (e: Exception) { + null + } + } + .groupBy(PackageItemResolver::getPermissionGroup) + .asSequence().map { (group, permissionInfo) -> + val permissionGroupInfo = try { + group?.let { packageManager.getPermissionGroupInfo(it, 0) } + } catch (e: Exception) { + null + } + Pair(permissionGroupInfo, permissionInfo) + } + .groupBy({ it.first }, { it.second }) + if (permissions.isNotEmpty()) { + val permissionsItems = mutableListOf() + permissionsItems += permissions.asSequence().filter { it.key != null } + .map { Item.PermissionsItem(it.key, it.value.flatten()) } + permissions.asSequence().find { it.key == null } + ?.let { + permissionsItems += Item.PermissionsItem(null, it.value.flatten()) + } + if (ExpandType.PERMISSIONS in expanded) { + items += Item.SectionItem( + SectionType.PERMISSIONS, + ExpandType.PERMISSIONS, + emptyList(), + permissionsItems.size + ) + items += permissionsItems + } else { + items += Item.SectionItem( + SectionType.PERMISSIONS, + ExpandType.PERMISSIONS, + permissionsItems, + 0 + ) + } + } + } + + val compatibleReleasePairs = products.asSequence() + .flatMap { (product, repository) -> + product.releases.asSequence() + .filter { allowIncompatibleVersion || it.incompatibilities.isEmpty() } + .map { Pair(it, repository) } + } + + val versionsWithMultiSignature = compatibleReleasePairs + .filterNot { release?.signature?.isEmpty() == true } + .map { (release, _) -> release.versionCode to release.signature } + .distinct() + .groupBy { it.first } + .filter { (_, entry) -> entry.size >= 2 } + .keys + + val releaseItems = compatibleReleasePairs + .map { (release, repository) -> + Item.ReleaseItem( + repository = repository, + release = release, + selectedRepository = repository.id == productRepository.second.id, + showSignature = release.versionCode in versionsWithMultiSignature + ) + } + .sortedByDescending { it.release.versionCode } + .toList() + if (releaseItems.isNotEmpty()) { + items += Item.SectionItem(SectionType.VERSIONS) + if (releaseItems.size > MAX_RELEASE_ITEMS && ExpandType.VERSIONS !in expanded) { + items += releaseItems.take(MAX_RELEASE_ITEMS) + items += Item.ExpandItem( + ExpandType.VERSIONS, + false, + releaseItems.takeLast(releaseItems.size - MAX_RELEASE_ITEMS) + ) + } else { + items += releaseItems + } + } + + this.product = productRepository.first + this.installedItem = installedItem + notifyDataSetChanged() + } + + var action: Action? = null + set(value) { + val index = items.indexOf(Item.InstallButtonItem) + val progressBarIndex = items.indexOf(Item.DownloadStatusItem) + if (index > 0 && progressBarIndex > 0) { + notifyItemChanged(index) + notifyItemChanged(progressBarIndex) + } + field = value + } + + var status: Status = Status.Idle + set(value) { + if (field != value) { + val index = items.indexOf(Item.DownloadStatusItem) + if (index > 0) notifyItemChanged(index) + } + field = value + } + + override val viewTypeClass: Class + get() = ViewType::class.java + + override fun getItemCount(): Int = items.size + override fun getItemDescriptor(position: Int): String = items[position].descriptor + override fun getItemEnumViewType(position: Int): ViewType = items[position].viewType + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: ViewType + ): RecyclerView.ViewHolder { + return when (viewType) { + ViewType.APP_INFO -> AppInfoViewHolder(parent.inflate(R.layout.app_detail_header)) + .apply { + favouriteButton.setOnClickListener { callbacks.onFavouriteClicked() } + } + + ViewType.DOWNLOAD_STATUS -> DownloadStatusViewHolder( + parent.inflate(R.layout.download_status) + ) + + ViewType.INSTALL_BUTTON -> InstallButtonViewHolder( + parent.inflate(R.layout.install_button) + ).apply { + button.setOnClickListener { action?.let(callbacks::onActionClick) } + } + + ViewType.SCREENSHOT -> ScreenShotViewHolder(parent.context) + ViewType.SWITCH -> SwitchViewHolder(parent.inflate(R.layout.switch_item)).apply { + itemView.setOnClickListener { + val switchItem = items[absoluteAdapterPosition] as Item.SwitchItem + val productPreference = when (switchItem.switchType) { + SwitchType.IGNORE_ALL_UPDATES -> { + ProductPreferences[switchItem.packageName].let { + it.copy( + ignoreUpdates = !it.ignoreUpdates + ) + } + } + + SwitchType.IGNORE_THIS_UPDATE -> { + ProductPreferences[switchItem.packageName].let { + it.copy( + ignoreVersionCode = + if (it.ignoreVersionCode == switchItem.versionCode) { + 0 + } else { + switchItem.versionCode + } + ) + } + } + } + ProductPreferences[switchItem.packageName] = productPreference + callbacks.onPreferenceChanged(productPreference) + } + } + + ViewType.SECTION -> SectionViewHolder(parent.inflate(R.layout.section_item)).apply { + itemView.setOnClickListener { + val position = absoluteAdapterPosition + val sectionItem = items[position] as Item.SectionItem + if (sectionItem.items.isNotEmpty()) { + expanded += sectionItem.expandType + items[position] = Item.SectionItem( + sectionItem.sectionType, + sectionItem.expandType, + emptyList(), + sectionItem.items.size + sectionItem.collapseCount + ) + notifyItemChanged(position) + items.addAll(position + 1, sectionItem.items) + notifyItemRangeInserted(position + 1, sectionItem.items.size) + } else if (sectionItem.collapseCount > 0) { + expanded -= sectionItem.expandType + items[position] = Item.SectionItem( + sectionItem.sectionType, + sectionItem.expandType, + items.subList(position + 1, position + 1 + sectionItem.collapseCount) + .toList(), + 0 + ) + notifyItemChanged(position) + repeat(sectionItem.collapseCount) { items.removeAt(position + 1) } + notifyItemRangeRemoved(position + 1, sectionItem.collapseCount) + } + } + } + + ViewType.EXPAND -> ExpandViewHolder(parent.inflate(R.layout.expand_view_button)) + .apply { + itemView.setOnClickListener { + val position = absoluteAdapterPosition + val expandItem = items[position] as Item.ExpandItem + if (expandItem.expandType !in expanded) { + expanded += expandItem.expandType + if (expandItem.replace) { + items[position - 1] = expandItem.items[0] + notifyItemRangeChanged(position - 1, 2) + } else { + items.addAll(position, expandItem.items) + if (position > 0) { + notifyItemRangeInserted(position, expandItem.items.size) + notifyItemChanged(position + expandItem.items.size) + } + } + } else { + expanded -= expandItem.expandType + if (expandItem.replace) { + items[position - 1] = expandItem.items[1] + notifyItemRangeChanged(position - 1, 2) + } else { + items.removeAll(expandItem.items) + if (position > 0) { + notifyItemRangeRemoved( + position - expandItem.items.size, + expandItem.items.size + ) + notifyItemChanged(position - expandItem.items.size) + } + } + } + } + } + + ViewType.TEXT -> TextViewHolder(parent.context) + ViewType.LINK -> LinkViewHolder(parent.inflate(R.layout.link_item)).apply { + itemView.setOnClickListener { + val linkItem = items[absoluteAdapterPosition] as Item.LinkItem + if (linkItem.uri?.let { callbacks.onUriClick(it, false) } != true) { + linkItem.displayLink?.let { copyLinkToClipboard(itemView, it) } + } + } + itemView.setOnLongClickListener { + val linkItem = items[absoluteAdapterPosition] as Item.LinkItem + linkItem.displayLink?.let { copyLinkToClipboard(itemView, it) } + true + } + } + + ViewType.PERMISSIONS -> PermissionsViewHolder(parent.inflate(R.layout.permissions_item)) + .apply { + itemView.setOnClickListener { + val permissionsItem = items[absoluteAdapterPosition] as Item.PermissionsItem + callbacks.onPermissionsClick( + permissionsItem.group?.name, + permissionsItem.permissions.map { it.name } + ) + } + } + + ViewType.RELEASE -> ReleaseViewHolder(parent.inflate(R.layout.release_item)).apply { + itemView.setOnClickListener { + val releaseItem = items[absoluteAdapterPosition] as Item.ReleaseItem + callbacks.onReleaseClick(releaseItem.release) + } + itemView.setOnLongClickListener { + val releaseItem = items[absoluteAdapterPosition] as Item.ReleaseItem + copyLinkToClipboard( + itemView, + releaseItem.release.getDownloadUrl(releaseItem.repository) + ) + true + } + } + + ViewType.EMPTY -> EmptyViewHolder(parent.context).apply { + copyRepoAddress.setOnClickListener { + repoAddress.text?.let { link -> + callbacks.onRequestAddRepository(link.toString()) + } + } + } + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + onBindViewHolder(holder, position, emptyList()) + } + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + payloads: List + ) { + val context = holder.itemView.context + val item = items[position] + when (getItemEnumViewType(position)) { + ViewType.APP_INFO -> { + holder as AppInfoViewHolder + item as Item.AppInfoItem + var showAuthor = item.product.author.name.isNotEmpty() + val iconUrl = + item.product.item().icon(view = holder.icon, repository = item.repository) + holder.icon.load(iconUrl) { + authentication(item.repository.authentication) + } + val authorText = + if (showAuthor) { + buildSpannedString { + append("by ") + bold { append(item.product.author.name) } + } + } else { + buildSpannedString { bold { append(item.product.packageName) } } + } + holder.authorName.text = authorText + holder.packageName.text = authorText + if (item.product.author.name.isNotEmpty()) { + holder.icon.setOnClickListener { + showAuthor = !showAuthor + val newText = if (showAuthor) { + buildSpannedString { + append("by ") + bold { append(item.product.author.name) } + } + } else { + buildSpannedString { bold { append(item.product.packageName) } } + } + holder.textSwitcher.setText(newText) + } + } + holder.name.text = item.product.name + + holder.version.apply { + text = installedItem?.version ?: product?.version + if (product?.canUpdate(installedItem) == true) { + if (background == null) { + background = context.corneredBackground + setPadding(8.dp, 4.dp, 8.dp, 4.dp) + backgroundTintList = + context.getColorFromAttr(MaterialR.attr.colorSecondaryContainer) + setTextColor(context.getColorFromAttr(MaterialR.attr.colorSecondary)) + } + } else { + if (background != null) { + setPadding(0, 0, 0, 0) + setTextColor( + context.getColorFromAttr(android.R.attr.colorControlNormal) + ) + background = null + } + } + } + holder.size.text = product?.displayRelease?.size?.formatSize() + + holder.dev.setOnClickListener { + product?.source?.let { link -> + if (link.isNotEmpty()) { + context.startActivity(Intent(Intent.ACTION_VIEW, link.toUri())) + } + } + } + holder.dev.setOnLongClickListener { + product?.source?.let { link -> + if (link.isNotEmpty()) copyLinkToClipboard(holder.dev, link) + } + true + } + holder.favouriteButton.isChecked = isFavourite + } + + ViewType.DOWNLOAD_STATUS -> { + holder as DownloadStatusViewHolder + item as Item.DownloadStatusItem + val status = status + holder.itemView.isVisible = status != Status.Idle + holder.statusText.isVisible = status != Status.Idle + holder.progress.isVisible = status != Status.Idle + if (status != Status.Idle) { + when (status) { + is Status.Pending -> { + holder.statusText.setText(stringRes.waiting_to_start_download) + holder.progress.isIndeterminate = true + } + + is Status.Connecting -> { + holder.statusText.setText(stringRes.connecting) + holder.progress.isIndeterminate = true + } + + is Status.Downloading -> { + holder.statusText.text = context.getString( + stringRes.downloading_FORMAT, + if (status.total == null) { + status.read.toString() + } else { + "${status.read} / ${status.total}" + } + ) + holder.progress.isIndeterminate = status.total == null + if (status.total != null) { + holder.progress.progress = + ( + holder.progress.max.toFloat() * + status.read.value / + status.total.value + ).roundToInt() + } + } + + Status.Installing -> { + holder.statusText.setText(stringRes.installing) + holder.progress.isIndeterminate = true + } + + Status.PendingInstall -> { + holder.statusText.setText(stringRes.waiting_to_start_installation) + holder.progress.isIndeterminate = true + } + + Status.Idle -> {} + } + } + Unit + } + + ViewType.INSTALL_BUTTON -> { + holder as InstallButtonViewHolder + item as Item.InstallButtonItem + val action = action + holder.button.apply { + isEnabled = action != null + if (action != null) { + icon = context.getDrawableCompat(action.iconResId) + setText(action.titleResId) + setTextColor( + if (action == Action.CANCEL) { + holder.actionTintOnCancel + } else { + holder.actionTintOnNormal + } + ) + backgroundTintList = if (action == Action.CANCEL) { + holder.actionTintCancel + } else { + holder.actionTintNormal + } + iconTint = if (action == Action.CANCEL) { + holder.actionTintOnCancel + } else { + holder.actionTintOnNormal + } + } else { + icon = context.getDrawableCompat(drawableRes.ic_cancel) + setText(stringRes.cancel) + setTextColor(holder.actionTintOnDisabled) + backgroundTintList = holder.actionTintDisabled + iconTint = holder.actionTintOnDisabled + } + } + } + + ViewType.SCREENSHOT -> { + holder as ScreenShotViewHolder + item as Item.ScreenshotItem + holder.screenshotsRecycler.run { + isNestedScrollingEnabled = false + clipToPadding = false + setPadding(8.dp, 8.dp, 8.dp, 8.dp) + layoutManager = + LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) + adapter = + ScreenshotsAdapter { screenshot, view -> + callbacks.onScreenshotClick(screenshot, view) + }.apply { + setScreenshots(item.repository, item.packageName, item.screenshots) + } + } + } + + ViewType.SWITCH -> { + holder as SwitchViewHolder + item as Item.SwitchItem + val (checked, enabled) = when (item.switchType) { + SwitchType.IGNORE_ALL_UPDATES -> { + val productPreference = ProductPreferences[item.packageName] + Pair(productPreference.ignoreUpdates, true) + } + + SwitchType.IGNORE_THIS_UPDATE -> { + val productPreference = ProductPreferences[item.packageName] + Pair( + productPreference.ignoreUpdates || + productPreference.ignoreVersionCode == item.versionCode, + !productPreference.ignoreUpdates + ) + } + } + with(holder) { + switch.setText(item.switchType.titleResId) + switch.isChecked = checked + statefulViews.forEach { it.isEnabled = enabled } + } + } + + ViewType.SECTION -> { + holder as SectionViewHolder + item as Item.SectionItem + val expandable = item.items.isNotEmpty() || item.collapseCount > 0 + holder.itemView.isEnabled = expandable + holder.itemView.let { + it.setPadding( + it.paddingLeft, + it.paddingTop, + it.paddingRight, + if (expandable) it.paddingTop else 0 + ) + } + val color = context.getColorFromAttr(item.sectionType.colorAttrResId) + holder.title.setTextColor(color) + holder.title.text = context.getString(item.sectionType.titleResId) + holder.icon.isVisible = expandable + holder.icon.scaleY = if (item.collapseCount > 0) -1f else 1f + holder.icon.imageTintList = color + } + + ViewType.EXPAND -> { + holder as ExpandViewHolder + item as Item.ExpandItem + holder.button.text = if (item.expandType !in expanded) { + when (item.expandType) { + ExpandType.VERSIONS -> context.getString(stringRes.show_older_versions) + else -> context.getString(stringRes.show_more) + } + } else { + context.getString(stringRes.show_less) + } + } + + ViewType.TEXT -> { + holder as TextViewHolder + item as Item.TextItem + holder.text.text = item.text + } + + ViewType.LINK -> { + holder as LinkViewHolder + item as Item.LinkItem + val layoutParams = holder.itemView.layoutParams as RecyclerView.LayoutParams + layoutParams.topMargin = + if (position > 0 && items[position - 1] !is Item.LinkItem) { + -context.resources.sizeScaled(8) + } else { + 0 + } + holder.itemView.isEnabled = item.uri != null + holder.icon.setImageResource(item.iconResId) + holder.text.text = item.getTitle(context) + holder.link.isVisible = item.uri != null + holder.link.text = item.displayLink + } + + ViewType.PERMISSIONS -> { + holder as PermissionsViewHolder + item as Item.PermissionsItem + val layoutParams = holder.itemView.layoutParams as RecyclerView.LayoutParams + layoutParams.topMargin = + if (position > 0 && items[position - 1] !is Item.PermissionsItem) { + -context.resources.sizeScaled(8) + } else { + 0 + } + val packageManager = context.packageManager + holder.icon.setImageDrawable( + if (item.group != null && item.group.icon != 0) { + item.group.loadUnbadgedIcon(packageManager) + } else { + null + } ?: context.getMutatedIcon(drawableRes.ic_perm_device_information) + ) + val localCache = PackageItemResolver.LocalCache() + val labels = item.permissions.map { permission -> + val labelFromPackage = + PackageItemResolver.loadLabel(context, localCache, permission) + val label = labelFromPackage ?: run { + val prefixes = + listOf("android.permission.", "com.android.browser.permission.") + prefixes.find { permission.name.startsWith(it) }?.let { it -> + val transform = permission.name.substring(it.length) + if (transform.matches("[A-Z_]+".toRegex())) { + transform.split("_") + .joinToString(separator = " ") { it.lowercase(Locale.US) } + } else { + null + } + } + } + if (label == null) { + Pair(false, permission.name) + } else { + Pair( + true, + label.first().uppercaseChar() + label.substring(1, label.length) + ) + } + } + val builder = SpannableStringBuilder() + ( + labels.asSequence().filter { it.first } + labels.asSequence() + .filter { !it.first } + ).forEach { + if (builder.isNotEmpty()) { + builder.append("\n\n") + builder.setSpan( + RelativeSizeSpan(1f / 3f), + builder.length - 2, + builder.length, + SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + builder.append(it.second) + if (!it.first) { + // Replace dots with spans to enable word wrap + it.second.asSequence() + .mapIndexedNotNull { index, c -> if (c == '.') index else null } + .map { index -> index + builder.length - it.second.length } + .forEach { index -> + builder.setSpan( + DotSpan(), + index, + index + 1, + SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } + } + holder.text.text = builder + } + + ViewType.RELEASE -> { + holder as ReleaseViewHolder + item as Item.ReleaseItem + val incompatibility = item.release.incompatibilities.firstOrNull() + val singlePlatform = + if (item.release.platforms.size == 1) item.release.platforms.first() else null + val installed = installedItem?.versionCode == item.release.versionCode && + installedItem?.signature == item.release.signature + val suggested = + incompatibility == null && item.release.selected && item.selectedRepository + + if (suggested) { + holder.itemView.apply { + background = context.corneredBackground + backgroundTintList = + holder.itemView.context.getColorFromAttr(MaterialR.attr.colorSurface) + } + } else { + holder.itemView.background = null + } + holder.version.text = + context.getString(stringRes.version_FORMAT, item.release.version) + + holder.status.apply { + isVisible = installed || suggested + setText( + when { + installed -> stringRes.installed + suggested -> stringRes.suggested + else -> stringRes.unknown + } + ) + background = context.corneredBackground + setPadding(15, 15, 15, 15) + backgroundTintList = + context.getColorFromAttr(MaterialR.attr.colorSecondaryContainer) + setTextColor(context.getColorFromAttr(MaterialR.attr.colorOnSecondaryContainer)) + } + holder.source.text = + context.getString(stringRes.provided_by_FORMAT, item.repository.name) + val instant = Instant.fromEpochMilliseconds(item.release.added) + // FDroid uses UTC time + val date = instant.toLocalDateTime(TimeZone.UTC) + val dateFormat = try { + date.toJavaLocalDateTime() + .format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)) + } catch (e: Exception) { + e.printStackTrace() + holder.dateFormat.format(item.release.added) + } + holder.added.text = dateFormat + holder.size.text = item.release.size.formatSize() + holder.signature.isVisible = + item.showSignature && item.release.signature.isNotEmpty() + if (item.showSignature && item.release.signature.isNotEmpty()) { + val bytes = + item.release.signature + .uppercase(Locale.US) + .windowed(2, 2, false) + .take(8) + val signature = bytes.joinToString(separator = " ") + val builder = SpannableStringBuilder( + context.getString( + stringRes.signature_FORMAT, + signature + ) + ) + val index = builder.indexOf(signature) + if (index >= 0) { + bytes.forEachIndexed { i, _ -> + builder.setSpan( + TypefaceSpan("monospace"), + index + 3 * i, + index + 3 * i + 2, + SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } + holder.signature.text = builder + } + holder.compatibility.isVisible = incompatibility != null || singlePlatform != null + if (incompatibility != null) { + holder.compatibility.setTextColor( + context.getColorFromAttr(MaterialR.attr.colorError) + ) + holder.compatibility.text = when (incompatibility) { + is Release.Incompatibility.MinSdk, + is Release.Incompatibility.MaxSdk + -> context.getString( + stringRes.incompatible_with_FORMAT, + Android.name + ) + + is Release.Incompatibility.Platform -> context.getString( + stringRes.incompatible_with_FORMAT, + Android.primaryPlatform ?: context.getString(stringRes.unknown) + ) + + is Release.Incompatibility.Feature -> context.getString( + stringRes.requires_FORMAT, + incompatibility.feature + ) + } + } else if (singlePlatform != null) { + holder.compatibility.setTextColor( + context.getColorFromAttr(android.R.attr.textColorSecondary) + ) + holder.compatibility.text = + context.getString(stringRes.only_compatible_with_FORMAT, singlePlatform) + } + val enabled = status == Status.Idle + holder.statefulViews.forEach { it.isEnabled = enabled } + } + + ViewType.EMPTY -> { + holder as EmptyViewHolder + item as Item.EmptyItem + holder.packageName.text = item.packageName + if (item.repoAddress != null) { + holder.repoTitle.setText(stringRes.repository_not_found) + holder.repoAddress.text = item.repoAddress + } + } + } + } + + private fun formatHtml(text: String): SpannableStringBuilder { + val html = HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_LEGACY) + val builder = run { + val builder = SpannableStringBuilder(html) + val last = builder.indexOfLast { it != '\n' } + val first = builder.indexOfFirst { it != '\n' } + if (last >= 0) { + builder.delete(last + 1, builder.length) + } + if (first in 1 until last) { + builder.delete(0, first - 1) + } + generateSequence(builder) { + val index = it.indexOf("\n\n\n") + if (index >= 0) it.delete(index, index + 1) else null + }.last() + } + LinkifyCompat.addLinks(builder, Linkify.WEB_URLS or Linkify.EMAIL_ADDRESSES) + val urlSpans = builder + .getSpans(0, builder.length, URLSpan::class.java) + .orEmpty() + for (span in urlSpans) { + val start = builder.getSpanStart(span) + val end = builder.getSpanEnd(span) + val flags = builder.getSpanFlags(span) + builder.removeSpan(span) + builder.setSpan(LinkSpan(span.url, this), start, end, flags) + } + val bulletSpans = builder + .getSpans(0, builder.length, BulletSpan::class.java) + .orEmpty() + .asSequence().map { Pair(it, builder.getSpanStart(it)) } + .sortedByDescending { it.second } + for (spanPair in bulletSpans) { + val (span, start) = spanPair + builder.removeSpan(span) + builder.insert(start, "\u2022 ") + } + return builder + } + + private fun copyLinkToClipboard(view: View, link: String) { + view.context.copyToClipboard(link) + Snackbar.make(view, stringRes.link_copied_to_clipboard, Snackbar.LENGTH_SHORT).show() + } + + private class LinkSpan(private val url: String, productAdapter: AppDetailAdapter) : + ClickableSpan() { + private val productAdapterReference = WeakReference(productAdapter) + + override fun onClick(view: View) { + val productAdapter = productAdapterReference.get() + val uri = try { + Uri.parse(url) + } catch (e: Exception) { + e.printStackTrace() + null + } + if (productAdapter != null && uri != null) { + productAdapter.callbacks.onUriClick(uri, true) + } + } + } + + private class DotSpan : ReplacementSpan() { + override fun getSize( + paint: Paint, + text: CharSequence?, + start: Int, + end: Int, + fm: Paint.FontMetricsInt? + ): Int { + return paint.measureText(".").roundToInt() + } + + override fun draw( + canvas: Canvas, + text: CharSequence?, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { + canvas.drawText(".", x, y.toFloat(), paint) + } + } + + @Parcelize + class SavedState internal constructor(internal val expanded: Set) : Parcelable + + fun saveState(): SavedState? { + return if (expanded.isNotEmpty()) { + SavedState(expanded) + } else { + null + } + } + + fun restoreState(savedState: SavedState) { + expanded.clear() + expanded += savedState.expanded + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/ui/appDetail/AppDetailFragment.kt b/app/src/main/kotlin/com/leos/droidify/ui/appDetail/AppDetailFragment.kt new file mode 100644 index 0000000..7550435 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/ui/appDetail/AppDetailFragment.kt @@ -0,0 +1,537 @@ +package com.leos.droidify.ui.appDetail + +import android.content.ActivityNotFoundException +import android.content.ComponentName +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.provider.Settings +import android.view.MenuItem +import android.view.View +import android.widget.ImageView +import androidx.appcompat.app.AlertDialog +import androidx.core.net.toUri +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.SimpleItemAnimator +import coil.load +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.leos.core.common.extension.getLauncherActivities +import com.leos.core.common.extension.getMutatedIcon +import com.leos.core.common.extension.isFirstItemVisible +import com.leos.core.common.extension.isSystemApplication +import com.leos.core.common.extension.systemBarsPadding +import com.leos.core.common.extension.updateAsMutable +import com.leos.core.domain.InstalledItem +import com.leos.core.domain.Product +import com.leos.core.domain.ProductPreference +import com.leos.core.domain.Release +import com.leos.core.domain.Repository +import com.leos.core.domain.findSuggested +import com.leos.droidify.content.ProductPreferences +import com.leos.droidify.service.Connection +import com.leos.droidify.service.DownloadService +import com.leos.droidify.ui.Message +import com.leos.droidify.ui.MessageDialog +import com.leos.droidify.ui.ScreenFragment +import com.leos.droidify.ui.appDetail.AppDetailViewModel.Companion.ARG_PACKAGE_NAME +import com.leos.droidify.ui.appDetail.AppDetailViewModel.Companion.ARG_REPO_ADDRESS +import com.leos.droidify.utility.extension.ImageUtils.url +import com.leos.droidify.utility.extension.screenActivity +import com.leos.droidify.utility.extension.startUpdate +import com.leos.installer.model.InstallState +import com.leos.installer.model.isCancellable +import com.stfalcon.imageviewer.StfalconImageViewer +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import com.leos.core.common.R.string as stringRes + +@AndroidEntryPoint +class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks { + companion object { + private const val STATE_LAYOUT_MANAGER = "layoutManager" + private const val STATE_ADAPTER = "adapter" + } + + constructor(packageName: String, repoAddress: String? = null) : this() { + arguments = bundleOf( + ARG_PACKAGE_NAME to packageName, + ARG_REPO_ADDRESS to repoAddress + ) + } + + private enum class Action( + val id: Int, + val adapterAction: AppDetailAdapter.Action + ) { + INSTALL(1, AppDetailAdapter.Action.INSTALL), + UPDATE(2, AppDetailAdapter.Action.UPDATE), + LAUNCH(3, AppDetailAdapter.Action.LAUNCH), + DETAILS(4, AppDetailAdapter.Action.DETAILS), + UNINSTALL(5, AppDetailAdapter.Action.UNINSTALL), + SHARE(6, AppDetailAdapter.Action.SHARE) + } + + private class Installed( + val installedItem: InstalledItem, + val isSystem: Boolean, + val launcherActivities: List> + ) + + private val viewModel: AppDetailViewModel by viewModels() + + private var layoutManagerState: LinearLayoutManager.SavedState? = null + + private var actions = Pair(emptySet(), null as Action?) + private var products = emptyList>() + private var installed: Installed? = null + private var downloading = false + private var installing: InstallState? = null + + private var recyclerView: RecyclerView? = null + private var detailAdapter: AppDetailAdapter? = null + + private val downloadConnection = Connection( + serviceClass = DownloadService::class.java, + onBind = { _, binder -> + lifecycleScope.launch { + binder.downloadState.collect(::updateDownloadState) + } + } + ) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + detailAdapter = AppDetailAdapter(this@AppDetailFragment) + screenActivity.onToolbarCreated(toolbar) + toolbar.menu.apply { + Action.entries.forEach { action -> + add(0, action.id, 0, action.adapterAction.titleResId) + .setIcon(toolbar.context.getMutatedIcon(action.adapterAction.iconResId)) + .setVisible(false) + .setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS) + .setOnMenuItemClickListener { + onActionClick(action.adapterAction) + true + } + } + } + + val content = fragmentBinding.fragmentContent + content.addView( + RecyclerView(content.context).apply { + id = android.R.id.list + this.layoutManager = LinearLayoutManager( + context, + LinearLayoutManager.VERTICAL, + false + ) + isMotionEventSplittingEnabled = false + isVerticalScrollBarEnabled = false + adapter = detailAdapter + (itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false + if (detailAdapter != null) { + savedInstanceState?.getParcelable(STATE_ADAPTER) + ?.let(detailAdapter!!::restoreState) + } + layoutManagerState = savedInstanceState?.getParcelable(STATE_LAYOUT_MANAGER) + recyclerView = this + systemBarsPadding(includeFab = false) + } + ) + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + launch { + viewModel.state.collectLatest { state -> + products = state.products.mapNotNull { product -> + val requiredRepo = state.repos.find { it.id == product.repositoryId } + requiredRepo?.let { product to it } + } + layoutManagerState?.let { + recyclerView?.layoutManager!!.onRestoreInstanceState(it) + } + layoutManagerState = null + installed = state.installedItem?.let { + with(requireContext().packageManager) { + val isSystem = isSystemApplication(viewModel.packageName) + val launcherActivities = if (state.isSelf) { + emptyList() + } else { + getLauncherActivities(viewModel.packageName) + } + Installed(it, isSystem, launcherActivities) + } + } + val adapter = recyclerView?.adapter as? AppDetailAdapter + + // `delay` is cancellable hence it waits for 50 milliseconds to show empty page + if (products.isEmpty()) delay(50) + + adapter?.setProducts( + context = requireContext(), + packageName = viewModel.packageName, + suggestedRepo = state.addressIfUnavailable, + products = products, + installedItem = state.installedItem, + isFavourite = state.isFavourite, + allowIncompatibleVersion = state.allowIncompatibleVersions + ) + updateButtons() + } + } + launch { + viewModel.installerState.collect(::updateInstallState) + } + launch { + recyclerView?.isFirstItemVisible?.collect(::updateToolbarButtons) + } + } + } + + downloadConnection.bind(requireContext()) + } + + override fun onDestroyView() { + super.onDestroyView() + recyclerView = null + detailAdapter = null + + downloadConnection.unbind(requireContext()) + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + + val layoutManagerState = + layoutManagerState ?: recyclerView?.layoutManager?.onSaveInstanceState() + layoutManagerState?.let { outState.putParcelable(STATE_LAYOUT_MANAGER, it) } + val adapterState = (recyclerView?.adapter as? AppDetailAdapter)?.saveState() + adapterState?.let { outState.putParcelable(STATE_ADAPTER, it) } + } + + private fun updateButtons( + preference: ProductPreference = ProductPreferences[viewModel.packageName] + ) { + val installed = installed + val product = products.findSuggested(installed?.installedItem)?.first + val compatible = product != null && product.selectedReleases.firstOrNull() + .let { it != null && it.incompatibilities.isEmpty() } + val canInstall = product != null && installed == null && compatible + val canUpdate = + product != null && compatible && product.canUpdate(installed?.installedItem) && + !preference.shouldIgnoreUpdate(product.versionCode) + val canUninstall = product != null && installed != null && !installed.isSystem + val canLaunch = + product != null && installed != null && installed.launcherActivities.isNotEmpty() + + val actions = buildSet { + if (canInstall) add(Action.INSTALL) + if (canUpdate) add(Action.UPDATE) + if (canLaunch) add(Action.LAUNCH) + if (installed != null) add(Action.DETAILS) + if (canUninstall) add(Action.UNINSTALL) + add(Action.SHARE) + } + + val primaryAction = when { + canUpdate -> Action.UPDATE + canLaunch -> Action.LAUNCH + canInstall -> Action.INSTALL + installed != null -> Action.DETAILS + else -> Action.SHARE + } + + val adapterAction = when { + installing == InstallState.Installing -> null + installing == InstallState.Pending -> AppDetailAdapter.Action.CANCEL + downloading -> AppDetailAdapter.Action.CANCEL + else -> primaryAction.adapterAction + } + + (recyclerView?.adapter as? AppDetailAdapter)?.action = adapterAction + + for (action in sequenceOf( + Action.INSTALL, + Action.UPDATE, + )) { + toolbar.menu.findItem(action.id).isEnabled = !downloading + } + this.actions = Pair(actions, primaryAction) + updateToolbarButtons() + } + + private fun updateToolbarButtons( + isActionVisible: Boolean = (recyclerView?.layoutManager as LinearLayoutManager) + .findFirstVisibleItemPosition() == 0 + ) { + toolbar.title = if (isActionVisible) { + getString(stringRes.application) + } else { + products.firstOrNull()?.first?.name ?: getString(stringRes.application) + } + val (actions, primaryAction) = actions + val displayActions = actions.updateAsMutable { + if (isActionVisible && primaryAction != null) { + remove(primaryAction) + } + if (size >= 4 && resources.configuration.screenWidthDp < 400) { + remove(Action.DETAILS) + } + } + Action.entries.forEach { action -> + toolbar.menu.findItem(action.id).isVisible = action in displayActions + } + } + + private fun updateInstallState(installerState: InstallState?) { + val status = when (installerState) { + InstallState.Pending -> AppDetailAdapter.Status.PendingInstall + InstallState.Installing -> AppDetailAdapter.Status.Installing + else -> AppDetailAdapter.Status.Idle + } + (recyclerView?.adapter as? AppDetailAdapter)?.status = status + installing = installerState + updateButtons() + } + + private fun updateDownloadState(state: DownloadService.DownloadState) { + val packageName = viewModel.packageName + val isPending = packageName in state.queue + val isDownloading = state isDownloading packageName + val isCompleted = state isComplete packageName + val isActive = isPending || isDownloading + if (isPending) { + detailAdapter?.status = AppDetailAdapter.Status.Pending + } + if (isDownloading) { + detailAdapter?.status = when (state.currentItem) { + is DownloadService.State.Connecting -> AppDetailAdapter.Status.Connecting + is DownloadService.State.Downloading -> AppDetailAdapter.Status.Downloading( + state.currentItem.read, + state.currentItem.total + ) + + else -> AppDetailAdapter.Status.Idle + } + } + if (isCompleted) { + detailAdapter?.status = AppDetailAdapter.Status.Idle + } + if (this.downloading != isActive) { + this.downloading = isActive + updateButtons() + } + if (state.currentItem is DownloadService.State.Success && isResumed) { + viewModel.installPackage( + state.currentItem.packageName, + state.currentItem.release.cacheFileName + ) + } + } + + override fun onActionClick(action: AppDetailAdapter.Action) { + when (action) { + AppDetailAdapter.Action.INSTALL, + AppDetailAdapter.Action.UPDATE + -> downloadConnection.startUpdate( + viewModel.packageName, + installed?.installedItem, + products + ) + + AppDetailAdapter.Action.LAUNCH -> { + val launcherActivities = installed?.launcherActivities.orEmpty() + if (launcherActivities.size >= 2) { + LaunchDialog(launcherActivities).show( + childFragmentManager, + LaunchDialog::class.java.name + ) + } else { + launcherActivities.firstOrNull()?.let { startLauncherActivity(it.first) } + } + } + + AppDetailAdapter.Action.DETAILS -> { + startActivity( + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + "package:${viewModel.packageName}".toUri() + ) + ) + } + + AppDetailAdapter.Action.UNINSTALL -> viewModel.uninstallPackage() + + AppDetailAdapter.Action.CANCEL -> { + val binder = downloadConnection.binder + if (installing?.isCancellable == true) { + viewModel.removeQueue() + } else if (downloading && binder != null) { + binder.cancel(viewModel.packageName) + } + } + + AppDetailAdapter.Action.SHARE -> { + val repo = products[0].second + val address = when { + repo.name == "F-Droid" -> + "https://www.f-droid.org/packages/" + + "${viewModel.packageName}/" + + "IzzyOnDroid" in repo.name -> { + "https://apt.izzysoft.de/fdroid/index/apk/${viewModel.packageName}" + } + + else -> { + "https://droidify.eu.org/app/?id=" + + "${viewModel.packageName}&repo_address=${repo.address}" + } + } + val sendIntent = Intent(Intent.ACTION_SEND) + .putExtra(Intent.EXTRA_TEXT, address) + .setType("text/plain") + startActivity(Intent.createChooser(sendIntent, null)) + } + } + } + + override fun onFavouriteClicked() { + viewModel.setFavouriteState() + } + + private fun startLauncherActivity(name: String) { + try { + startActivity( + Intent(Intent.ACTION_MAIN) + .addCategory(Intent.CATEGORY_LAUNCHER) + .setComponent(ComponentName(viewModel.packageName, name)) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } catch (e: Exception) { + e.printStackTrace() + } + } + + override fun onPreferenceChanged(preference: ProductPreference) { + updateButtons(preference) + } + + override fun onPermissionsClick(group: String?, permissions: List) { + MessageDialog(Message.Permissions(group, permissions)) + .show(childFragmentManager) + } + + override fun onScreenshotClick(screenshot: Product.Screenshot, parentView: ImageView) { + val product = products + .firstOrNull { (product, _) -> + product.screenshots.find { it === screenshot }?.identifier != null + } + ?: return + val screenshots = product.first.screenshots + val position = screenshots.indexOfFirst { screenshot.identifier == it.identifier } + StfalconImageViewer + .Builder(context, screenshots) { view, current -> + view.load(current.url(product.second, viewModel.packageName)) + } + .withTransitionFrom(parentView) + .withStartPosition(position) + .show() + } + + override fun onReleaseClick(release: Release) { + val installedItem = installed?.installedItem + when { + release.incompatibilities.isNotEmpty() -> { + MessageDialog( + Message.ReleaseIncompatible( + release.incompatibilities, + release.platforms, + release.minSdkVersion, + release.maxSdkVersion + ) + ).show(childFragmentManager) + } + + installedItem != null && installedItem.versionCode > release.versionCode -> { + MessageDialog(Message.ReleaseOlder).show(childFragmentManager) + } + + installedItem != null && installedItem.signature != release.signature -> { + MessageDialog(Message.ReleaseSignatureMismatch).show( + childFragmentManager + ) + } + + else -> { + val productRepository = + products.asSequence().filter { (product, _) -> + product.releases.any { it === release } + }.firstOrNull() + if (productRepository != null) { + downloadConnection.binder?.enqueue( + viewModel.packageName, + productRepository.first.name, + productRepository.second, + release, + installedItem != null + ) + } + } + } + } + + override fun onRequestAddRepository(address: String) { + screenActivity.navigateAddRepository(address) + } + + override fun onUriClick(uri: Uri, shouldConfirm: Boolean): Boolean { + return if (shouldConfirm && (uri.scheme == "http" || uri.scheme == "https")) { + MessageDialog(Message.Link(uri)).show(childFragmentManager) + true + } else { + try { + startActivity(Intent(Intent.ACTION_VIEW, uri)) + true + } catch (e: ActivityNotFoundException) { + e.printStackTrace() + false + } + } + } + + class LaunchDialog() : DialogFragment() { + companion object { + private const val EXTRA_NAMES = "names" + private const val EXTRA_LABELS = "labels" + } + + constructor(launcherActivities: List>) : this() { + arguments = Bundle().apply { + putStringArrayList(EXTRA_NAMES, ArrayList(launcherActivities.map { it.first })) + putStringArrayList(EXTRA_LABELS, ArrayList(launcherActivities.map { it.second })) + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog { + val names = requireArguments().getStringArrayList(EXTRA_NAMES)!! + val labels = requireArguments().getStringArrayList(EXTRA_LABELS)!! + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(stringRes.launch) + .setItems(labels.toTypedArray()) { _, position -> + (parentFragment as AppDetailFragment) + .startLauncherActivity(names[position]) + } + .setNegativeButton(stringRes.cancel, null) + .create() + } + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/ui/appDetail/AppDetailViewModel.kt b/app/src/main/kotlin/com/leos/droidify/ui/appDetail/AppDetailViewModel.kt new file mode 100644 index 0000000..063233a --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/ui/appDetail/AppDetailViewModel.kt @@ -0,0 +1,103 @@ +package com.leos.droidify.ui.appDetail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.leos.core.common.extension.asStateFlow +import com.leos.core.common.toPackageName +import com.leos.core.datastore.SettingsRepository +import com.leos.core.domain.InstalledItem +import com.leos.core.domain.Product +import com.leos.core.domain.Repository +import com.leos.droidify.BuildConfig +import com.leos.droidify.database.Database +import com.leos.installer.InstallManager +import com.leos.installer.model.InstallState +import com.leos.installer.model.installFrom +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch + +@HiltViewModel +class AppDetailViewModel @Inject constructor( + private val installer: InstallManager, + private val settingsRepository: SettingsRepository, + savedStateHandle: SavedStateHandle +) : ViewModel() { + + val packageName: String = requireNotNull(savedStateHandle[ARG_PACKAGE_NAME]) + + private val repoAddress: StateFlow = + savedStateHandle.getStateFlow(ARG_REPO_ADDRESS, null) + + val installerState: StateFlow = + installer.state.mapNotNull { stateMap -> + stateMap[packageName.toPackageName()] + }.asStateFlow(null) + + val state = + combine( + Database.ProductAdapter.getStream(packageName), + Database.RepositoryAdapter.getAllStream(), + Database.InstalledAdapter.getStream(packageName), + repoAddress, + flow { emit(settingsRepository.getInitial()) } + ) { products, repositories, installedItem, suggestedAddress, initialSettings -> + val idAndRepos = repositories.associateBy { it.id } + val filteredProducts = products.filter { product -> + idAndRepos[product.repositoryId] != null + } + AppDetailUiState( + products = filteredProducts, + repos = repositories, + installedItem = installedItem, + isFavourite = packageName in initialSettings.favouriteApps, + allowIncompatibleVersions = initialSettings.incompatibleVersions, + isSelf = packageName == BuildConfig.APPLICATION_ID, + addressIfUnavailable = suggestedAddress + ) + }.asStateFlow(AppDetailUiState()) + + fun setFavouriteState() { + viewModelScope.launch { + settingsRepository.toggleFavourites(packageName) + } + } + + fun installPackage(packageName: String, fileName: String) { + viewModelScope.launch { + installer install (packageName installFrom fileName) + } + } + + fun uninstallPackage() { + viewModelScope.launch { + installer uninstall packageName.toPackageName() + } + } + + fun removeQueue() { + viewModelScope.launch { + installer remove packageName.toPackageName() + } + } + + companion object { + const val ARG_PACKAGE_NAME = "package_name" + const val ARG_REPO_ADDRESS = "repo_address" + } +} + +data class AppDetailUiState( + val products: List = emptyList(), + val repos: List = emptyList(), + val installedItem: InstalledItem? = null, + val isSelf: Boolean = false, + val isFavourite: Boolean = false, + val allowIncompatibleVersions: Boolean = false, + val addressIfUnavailable: String? = null +) diff --git a/app/src/main/kotlin/com/leos/droidify/ui/appDetail/ScreenshotsAdapter.kt b/app/src/main/kotlin/com/leos/droidify/ui/appDetail/ScreenshotsAdapter.kt new file mode 100644 index 0000000..d13199c --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/ui/appDetail/ScreenshotsAdapter.kt @@ -0,0 +1,126 @@ +package com.leos.droidify.ui.appDetail + +import android.content.Context +import android.graphics.drawable.Drawable +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import androidx.recyclerview.widget.RecyclerView +import coil.load +import coil.size.Scale +import com.google.android.material.R as MaterialR +import com.google.android.material.imageview.ShapeableImageView +import com.leos.core.common.R.dimen as dimenRes +import com.leos.core.common.extension.aspectRatio +import com.leos.core.common.extension.authentication +import com.leos.core.common.extension.camera +import com.leos.core.common.extension.dp +import com.leos.core.common.extension.getColorFromAttr +import com.leos.core.common.extension.selectableBackground +import com.leos.core.domain.Product +import com.leos.core.domain.Repository +import com.leos.droidify.graphics.PaddingDrawable +import com.leos.droidify.utility.extension.ImageUtils.url +import com.leos.droidify.widget.StableRecyclerAdapter + +class ScreenshotsAdapter(private val onClick: (Product.Screenshot, ImageView) -> Unit) : + StableRecyclerAdapter() { + enum class ViewType { SCREENSHOT } + + private val items = mutableListOf() + + private class ViewHolder(context: Context) : + RecyclerView.ViewHolder(FrameLayout(context)) { + val image: ShapeableImageView = object : ShapeableImageView(context) { + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + setMeasuredDimension(measuredWidth, measuredHeight) + } + } + val placeholderColor = context.getColorFromAttr(MaterialR.attr.colorPrimaryContainer) + val radius = context.resources.getDimension(dimenRes.shape_small_corner) + + val imageShapeModel = image.shapeAppearanceModel.toBuilder() + .setAllCornerSizes(radius) + .build() + val cameraIcon = context.camera + .apply { setTintList(placeholderColor) } + val placeholder: Drawable = PaddingDrawable(cameraIcon, 3f, context.aspectRatio) + + init { + with(image) { + shapeAppearanceModel = imageShapeModel + background = context.selectableBackground + isFocusable = true + } + with(itemView as FrameLayout) { + layoutParams = RecyclerView.LayoutParams( + RecyclerView.LayoutParams.WRAP_CONTENT, + 150.dp + ).apply { + marginStart = radius.toInt() + marginEnd = radius.toInt() + } + foregroundGravity = Gravity.CENTER + addView(image) + } + } + } + + fun setScreenshots( + repository: Repository, + packageName: String, + screenshots: List + ) { + items.clear() + items += screenshots.map { Item.ScreenshotItem(repository, packageName, it) } + notifyItemRangeInserted(0, screenshots.size) + } + + override val viewTypeClass: Class + get() = ViewType::class.java + + override fun getItemEnumViewType(position: Int): ViewType { + return ViewType.SCREENSHOT + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: ViewType + ): RecyclerView.ViewHolder { + return ViewHolder(parent.context).apply { + image.setOnClickListener { onClick(items[absoluteAdapterPosition].screenshot, it as ImageView) } + } + } + + override fun getItemDescriptor(position: Int): String = items[position].descriptor + override fun getItemCount(): Int = items.size + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + holder as ViewHolder + val item = items[position] + holder.image.load( + item.screenshot.url(item.repository, item.packageName) + ) { + scale(Scale.FILL) + placeholder(holder.placeholder) + error(holder.placeholder) + authentication(item.repository.authentication) + } + } + + private sealed class Item { + abstract val descriptor: String + + class ScreenshotItem( + val repository: Repository, + val packageName: String, + val screenshot: Product.Screenshot + ) : Item() { + override val descriptor: String + get() = "screenshot.${repository.id}.${screenshot.identifier}" + } + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/ui/appList/AppListAdapter.kt b/app/src/main/kotlin/com/leos/droidify/ui/appList/AppListAdapter.kt new file mode 100644 index 0000000..2c6baa1 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/ui/appList/AppListAdapter.kt @@ -0,0 +1,200 @@ +package com.leos.droidify.ui.appList + +import android.content.Context +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.TextView +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import coil.load +import com.google.android.material.R as MaterialR +import com.google.android.material.imageview.ShapeableImageView +import com.google.android.material.progressindicator.CircularProgressIndicator +import com.leos.core.common.extension.authentication +import com.leos.core.common.extension.corneredBackground +import com.leos.core.common.extension.dp +import com.leos.core.common.extension.getColorFromAttr +import com.leos.core.common.extension.inflate +import com.leos.core.common.extension.setTextSizeScaled +import com.leos.core.common.nullIfEmpty +import com.leos.core.domain.ProductItem +import com.leos.core.domain.Repository +import com.leos.droidify.R +import com.leos.droidify.database.Database +import com.leos.droidify.utility.extension.ImageUtils.icon +import com.leos.droidify.utility.extension.resources.TypefaceExtra +import com.leos.droidify.widget.CursorRecyclerAdapter + +class AppListAdapter( + private val source: AppListFragment.Source, + private val onClick: (ProductItem) -> Unit +) : CursorRecyclerAdapter() { + + enum class ViewType { PRODUCT, LOADING, EMPTY } + + private class ProductViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val name = itemView.findViewById(R.id.name)!! + val status = itemView.findViewById(R.id.status)!! + val summary = itemView.findViewById(R.id.summary)!! + val icon = itemView.findViewById(R.id.icon)!! + } + + private class LoadingViewHolder(context: Context) : + RecyclerView.ViewHolder(FrameLayout(context)) { + init { + with(itemView as FrameLayout) { + val progressBar = CircularProgressIndicator(context) + addView(progressBar) + layoutParams = RecyclerView.LayoutParams( + RecyclerView.LayoutParams.MATCH_PARENT, + RecyclerView.LayoutParams.MATCH_PARENT + ) + } + } + } + + private class EmptyViewHolder(context: Context) : + RecyclerView.ViewHolder(TextView(context)) { + val text: TextView + get() = itemView as TextView + + init { + with(itemView as TextView) { + gravity = Gravity.CENTER + setPadding(20.dp, 20.dp, 20.dp, 20.dp) + typeface = TypefaceExtra.light + setTextColor(context.getColorFromAttr(android.R.attr.colorPrimary)) + setTextSizeScaled(20) + layoutParams = RecyclerView.LayoutParams( + RecyclerView.LayoutParams.MATCH_PARENT, + RecyclerView.LayoutParams.MATCH_PARENT + ) + } + } + } + + var repositories: Map = emptyMap() + set(value) { + field = value + notifyDataSetChanged() + } + + var emptyText: String = "" + set(value) { + if (field != value) { + field = value + if (isEmpty) { + notifyDataSetChanged() + } + } + } + + override val viewTypeClass: Class + get() = ViewType::class.java + + private val isEmpty: Boolean + get() = super.getItemCount() == 0 + + override fun getItemCount(): Int = if (isEmpty) 1 else super.getItemCount() + override fun getItemId(position: Int): Long = if (isEmpty) -1 else super.getItemId(position) + + override fun getItemEnumViewType(position: Int): ViewType { + return when { + !isEmpty -> ViewType.PRODUCT + cursor == null -> ViewType.LOADING + else -> ViewType.EMPTY + } + } + + private fun getProductItem(position: Int): ProductItem { + return Database.ProductAdapter.transformItem(moveTo(position)) + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: ViewType + ): RecyclerView.ViewHolder { + return when (viewType) { + ViewType.PRODUCT -> ProductViewHolder(parent.inflate(R.layout.product_item)).apply { + itemView.setOnClickListener { onClick(getProductItem(absoluteAdapterPosition)) } + } + + ViewType.LOADING -> LoadingViewHolder(parent.context) + ViewType.EMPTY -> EmptyViewHolder(parent.context) + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (getItemEnumViewType(position)) { + ViewType.PRODUCT -> { + holder as ProductViewHolder + val productItem = getProductItem(if (position > -1) position else 0) + holder.name.text = productItem.name + holder.summary.text = productItem.summary + holder.summary.isVisible = + productItem.summary.isNotEmpty() && productItem.name != productItem.summary + val repository: Repository? = repositories[productItem.repositoryId] + if (repository != null) { + val iconUrl = productItem.icon(view = holder.icon, repository = repository) + holder.icon.load(iconUrl) { + authentication(repository.authentication) + } + } + with(holder.status) { + val versionText = if (source == AppListFragment.Source.UPDATES) { + productItem.version + } else { + productItem.installedVersion.nullIfEmpty() ?: productItem.version + } + text = versionText + val isInstalled = productItem.installedVersion.nullIfEmpty() != null + when { + productItem.canUpdate -> { + backgroundTintList = + context.getColorFromAttr(MaterialR.attr.colorSecondaryContainer) + setTextColor( + context.getColorFromAttr(MaterialR.attr.colorOnSecondaryContainer) + ) + } + + isInstalled -> { + backgroundTintList = + context.getColorFromAttr(MaterialR.attr.colorPrimaryContainer) + setTextColor( + context.getColorFromAttr(MaterialR.attr.colorOnPrimaryContainer) + ) + } + + else -> { + setPadding(0, 0, 0, 0) + setTextColor( + holder.status.context.getColorFromAttr( + MaterialR.attr.colorOnBackground + ) + ) + background = null + return@with + } + } + background = context.corneredBackground + 6.dp.let { setPadding(it, it, it, it) } + } + val enabled = productItem.compatible || productItem.installedVersion.isNotEmpty() + sequenceOf(holder.name, holder.status, holder.summary).forEach { + it.isEnabled = enabled + } + } + + ViewType.LOADING -> { + // Do nothing + } + + ViewType.EMPTY -> { + holder as EmptyViewHolder + holder.text.text = emptyText + } + }::class + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/ui/appList/AppListFragment.kt b/app/src/main/kotlin/com/leos/droidify/ui/appList/AppListFragment.kt new file mode 100644 index 0000000..ef78863 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/ui/appList/AppListFragment.kt @@ -0,0 +1,198 @@ +package com.leos.droidify.ui.appList + +import android.database.Cursor +import android.os.Bundle +import android.os.Parcelable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.leos.core.common.R as CommonR +import com.leos.core.common.R.string as stringRes +import com.leos.core.common.extension.dp +import com.leos.core.common.extension.isFirstItemVisible +import com.leos.core.common.extension.systemBarsMargin +import com.leos.core.common.extension.systemBarsPadding +import com.leos.core.domain.ProductItem +import com.leos.droidify.database.CursorOwner +import com.leos.droidify.databinding.RecyclerViewWithFabBinding +import com.leos.droidify.utility.extension.screenActivity +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class AppListFragment() : Fragment(), CursorOwner.Callback { + + private val viewModel: AppListViewModel by viewModels() + + private var _binding: RecyclerViewWithFabBinding? = null + private val binding get() = _binding!! + + companion object { + private const val STATE_LAYOUT_MANAGER = "layoutManager" + + private const val EXTRA_SOURCE = "source" + } + + enum class Source( + val titleResId: Int, + val sections: Boolean, + val order: Boolean, + val updateAll: Boolean + ) { + AVAILABLE(stringRes.available, true, true, false), + INSTALLED(stringRes.installed, false, true, false), + UPDATES(stringRes.updates, false, false, true) + } + + constructor(source: Source) : this() { + arguments = Bundle().apply { + putString(EXTRA_SOURCE, source.name) + } + } + + val source: Source + get() = requireArguments().getString(EXTRA_SOURCE)!!.let(Source::valueOf) + + private lateinit var recyclerView: RecyclerView + private lateinit var recyclerViewAdapter: AppListAdapter + private var shortAnimationDuration: Int = 0 + private var layoutManagerState: Parcelable? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = RecyclerViewWithFabBinding.inflate(inflater, container, false) + + shortAnimationDuration = resources.getInteger(android.R.integer.config_shortAnimTime) + + viewModel.syncConnection.bind(requireContext()) + + recyclerView = binding.recyclerView.apply { + layoutManager = LinearLayoutManager(context) + isMotionEventSplittingEnabled = false + setHasFixedSize(true) + recycledViewPool.setMaxRecycledViews(AppListAdapter.ViewType.PRODUCT.ordinal, 30) + recyclerViewAdapter = AppListAdapter(source) { + screenActivity.navigateProduct(it.packageName) + } + adapter = recyclerViewAdapter + systemBarsPadding() + } + val fab = binding.scrollUp + with(fab) { + if (source.updateAll) { + text = getString(CommonR.string.update_all) + setOnClickListener { viewModel.updateAll() } + setIconResource(CommonR.drawable.ic_download) + alpha = 1f + viewLifecycleOwner.lifecycleScope.launch { + viewModel.showUpdateAllButton.collect { + isVisible = it + } + } + systemBarsMargin(16.dp) + } else { + text = "" + setIconResource(CommonR.drawable.arrow_up) + setOnClickListener { recyclerView.smoothScrollToPosition(0) } + alpha = 0f + isVisible = true + systemBarsMargin(16.dp) + } + } + + viewLifecycleOwner.lifecycleScope.launch { + if (!source.updateAll) { + recyclerView.isFirstItemVisible.collect { showFab -> + fab.animate() + .alpha(if (!showFab) 1f else 0f) + .setDuration(shortAnimationDuration.toLong()) + .setListener(null) + } + } + } + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + layoutManagerState = savedInstanceState?.getParcelable(STATE_LAYOUT_MANAGER) + + updateRequest() + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + launch { + viewModel.reposStream.collect { repos -> + recyclerViewAdapter.repositories = repos.associateBy { it.id } + } + } + launch { + viewModel.sortOrderFlow.collect { + updateRequest() + } + } + } + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + (layoutManagerState ?: recyclerView.layoutManager?.onSaveInstanceState()) + ?.let { outState.putParcelable(STATE_LAYOUT_MANAGER, it) } + } + + override fun onDestroyView() { + super.onDestroyView() + viewModel.syncConnection.unbind(requireContext()) + _binding = null + screenActivity.cursorOwner.detach(this) + } + + override fun onCursorData(request: CursorOwner.Request, cursor: Cursor?) { + recyclerViewAdapter.cursor = cursor + recyclerViewAdapter.emptyText = when { + cursor == null -> "" + viewModel.searchQuery.value.isNotEmpty() -> { + getString(stringRes.no_matching_applications_found) + } + + else -> when (source) { + Source.AVAILABLE -> getString(stringRes.no_applications_available) + Source.INSTALLED -> getString(stringRes.no_applications_installed) + Source.UPDATES -> getString(stringRes.all_applications_up_to_date) + } + } + layoutManagerState?.let { + layoutManagerState = null + recyclerView.layoutManager?.onRestoreInstanceState(it) + } + } + + internal fun setSearchQuery(searchQuery: String) { + viewModel.setSearchQuery(searchQuery) { + updateRequest() + } + } + + internal fun setSection(section: ProductItem.Section) { + viewModel.setSection(section) { + updateRequest() + } + } + + private fun updateRequest() { + if (view != null) { + screenActivity.cursorOwner.attach(this, viewModel.request(source)) + } + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/ui/appList/AppListViewModel.kt b/app/src/main/kotlin/com/leos/droidify/ui/appList/AppListViewModel.kt new file mode 100644 index 0000000..39bdc5c --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/ui/appList/AppListViewModel.kt @@ -0,0 +1,91 @@ +package com.leos.droidify.ui.appList + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.leos.core.common.extension.asStateFlow +import com.leos.core.datastore.SettingsRepository +import com.leos.core.datastore.get +import com.leos.core.datastore.model.SortOrder +import com.leos.core.domain.ProductItem +import com.leos.core.domain.ProductItem.Section.All +import com.leos.droidify.database.CursorOwner +import com.leos.droidify.database.Database +import com.leos.droidify.service.Connection +import com.leos.droidify.service.SyncService +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +@HiltViewModel +class AppListViewModel +@Inject constructor( + settingsRepository: SettingsRepository +) : ViewModel() { + + val reposStream = Database.RepositoryAdapter + .getAllStream() + .asStateFlow(emptyList()) + + val showUpdateAllButton = Database.ProductAdapter + .getUpdatesStream() + .map { it.isNotEmpty() } + .asStateFlow(false) + + val sortOrderFlow = settingsRepository.get { sortOrder } + .asStateFlow(SortOrder.UPDATED) + + private val sections = MutableStateFlow(All) + + val searchQuery = MutableStateFlow("") + + val syncConnection = Connection(SyncService::class.java) + + fun updateAll() { + viewModelScope.launch { + syncConnection.binder?.updateAllApps() + } + } + + fun request(source: AppListFragment.Source): CursorOwner.Request { + return when (source) { + AppListFragment.Source.AVAILABLE -> CursorOwner.Request.ProductsAvailable( + searchQuery.value, + sections.value, + sortOrderFlow.value + ) + + AppListFragment.Source.INSTALLED -> CursorOwner.Request.ProductsInstalled( + searchQuery.value, + sections.value, + sortOrderFlow.value + ) + + AppListFragment.Source.UPDATES -> CursorOwner.Request.ProductsUpdates( + searchQuery.value, + sections.value, + sortOrderFlow.value + ) + } + } + + fun setSection(newSection: ProductItem.Section, perform: () -> Unit) { + viewModelScope.launch { + if (newSection != sections.value) { + sections.emit(newSection) + launch(Dispatchers.Main) { perform() } + } + } + } + + fun setSearchQuery(newSearchQuery: String, perform: () -> Unit) { + viewModelScope.launch { + if (newSearchQuery != searchQuery.value) { + searchQuery.emit(newSearchQuery) + launch(Dispatchers.Main) { perform() } + } + } + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/ui/favourites/FavouriteFragmentAdapter.kt b/app/src/main/kotlin/com/leos/droidify/ui/favourites/FavouriteFragmentAdapter.kt new file mode 100644 index 0000000..a761019 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/ui/favourites/FavouriteFragmentAdapter.kt @@ -0,0 +1,98 @@ +package com.leos.droidify.ui.favourites + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import coil.load +import com.google.android.material.R as MaterialR +import com.leos.core.common.extension.authentication +import com.leos.core.common.extension.corneredBackground +import com.leos.core.common.extension.dp +import com.leos.core.common.extension.getColorFromAttr +import com.leos.core.common.nullIfEmpty +import com.leos.core.domain.Product +import com.leos.core.domain.Repository +import com.leos.droidify.databinding.ProductItemBinding +import com.leos.droidify.utility.extension.ImageUtils.icon + +class FavouriteFragmentAdapter( + private val onProductClick: (String) -> Unit +) : RecyclerView.Adapter() { + + inner class ViewHolder(binding: ProductItemBinding) : RecyclerView.ViewHolder(binding.root) { + val icon = binding.icon + val name = binding.name + val summary = binding.summary + val version = binding.status + } + + var apps: List> = emptyList() + set(value) { + field = value + notifyDataSetChanged() + } + + var repositories: Map = emptyMap() + set(value) { + field = value + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder = + ViewHolder( + ProductItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ).apply { + itemView.setOnClickListener { + if (apps.isNotEmpty() && apps[absoluteAdapterPosition].firstOrNull() != null) { + onProductClick(apps[absoluteAdapterPosition].first().packageName) + } + } + } + + override fun getItemCount(): Int = apps.size + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val item = apps[position].first().item() + val repository: Repository? = repositories[item.repositoryId] + holder.name.text = item.name + holder.summary.isVisible = item.summary.isNotEmpty() + holder.summary.text = item.summary + if (repository != null) { + val iconUrl = item.icon(holder.icon, repository) + holder.icon.load(iconUrl) { + authentication(repository.authentication) + } + } + holder.version.apply { + text = item.installedVersion.nullIfEmpty() ?: item.version + val isInstalled = item.installedVersion.nullIfEmpty() != null + when { + item.canUpdate -> { + backgroundTintList = + context.getColorFromAttr(MaterialR.attr.colorSecondaryContainer) + setTextColor(context.getColorFromAttr(MaterialR.attr.colorOnSecondaryContainer)) + } + + isInstalled -> { + backgroundTintList = + context.getColorFromAttr(MaterialR.attr.colorPrimaryContainer) + setTextColor(context.getColorFromAttr(MaterialR.attr.colorOnPrimaryContainer)) + } + + else -> { + setPadding(0, 0, 0, 0) + setTextColor(context.getColorFromAttr(MaterialR.attr.colorOnBackground)) + background = null + return@apply + } + } + background = context.corneredBackground + 6.dp.let { setPadding(it, it, it, it) } + } + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/ui/favourites/FavouritesFragment.kt b/app/src/main/kotlin/com/leos/droidify/ui/favourites/FavouritesFragment.kt new file mode 100644 index 0000000..8b5738f --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/ui/favourites/FavouritesFragment.kt @@ -0,0 +1,79 @@ +package com.leos.droidify.ui.favourites + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.leos.core.common.R as CommonR +import com.leos.core.common.extension.systemBarsPadding +import com.leos.droidify.database.Database +import com.leos.droidify.ui.ScreenFragment +import com.leos.droidify.utility.extension.screenActivity +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class FavouritesFragment : ScreenFragment() { + + private val viewModel: FavouritesViewModel by viewModels() + + private lateinit var recyclerView: RecyclerView + private lateinit var recyclerViewAdapter: FavouriteFragmentAdapter + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + super.onCreateView(inflater, container, savedInstanceState) + val view = fragmentBinding.root.apply { + val content = fragmentBinding.fragmentContent + content.addView( + RecyclerView(content.context).apply { + id = android.R.id.list + layoutManager = LinearLayoutManager(context) + isVerticalScrollBarEnabled = false + setHasFixedSize(true) + recyclerViewAdapter = + FavouriteFragmentAdapter { screenActivity.navigateProduct(it) } + this.adapter = recyclerViewAdapter + systemBarsPadding(includeFab = false) + recyclerView = this + } + ) + } + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + launch { + viewModel.favouriteApps.collect { apps -> + recyclerViewAdapter.apps = apps + } + } + launch { + Database.RepositoryAdapter + .getAllStream() + .flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.RESUMED) + .collectLatest { repositories -> + recyclerViewAdapter.repositories = repositories.associateBy { it.id } + } + } + } + } + + toolbar.title = getString(CommonR.string.favourites) + return view + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + screenActivity.onToolbarCreated(toolbar) + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/ui/favourites/FavouritesViewModel.kt b/app/src/main/kotlin/com/leos/droidify/ui/favourites/FavouritesViewModel.kt new file mode 100644 index 0000000..162b828 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/ui/favourites/FavouritesViewModel.kt @@ -0,0 +1,35 @@ +package com.leos.droidify.ui.favourites + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.leos.core.common.extension.asStateFlow +import com.leos.core.datastore.SettingsRepository +import com.leos.core.datastore.get +import com.leos.core.domain.Product +import com.leos.droidify.database.Database +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +@HiltViewModel +class FavouritesViewModel @Inject constructor( + private val settingsRepository: SettingsRepository +) : ViewModel() { + + val favouriteApps: StateFlow>> = + settingsRepository + .get { favouriteApps } + .map { favourites -> + favourites.mapNotNull { app -> + Database.ProductAdapter.get(app, null).ifEmpty { null } + } + }.asStateFlow(emptyList()) + + fun updateFavourites(packageName: String) { + viewModelScope.launch { + settingsRepository.toggleFavourites(packageName) + } + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/ui/repository/EditRepositoryFragment.kt b/app/src/main/kotlin/com/leos/droidify/ui/repository/EditRepositoryFragment.kt new file mode 100644 index 0000000..5ced897 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/ui/repository/EditRepositoryFragment.kt @@ -0,0 +1,475 @@ +package com.leos.droidify.ui.repository + +import android.os.Bundle +import android.text.Selection +import android.util.Base64 +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.core.net.toUri +import androidx.core.os.bundleOf +import androidx.core.view.isVisible +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.lifecycleScope +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import com.leos.core.common.extension.clipboardManager +import com.leos.core.common.extension.get +import com.leos.core.common.extension.getMutatedIcon +import com.leos.core.common.nullIfEmpty +import com.leos.core.domain.Repository +import com.leos.droidify.database.Database +import com.leos.droidify.databinding.EditRepositoryBinding +import com.leos.droidify.service.Connection +import com.leos.droidify.service.SyncService +import com.leos.droidify.ui.Message +import com.leos.droidify.ui.MessageDialog +import com.leos.droidify.ui.ScreenFragment +import com.leos.droidify.utility.extension.screenActivity +import com.leos.network.Downloader +import com.leos.network.NetworkResponse +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import java.net.URI +import java.net.URISyntaxException +import java.net.URL +import java.nio.charset.Charset +import java.util.Locale +import javax.inject.Inject +import kotlin.math.min +import com.leos.core.common.R as CommonR +import com.leos.core.common.R.string as stringRes + +@AndroidEntryPoint +class EditRepositoryFragment() : ScreenFragment() { + + constructor(repositoryId: Long?, repoAddress: String?) : this() { + arguments = + bundleOf(EXTRA_REPOSITORY_ID to repositoryId, EXTRA_REPOSITORY_ADDRESS to repoAddress) + } + + private var _binding: EditRepositoryBinding? = null + private val binding get() = _binding!! + + private val repoId: Long? + get() = arguments?.getLong(EXTRA_REPOSITORY_ID) + + private val repoAddress: String? + get() = arguments?.getString(EXTRA_REPOSITORY_ADDRESS) + + private var saveMenuItem: MenuItem? = null + + private val syncConnection = Connection(SyncService::class.java) + private var checkInProgress = false + private var checkJob: Job? = null + + private var takenAddresses = emptySet() + + @Inject + lateinit var downloader: Downloader + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + _binding = EditRepositoryBinding.inflate(layoutInflater) + + syncConnection.bind(requireContext()) + + screenActivity.onToolbarCreated(toolbar) + toolbar.title = + getString( + if (repoId != null) stringRes.edit_repository else stringRes.add_repository + ) + + saveMenuItem = toolbar.menu.add(stringRes.save) + .setIcon(toolbar.context.getMutatedIcon(CommonR.drawable.ic_save)) + .setEnabled(false) + .setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS).setOnMenuItemClickListener { + onSaveRepositoryClick(true) + true + } + + val content = fragmentBinding.fragmentContent + + content.addView(binding.root) + + val validChar: (Char) -> Boolean = { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' } + + binding.fingerprint.doAfterTextChanged { text -> + fun logicalPosition(text: String, position: Int): Int { + return if (position > 0) { + text.asSequence().take(position) + .count(validChar) + } else { + position + } + } + + fun realPosition(text: String, position: Int): Int { + return if (position > 0) { + var left = position + val index = text.indexOfFirst { + validChar(it) && run { + left -= 1 + left <= 0 + } + } + if (index >= 0) min(index + 1, text.length) else text.length + } else { + position + } + } + + val inputString = text.toString() + val outputString = inputString + .uppercase(Locale.US) + .filter(validChar) + .windowed(2, 2, true).take(32) + .joinToString(separator = " ") + if (inputString != outputString) { + val inputStart = logicalPosition(inputString, Selection.getSelectionStart(text)) + val inputEnd = logicalPosition(inputString, Selection.getSelectionEnd(text)) + text?.replace(0, text.length, outputString) + Selection.setSelection( + text, + realPosition(outputString, inputStart), + realPosition(outputString, inputEnd) + ) + } + } + + if (savedInstanceState == null) { + val repository = repoId?.let(Database.RepositoryAdapter::get) + if (repository == null) { + val text = repoAddress ?: kotlin.run { + context?.clipboardManager?.primaryClip?.takeIf { it.itemCount > 0 } + ?.getItemAt(0)?.text?.toString().orEmpty() + } + val (addressText, fingerprintText) = try { + val uri = URL(text).toString().toUri() + val fingerprintText = uri["fingerprint"]?.nullIfEmpty() + ?: uri["FINGERPRINT"]?.nullIfEmpty() + Pair( + uri.buildUpon().path(uri.path?.pathCropped).query(null).fragment(null) + .build().toString(), + fingerprintText + ) + } catch (e: Exception) { + Pair(null, null) + } + binding.address.setText(addressText) + binding.fingerprint.setText(fingerprintText) + } else { + binding.address.setText(repository.address) + val mirrors = repository.mirrors.map { it.withoutKnownPath } + binding.addressContainer.apply { + isEndIconVisible = mirrors.isNotEmpty() + setEndIconDrawable(CommonR.drawable.ic_arrow_down) + setEndIconOnClickListener { + SelectMirrorDialog(mirrors).show( + childFragmentManager, + SelectMirrorDialog::class.java.name + ) + } + } + binding.fingerprint.setText(repository.fingerprint) + val (usernameText, passwordText) = repository.authentication.nullIfEmpty() + ?.let { if (it.startsWith("Basic ")) it.substring(6) else null }?.let { + try { + Base64.decode(it, Base64.NO_WRAP).toString(Charset.defaultCharset()) + } catch (e: Exception) { + e.printStackTrace() + null + } + }?.let { + val index = it.indexOf(':') + if (index >= 0) { + Pair( + it.substring(0, index), + it.substring(index + 1) + ) + } else { + null + } + } ?: Pair(null, null) + binding.username.setText(usernameText) + binding.password.setText(passwordText) + } + } + + binding.address.doAfterTextChanged { invalidateAddress() } + binding.fingerprint.doAfterTextChanged { invalidateFingerprint() } + binding.username.doAfterTextChanged { invalidateUsernamePassword() } + binding.password.doAfterTextChanged { invalidateUsernamePassword() } + + (binding.overlay.parent as ViewGroup).layoutTransition?.setDuration(200L) + binding.overlay.background!!.apply { + mutate() + alpha = 0xcc + } + binding.skip.setOnClickListener { + if (checkInProgress) { + checkInProgress = false + checkJob?.cancel() + onSaveRepositoryClick(false) + } + } + + viewLifecycleOwner.lifecycleScope.launch { + val list = Database.RepositoryAdapter.getAll() + takenAddresses = list.asSequence().filter { it.id != repoId } + .flatMap { (it.mirrors + it.address).asSequence() } + .map { it.withoutKnownPath } + .toSet() + invalidateAddress() + } + invalidateAddress() + invalidateFingerprint() + invalidateUsernamePassword() + } + + override fun onDestroyView() { + super.onDestroyView() + + saveMenuItem = null + syncConnection.unbind(requireContext()) + _binding = null + } + + private var addressError = false + private var fingerprintError = false + private var usernamePasswordError = false + + private fun invalidateAddress() { + invalidateAddress(binding.address.text.toString()) + } + + private fun invalidateAddress(addressText: String) { + val normalizedAddress = normalizeAddress(addressText) + val addressErrorResId = if (normalizedAddress != null) { + if (normalizedAddress.withoutKnownPath in takenAddresses) { + stringRes.already_exists + } else { + null + } + } else { + stringRes.invalid_address + } + addressError = addressErrorResId != null + addressErrorResId?.let { binding.address.error = getString(it) } + invalidateState() + } + + private fun invalidateFingerprint() { + val fingerprint = binding.fingerprint.text.toString().replace(" ", "") + val fingerprintInvalid = fingerprint.isNotEmpty() && fingerprint.length != 64 + if (fingerprintInvalid) { + binding.fingerprint.error = getString(stringRes.invalid_fingerprint_format) + } + fingerprintError = fingerprintInvalid + invalidateState() + } + + private fun invalidateUsernamePassword() { + val username = binding.username.text.toString() + val password = binding.password.text.toString() + val usernameInvalid = username.contains(':') + val usernameEmpty = username.isEmpty() && password.isNotEmpty() + val passwordEmpty = username.isNotEmpty() && password.isEmpty() + if (usernameEmpty) { + binding.username.error = getString(stringRes.username_missing) + } else if (passwordEmpty) { + binding.password.error = getString(stringRes.password_missing) + } else if (usernameInvalid) { + binding.username.error = getString(stringRes.invalid_username_format) + } + usernamePasswordError = usernameInvalid || usernameEmpty || passwordEmpty + invalidateState() + } + + private fun invalidateState() { + saveMenuItem!!.isEnabled = + !addressError && !fingerprintError && !usernamePasswordError && !checkInProgress + binding.apply { + sequenceOf(address, fingerprint, username, password).forEach { + it.isEnabled = !checkInProgress + } + } + binding.overlay.isVisible = checkInProgress + } + + private val String.pathCropped: String + get() { + val index = indexOfLast { it != '/' } + return if (index >= 0 && index < length - 1) substring(0, index + 1) else this + } + + private val String.withoutKnownPath: String + get() { + val cropped = pathCropped + val endsWith = + addressSuffixes.asSequence() + .sortedByDescending { it.length } + .find { cropped.endsWith("/$it") } + return if (endsWith != null) { + cropped.substring( + 0, + cropped.length - endsWith.length - 1 + ) + } else { + cropped + } + } + + private fun normalizeAddress(address: String): String? { + val uri = try { + val uri = URI(address) + if (uri.isAbsolute) uri.normalize() else null + } catch (e: URISyntaxException) { + return null + } + return try { + uri?.toURL()?.toURI()?.toString()?.removeSuffix("/") + } catch (e: URISyntaxException) { + null + } + } + + private fun setMirror(address: String) { + binding.address.setText(address) + } + + private fun onSaveRepositoryClick(check: Boolean) { + if (!checkInProgress) { + val address = normalizeAddress(binding.address.text.toString())!! + val fingerprint = binding.fingerprint.text.toString().replace(" ", "") + val username = binding.username.text.toString().nullIfEmpty() + val password = binding.password.text.toString().nullIfEmpty() + val authentication = username?.let { u -> + password?.let { p -> + Base64.encodeToString( + "$u:$p".toByteArray(Charset.defaultCharset()), + Base64.NO_WRAP + ) + } + }?.let { "Basic $it" }.orEmpty() + + if (check) { + checkJob = viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) { + val resultAddress = try { + checkAddress(address, authentication) + } catch (e: Exception) { + e.printStackTrace() + failedAddressCheck() + null + } + val allow = resultAddress == address || run { + if (resultAddress == null) return@run false + binding.address.setText(resultAddress) + invalidateAddress(resultAddress) + !addressError + } + if (allow && resultAddress != null) { + onSaveRepositoryProceedInvalidate( + resultAddress, + fingerprint, + authentication + ) + } else { + invalidateState() + } + invalidateState() + } + } else { + onSaveRepositoryProceedInvalidate(address, fingerprint, authentication) + } + } + } + + private suspend fun checkAddress( + address: String, + authentication: String + ): String? = coroutineScope { + checkInProgress = true + invalidateState() + val allAddresses = addressSuffixes.map { "$address/$it" } + address + val pathCheck = allAddresses.map { + async { + downloader.headCall( + url = "$it/index-v1.jar", + headers = { authentication(authentication) } + ) is NetworkResponse.Success + } + } + val indexOfValidAddress = pathCheck.awaitAll().indexOf(true) + allAddresses[indexOfValidAddress].nullIfEmpty() + } + + private fun onSaveRepositoryProceedInvalidate( + address: String, + fingerprint: String, + authentication: String + ) { + val binder = syncConnection.binder + if (binder != null) { + val repositoryId = repoId + if (repositoryId != null && binder.isCurrentlySyncing(repositoryId)) { + MessageDialog(Message.CantEditSyncing).show(childFragmentManager) + invalidateState() + } else { + val repository = repositoryId?.let(Database.RepositoryAdapter::get) + ?.edit(address, fingerprint, authentication) + ?: Repository.newRepository(address, fingerprint, authentication) + val changedRepository = Database.RepositoryAdapter.put(repository) + if (repositoryId == null && changedRepository.enabled) { + binder.sync(changedRepository) + } + screenActivity.onBackPressed() + } + } else { + invalidateState() + } + } + + private fun failedAddressCheck() { + checkInProgress = false + invalidateState() + Snackbar.make( + requireView(), + CommonR.string.repository_unreachable, + Snackbar.LENGTH_SHORT + ).show() + } + + class SelectMirrorDialog() : DialogFragment() { + constructor(mirrors: List) : this() { + arguments = bundleOf(EXTRA_MIRRORS to ArrayList(mirrors)) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog { + val mirrors = requireArguments().getStringArrayList(EXTRA_MIRRORS)!! + return MaterialAlertDialogBuilder(requireContext()).setTitle(stringRes.select_mirror) + .setItems(mirrors.toTypedArray()) { _, position -> + (parentFragment as EditRepositoryFragment).setMirror(mirrors[position]) + }.setNegativeButton(stringRes.cancel, null).create() + } + + private companion object { + const val EXTRA_MIRRORS = "mirrors" + } + } + + private companion object { + const val EXTRA_REPOSITORY_ID = "repositoryId" + const val EXTRA_REPOSITORY_ADDRESS = "repositoryAddress" + + val addressSuffixes = listOf("fdroid/repo", "repo") + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/ui/repository/RepositoriesAdapter.kt b/app/src/main/kotlin/com/leos/droidify/ui/repository/RepositoriesAdapter.kt new file mode 100644 index 0000000..c09c7c4 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/ui/repository/RepositoriesAdapter.kt @@ -0,0 +1,74 @@ +package com.leos.droidify.ui.repository + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.leos.core.domain.Repository +import com.leos.droidify.database.Database +import com.leos.droidify.databinding.RepositoryItemBinding +import com.leos.droidify.widget.CursorRecyclerAdapter + +class RepositoriesAdapter( + private val navigate: (Repository) -> Unit, + private val onSwitch: (repository: Repository, isEnabled: Boolean) -> Boolean +) : CursorRecyclerAdapter() { + enum class ViewType { REPOSITORY } + + private class ViewHolder(itemView: RepositoryItemBinding) : + RecyclerView.ViewHolder(itemView.root) { + val checkMark = itemView.repositoryState + val repoName = itemView.repositoryName + val repoDesc = itemView.repositoryDescription + + var isEnabled = true + } + + override val viewTypeClass: Class + get() = ViewType::class.java + + override fun getItemEnumViewType(position: Int): ViewType { + return ViewType.REPOSITORY + } + + private fun getRepository(position: Int): Repository { + return Database.RepositoryAdapter.transform(moveTo(position.takeUnless { it < 0 } ?: 0)) + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: ViewType + ): RecyclerView.ViewHolder { + return ViewHolder( + RepositoryItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ).apply { + itemView.setOnLongClickListener { + navigate(getRepository(absoluteAdapterPosition)) + true + } + itemView.setOnClickListener { + isEnabled = !isEnabled + onSwitch(getRepository(absoluteAdapterPosition), isEnabled) + } + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + holder as ViewHolder + val repository = getRepository(position) + + holder.isEnabled = repository.enabled + holder.repoName.text = repository.name + holder.repoDesc.text = repository.description.trim() + + if (repository.enabled) { + holder.checkMark.visibility = View.VISIBLE + } else { + holder.checkMark.visibility = View.INVISIBLE + } + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/ui/repository/RepositoriesFragment.kt b/app/src/main/kotlin/com/leos/droidify/ui/repository/RepositoriesFragment.kt new file mode 100644 index 0000000..2df3a63 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/ui/repository/RepositoriesFragment.kt @@ -0,0 +1,98 @@ +package com.leos.droidify.ui.repository + +import android.database.Cursor +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.LinearLayoutManager +import com.leos.core.common.R as CommonR +import com.leos.core.common.extension.dp +import com.leos.core.common.extension.systemBarsMargin +import com.leos.core.common.extension.systemBarsPadding +import com.leos.droidify.database.CursorOwner +import com.leos.droidify.databinding.RecyclerViewWithFabBinding +import com.leos.droidify.service.Connection +import com.leos.droidify.service.SyncService +import com.leos.droidify.ui.ScreenFragment +import com.leos.droidify.utility.extension.screenActivity +import com.leos.droidify.widget.addDivider + +class RepositoriesFragment : ScreenFragment(), CursorOwner.Callback { + + private var _binding: RecyclerViewWithFabBinding? = null + private val binding get() = _binding!! + + private val syncConnection = Connection(SyncService::class.java) + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + super.onCreateView(inflater, container, savedInstanceState) + _binding = RecyclerViewWithFabBinding.inflate(inflater, container, false) + val view = fragmentBinding.root.apply { + binding.scrollUp.apply { + setIconResource(CommonR.drawable.ic_add) + setText(CommonR.string.add_repository) + setOnClickListener { screenActivity.navigateAddRepository() } + systemBarsMargin(16.dp) + } + binding.recyclerView.apply { + layoutManager = LinearLayoutManager(context) + isMotionEventSplittingEnabled = false + setHasFixedSize(true) + adapter = RepositoriesAdapter( + navigate = { screenActivity.navigateRepository(it.id) } + ) { repository, isEnabled -> + repository.enabled != isEnabled && + syncConnection.binder?.setEnabled(repository, isEnabled) == true + } + addDivider { _, _, configuration -> + configuration.set( + needDivider = true, + toTop = false, + paddingStart = 16.dp, + paddingEnd = 16.dp + ) + } + systemBarsPadding() + } + fragmentBinding.fragmentContent.addView(binding.root) + } + handleFab() + return view + } + + private fun handleFab() { + binding.recyclerView.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> + if (scrollY > oldScrollY) { + binding.scrollUp.shrink() + } else { + binding.scrollUp.extend() + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + syncConnection.bind(requireContext()) + screenActivity.cursorOwner.attach(this, CursorOwner.Request.Repositories) + screenActivity.onToolbarCreated(toolbar) + toolbar.title = getString(CommonR.string.repositories) + } + + override fun onDestroyView() { + super.onDestroyView() + + _binding = null + syncConnection.unbind(requireContext()) + screenActivity.cursorOwner.detach(this) + } + + override fun onCursorData(request: CursorOwner.Request, cursor: Cursor?) { + (binding.recyclerView.adapter as RepositoriesAdapter).cursor = cursor + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/ui/repository/RepositoryFragment.kt b/app/src/main/kotlin/com/leos/droidify/ui/repository/RepositoryFragment.kt new file mode 100644 index 0000000..203a94b --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/ui/repository/RepositoryFragment.kt @@ -0,0 +1,168 @@ +package com.leos.droidify.ui.repository + +import android.os.Bundle +import android.text.SpannableStringBuilder +import android.text.format.DateUtils +import android.text.style.ForegroundColorSpan +import android.text.style.TypefaceSpan +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.core.os.bundleOf +import androidx.core.widget.NestedScrollView +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.leos.core.common.extension.getColorFromAttr +import com.leos.core.common.extension.systemBarsPadding +import com.leos.core.domain.Repository +import com.leos.droidify.databinding.RepositoryPageBinding +import com.leos.droidify.ui.Message +import com.leos.droidify.ui.MessageDialog +import com.leos.droidify.ui.ScreenFragment +import com.leos.droidify.utility.extension.screenActivity +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import java.util.Date +import java.util.Locale +import com.google.android.material.R as MaterialR +import com.leos.core.common.R.string as stringRes + +@AndroidEntryPoint +class RepositoryFragment() : ScreenFragment() { + + private var _binding: RepositoryPageBinding? = null + private val binding get() = _binding!! + + private val viewModel: RepositoryViewModel by viewModels() + + constructor(repositoryId: Long) : this() { + arguments = bundleOf(RepositoryViewModel.ARG_REPO_ID to repositoryId) + } + + private var layout: LinearLayout? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + super.onCreateView(inflater, container, savedInstanceState) + _binding = RepositoryPageBinding.inflate(inflater, container, false) + viewModel.bindService(requireContext()) + screenActivity.onToolbarCreated(toolbar) + toolbar.title = getString(stringRes.repository) + val scroll = NestedScrollView(binding.root.context) + scroll.addView(binding.root) + scroll.systemBarsPadding() + fragmentBinding.fragmentContent.addView(scroll) + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + viewModel.state.collectLatest { + setupView(it.repo, it.appCount) + } + } + } + return fragmentBinding.root + } + + override fun onDestroyView() { + super.onDestroyView() + + layout = null + viewModel.unbindService(requireContext()) + } + + private fun setupView(repository: Repository?, appCount: Int) { + with(binding) { + address.title.setText(stringRes.address) + if (repository == null) { + address.text.text = getString(stringRes.unknown) + } else { + repoSwitch.isChecked = repository.enabled + repoSwitch.setOnCheckedChangeListener { _, isChecked -> + viewModel.enabledRepository(isChecked) + } + + address.text.text = repository.address + toolbar.title = repository.name + repoName.title.setText(stringRes.name) + repoName.text.text = repository.name + + repoDescription.title.setText(stringRes.description) + repoDescription.text.text = repository.description.replace('\n', ' ').trim() + + recentlyUpdated.title.setText(stringRes.recently_updated) + recentlyUpdated.text.text = run { + val lastUpdated = repository.updated + if (lastUpdated > 0L) { + val date = Date(repository.updated) + val format = + if (DateUtils.isToday(date.time)) { + DateUtils.FORMAT_SHOW_TIME + } else { + DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_DATE + } + DateUtils.formatDateTime(requireContext(), date.time, format) + } else { + getString(stringRes.unknown) + } + } + + numberOfApps.title.setText(stringRes.number_of_applications) + numberOfApps.text.text = appCount.toString() + + repoFingerprint.title.setText(stringRes.fingerprint) + if (repository.fingerprint.isEmpty()) { + if (repository.updated > 0L) { + val builder = + SpannableStringBuilder(getString(stringRes.repository_unsigned_DESC)) + builder.setSpan( + ForegroundColorSpan( + requireContext() + .getColorFromAttr(MaterialR.attr.colorError) + .defaultColor + ), + 0, + builder.length, + SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE + ) + repoFingerprint.text.text = builder + } + } else { + val fingerprint = + SpannableStringBuilder( + repository.fingerprint.windowed(2, 2, false) + .take(32).joinToString(separator = " ") { it.uppercase(Locale.US) } + ) + fingerprint.setSpan( + TypefaceSpan("monospace"), + 0, + fingerprint.length, + SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE + ) + repoFingerprint.text.text = fingerprint + } + } + + editRepoButton.setOnClickListener { + screenActivity.navigateEditRepository(viewModel.id) + } + + deleteRepoButton.setOnClickListener { + MessageDialog( + Message.DeleteRepositoryConfirm + ).show(childFragmentManager) + } + } + } + + internal fun onDeleteConfirm() { + viewModel.deleteRepository( + onDelete = { requireActivity().onBackPressed() } + ) + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/ui/repository/RepositoryViewModel.kt b/app/src/main/kotlin/com/leos/droidify/ui/repository/RepositoryViewModel.kt new file mode 100644 index 0000000..f6689b8 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/ui/repository/RepositoryViewModel.kt @@ -0,0 +1,64 @@ +package com.leos.droidify.ui.repository + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.leos.core.common.extension.asStateFlow +import com.leos.core.domain.Repository +import com.leos.droidify.database.Database +import com.leos.droidify.service.Connection +import com.leos.droidify.service.SyncService +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class RepositoryViewModel @Inject constructor( + savedStateHandle: SavedStateHandle +) : ViewModel() { + + val id: Long = savedStateHandle[ARG_REPO_ID] ?: -1 + + private val repoStream = Database.RepositoryAdapter.getStream(id) + + private val countStream = Database.ProductAdapter.getCountStream(id) + + val state = combine(repoStream, countStream) { repo, count -> + RepositoryPageItem(repo, count) + }.asStateFlow(RepositoryPageItem()) + + private val syncConnection = Connection(SyncService::class.java) + + fun bindService(context: Context) { + syncConnection.bind(context) + } + + fun unbindService(context: Context) { + syncConnection.unbind(context) + } + + fun enabledRepository(enable: Boolean) { + viewModelScope.launch { + val repo = repoStream.first { it != null }!! + syncConnection.binder?.setEnabled(repo, enable) + } + } + + fun deleteRepository(onDelete: () -> Unit) { + if (syncConnection.binder?.deleteRepository(id) == true) { + onDelete() + } + } + + companion object { + const val ARG_REPO_ID = "repo_id" + } +} + +data class RepositoryPageItem( + val repo: Repository? = null, + val appCount: Int = 0 +) diff --git a/app/src/main/kotlin/com/leos/droidify/ui/settings/SettingsFragment.kt b/app/src/main/kotlin/com/leos/droidify/ui/settings/SettingsFragment.kt new file mode 100644 index 0000000..e10ec61 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/ui/settings/SettingsFragment.kt @@ -0,0 +1,493 @@ +package com.leos.droidify.ui.settings + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.* +import androidx.activity.result.contract.ActivityResultContracts.CreateDocument +import androidx.activity.result.contract.ActivityResultContracts.OpenDocument +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isVisible +import androidx.core.widget.NestedScrollView +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.* +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import com.google.android.material.textfield.TextInputEditText +import com.leos.core.common.BuildConfig as CommonBuildConfig +import com.leos.core.common.R as CommonR +import com.leos.core.common.SdkCheck +import com.leos.core.common.extension.homeAsUp +import com.leos.core.common.extension.systemBarsPadding +import com.leos.core.common.extension.updateAsMutable +import com.leos.core.datastore.Settings +import com.leos.core.datastore.extension.* +import com.leos.core.datastore.model.* +import com.leos.droidify.BuildConfig +import com.leos.droidify.databinding.EnumTypeBinding +import com.leos.droidify.databinding.SettingsPageBinding +import com.leos.droidify.databinding.SwitchTypeBinding +import dagger.hilt.android.AndroidEntryPoint +import java.util.Locale +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class SettingsFragment : Fragment() { + + companion object { + fun newInstance() = SettingsFragment() + + private const val BACKUP_MIME_TYPE = "application/json" + private const val REPO_BACKUP_NAME = "droidify_repos.json" + private const val SETTINGS_BACKUP_NAME = "droidify_settings.json" + + private val localeCodesList: List = CommonBuildConfig.DETECTED_LOCALES + .toList() + .updateAsMutable { add(0, "system") } + + private const val FOXY_DROID_TITLE = "FoxyDroid" + private const val FOXY_DROID_URL = "https://github.com/kitsunyan/foxy-droid" + + private const val DROID_IFY_TITLE = "LeOS-Droid" + private const val DROID_IFY_URL = "https://github.com/LeOS-GSI/LeOS-Droid-ify" + } + + private val viewModel: SettingsViewModel by viewModels() + private var _binding: SettingsPageBinding? = null + private val binding get() = _binding!! + + private val createExportFileForSettings = + registerForActivityResult(CreateDocument(BACKUP_MIME_TYPE)) { fileUri -> + if (fileUri != null) { + viewModel.exportSettings(fileUri) + } + } + + private val openImportFileForSettings = + registerForActivityResult(OpenDocument()) { fileUri -> + if (fileUri != null) { + viewModel.importSettings(fileUri) + } else { + viewModel.createSnackbar(CommonR.string.file_format_error_DESC) + } + } + + private val createExportFileForRepos = + registerForActivityResult(CreateDocument(BACKUP_MIME_TYPE)) { fileUri -> + if (fileUri != null) { + viewModel.exportRepos(fileUri) + } + } + + private val openImportFileForRepos = + registerForActivityResult(OpenDocument()) { fileUri -> + if (fileUri != null) { + viewModel.importRepos(fileUri) + } else { + viewModel.createSnackbar(CommonR.string.file_format_error_DESC) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = SettingsPageBinding.inflate(inflater, container, false) + binding.nestedScrollView.systemBarsPadding() + val toolbar = binding.toolbar + toolbar.navigationIcon = toolbar.context.homeAsUp + toolbar.setNavigationOnClickListener { activity?.onBackPressed() } + toolbar.title = getString(CommonR.string.settings) + with(binding) { + dynamicTheme.root.isVisible = SdkCheck.isSnowCake + dynamicTheme.connect( + titleText = getString(CommonR.string.material_you), + contentText = getString(CommonR.string.material_you_desc), + setting = viewModel.getInitialSetting { dynamicTheme } + ) + homeScreenSwiping.connect( + titleText = getString(CommonR.string.home_screen_swiping), + contentText = getString(CommonR.string.home_screen_swiping_DESC), + setting = viewModel.getInitialSetting { homeScreenSwiping } + ) + autoUpdate.connect( + titleText = getString(CommonR.string.auto_update), + contentText = getString(CommonR.string.auto_update_apps), + setting = viewModel.getInitialSetting { autoUpdate } + ) + notifyUpdates.connect( + titleText = getString(CommonR.string.notify_about_updates), + contentText = getString(CommonR.string.notify_about_updates_summary), + setting = viewModel.getInitialSetting { notifyUpdate } + ) + unstableUpdates.connect( + titleText = getString(CommonR.string.unstable_updates), + contentText = getString(CommonR.string.unstable_updates_summary), + setting = viewModel.getInitialSetting { unstableUpdate } + ) + incompatibleUpdates.connect( + titleText = getString(CommonR.string.incompatible_versions), + contentText = getString(CommonR.string.incompatible_versions_summary), + setting = viewModel.getInitialSetting { incompatibleVersions } + ) + language.connect( + titleText = getString(CommonR.string.prefs_language_title), + map = { translateLocale(getLocaleOfCode(it)) }, + setting = viewModel.getSetting { language } + ) { selectedLocale, valueToString -> + addSingleCorrectDialog( + initialValue = selectedLocale, + values = localeCodesList, + title = CommonR.string.prefs_language_title, + iconRes = CommonR.drawable.ic_language, + valueToString = valueToString, + onClick = viewModel::setLanguage + ) + } + theme.connect( + titleText = getString(CommonR.string.theme), + setting = viewModel.getSetting { theme }, + map = { themeName(it) } + ) { theme, valueToString -> + addSingleCorrectDialog( + initialValue = theme, + values = Theme.entries, + title = CommonR.string.themes, + iconRes = CommonR.drawable.ic_themes, + valueToString = valueToString, + onClick = viewModel::setTheme + ) + } + cleanUp.connect( + titleText = getString(CommonR.string.cleanup_title), + setting = viewModel.getSetting { cleanUpInterval }, + map = { toTime(it) } + ) { duration, valueToString -> + addSingleCorrectDialog( + initialValue = duration, + values = cleanUpIntervals, + title = CommonR.string.cleanup_title, + iconRes = CommonR.drawable.ic_time, + valueToString = valueToString, + onClick = viewModel::setCleanUpInterval + ) + } + autoSync.connect( + titleText = getString(CommonR.string.sync_repositories_automatically), + setting = viewModel.getSetting { autoSync }, + map = { autoSyncName(it) } + ) { autoSync, valueToString -> + addSingleCorrectDialog( + initialValue = autoSync, + values = AutoSync.entries, + title = CommonR.string.sync_repositories_automatically, + iconRes = CommonR.drawable.ic_sync_type, + valueToString = valueToString, + onClick = viewModel::setAutoSync + ) + } + installer.connect( + titleText = getString(CommonR.string.installer), + setting = viewModel.getSetting { installerType }, + map = { installerName(it) } + ) { installerType, valueToString -> + addSingleCorrectDialog( + initialValue = installerType, + values = InstallerType.entries, + title = CommonR.string.installer, + iconRes = CommonR.drawable.ic_apk_install, + valueToString = valueToString, + onClick = viewModel::setInstaller + ) + } + proxyType.connect( + titleText = getString(CommonR.string.proxy_type), + setting = viewModel.getSetting { proxy.type }, + map = { proxyName(it) } + ) { proxyType, valueToString -> + addSingleCorrectDialog( + initialValue = proxyType, + values = ProxyType.entries, + title = CommonR.string.proxy_type, + iconRes = CommonR.drawable.ic_proxy, + valueToString = valueToString, + onClick = viewModel::setProxyType + ) + } + proxyHost.connect( + titleText = getString(CommonR.string.proxy_host), + setting = viewModel.getSetting { proxy.host }, + map = { it } + ) { host, _ -> + addEditTextDialog( + initialValue = host, + title = CommonR.string.proxy_host, + onFinish = viewModel::setProxyHost + ) + } + proxyPort.connect( + titleText = getString(CommonR.string.proxy_port), + setting = viewModel.getSetting { proxy.port }, + map = { it.toString() } + ) { port, _ -> + addEditTextDialog( + initialValue = port.toString(), + title = CommonR.string.proxy_port, + onFinish = viewModel::setProxyPort + ) + } + + forceCleanUp.title.text = getString(CommonR.string.force_clean_up) + forceCleanUp.content.text = getString(CommonR.string.force_clean_up_DESC) + + importSettings.title.text = getString(CommonR.string.import_settings_title) + importSettings.content.text = getString(CommonR.string.import_settings_DESC) + exportSettings.title.text = getString(CommonR.string.export_settings_title) + exportSettings.content.text = getString(CommonR.string.export_settings_DESC) + + importRepos.title.text = getString(CommonR.string.import_repos_title) + importRepos.content.text = getString(CommonR.string.import_repos_DESC) + exportRepos.title.text = getString(CommonR.string.export_repos_title) + exportRepos.content.text = getString(CommonR.string.export_repos_DESC) + + creditFoxy.title.text = getString(CommonR.string.special_credits) + creditFoxy.content.text = FOXY_DROID_TITLE + droidify.title.text = DROID_IFY_TITLE + droidify.content.text = BuildConfig.VERSION_NAME + } + setChangeListener() + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + launch { + viewModel.snackbarStringId.collect { + Snackbar.make(binding.root, it, Snackbar.LENGTH_LONG).show() + } + } + launch { + viewModel.settingsFlow.collect(::updateSettings) + } + } + } + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private fun setChangeListener() { + with(binding) { + dynamicTheme.checked.setOnCheckedChangeListener { _, checked -> + viewModel.setDynamicTheme(checked) + } + homeScreenSwiping.checked.setOnCheckedChangeListener { _, checked -> + viewModel.setHomeScreenSwiping(checked) + } + notifyUpdates.checked.setOnCheckedChangeListener { _, checked -> + viewModel.setNotifyUpdates(checked) + } + autoUpdate.checked.setOnCheckedChangeListener { _, checked -> + viewModel.setAutoUpdate(checked) + } + unstableUpdates.checked.setOnCheckedChangeListener { _, checked -> + viewModel.setUnstableUpdates(checked) + } + incompatibleUpdates.checked.setOnCheckedChangeListener { _, checked -> + viewModel.setIncompatibleUpdates(checked) + } + forceCleanUp.root.setOnClickListener { + viewModel.forceCleanup(it.context) + } + importSettings.root.setOnClickListener { + openImportFileForSettings.launch(arrayOf(BACKUP_MIME_TYPE)) + } + exportSettings.root.setOnClickListener { + createExportFileForSettings.launch(SETTINGS_BACKUP_NAME) + } + importRepos.root.setOnClickListener { + openImportFileForRepos.launch(arrayOf(BACKUP_MIME_TYPE)) + } + exportRepos.root.setOnClickListener { + createExportFileForRepos.launch(REPO_BACKUP_NAME) + } + creditFoxy.root.setOnClickListener { + openLink(FOXY_DROID_URL) + } + droidify.root.setOnClickListener { + openLink(DROID_IFY_URL) + } + } + } + + private fun updateSettings(settings: Settings) { + with(binding) { + val allowProxies = settings.proxy.type != ProxyType.DIRECT + proxyHost.root.isVisible = allowProxies + proxyPort.root.isVisible = allowProxies + forceCleanUp.root.isVisible = settings.cleanUpInterval == Duration.INFINITE + } + } + + private val cleanUpIntervals = + listOf(6.hours, 12.hours, 18.hours, 1.days, 2.days, Duration.INFINITE) + + private fun translateLocale(locale: Locale?): String { + val country = locale?.getDisplayCountry(locale) + val language = locale?.getDisplayLanguage(locale) + val languageDisplay = if (locale != null) { + ( + language?.replaceFirstChar { it.uppercase(Locale.getDefault()) } + + ( + if (country?.isNotEmpty() == true && country.compareTo( + language.toString(), + true + ) != 0 + ) { + "($country)" + } else { + "" + } + ) + ) + } else { + getString(CommonR.string.system) + } + return languageDisplay + } + + private fun openLink(link: String) { + try { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link))) + } catch (e: IllegalStateException) { + viewModel.createSnackbar(CommonR.string.cannot_open_link) + } + } + + @Suppress("DEPRECATION") + private fun Context.getLocaleOfCode(localeCode: String): Locale? = when { + localeCode.isEmpty() -> if (SdkCheck.isNougat) { + resources.configuration.locales[0] + } else { + resources.configuration.locale + } + + localeCode.contains("-r") -> Locale( + localeCode.substring(0, 2), + localeCode.substring(4) + ) + + localeCode.contains("_") -> Locale( + localeCode.substring(0, 2), + localeCode.substring(3) + ) + + localeCode == "system" -> null + else -> Locale(localeCode) + } + + private fun EnumTypeBinding.connect( + titleText: String, + setting: Flow, + map: Context.(T) -> String, + dialog: View.(T, valueToString: Context.(T) -> String) -> AlertDialog + ) { + title.text = titleText + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + setting.collect { + with(root.context) { + content.text = map(it) + } + root.setOnClickListener { _ -> + root.dialog(it, map).show() + } + } + } + } + } + + private fun SwitchTypeBinding.connect( + titleText: String, + contentText: String, + setting: Flow + ) { + title.text = titleText + content.text = contentText + root.setOnClickListener { + checked.isChecked = !checked.isChecked + } + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + setting.collect { + checked.isChecked = it + } + } + } + } + + private fun View.addSingleCorrectDialog( + initialValue: T, + values: List, + @StringRes title: Int, + @DrawableRes iconRes: Int, + onClick: (T) -> Unit, + valueToString: Context.(T) -> String + ) = MaterialAlertDialogBuilder(context) + .setTitle(title) + .setIcon(iconRes) + .setSingleChoiceItems( + values.map { context.valueToString(it) }.toTypedArray(), + values.indexOf(initialValue) + ) { dialog, newValue -> + dialog.dismiss() + post { + onClick(values.elementAt(newValue)) + } + } + .setNegativeButton(CommonR.string.cancel, null) + .create() + + private fun View.addEditTextDialog( + initialValue: String, + @StringRes title: Int, + onFinish: (String) -> Unit + ): AlertDialog { + val scroll = NestedScrollView(context) + val customEditText = TextInputEditText(context) + customEditText.id = android.R.id.edit + val paddingValue = context.resources.getDimension(CommonR.dimen.shape_margin_large).toInt() + scroll.setPadding(paddingValue, 0, paddingValue, 0) + customEditText.setText(initialValue) + customEditText.hint = customEditText.text.toString() + customEditText.text?.let { editable -> customEditText.setSelection(editable.length) } + customEditText.requestFocus() + scroll.addView( + customEditText, + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + return MaterialAlertDialogBuilder(context) + .setTitle(title) + .setView(scroll) + .setPositiveButton(CommonR.string.ok) { _, _ -> + post { onFinish(customEditText.text.toString()) } + } + .setNegativeButton(CommonR.string.cancel, null) + .create() + .apply { + window!!.setSoftInputMode( + WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE + ) + } + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/leos/droidify/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..8965d05 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/ui/settings/SettingsViewModel.kt @@ -0,0 +1,197 @@ +package com.leos.droidify.ui.settings + +import android.content.Context +import android.net.Uri +import androidx.annotation.StringRes +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.leos.core.common.extension.toLocale +import com.leos.core.datastore.Settings +import com.leos.core.datastore.SettingsRepository +import com.leos.core.datastore.get +import com.leos.core.datastore.model.AutoSync +import com.leos.core.datastore.model.InstallerType +import com.leos.core.datastore.model.ProxyType +import com.leos.core.datastore.model.Theme +import com.leos.droidify.database.Database +import com.leos.droidify.database.RepositoryExporter +import com.leos.droidify.work.CleanUpWorker +import com.leos.installer.installers.shizuku.ShizukuPermissionHandler +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.time.Duration +import com.leos.core.common.R as CommonR + +@HiltViewModel +class SettingsViewModel +@Inject constructor( + private val settingsRepository: SettingsRepository, + private val shizukuPermissionHandler: ShizukuPermissionHandler, + private val repositoryExporter: RepositoryExporter +) : ViewModel() { + + private val initialSetting = flow { + emit(settingsRepository.getInitial()) + } + val settingsFlow get() = settingsRepository.data + + private val _snackbarStringId = MutableSharedFlow() + val snackbarStringId = _snackbarStringId.asSharedFlow() + + fun getSetting(block: Settings.() -> T): Flow = settingsRepository.get(block) + + fun getInitialSetting(block: Settings.() -> T): Flow = initialSetting.map { it.block() } + + fun setLanguage(language: String) { + viewModelScope.launch { + val appLocale = LocaleListCompat.create(language.toLocale()) + AppCompatDelegate.setApplicationLocales(appLocale) + settingsRepository.setLanguage(language) + } + } + + fun setTheme(theme: Theme) { + viewModelScope.launch { + settingsRepository.setTheme(theme) + } + } + + fun setDynamicTheme(enable: Boolean) { + viewModelScope.launch { + settingsRepository.setDynamicTheme(enable) + } + } + + fun setHomeScreenSwiping(enable: Boolean) { + viewModelScope.launch { + settingsRepository.setHomeScreenSwiping(enable) + } + } + + fun setCleanUpInterval(interval: Duration) { + viewModelScope.launch { + settingsRepository.setCleanUpInterval(interval) + } + } + + fun forceCleanup(context: Context) { + viewModelScope.launch { + CleanUpWorker.force(context) + } + } + + fun setAutoSync(autoSync: AutoSync) { + viewModelScope.launch { + settingsRepository.setAutoSync(autoSync) + } + } + + fun setNotifyUpdates(enable: Boolean) { + viewModelScope.launch { + settingsRepository.enableNotifyUpdates(enable) + } + } + + fun setAutoUpdate(enable: Boolean) { + viewModelScope.launch { + settingsRepository.setAutoUpdate(enable) + } + } + + fun setUnstableUpdates(enable: Boolean) { + viewModelScope.launch { + settingsRepository.enableUnstableUpdates(enable) + } + } + + fun setIncompatibleUpdates(enable: Boolean) { + viewModelScope.launch { + settingsRepository.enableIncompatibleVersion(enable) + } + } + + fun setProxyType(proxyType: ProxyType) { + viewModelScope.launch { + settingsRepository.setProxyType(proxyType) + } + } + + fun setProxyHost(proxyHost: String) { + viewModelScope.launch { + settingsRepository.setProxyHost(proxyHost) + } + } + + fun setProxyPort(proxyPort: String) { + viewModelScope.launch { + try { + settingsRepository.setProxyPort(proxyPort.toInt()) + } catch (e: NumberFormatException) { + createSnackbar(CommonR.string.proxy_port_error_not_int) + } + } + } + + fun setInstaller(installerType: InstallerType) { + viewModelScope.launch { + settingsRepository.setInstallerType(installerType) + if (installerType == InstallerType.SHIZUKU) handleShizuku() + } + } + + fun exportSettings(file: Uri) { + viewModelScope.launch { + settingsRepository.export(file) + } + } + + fun importSettings(file: Uri) { + viewModelScope.launch { + settingsRepository.import(file) + } + } + + fun exportRepos(file: Uri) { + viewModelScope.launch { + val repos = Database.RepositoryAdapter.getAll() + repositoryExporter.export(repos, file) + } + } + + fun importRepos(file: Uri) { + viewModelScope.launch { + val repos = repositoryExporter.import(file) + Database.RepositoryAdapter.importRepos(repos) + } + } + + fun createSnackbar(@StringRes message: Int) { + viewModelScope.launch { + _snackbarStringId.emit(message) + } + } + + private fun handleShizuku() { + viewModelScope.launch { + val state = shizukuPermissionHandler.state.first() + if (state.isAlive && state.isPermissionGranted) cancel() + if (state.isInstalled) { + if (!state.isAlive) { + createSnackbar(CommonR.string.shizuku_not_alive) + } + } else { + createSnackbar(CommonR.string.shizuku_not_installed) + } + } + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/ui/tabsFragment/TabsFragment.kt b/app/src/main/kotlin/com/leos/droidify/ui/tabsFragment/TabsFragment.kt new file mode 100644 index 0000000..7b0f19e --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/ui/tabsFragment/TabsFragment.kt @@ -0,0 +1,635 @@ +package com.leos.droidify.ui.tabsFragment + +import android.animation.ValueAnimator +import android.content.Context +import android.content.res.ColorStateList +import android.os.Build +import android.os.Bundle +import android.view.Gravity +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.view.animation.DecelerateInterpolator +import android.widget.FrameLayout +import android.widget.TextView +import androidx.appcompat.widget.SearchView +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.adapter.FragmentStateAdapter +import androidx.viewpager2.widget.ViewPager2 +import com.google.android.material.elevation.SurfaceColors +import com.google.android.material.shape.MaterialShapeDrawable +import com.google.android.material.shape.ShapeAppearanceModel +import com.google.android.material.tabs.TabLayoutMediator +import com.leos.core.common.R as CommonR +import com.leos.core.common.R.string as stringRes +import com.leos.core.common.device.Huawei +import com.leos.core.common.extension.dp +import com.leos.core.common.extension.getMutatedIcon +import com.leos.core.common.extension.selectableBackground +import com.leos.core.common.extension.systemBarsPadding +import com.leos.core.common.sdkAbove +import com.leos.core.datastore.extension.sortOrderName +import com.leos.core.datastore.model.SortOrder +import com.leos.core.domain.ProductItem +import com.leos.droidify.R +import com.leos.droidify.databinding.TabsToolbarBinding +import com.leos.droidify.service.Connection +import com.leos.droidify.service.SyncService +import com.leos.droidify.ui.ScreenFragment +import com.leos.droidify.ui.appList.AppListFragment +import com.leos.droidify.utility.extension.resources.sizeScaled +import com.leos.droidify.utility.extension.screenActivity +import com.leos.droidify.widget.DividerConfiguration +import com.leos.droidify.widget.FocusSearchView +import com.leos.droidify.widget.StableRecyclerAdapter +import com.leos.droidify.widget.addDivider +import dagger.hilt.android.AndroidEntryPoint +import kotlin.math.abs +import kotlin.math.roundToInt +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class TabsFragment : ScreenFragment() { + + private var _tabsBinding: TabsToolbarBinding? = null + private val tabsBinding get() = _tabsBinding!! + + private val viewModel: TabsViewModel by viewModels() + + companion object { + private const val STATE_SEARCH_FOCUSED = "searchFocused" + private const val STATE_SEARCH_QUERY = "searchQuery" + private const val STATE_SHOW_SECTIONS = "showSections" + } + + private class Layout(view: TabsToolbarBinding) { + val tabs = view.tabs + val sectionLayout = view.sectionLayout + val sectionChange = view.sectionChange + val sectionName = view.sectionName + val sectionIcon = view.sectionIcon + } + + private var favouritesItem: MenuItem? = null + private var searchMenuItem: MenuItem? = null + private var sortOrderMenu: Pair>? = null + private var syncRepositoriesMenuItem: MenuItem? = null + private var layout: Layout? = null + private var sectionsList: RecyclerView? = null + private var sectionsAdapter: SectionsAdapter? = null + private var viewPager: ViewPager2? = null + + private var showSections = false + set(value) { + if (field != value) { + field = value + val layout = layout + layout?.tabs?.let { + (0 until it.childCount) + .forEach { index -> it.getChildAt(index)!!.isEnabled = !value } + } + layout?.sectionIcon?.scaleY = if (value) -1f else 1f + if (((sectionsList?.parent as? View)?.height ?: 0) > 0) { + animateSectionsList() + } + } + } + + private var searchQuery = "" + + private val syncConnection = Connection( + serviceClass = SyncService::class.java, + onBind = { _, _ -> + viewPager?.let { + val source = AppListFragment.Source.entries[it.currentItem] + updateUpdateNotificationBlocker(source) + } + } + ) + + private var sectionsAnimator: ValueAnimator? = null + + private var needSelectUpdates = false + + private val productFragments: Sequence + get() = if (host == null) { + emptySequence() + } else { + childFragmentManager.fragments.asSequence().mapNotNull { it as? AppListFragment } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + _tabsBinding = TabsToolbarBinding.inflate(layoutInflater) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + syncConnection.bind(requireContext()) + + sectionsAdapter = SectionsAdapter { + if (showSections) { + viewModel.setSection(it) + sectionsList?.scrollToPosition(0) + showSections = false + } + } + + screenActivity.onToolbarCreated(toolbar) + toolbar.title = getString(R.string.application_name) + // Move focus from SearchView to Toolbar + toolbar.isFocusable = true + + val searchView = FocusSearchView(toolbar.context).apply { + maxWidth = Int.MAX_VALUE + queryHint = getString(stringRes.search) + setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + clearFocus() + return true + } + + override fun onQueryTextChange(newText: String?): Boolean { + if (isResumed) { + searchQuery = newText.orEmpty() + productFragments.forEach { it.setSearchQuery(newText.orEmpty()) } + } + return true + } + }) + } + + toolbar.menu.apply { + if (!Huawei.isHuaweiEmui) { + sdkAbove(Build.VERSION_CODES.P) { + setGroupDividerEnabled(true) + } + } + + searchMenuItem = add(0, R.id.toolbar_search, 0, stringRes.search) + .setIcon(toolbar.context.getMutatedIcon(CommonR.drawable.ic_search)) + .setActionView(searchView) + .setShowAsActionFlags( + MenuItem.SHOW_AS_ACTION_ALWAYS or MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW + ) + + syncRepositoriesMenuItem = add(0, 0, 0, stringRes.sync_repositories) + .setIcon(toolbar.context.getMutatedIcon(CommonR.drawable.ic_sync)) + .setOnMenuItemClickListener { + syncConnection.binder?.sync(SyncService.SyncRequest.MANUAL) + true + } + + sortOrderMenu = addSubMenu(0, 0, 0, stringRes.sorting_order) + .setIcon(toolbar.context.getMutatedIcon(CommonR.drawable.ic_sort)) + .let { menu -> + menu.item.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS) + val menuItems = SortOrder.entries.map { sortOrder -> + menu.add(context.sortOrderName(sortOrder)) + .setOnMenuItemClickListener { + viewModel.setSortOrder(sortOrder) + true + } + } + menu.setGroupCheckable(0, true, true) + Pair(menu.item, menuItems) + } + + favouritesItem = add(1, 0, 0, stringRes.favourites) + .setIcon( + toolbar.context.getMutatedIcon(CommonR.drawable.ic_favourite_checked) + ) + .setOnMenuItemClickListener { + view.post { screenActivity.navigateFavourites() } + true + } + + add(1, 0, 0, stringRes.repositories) + .setOnMenuItemClickListener { + view.post { screenActivity.navigateRepositories() } + true + } + + add(1, 0, 0, stringRes.settings) + .setOnMenuItemClickListener { + view.post { screenActivity.navigatePreferences() } + true + } + } + + searchQuery = savedInstanceState?.getString(STATE_SEARCH_QUERY).orEmpty() + productFragments.forEach { it.setSearchQuery(searchQuery) } + + val toolbarExtra = fragmentBinding.toolbarExtra + toolbarExtra.addView(tabsBinding.root) + val layout = Layout(tabsBinding) + this.layout = layout + + showSections = (savedInstanceState?.getByte(STATE_SHOW_SECTIONS)?.toInt() ?: 0) != 0 + + val content = fragmentBinding.fragmentContent + + viewPager = ViewPager2(content.context).apply { + id = R.id.fragment_pager + adapter = object : FragmentStateAdapter(this@TabsFragment) { + override fun getItemCount(): Int = AppListFragment.Source.entries.size + override fun createFragment(position: Int): Fragment = AppListFragment( + AppListFragment.Source.entries[position] + ) + } + content.addView(this) + registerOnPageChangeCallback(pageChangeCallback) + offscreenPageLimit = 1 + } + + viewPager?.let { + TabLayoutMediator(layout.tabs, it) { tab, position -> + tab.text = getString(AppListFragment.Source.entries[position].titleResId) + }.attach() + } + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + launch { + viewModel.sections.collect(::updateSections) + } + launch { + viewModel.sortOrder.collect(::updateOrder) + } + launch { + viewModel.currentSection.collect(::updateSection) + } + launch { + viewModel.allowHomeScreenSwiping.collect { + viewPager?.isUserInputEnabled = it + } + } + } + } + + val backgroundPath = ShapeAppearanceModel.builder() + .setAllCornerSizes( + context?.resources?.getDimension(CommonR.dimen.shape_large_corner) ?: 0F + ) + .build() + val sectionBackground = MaterialShapeDrawable(backgroundPath) + val color = SurfaceColors.SURFACE_3.getColor(requireContext()) + sectionBackground.fillColor = ColorStateList.valueOf(color) + val sectionsList = RecyclerView(toolbar.context).apply { + id = R.id.sections_list + layoutManager = LinearLayoutManager(context) + isMotionEventSplittingEnabled = false + isVerticalScrollBarEnabled = false + setHasFixedSize(true) + adapter = sectionsAdapter + sectionsAdapter?.let { addDivider(it::configureDivider) } + background = sectionBackground + elevation = 4.dp.toFloat() + content.addView(this) + val margins = 8.dp + (layoutParams as ViewGroup.MarginLayoutParams).setMargins(margins, margins, margins, 0) + visibility = View.GONE + systemBarsPadding(includeFab = false) + } + this.sectionsList = sectionsList + + var lastContentHeight = -1 + content.viewTreeObserver.addOnGlobalLayoutListener { + if (this.view != null) { + val initial = lastContentHeight <= 0 + val contentHeight = content.height + if (lastContentHeight != contentHeight) { + lastContentHeight = contentHeight + if (initial) { + sectionsList.layoutParams.height = if (showSections) contentHeight else 0 + sectionsList.isVisible = showSections + sectionsList.requestLayout() + } else { + animateSectionsList() + } + } + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + + favouritesItem = null + searchMenuItem = null + sortOrderMenu = null + syncRepositoriesMenuItem = null + layout = null + sectionsList = null + sectionsAdapter = null + viewPager = null + + syncConnection.unbind(requireContext()) + sectionsAnimator?.cancel() + sectionsAnimator = null + + _tabsBinding = null + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + + outState.putBoolean(STATE_SEARCH_FOCUSED, searchMenuItem?.actionView?.hasFocus() == true) + outState.putString(STATE_SEARCH_QUERY, searchQuery) + outState.putByte(STATE_SHOW_SECTIONS, if (showSections) 1 else 0) + } + + override fun onViewStateRestored(savedInstanceState: Bundle?) { + super.onViewStateRestored(savedInstanceState) + + (searchMenuItem?.actionView as FocusSearchView).allowFocus = true + if (needSelectUpdates) { + needSelectUpdates = false + selectUpdatesInternal(false) + } + } + + override fun onBackPressed(): Boolean { + return when { + viewModel.currentSection.value != ProductItem.Section.All -> { + viewModel.setSection(ProductItem.Section.All) + true + } + + searchMenuItem?.isActionViewExpanded == true -> { + searchMenuItem?.collapseActionView() + true + } + + showSections -> { + showSections = false + true + } + + else -> { + super.onBackPressed() + } + } + } + + internal fun selectUpdates() = selectUpdatesInternal(true) + + private fun updateUpdateNotificationBlocker(activeSource: AppListFragment.Source) { + val blockerFragment = if (activeSource == AppListFragment.Source.UPDATES) { + productFragments.find { it.source == activeSource } + } else { + null + } + syncConnection.binder?.setUpdateNotificationBlocker(blockerFragment) + } + + private fun selectUpdatesInternal(allowSmooth: Boolean) { + if (view != null) { + val viewPager = viewPager + viewPager?.setCurrentItem( + AppListFragment.Source.UPDATES.ordinal, + allowSmooth && viewPager.isLaidOut + ) + } else { + needSelectUpdates = true + } + } + + private fun updateOrder(sortOrder: SortOrder) { + sortOrderMenu!!.second[sortOrder.ordinal].isChecked = true + } + + private fun updateSections( + sectionsList: List + ) { + sectionsAdapter?.sections = sectionsList + layout?.run { + sectionIcon.isVisible = sectionsList.any { it !is ProductItem.Section.All } + sectionLayout.setOnClickListener { showSections = isVisible && !showSections } + } + } + + private fun updateSection(section: ProductItem.Section) { + layout?.sectionName?.text = when (section) { + is ProductItem.Section.All -> getString(stringRes.all_applications) + is ProductItem.Section.Category -> section.name + is ProductItem.Section.Repository -> section.name + } + productFragments.filter { it.source.sections }.forEach { it.setSection(section) } + } + + private fun animateSectionsList() { + val sectionsList = sectionsList!! + val value = if (sectionsList.visibility != View.VISIBLE) { + 0f + } else { + sectionsList.height.toFloat() / (sectionsList.parent as View).height + } + val target = if (showSections) 0.98f else 0f + sectionsAnimator?.cancel() + sectionsAnimator = null + + if (value != target) { + sectionsAnimator = ValueAnimator.ofFloat(value, target).apply { + duration = (250 * abs(target - value)).toLong() + interpolator = DecelerateInterpolator(2f) + addUpdateListener { + val newValue = animatedValue as Float + sectionsList.apply { + val height = ((parent as View).height * newValue).toInt() + val visible = height > 0 + if ((visibility == View.VISIBLE) != visible) isVisible = visible + if (layoutParams.height != height) { + layoutParams.height = height + requestLayout() + } + } + if (target <= 0f && newValue <= 0f) { + sectionsAnimator = null + } + } + start() + } + } + } + + private val pageChangeCallback = object : ViewPager2.OnPageChangeCallback() { + override fun onPageScrolled( + position: Int, + positionOffset: Float, + positionOffsetPixels: Int + ) { + val layout = layout!! + val fromSections = AppListFragment.Source.entries[position].sections + val toSections = if (positionOffset <= 0f) { + fromSections + } else { + AppListFragment.Source.entries[position + 1].sections + } + val offset = if (fromSections != toSections) { + if (fromSections) 1f - positionOffset else positionOffset + } else { + if (fromSections) 1f else 0f + } + assert(layout.sectionLayout.childCount == 1) + val child = layout.sectionLayout.getChildAt(0) + val height = child.layoutParams.height + assert(height > 0) + val currentHeight = (offset * height).roundToInt() + if (layout.sectionLayout.layoutParams.height != currentHeight) { + layout.sectionLayout.layoutParams.height = currentHeight + layout.sectionLayout.requestLayout() + } + } + + override fun onPageSelected(position: Int) { + val source = AppListFragment.Source.entries[position] + updateUpdateNotificationBlocker(source) + sortOrderMenu!!.first.apply { + isVisible = source.order + setShowAsActionFlags( + if (!source.order || + resources.configuration.screenWidthDp >= 300 + ) { + MenuItem.SHOW_AS_ACTION_ALWAYS + } else { + 0 + } + ) + } + syncRepositoriesMenuItem!!.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS) + if (showSections && !source.sections) { + showSections = false + } + } + + override fun onPageScrollStateChanged(state: Int) { + val source = AppListFragment.Source.entries[viewPager!!.currentItem] + layout!!.sectionChange.isEnabled = + state != ViewPager2.SCROLL_STATE_DRAGGING && source.sections + if (state == ViewPager2.SCROLL_STATE_IDLE) { + // onPageSelected can be called earlier than fragments created + updateUpdateNotificationBlocker(source) + } + } + } + + private class SectionsAdapter( + private val onClick: (ProductItem.Section) -> Unit + ) : StableRecyclerAdapter() { + enum class ViewType { SECTION } + + private class SectionViewHolder(context: Context) : + RecyclerView.ViewHolder(FrameLayout(context)) { + val title: TextView = TextView(context) + + init { + with(title) { + gravity = Gravity.CENTER_VERTICAL + setPadding(16.dp, 0, 16.dp, 0) + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + } + with(itemView as FrameLayout) { + layoutParams = RecyclerView.LayoutParams( + RecyclerView.LayoutParams.MATCH_PARENT, + 48.dp + ) + background = context.selectableBackground + addView(title) + } + } + } + + var sections: List = emptyList() + set(value) { + field = value + notifyDataSetChanged() + } + + fun configureDivider( + context: Context, + position: Int, + configuration: DividerConfiguration + ) { + val currentSection = sections[position] + val nextSection = sections.getOrNull(position + 1) + when { + nextSection != null && currentSection.javaClass != nextSection.javaClass -> { + val padding = context.resources.sizeScaled(16) + configuration.set( + needDivider = true, + toTop = false, + paddingStart = padding, + paddingEnd = padding + ) + } + + else -> { + configuration.set( + needDivider = false, + toTop = false, + paddingStart = 0, + paddingEnd = 0 + ) + } + } + } + + override val viewTypeClass: Class + get() = ViewType::class.java + + override fun getItemCount(): Int = sections.size + override fun getItemDescriptor(position: Int): String = sections[position].toString() + override fun getItemEnumViewType(position: Int): ViewType = ViewType.SECTION + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: ViewType + ): RecyclerView.ViewHolder { + return SectionViewHolder(parent.context).apply { + itemView.setOnClickListener { onClick(sections[absoluteAdapterPosition]) } + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + holder as SectionViewHolder + val section = sections[position] + val previousSection = sections.getOrNull(position - 1) + val nextSection = sections.getOrNull(position + 1) + val margin = holder.itemView.resources.sizeScaled(8) + val layoutParams = holder.itemView.layoutParams as RecyclerView.LayoutParams + layoutParams.topMargin = if (previousSection == null || + section.javaClass != previousSection.javaClass + ) { + margin + } else { + 0 + } + layoutParams.bottomMargin = if (nextSection == null || + section.javaClass != nextSection.javaClass + ) { + margin + } else { + 0 + } + holder.title.text = when (section) { + is ProductItem.Section.All -> holder.itemView.resources.getString( + stringRes.all_applications + ) + + is ProductItem.Section.Category -> section.name + is ProductItem.Section.Repository -> section.name + } + } + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/ui/tabsFragment/TabsViewModel.kt b/app/src/main/kotlin/com/leos/droidify/ui/tabsFragment/TabsViewModel.kt new file mode 100644 index 0000000..6b229a4 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/ui/tabsFragment/TabsViewModel.kt @@ -0,0 +1,67 @@ +package com.leos.droidify.ui.tabsFragment + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.leos.core.common.extension.asStateFlow +import com.leos.core.datastore.SettingsRepository +import com.leos.core.datastore.get +import com.leos.core.datastore.model.SortOrder +import com.leos.core.domain.ProductItem +import com.leos.droidify.database.Database +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +@HiltViewModel +class TabsViewModel @Inject constructor( + private val settingsRepository: SettingsRepository, + private val savedStateHandle: SavedStateHandle +) : ViewModel() { + + val currentSection = + savedStateHandle.getStateFlow(STATE_SECTION, ProductItem.Section.All) + + val sortOrder = settingsRepository + .get { sortOrder } + .asStateFlow(SortOrder.UPDATED) + + val allowHomeScreenSwiping = settingsRepository + .get { homeScreenSwiping } + .asStateFlow(false) + + val sections = + combine( + Database.CategoryAdapter.getAllStream(), + Database.RepositoryAdapter.getEnabledStream() + ) { categories, repos -> + val productCategories = categories + .asSequence() + .sorted() + .map(ProductItem.Section::Category) + .toList() + + val enabledRepositories = repos + .map { ProductItem.Section.Repository(it.id, it.name) } + enabledRepositories.ifEmpty { setSection(ProductItem.Section.All) } + listOf(ProductItem.Section.All) + productCategories + enabledRepositories + } + .catch { it.printStackTrace() } + .asStateFlow(emptyList()) + + fun setSection(section: ProductItem.Section) { + savedStateHandle[STATE_SECTION] = section + } + + fun setSortOrder(sortOrder: SortOrder) { + viewModelScope.launch { + settingsRepository.setSortOrder(sortOrder) + } + } + + companion object { + private const val STATE_SECTION = "section" + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/utility/PackageItemResolver.kt b/app/src/main/kotlin/com/leos/droidify/utility/PackageItemResolver.kt new file mode 100644 index 0000000..28173b4 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/utility/PackageItemResolver.kt @@ -0,0 +1,153 @@ +package com.leos.droidify.utility + +import android.Manifest +import android.content.Context +import android.content.pm.PackageItemInfo +import android.content.pm.PermissionInfo +import android.content.res.Resources +import android.os.Build +import com.leos.core.common.SdkCheck +import java.util.Locale + +object PackageItemResolver { + class LocalCache { + internal val resources = mutableMapOf() + } + + private data class CacheKey(val locales: List, val packageName: String, val resId: Int) + + private val cache = mutableMapOf() + + private fun load( + context: Context, + localCache: LocalCache, + packageName: String, + nonLocalized: CharSequence?, + resId: Int + ): CharSequence? { + return when { + nonLocalized != null -> { + nonLocalized + } + + resId != 0 -> { + val locales = if (SdkCheck.isNougat) { + val localesList = context.resources.configuration.locales + (0 until localesList.size()).map(localesList::get) + } else { + @Suppress("DEPRECATION") + listOf(context.resources.configuration.locale) + } + val cacheKey = CacheKey(locales, packageName, resId) + if (cache.containsKey(cacheKey)) { + cache[cacheKey] + } else { + val resources = localCache.resources[packageName] ?: run { + val resources = try { + val resources = + context.packageManager.getResourcesForApplication(packageName) + @Suppress("DEPRECATION") + resources.updateConfiguration(context.resources.configuration, null) + resources + } catch (e: Exception) { + null + } + resources?.let { localCache.resources[packageName] = it } + resources + } + val label = resources?.getString(resId) + cache[cacheKey] = label + label + } + } + + else -> { + null + } + } + } + + fun loadLabel( + context: Context, + localCache: LocalCache, + packageItemInfo: PackageItemInfo + ): CharSequence? { + return load( + context, + localCache, + packageItemInfo.packageName, + packageItemInfo.nonLocalizedLabel, + packageItemInfo.labelRes + ) + } + + fun loadDescription( + context: Context, + localCache: LocalCache, + permissionInfo: PermissionInfo + ): CharSequence? { + return load( + context, + localCache, + permissionInfo.packageName, + permissionInfo.nonLocalizedDescription, + permissionInfo.descriptionRes + ) + } + + fun getPermissionGroup(permissionInfo: PermissionInfo): String? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + when (permissionInfo.name) { + Manifest.permission.READ_CONTACTS, + Manifest.permission.WRITE_CONTACTS, + Manifest.permission.GET_ACCOUNTS + -> Manifest.permission_group.CONTACTS + + Manifest.permission.READ_CALENDAR, + Manifest.permission.WRITE_CALENDAR + -> Manifest.permission_group.CALENDAR + + Manifest.permission.SEND_SMS, + Manifest.permission.RECEIVE_SMS, + Manifest.permission.READ_SMS, + Manifest.permission.RECEIVE_MMS, + Manifest.permission.RECEIVE_WAP_PUSH, + "android.permission.READ_CELL_BROADCASTS" + -> Manifest.permission_group.SMS + + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.ACCESS_MEDIA_LOCATION + -> Manifest.permission_group.STORAGE + + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_BACKGROUND_LOCATION + -> Manifest.permission_group.LOCATION + + Manifest.permission.READ_CALL_LOG, + Manifest.permission.WRITE_CALL_LOG, + @Suppress("DEPRECATION") + Manifest.permission.PROCESS_OUTGOING_CALLS + -> Manifest.permission_group.CALL_LOG + + Manifest.permission.READ_PHONE_STATE, + Manifest.permission.READ_PHONE_NUMBERS, + Manifest.permission.CALL_PHONE, + Manifest.permission.ADD_VOICEMAIL, + Manifest.permission.USE_SIP, + Manifest.permission.ANSWER_PHONE_CALLS, + Manifest.permission.ACCEPT_HANDOVER + -> Manifest.permission_group.PHONE + + Manifest.permission.RECORD_AUDIO -> Manifest.permission_group.MICROPHONE + Manifest.permission.ACTIVITY_RECOGNITION -> + Manifest.permission_group.ACTIVITY_RECOGNITION + Manifest.permission.CAMERA -> Manifest.permission_group.CAMERA + Manifest.permission.BODY_SENSORS -> Manifest.permission_group.SENSORS + else -> null + } + } else { + permissionInfo.group + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/utility/ProgressInputStream.kt b/app/src/main/kotlin/com/leos/droidify/utility/ProgressInputStream.kt new file mode 100644 index 0000000..fd07d92 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/utility/ProgressInputStream.kt @@ -0,0 +1,36 @@ +package com.leos.droidify.utility + +import java.io.InputStream + +fun InputStream.getProgress(callback: (Long) -> Unit): InputStream = + ProgressInputStream(this, callback) + +private class ProgressInputStream( + private val inputStream: InputStream, + private val callback: (Long) -> Unit +) : InputStream() { + private var count = 0L + + private inline fun notify(one: Boolean, read: () -> T): T { + val result = read() + count += if (one) 1L else result.toLong() + callback(count) + return result + } + + override fun read(): Int = notify(true) { inputStream.read() } + override fun read(b: ByteArray): Int = notify(false) { inputStream.read(b) } + override fun read(b: ByteArray, off: Int, len: Int): Int = + notify(false) { inputStream.read(b, off, len) } + + override fun skip(n: Long): Long = notify(false) { inputStream.skip(n) } + + override fun available(): Int { + return inputStream.available() + } + + override fun close() { + inputStream.close() + super.close() + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/utility/extension/Android.kt b/app/src/main/kotlin/com/leos/droidify/utility/extension/Android.kt new file mode 100644 index 0000000..9f589ad --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/utility/extension/Android.kt @@ -0,0 +1,14 @@ +@file:Suppress("PackageDirectoryMismatch") + +package com.leos.droidify.utility.extension.android + +import android.os.Build + +object Android { + val name: String = "Android ${Build.VERSION.RELEASE}" + + val platforms = Build.SUPPORTED_ABIS.toSet() + + val primaryPlatform: String? = Build.SUPPORTED_64_BIT_ABIS?.firstOrNull() + ?: Build.SUPPORTED_32_BIT_ABIS?.firstOrNull() +} diff --git a/app/src/main/kotlin/com/leos/droidify/utility/extension/Connection.kt b/app/src/main/kotlin/com/leos/droidify/utility/extension/Connection.kt new file mode 100644 index 0000000..a54721d --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/utility/extension/Connection.kt @@ -0,0 +1,37 @@ +package com.leos.droidify.utility.extension + +import com.leos.core.domain.InstalledItem +import com.leos.core.domain.Product +import com.leos.core.domain.Repository +import com.leos.core.domain.findSuggested +import com.leos.droidify.service.Connection +import com.leos.droidify.service.DownloadService +import com.leos.droidify.utility.extension.android.Android + +fun Connection.startUpdate( + packageName: String, + installedItem: InstalledItem?, + products: List> +) { + if (binder == null || products.isEmpty()) return + + val (product, repository) = products.findSuggested(installedItem) ?: return + + val compatibleReleases = product.selectedReleases + .filter { installedItem == null || installedItem.signature == it.signature } + .ifEmpty { return } + + val selectedRelease = compatibleReleases.singleOrNull() ?: compatibleReleases.run { + filter { Android.primaryPlatform in it.platforms }.minByOrNull { it.platforms.size } + ?: minByOrNull { it.platforms.size } + ?: firstOrNull() + } ?: return + + requireNotNull(binder).enqueue( + packageName = packageName, + name = product.name, + repository = repository, + release = selectedRelease, + isUpdate = installedItem != null + ) +} diff --git a/app/src/main/kotlin/com/leos/droidify/utility/extension/Fragment.kt b/app/src/main/kotlin/com/leos/droidify/utility/extension/Fragment.kt new file mode 100644 index 0000000..ea9d188 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/utility/extension/Fragment.kt @@ -0,0 +1,7 @@ +package com.leos.droidify.utility.extension + +import androidx.fragment.app.Fragment +import com.leos.droidify.ScreenActivity + +inline val Fragment.screenActivity: ScreenActivity + get() = requireActivity() as ScreenActivity diff --git a/app/src/main/kotlin/com/leos/droidify/utility/extension/ImageUtils.kt b/app/src/main/kotlin/com/leos/droidify/utility/extension/ImageUtils.kt new file mode 100644 index 0000000..acae45e --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/utility/extension/ImageUtils.kt @@ -0,0 +1,46 @@ +package com.leos.droidify.utility.extension + +import android.view.View +import com.leos.core.common.Singleton +import com.leos.core.common.extension.dpi +import com.leos.core.domain.Product +import com.leos.core.domain.ProductItem +import com.leos.core.domain.Repository + +object ImageUtils { + private val SUPPORTED_DPI = listOf(120, 160, 240, 320, 480, 640) + private var DeviceDpi = Singleton() + + fun Product.Screenshot.url( + repository: Repository, + packageName: String + ): String { + val phoneType = when (type) { + Product.Screenshot.Type.PHONE -> "phoneScreenshots" + Product.Screenshot.Type.SMALL_TABLET -> "sevenInchScreenshots" + Product.Screenshot.Type.LARGE_TABLET -> "tenInchScreenshots" + } + return "${repository.address}/$packageName/$locale/$phoneType/$path" + } + + fun ProductItem.icon( + view: View, + repository: Repository + ): String? { + if (packageName.isBlank()) return null + if (icon.isBlank() && metadataIcon.isBlank()) return null + if (repository.version < 11 && icon.isNotBlank()) { + return "${repository.address}/icons/$icon" + } + if (icon.isNotBlank()) { + val deviceDpi = DeviceDpi.getOrUpdate { + (SUPPORTED_DPI.find { it >= view.dpi } ?: SUPPORTED_DPI.last()).toString() + } + return "${repository.address}/icons-$deviceDpi/$icon" + } + if (metadataIcon.isNotBlank()) { + return "${repository.address}/$packageName/$metadataIcon" + } + return null + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/utility/extension/PackageInfo.kt b/app/src/main/kotlin/com/leos/droidify/utility/extension/PackageInfo.kt new file mode 100644 index 0000000..4f58227 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/utility/extension/PackageInfo.kt @@ -0,0 +1,17 @@ +package com.leos.droidify.utility.extension + +import android.content.pm.PackageInfo +import com.leos.core.common.extension.calculateHash +import com.leos.core.common.extension.singleSignature +import com.leos.core.common.extension.versionCodeCompat +import com.leos.core.domain.InstalledItem + +fun PackageInfo.toInstalledItem(): InstalledItem { + val signatureString = singleSignature?.calculateHash().orEmpty() + return InstalledItem( + packageName, + versionName.orEmpty(), + versionCodeCompat, + signatureString + ) +} diff --git a/app/src/main/kotlin/com/leos/droidify/utility/extension/Resources.kt b/app/src/main/kotlin/com/leos/droidify/utility/extension/Resources.kt new file mode 100644 index 0000000..bfd055b --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/utility/extension/Resources.kt @@ -0,0 +1,16 @@ +@file:Suppress("PackageDirectoryMismatch") + +package com.leos.droidify.utility.extension.resources + +import android.content.res.Resources +import android.graphics.Typeface +import kotlin.math.roundToInt + +object TypefaceExtra { + val medium = Typeface.create("sans-serif-medium", Typeface.NORMAL)!! + val light = Typeface.create("sans-serif-light", Typeface.NORMAL)!! +} + +fun Resources.sizeScaled(size: Int): Int { + return (size * displayMetrics.density).roundToInt() +} diff --git a/app/src/main/kotlin/com/leos/droidify/utility/serialization/ProductItemSerialization.kt b/app/src/main/kotlin/com/leos/droidify/utility/serialization/ProductItemSerialization.kt new file mode 100644 index 0000000..1c7a69f --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/utility/serialization/ProductItemSerialization.kt @@ -0,0 +1,55 @@ +package com.leos.droidify.utility.serialization + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.leos.core.common.extension.forEachKey +import com.leos.core.domain.ProductItem + +fun ProductItem.serialize(generator: JsonGenerator) { + generator.writeNumberField("serialVersion", 1) + generator.writeNumberField("repositoryId", repositoryId) + generator.writeStringField("packageName", packageName) + generator.writeStringField("name", name) + generator.writeStringField("summary", summary) + generator.writeStringField("icon", icon) + generator.writeStringField("metadataIcon", metadataIcon) + generator.writeStringField("version", version) + generator.writeStringField("installedVersion", installedVersion) + generator.writeBooleanField("compatible", compatible) + generator.writeBooleanField("canUpdate", canUpdate) + generator.writeNumberField("matchRank", matchRank) +} + +fun JsonParser.productItem(): ProductItem { + var repositoryId = 0L + var packageName = "" + var name = "" + var summary = "" + var icon = "" + var metadataIcon = "" + var version = "" + var installedVersion = "" + var compatible = false + var canUpdate = false + var matchRank = 0 + forEachKey { + when { + it.number("repositoryId") -> repositoryId = valueAsLong + it.string("packageName") -> packageName = valueAsString + it.string("name") -> name = valueAsString + it.string("summary") -> summary = valueAsString + it.string("icon") -> icon = valueAsString + it.string("metadataIcon") -> metadataIcon = valueAsString + it.string("version") -> version = valueAsString + it.string("installedVersion") -> installedVersion = valueAsString + it.boolean("compatible") -> compatible = valueAsBoolean + it.boolean("canUpdate") -> canUpdate = valueAsBoolean + it.number("matchRank") -> matchRank = valueAsInt + else -> skipChildren() + } + } + return ProductItem( + repositoryId, packageName, name, summary, icon, metadataIcon, + version, installedVersion, compatible, canUpdate, matchRank + ) +} diff --git a/app/src/main/kotlin/com/leos/droidify/utility/serialization/ProductPreferenceSerialization.kt b/app/src/main/kotlin/com/leos/droidify/utility/serialization/ProductPreferenceSerialization.kt new file mode 100644 index 0000000..186a3a1 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/utility/serialization/ProductPreferenceSerialization.kt @@ -0,0 +1,24 @@ +package com.leos.droidify.utility.serialization + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.leos.core.common.extension.forEachKey +import com.leos.core.domain.ProductPreference + +fun ProductPreference.serialize(generator: JsonGenerator) { + generator.writeBooleanField("ignoreUpdates", ignoreUpdates) + generator.writeNumberField("ignoreVersionCode", ignoreVersionCode) +} + +fun JsonParser.productPreference(): ProductPreference { + var ignoreUpdates = false + var ignoreVersionCode = 0L + forEachKey { + when { + it.boolean("ignoreUpdates") -> ignoreUpdates = valueAsBoolean + it.number("ignoreVersionCode") -> ignoreVersionCode = valueAsLong + else -> skipChildren() + } + } + return ProductPreference(ignoreUpdates, ignoreVersionCode) +} diff --git a/app/src/main/kotlin/com/leos/droidify/utility/serialization/ProductSerialization.kt b/app/src/main/kotlin/com/leos/droidify/utility/serialization/ProductSerialization.kt new file mode 100644 index 0000000..caa5269 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/utility/serialization/ProductSerialization.kt @@ -0,0 +1,208 @@ +package com.leos.droidify.utility.serialization + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.JsonToken +import com.leos.core.common.extension.collectNotNull +import com.leos.core.common.extension.collectNotNullStrings +import com.leos.core.common.extension.forEachKey +import com.leos.core.common.extension.writeArray +import com.leos.core.common.extension.writeDictionary +import com.leos.core.domain.Product +import com.leos.core.domain.Release + +fun Product.serialize(generator: JsonGenerator) { + generator.writeNumberField("repositoryId", repositoryId) + generator.writeNumberField("serialVersion", 1) + generator.writeStringField("packageName", packageName) + generator.writeStringField("name", name) + generator.writeStringField("summary", summary) + generator.writeStringField("description", description) + generator.writeStringField("whatsNew", whatsNew) + generator.writeStringField("icon", icon) + generator.writeStringField("metadataIcon", metadataIcon) + generator.writeStringField("authorName", author.name) + generator.writeStringField("authorEmail", author.email) + generator.writeStringField("authorWeb", author.web) + generator.writeStringField("source", source) + generator.writeStringField("changelog", changelog) + generator.writeStringField("web", web) + generator.writeStringField("tracker", tracker) + generator.writeNumberField("added", added) + generator.writeNumberField("updated", updated) + generator.writeNumberField("suggestedVersionCode", suggestedVersionCode) + generator.writeArray("categories") { categories.forEach(::writeString) } + generator.writeArray("antiFeatures") { antiFeatures.forEach(::writeString) } + generator.writeArray("licenses") { licenses.forEach(::writeString) } + generator.writeArray("donates") { + donates.forEach { + writeDictionary { + when (it) { + is Product.Donate.Regular -> { + writeStringField("type", "") + writeStringField("url", it.url) + } + + is Product.Donate.Bitcoin -> { + writeStringField("type", "bitcoin") + writeStringField("address", it.address) + } + + is Product.Donate.Litecoin -> { + writeStringField("type", "litecoin") + writeStringField("address", it.address) + } + + is Product.Donate.Flattr -> { + writeStringField("type", "flattr") + writeStringField("id", it.id) + } + + is Product.Donate.Liberapay -> { + writeStringField("type", "liberapay") + writeStringField("id", it.id) + } + + is Product.Donate.OpenCollective -> { + writeStringField("type", "openCollective") + writeStringField("id", it.id) + } + }::class + } + } + } + generator.writeArray("screenshots") { + screenshots.forEach { + writeDictionary { + writeStringField("locale", it.locale) + writeStringField("type", it.type.jsonName) + writeStringField("path", it.path) + } + } + } + generator.writeArray("releases") { releases.forEach { writeDictionary { it.serialize(this) } } } +} + +fun JsonParser.product(): Product { + var repositoryId = 0L + var packageName = "" + var name = "" + var summary = "" + var description = "" + var whatsNew = "" + var icon = "" + var metadataIcon = "" + var authorName = "" + var authorEmail = "" + var authorWeb = "" + var source = "" + var changelog = "" + var web = "" + var tracker = "" + var added = 0L + var updated = 0L + var suggestedVersionCode = 0L + var categories = emptyList() + var antiFeatures = emptyList() + var licenses = emptyList() + var donates = emptyList() + var screenshots = emptyList() + var releases = emptyList() + forEachKey { it -> + when { + it.string("repositoryId") -> repositoryId = valueAsLong + it.string("packageName") -> packageName = valueAsString + it.string("name") -> name = valueAsString + it.string("summary") -> summary = valueAsString + it.string("description") -> description = valueAsString + it.string("whatsNew") -> whatsNew = valueAsString + it.string("icon") -> icon = valueAsString + it.string("metadataIcon") -> metadataIcon = valueAsString + it.string("authorName") -> authorName = valueAsString + it.string("authorEmail") -> authorEmail = valueAsString + it.string("authorWeb") -> authorWeb = valueAsString + it.string("source") -> source = valueAsString + it.string("changelog") -> changelog = valueAsString + it.string("web") -> web = valueAsString + it.string("tracker") -> tracker = valueAsString + it.number("added") -> added = valueAsLong + it.number("updated") -> updated = valueAsLong + it.number("suggestedVersionCode") -> suggestedVersionCode = valueAsLong + it.array("categories") -> categories = collectNotNullStrings() + it.array("antiFeatures") -> antiFeatures = collectNotNullStrings() + it.array("licenses") -> licenses = collectNotNullStrings() + it.array("donates") -> donates = collectNotNull(JsonToken.START_OBJECT) { + var type = "" + var url = "" + var address = "" + var id = "" + forEachKey { + when { + it.string("type") -> type = valueAsString + it.string("url") -> url = valueAsString + it.string("address") -> address = valueAsString + it.string("id") -> id = valueAsString + else -> skipChildren() + } + } + when (type) { + "" -> Product.Donate.Regular(url) + "bitcoin" -> Product.Donate.Bitcoin(address) + "litecoin" -> Product.Donate.Litecoin(address) + "flattr" -> Product.Donate.Flattr(id) + "liberapay" -> Product.Donate.Liberapay(id) + "openCollective" -> Product.Donate.OpenCollective(id) + else -> null + } + } + + it.array("screenshots") -> + screenshots = + collectNotNull(JsonToken.START_OBJECT) { + var locale = "" + var type = "" + var path = "" + forEachKey { + when { + it.string("locale") -> locale = valueAsString + it.string("type") -> type = valueAsString + it.string("path") -> path = valueAsString + else -> skipChildren() + } + } + Product.Screenshot.Type.entries.find { it.jsonName == type } + ?.let { Product.Screenshot(locale, it, path) } + } + + it.array("releases") -> + releases = + collectNotNull(JsonToken.START_OBJECT) { release() } + + else -> skipChildren() + } + } + return Product( + repositoryId, + packageName, + name, + summary, + description, + whatsNew, + icon, + metadataIcon, + Product.Author(authorName, authorEmail, authorWeb), + source, + changelog, + web, + tracker, + added, + updated, + suggestedVersionCode, + categories, + antiFeatures, + licenses, + donates, + screenshots, + releases + ) +} diff --git a/app/src/main/kotlin/com/leos/droidify/utility/serialization/ReleaseSerialization.kt b/app/src/main/kotlin/com/leos/droidify/utility/serialization/ReleaseSerialization.kt new file mode 100644 index 0000000..8d43652 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/utility/serialization/ReleaseSerialization.kt @@ -0,0 +1,160 @@ +package com.leos.droidify.utility.serialization + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.JsonToken +import com.leos.core.common.extension.collectNotNull +import com.leos.core.common.extension.collectNotNullStrings +import com.leos.core.common.extension.forEachKey +import com.leos.core.common.extension.writeArray +import com.leos.core.common.extension.writeDictionary +import com.leos.core.domain.Release + +fun Release.serialize(generator: JsonGenerator) { + generator.writeNumberField("serialVersion", 1) + generator.writeBooleanField("selected", selected) + generator.writeStringField("version", version) + generator.writeNumberField("versionCode", versionCode) + generator.writeNumberField("added", added) + generator.writeNumberField("size", size) + generator.writeNumberField("minSdkVersion", minSdkVersion) + generator.writeNumberField("targetSdkVersion", targetSdkVersion) + generator.writeNumberField("maxSdkVersion", maxSdkVersion) + generator.writeStringField("source", source) + generator.writeStringField("release", release) + generator.writeStringField("hash", hash) + generator.writeStringField("hashType", hashType) + generator.writeStringField("signature", signature) + generator.writeStringField("obbMain", obbMain) + generator.writeStringField("obbMainHash", obbMainHash) + generator.writeStringField("obbMainHashType", obbMainHashType) + generator.writeStringField("obbPatch", obbPatch) + generator.writeStringField("obbPatchHash", obbPatchHash) + generator.writeStringField("obbPatchHashType", obbPatchHashType) + generator.writeArray("permissions") { permissions.forEach { writeString(it) } } + generator.writeArray("features") { features.forEach { writeString(it) } } + generator.writeArray("platforms") { platforms.forEach { writeString(it) } } + generator.writeArray("incompatibilities") { + incompatibilities.forEach { + writeDictionary { + when (it) { + is Release.Incompatibility.MinSdk -> { + writeStringField("type", "minSdk") + } + + is Release.Incompatibility.MaxSdk -> { + writeStringField("type", "maxSdk") + } + + is Release.Incompatibility.Platform -> { + writeStringField("type", "platform") + } + + is Release.Incompatibility.Feature -> { + writeStringField("type", "feature") + writeStringField("feature", it.feature) + } + }::class + } + } + } +} + +fun JsonParser.release(): Release { + var selected = false + var version = "" + var versionCode = 0L + var added = 0L + var size = 0L + var minSdkVersion = 0 + var targetSdkVersion = 0 + var maxSdkVersion = 0 + var source = "" + var release = "" + var hash = "" + var hashType = "" + var signature = "" + var obbMain = "" + var obbMainHash = "" + var obbMainHashType = "" + var obbPatch = "" + var obbPatchHash = "" + var obbPatchHashType = "" + var permissions = emptyList() + var features = emptyList() + var platforms = emptyList() + var incompatibilities = emptyList() + forEachKey { it -> + when { + it.boolean("selected") -> selected = valueAsBoolean + it.string("version") -> version = valueAsString + it.number("versionCode") -> versionCode = valueAsLong + it.number("added") -> added = valueAsLong + it.number("size") -> size = valueAsLong + it.number("minSdkVersion") -> minSdkVersion = valueAsInt + it.number("targetSdkVersion") -> targetSdkVersion = valueAsInt + it.number("maxSdkVersion") -> maxSdkVersion = valueAsInt + it.string("source") -> source = valueAsString + it.string("release") -> release = valueAsString + it.string("hash") -> hash = valueAsString + it.string("hashType") -> hashType = valueAsString + it.string("signature") -> signature = valueAsString + it.string("obbMain") -> obbMain = valueAsString + it.string("obbMainHash") -> obbMainHash = valueAsString + it.string("obbMainHashType") -> obbMainHashType = valueAsString + it.string("obbPatch") -> obbPatch = valueAsString + it.string("obbPatchHash") -> obbPatchHash = valueAsString + it.string("obbPatchHashType") -> obbPatchHashType = valueAsString + it.array("permissions") -> permissions = collectNotNullStrings() + it.array("features") -> features = collectNotNullStrings() + it.array("platforms") -> platforms = collectNotNullStrings() + it.array("incompatibilities") -> + incompatibilities = + collectNotNull(JsonToken.START_OBJECT) { + var type = "" + var feature = "" + forEachKey { + when { + it.string("type") -> type = valueAsString + it.string("feature") -> feature = valueAsString + else -> skipChildren() + } + } + when (type) { + "minSdk" -> Release.Incompatibility.MinSdk + "maxSdk" -> Release.Incompatibility.MaxSdk + "platform" -> Release.Incompatibility.Platform + "feature" -> Release.Incompatibility.Feature(feature) + else -> null + } + } + + else -> skipChildren() + } + } + return Release( + selected, + version, + versionCode, + added, + size, + minSdkVersion, + targetSdkVersion, + maxSdkVersion, + source, + release, + hash, + hashType, + signature, + obbMain, + obbMainHash, + obbMainHashType, + obbPatch, + obbPatchHash, + obbPatchHashType, + permissions, + features, + platforms, + incompatibilities + ) +} diff --git a/app/src/main/kotlin/com/leos/droidify/utility/serialization/RepositorySerialization.kt b/app/src/main/kotlin/com/leos/droidify/utility/serialization/RepositorySerialization.kt new file mode 100644 index 0000000..3a666c4 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/utility/serialization/RepositorySerialization.kt @@ -0,0 +1,63 @@ +package com.leos.droidify.utility.serialization + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.leos.core.common.extension.collectNotNullStrings +import com.leos.core.common.extension.forEachKey +import com.leos.core.common.extension.writeArray +import com.leos.core.domain.Repository + +fun Repository.serialize(generator: JsonGenerator) { + generator.writeNumberField("serialVersion", 1) + generator.writeNumberField("id", id) + generator.writeStringField("address", address) + generator.writeArray("mirrors") { mirrors.forEach { writeString(it) } } + generator.writeStringField("name", name) + generator.writeStringField("description", description) + generator.writeNumberField("version", version) + generator.writeBooleanField("enabled", enabled) + generator.writeStringField("fingerprint", fingerprint) + generator.writeStringField("lastModified", lastModified) + generator.writeStringField("entityTag", entityTag) + generator.writeNumberField("updated", updated) + generator.writeNumberField("timestamp", timestamp) + generator.writeStringField("authentication", authentication) +} + +fun JsonParser.repository(): Repository { + var id = -1L + var address = "" + var mirrors = emptyList() + var name = "" + var description = "" + var version = 0 + var enabled = false + var fingerprint = "" + var lastModified = "" + var entityTag = "" + var updated = 0L + var timestamp = 0L + var authentication = "" + forEachKey { + when { + it.string("id") -> id = valueAsLong + it.string("address") -> address = valueAsString + it.array("mirrors") -> mirrors = collectNotNullStrings() + it.string("name") -> name = valueAsString + it.string("description") -> description = valueAsString + it.number("version") -> version = valueAsInt + it.boolean("enabled") -> enabled = valueAsBoolean + it.string("fingerprint") -> fingerprint = valueAsString + it.string("lastModified") -> lastModified = valueAsString + it.string("entityTag") -> entityTag = valueAsString + it.number("updated") -> updated = valueAsLong + it.number("timestamp") -> timestamp = valueAsLong + it.string("authentication") -> authentication = valueAsString + else -> skipChildren() + } + } + return Repository( + id, address, mirrors, name, description, version, enabled, fingerprint, + lastModified, entityTag, updated, timestamp, authentication + ) +} diff --git a/app/src/main/kotlin/com/leos/droidify/widget/CursorRecyclerAdapter.kt b/app/src/main/kotlin/com/leos/droidify/widget/CursorRecyclerAdapter.kt new file mode 100644 index 0000000..8745ffc --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/widget/CursorRecyclerAdapter.kt @@ -0,0 +1,36 @@ +package com.leos.droidify.widget + +import android.database.Cursor +import androidx.recyclerview.widget.RecyclerView + +abstract class CursorRecyclerAdapter, VH : RecyclerView.ViewHolder> : + EnumRecyclerAdapter() { + init { + super.setHasStableIds(true) + } + + private var rowIdIndex = 0 + + var cursor: Cursor? = null + set(value) { + if (field != value) { + field?.close() + field = value + rowIdIndex = value?.getColumnIndexOrThrow("_id") ?: 0 + notifyDataSetChanged() + } + } + + final override fun setHasStableIds(hasStableIds: Boolean) { + throw UnsupportedOperationException() + } + + override fun getItemCount(): Int = cursor?.count ?: 0 + override fun getItemId(position: Int): Long = moveTo(position).getLong(rowIdIndex) + + fun moveTo(position: Int): Cursor { + val cursor = cursor!! + cursor.moveToPosition(position) + return cursor + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/widget/DividerItemDecoration.kt b/app/src/main/kotlin/com/leos/droidify/widget/DividerItemDecoration.kt new file mode 100644 index 0000000..8de2dd7 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/widget/DividerItemDecoration.kt @@ -0,0 +1,139 @@ +package com.leos.droidify.widget + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import com.leos.core.common.extension.divider +import com.leos.droidify.R +import kotlin.math.roundToInt + +fun RecyclerView.addDivider( + configure: ( + context: Context, + position: Int, + configuration: DividerConfiguration + ) -> Unit +) { + addItemDecoration( + DividerItemDecoration( + context = context, + configure = configure + ) + ) +} + +fun interface DividerConfiguration { + fun set(needDivider: Boolean, toTop: Boolean, paddingStart: Int, paddingEnd: Int) +} + +private class DividerItemDecoration( + context: Context, + private val configure: ( + context: Context, + position: Int, + configuration: DividerConfiguration + ) -> Unit +) : RecyclerView.ItemDecoration() { + + private class ConfigurationHolder : DividerConfiguration { + var needDivider = false + var toTop = false + var paddingStart = 0 + var paddingEnd = 0 + + override fun set(needDivider: Boolean, toTop: Boolean, paddingStart: Int, paddingEnd: Int) { + this.needDivider = needDivider + this.toTop = toTop + this.paddingStart = paddingStart + this.paddingEnd = paddingEnd + } + } + + private val View.configuration: ConfigurationHolder + get() = getTag(R.id.divider_configuration) as? ConfigurationHolder ?: run { + val configuration = ConfigurationHolder() + setTag(R.id.divider_configuration, configuration) + configuration + } + + private val divider = context.divider + private val bounds = Rect() + + private fun draw( + c: Canvas, + configuration: ConfigurationHolder, + view: View, + top: Int, + width: Int, + rtl: Boolean + ) { + val divider = divider + val left = if (rtl) configuration.paddingEnd else configuration.paddingStart + val right = width - (if (rtl) configuration.paddingStart else configuration.paddingEnd) + val translatedTop = top + view.translationY.roundToInt() + divider.alpha = (view.alpha * 0xff).toInt() + divider.setBounds(left, translatedTop, right, translatedTop + divider.intrinsicHeight) + divider.draw(c) + } + + override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { + val divider = divider + val bounds = bounds + val rtl = parent.layoutDirection == View.LAYOUT_DIRECTION_RTL + for (i in 0 until parent.childCount) { + val view = parent.getChildAt(i) + val configuration = view.configuration + if (configuration.needDivider) { + val position = parent.getChildAdapterPosition(view) + if (position == parent.adapter!!.itemCount - 1) { + parent.getDecoratedBoundsWithMargins(view, bounds) + draw(c, configuration, view, bounds.bottom, parent.width, rtl) + } else { + val toTopView = if (configuration.toTop && position >= 0) { + parent.findViewHolderForAdapterPosition(position + 1)?.itemView + } else { + null + } + if (toTopView != null) { + parent.getDecoratedBoundsWithMargins(toTopView, bounds) + draw( + c, + configuration, + toTopView, + bounds.top - divider.intrinsicHeight, + parent.width, + rtl + ) + } else { + parent.getDecoratedBoundsWithMargins(view, bounds) + draw( + c, + configuration, + view, + bounds.bottom - divider.intrinsicHeight, + parent.width, + rtl + ) + } + } + } + } + } + + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + val configuration = view.configuration + val position = parent.getChildAdapterPosition(view) + if (position >= 0) { + configure(view.context, position, configuration) + } + val needDivider = position < parent.adapter!!.itemCount - 1 && configuration.needDivider + outRect.set(0, 0, 0, if (needDivider) divider.intrinsicHeight else 0) + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/widget/EnumRecyclerAdapter.kt b/app/src/main/kotlin/com/leos/droidify/widget/EnumRecyclerAdapter.kt new file mode 100644 index 0000000..e56f37a --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/widget/EnumRecyclerAdapter.kt @@ -0,0 +1,29 @@ +package com.leos.droidify.widget + +import android.util.SparseArray +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView + +abstract class EnumRecyclerAdapter, VH : RecyclerView.ViewHolder> : + RecyclerView.Adapter() { + abstract val viewTypeClass: Class + + private val names = SparseArray() + + private fun getViewType(viewType: Int): VT { + return java.lang.Enum.valueOf(viewTypeClass, names.get(viewType)) + } + + final override fun getItemViewType(position: Int): Int { + val enum = getItemEnumViewType(position) + names.put(enum.ordinal, enum.name) + return enum.ordinal + } + + final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { + return onCreateViewHolder(parent, getViewType(viewType)) + } + + abstract fun getItemEnumViewType(position: Int): VT + abstract fun onCreateViewHolder(parent: ViewGroup, viewType: VT): VH +} diff --git a/app/src/main/kotlin/com/leos/droidify/widget/FocusSearchView.kt b/app/src/main/kotlin/com/leos/droidify/widget/FocusSearchView.kt new file mode 100644 index 0000000..2df5c39 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/widget/FocusSearchView.kt @@ -0,0 +1,39 @@ +package com.leos.droidify.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.KeyEvent +import androidx.appcompat.widget.SearchView + +class FocusSearchView : SearchView { + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( + context, + attrs, + defStyleAttr + ) + + var allowFocus = true + + override fun dispatchKeyEventPreIme(event: KeyEvent): Boolean { + // Always clear focus on back press + return if (hasFocus() && event.keyCode == KeyEvent.KEYCODE_BACK) { + if (event.action == KeyEvent.ACTION_UP) { + clearFocus() + } + true + } else { + super.dispatchKeyEventPreIme(event) + } + } + + override fun setIconified(iconify: Boolean) { + super.setIconified(iconify) + + // Don't focus view and raise keyboard unless allowed + if (!iconify && !allowFocus) { + clearFocus() + } + } +} diff --git a/app/src/main/kotlin/com/leos/droidify/widget/StableRecyclerAdapter.kt b/app/src/main/kotlin/com/leos/droidify/widget/StableRecyclerAdapter.kt new file mode 100644 index 0000000..becba8d --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/widget/StableRecyclerAdapter.kt @@ -0,0 +1,28 @@ +package com.leos.droidify.widget + +import androidx.recyclerview.widget.RecyclerView + +abstract class StableRecyclerAdapter, VH : RecyclerView.ViewHolder> : + EnumRecyclerAdapter() { + private var nextId = 1L + private val descriptorToId = mutableMapOf() + + init { + super.setHasStableIds(true) + } + + final override fun setHasStableIds(hasStableIds: Boolean) { + throw UnsupportedOperationException() + } + + override fun getItemId(position: Int): Long { + val descriptor = getItemDescriptor(position) + return descriptorToId[descriptor] ?: run { + val id = nextId++ + descriptorToId[descriptor] = id + id + } + } + + abstract fun getItemDescriptor(position: Int): String +} diff --git a/app/src/main/kotlin/com/leos/droidify/work/CleanUpWorker.kt b/app/src/main/kotlin/com/leos/droidify/work/CleanUpWorker.kt new file mode 100644 index 0000000..98f8914 --- /dev/null +++ b/app/src/main/kotlin/com/leos/droidify/work/CleanUpWorker.kt @@ -0,0 +1,74 @@ +package com.leos.droidify.work + +import android.content.Context +import android.util.Log +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.leos.core.common.cache.Cache +import com.leos.core.datastore.SettingsRepository +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlin.time.Duration +import kotlin.time.toJavaDuration +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@HiltWorker +class CleanUpWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted workerParams: WorkerParameters, + private val settingsRepository: SettingsRepository +) : CoroutineWorker(context, workerParams) { + companion object { + private const val TAG = "CleanUpWorker" + + fun removeAllSchedules(context: Context) { + val workManager = WorkManager.getInstance(context) + workManager.cancelUniqueWork(TAG) + } + + fun scheduleCleanup(context: Context, duration: Duration) { + val workManager = WorkManager.getInstance(context) + val cleanup = PeriodicWorkRequestBuilder(duration.toJavaDuration()) + .build() + + workManager.enqueueUniquePeriodicWork( + TAG, + ExistingPeriodicWorkPolicy.UPDATE, + cleanup + ) + Log.i(TAG, "Periodic work enqueued with duration: $duration") + } + + fun force(context: Context) { + val cleanup = OneTimeWorkRequestBuilder() + .build() + + val workManager = WorkManager.getInstance(context) + workManager.enqueueUniqueWork( + "$TAG.force", + ExistingWorkPolicy.KEEP, + cleanup + ) + Log.i(TAG, "Forced cleanup enqueued") + } + } + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + try { + Log.i(TAG, "doWork: Started Cleanup") + settingsRepository.setCleanupInstant() + Cache.cleanup(applicationContext) + Result.success() + } catch (e: Exception) { + Log.i(TAG, "doWork: Failed to clean up", e) + Result.failure() + } + } +} diff --git a/app/src/main/res/anim/slide_right_fade_in.xml b/app/src/main/res/anim/slide_right_fade_in.xml new file mode 100644 index 0000000..678b7e8 --- /dev/null +++ b/app/src/main/res/anim/slide_right_fade_in.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_right_fade_out.xml b/app/src/main/res/anim/slide_right_fade_out.xml new file mode 100644 index 0000000..abebebe --- /dev/null +++ b/app/src/main/res/anim/slide_right_fade_out.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/animator/slide_in.xml b/app/src/main/res/animator/slide_in.xml new file mode 100644 index 0000000..719d8b0 --- /dev/null +++ b/app/src/main/res/animator/slide_in.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/app/src/main/res/animator/slide_in_keep.xml b/app/src/main/res/animator/slide_in_keep.xml new file mode 100644 index 0000000..6a65126 --- /dev/null +++ b/app/src/main/res/animator/slide_in_keep.xml @@ -0,0 +1,3 @@ + + diff --git a/app/src/main/res/animator/slide_out.xml b/app/src/main/res/animator/slide_out.xml new file mode 100644 index 0000000..5d29ac1 --- /dev/null +++ b/app/src/main/res/animator/slide_out.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/favourite_icon.xml b/app/src/main/res/drawable/favourite_icon.xml new file mode 100644 index 0000000..6f092a0 --- /dev/null +++ b/app/src/main/res/drawable/favourite_icon.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..e009ebe --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..ae4f8e1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,53 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..a443a3f --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,53 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/tv_banner.xml b/app/src/main/res/drawable/tv_banner.xml new file mode 100644 index 0000000..94a3fed --- /dev/null +++ b/app/src/main/res/drawable/tv_banner.xml @@ -0,0 +1,209 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/app_detail_header.xml b/app/src/main/res/layout/app_detail_header.xml new file mode 100644 index 0000000..6de90b9 --- /dev/null +++ b/app/src/main/res/layout/app_detail_header.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/download_status.xml b/app/src/main/res/layout/download_status.xml new file mode 100644 index 0000000..e23853d --- /dev/null +++ b/app/src/main/res/layout/download_status.xml @@ -0,0 +1,24 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/edit_repository.xml b/app/src/main/res/layout/edit_repository.xml new file mode 100644 index 0000000..444c08f --- /dev/null +++ b/app/src/main/res/layout/edit_repository.xml @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/enum_type.xml b/app/src/main/res/layout/enum_type.xml new file mode 100644 index 0000000..f7b2651 --- /dev/null +++ b/app/src/main/res/layout/enum_type.xml @@ -0,0 +1,22 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/expand_view_button.xml b/app/src/main/res/layout/expand_view_button.xml new file mode 100644 index 0000000..3902db0 --- /dev/null +++ b/app/src/main/res/layout/expand_view_button.xml @@ -0,0 +1,10 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment.xml b/app/src/main/res/layout/fragment.xml new file mode 100644 index 0000000..b1270ad --- /dev/null +++ b/app/src/main/res/layout/fragment.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/install_button.xml b/app/src/main/res/layout/install_button.xml new file mode 100644 index 0000000..ba39268 --- /dev/null +++ b/app/src/main/res/layout/install_button.xml @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/link_item.xml b/app/src/main/res/layout/link_item.xml new file mode 100644 index 0000000..8090f00 --- /dev/null +++ b/app/src/main/res/layout/link_item.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/permissions_item.xml b/app/src/main/res/layout/permissions_item.xml new file mode 100644 index 0000000..6b51742 --- /dev/null +++ b/app/src/main/res/layout/permissions_item.xml @@ -0,0 +1,29 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/product_item.xml b/app/src/main/res/layout/product_item.xml new file mode 100644 index 0000000..17a0599 --- /dev/null +++ b/app/src/main/res/layout/product_item.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/recycler_view_with_fab.xml b/app/src/main/res/layout/recycler_view_with_fab.xml new file mode 100644 index 0000000..16596c0 --- /dev/null +++ b/app/src/main/res/layout/recycler_view_with_fab.xml @@ -0,0 +1,22 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/release_item.xml b/app/src/main/res/layout/release_item.xml new file mode 100644 index 0000000..d935656 --- /dev/null +++ b/app/src/main/res/layout/release_item.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/repository_item.xml b/app/src/main/res/layout/repository_item.xml new file mode 100644 index 0000000..2d29b5d --- /dev/null +++ b/app/src/main/res/layout/repository_item.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/repository_page.xml b/app/src/main/res/layout/repository_page.xml new file mode 100644 index 0000000..e409086 --- /dev/null +++ b/app/src/main/res/layout/repository_page.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/section_item.xml b/app/src/main/res/layout/section_item.xml new file mode 100644 index 0000000..8a46987 --- /dev/null +++ b/app/src/main/res/layout/section_item.xml @@ -0,0 +1,25 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/settings_page.xml b/app/src/main/res/layout/settings_page.xml new file mode 100644 index 0000000..5d4dfde --- /dev/null +++ b/app/src/main/res/layout/settings_page.xml @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/switch_item.xml b/app/src/main/res/layout/switch_item.xml new file mode 100644 index 0000000..325d7fd --- /dev/null +++ b/app/src/main/res/layout/switch_item.xml @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/switch_type.xml b/app/src/main/res/layout/switch_type.xml new file mode 100644 index 0000000..ef4b04e --- /dev/null +++ b/app/src/main/res/layout/switch_type.xml @@ -0,0 +1,39 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/tabs_toolbar.xml b/app/src/main/res/layout/tabs_toolbar.xml new file mode 100644 index 0000000..8f77d5c --- /dev/null +++ b/app/src/main/res/layout/tabs_toolbar.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/title_text_item.xml b/app/src/main/res/layout/title_text_item.xml new file mode 100644 index 0000000..25a199d --- /dev/null +++ b/app/src/main/res/layout/title_text_item.xml @@ -0,0 +1,22 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/navigation_menu_main.xml b/app/src/main/res/menu/navigation_menu_main.xml new file mode 100644 index 0000000..dbce94a --- /dev/null +++ b/app/src/main/res/menu/navigation_menu_main.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c4a603d --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..c4a603d --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..6f59063 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..453a5c9 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b7c66db Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..9ba2ce2 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..7ca750a Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..3432518 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..cc62746 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..49c37cb Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..7743386 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..9e6cb05 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..3d15e56 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..aa8d34d Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..1b9bb30 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..44b3d44 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..6b4fd36 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..64088a9 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #191C1A + \ No newline at end of file diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml new file mode 100644 index 0000000..ed0f374 --- /dev/null +++ b/app/src/main/res/values/ids.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml new file mode 100644 index 0000000..13a5712 --- /dev/null +++ b/app/src/main/res/xml/locales_config.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..1a54ed7 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/build-logic/gradle.properties b/build-logic/gradle.properties new file mode 100644 index 0000000..6977b71 --- /dev/null +++ b/build-logic/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configureondemand=true \ No newline at end of file diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts new file mode 100644 index 0000000..3c5b3d0 --- /dev/null +++ b/build-logic/settings.gradle.kts @@ -0,0 +1,15 @@ +dependencyResolutionManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} + +rootProject.name = "build-logic" +include(":structure") \ No newline at end of file diff --git a/build-logic/structure/build.gradle.kts b/build-logic/structure/build.gradle.kts new file mode 100644 index 0000000..dac79a4 --- /dev/null +++ b/build-logic/structure/build.gradle.kts @@ -0,0 +1,57 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + `kotlin-dsl` +} + +group = "buildlogic" + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} +tasks.withType().configureEach { + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } +} + +dependencies { + compileOnly(libs.android.gradlePlugin) + compileOnly(libs.kotlin.gradlePlugin) + compileOnly(libs.kotlin.ktlint) + compileOnly(libs.ksp.gradlePlugin) +} + +gradlePlugin { + plugins { + register("lintPlugin") { + id = "looker.lint" + implementationClass = "AndroidLintPlugin" + } + register("serializationPlugin") { + id = "looker.serialization" + implementationClass = "AndroidSerializationPlugin" + } + register("hiltPlugin") { + id = "looker.hilt" + implementationClass = "AndroidHiltPlugin" + } + register("hiltWorkPlugin") { + id = "looker.hilt.work" + implementationClass = "AndroidHiltWorkerPlugin" + } + register("roomPlugin") { + id = "looker.room" + implementationClass = "AndroidRoomPlugin" + } + register("androidApplicationPlugin") { + id = "looker.android.application" + implementationClass = "AndroidApplicationPlugin" + } + register("androidLibraryPlugin") { + id = "looker.android.library" + implementationClass = "AndroidLibraryPlugin" + } + } +} diff --git a/build-logic/structure/src/main/kotlin/AndroidApplicationPlugin.kt b/build-logic/structure/src/main/kotlin/AndroidApplicationPlugin.kt new file mode 100644 index 0000000..9a253a5 --- /dev/null +++ b/build-logic/structure/src/main/kotlin/AndroidApplicationPlugin.kt @@ -0,0 +1,38 @@ +import com.android.build.api.dsl.ApplicationExtension +import com.leos.droidify.configureKotlinAndroid +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.embeddedKotlin + +class AndroidApplicationPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + with(pluginManager) { + apply("com.android.application") + apply("org.jetbrains.kotlin.android") + } + + extensions.configure { + configureKotlinAndroid(this) + buildToolsVersion = DefaultConfig.buildTools + defaultConfig { + targetSdk = DefaultConfig.compileSdk + applicationId = DefaultConfig.appId + versionCode = DefaultConfig.versionCode + versionName = DefaultConfig.versionName + } + buildFeatures { + aidl = false + renderScript = false + shaders = false + } + } + dependencies { + add("implementation", embeddedKotlin("stdlib")) + add("implementation", embeddedKotlin("reflect")) + } + } + } +} diff --git a/build-logic/structure/src/main/kotlin/AndroidHiltPlugin.kt b/build-logic/structure/src/main/kotlin/AndroidHiltPlugin.kt new file mode 100644 index 0000000..3280557 --- /dev/null +++ b/build-logic/structure/src/main/kotlin/AndroidHiltPlugin.kt @@ -0,0 +1,21 @@ +import com.leos.droidify.getLibrary +import com.leos.droidify.libs +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies + +class AndroidHiltPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + with(pluginManager) { + apply("com.google.dagger.hilt.android") + apply("com.google.devtools.ksp") + } + + dependencies { + add("implementation", libs.getLibrary("hilt.android")) + add("ksp", libs.getLibrary("hilt.compiler")) + } + } + } +} diff --git a/build-logic/structure/src/main/kotlin/AndroidHiltWorkerPlugin.kt b/build-logic/structure/src/main/kotlin/AndroidHiltWorkerPlugin.kt new file mode 100644 index 0000000..76c1827 --- /dev/null +++ b/build-logic/structure/src/main/kotlin/AndroidHiltWorkerPlugin.kt @@ -0,0 +1,21 @@ +import com.leos.droidify.getLibrary +import com.leos.droidify.libs +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies + +class AndroidHiltWorkerPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + with(pluginManager) { + apply("looker.hilt") + } + + dependencies { + add("implementation", libs.getLibrary("androidx.work.ktx")) + add("implementation", libs.getLibrary("hilt.ext.work")) + add("ksp", libs.getLibrary("hilt.ext.compiler")) + } + } + } +} diff --git a/build-logic/structure/src/main/kotlin/AndroidLibraryPlugin.kt b/build-logic/structure/src/main/kotlin/AndroidLibraryPlugin.kt new file mode 100644 index 0000000..4ac7302 --- /dev/null +++ b/build-logic/structure/src/main/kotlin/AndroidLibraryPlugin.kt @@ -0,0 +1,42 @@ +import com.android.build.api.variant.LibraryAndroidComponentsExtension +import com.android.build.gradle.LibraryExtension +import com.leos.droidify.configureKotlinAndroid +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.embeddedKotlin + +class AndroidLibraryPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + with(pluginManager) { + apply("com.android.library") + apply("org.jetbrains.kotlin.android") + } + + extensions.configure { + configureKotlinAndroid(this) + defaultConfig.targetSdk = DefaultConfig.compileSdk + buildFeatures { + aidl = false + renderScript = false + shaders = false + resValues = false + } + } + extensions.configure { + beforeVariants { + it.enableAndroidTest = it.enableAndroidTest + && project.projectDir.resolve("src/androidTest").exists() + } + } + dependencies { + add("implementation", embeddedKotlin("stdlib")) + add("implementation", embeddedKotlin("reflect")) + add("testImplementation", embeddedKotlin("test")) + add("androidTestImplementation", embeddedKotlin("test")) + } + } + } +} diff --git a/build-logic/structure/src/main/kotlin/AndroidLintPlugin.kt b/build-logic/structure/src/main/kotlin/AndroidLintPlugin.kt new file mode 100644 index 0000000..9d9db7f --- /dev/null +++ b/build-logic/structure/src/main/kotlin/AndroidLintPlugin.kt @@ -0,0 +1,27 @@ +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.jlleitschuh.gradle.ktlint.KtlintExtension +import org.jlleitschuh.gradle.ktlint.reporter.ReporterType + +class AndroidLintPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + with(pluginManager) { + apply("org.jlleitschuh.gradle.ktlint") + } + + extensions.configure { + android.set(true) + ignoreFailures.set(true) + debug.set(true) + reporters { + reporter(ReporterType.HTML) + } + filter { + exclude("**/generated/**") + } + } + } + } +} diff --git a/build-logic/structure/src/main/kotlin/AndroidRoomPlugin.kt b/build-logic/structure/src/main/kotlin/AndroidRoomPlugin.kt new file mode 100644 index 0000000..f4d00a2 --- /dev/null +++ b/build-logic/structure/src/main/kotlin/AndroidRoomPlugin.kt @@ -0,0 +1,46 @@ +import com.google.devtools.ksp.gradle.KspExtension +import com.leos.droidify.getLibrary +import com.leos.droidify.libs +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies +import org.gradle.process.CommandLineArgumentProvider +import java.io.File + +class AndroidRoomPlugin : Plugin { + + override fun apply(target: Project) { + with(target) { + pluginManager.apply("com.google.devtools.ksp") + + extensions.configure { + // The schemas directory contains a schema file for each version of the Room database. + // This is required to enable Room auto migrations. + // See https://developer.android.com/reference/kotlin/androidx/room/AutoMigration. + arg(RoomSchemaArgProvider(File(projectDir, "schemas"))) + } + + dependencies { + add("implementation", libs.getLibrary("room.ktx")) + add("implementation", libs.getLibrary("room.runtime")) + add("ksp", libs.getLibrary("room.compiler")) + } + } + } + + /** + * https://issuetracker.google.com/issues/132245929 + * [Export schemas](https://developer.android.com/training/data-storage/room/migrating-db-versions#export-schemas) + */ + class RoomSchemaArgProvider( + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + val schemaDir: File, + ) : CommandLineArgumentProvider { + override fun asArguments() = listOf("room.schemaLocation=${schemaDir.path}") + } +} diff --git a/build-logic/structure/src/main/kotlin/AndroidSerializationPlugin.kt b/build-logic/structure/src/main/kotlin/AndroidSerializationPlugin.kt new file mode 100644 index 0000000..91fb16d --- /dev/null +++ b/build-logic/structure/src/main/kotlin/AndroidSerializationPlugin.kt @@ -0,0 +1,19 @@ +import com.leos.droidify.getLibrary +import com.leos.droidify.libs +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies + +class AndroidSerializationPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + with(pluginManager) { + apply("org.jetbrains.kotlin.plugin.serialization") + } + + dependencies { + add("implementation", libs.getLibrary("kotlinx.serialization.json")) + } + } + } +} diff --git a/build-logic/structure/src/main/kotlin/DefaultConfig.kt b/build-logic/structure/src/main/kotlin/DefaultConfig.kt new file mode 100644 index 0000000..5235411 --- /dev/null +++ b/build-logic/structure/src/main/kotlin/DefaultConfig.kt @@ -0,0 +1,9 @@ +object DefaultConfig { + // Update [release_build.yml] along with this + const val buildTools: String = "34.0.0" + const val appId = "com.leos.droidify" + const val compileSdk = 34 + const val minSdk = 23 + const val versionCode = 666 + const val versionName = "v01.6.4" +} diff --git a/build-logic/structure/src/main/kotlin/Modules.kt b/build-logic/structure/src/main/kotlin/Modules.kt new file mode 100644 index 0000000..41e6ced --- /dev/null +++ b/build-logic/structure/src/main/kotlin/Modules.kt @@ -0,0 +1,21 @@ +import org.gradle.kotlin.dsl.DependencyHandlerScope +import org.gradle.kotlin.dsl.project + +object Modules { + const val app = ":app" + const val coreCommon = ":core:common" + const val coreData = ":core:data" + const val coreDatabase = ":core:database" + const val coreDatastore = ":core:datastore" + const val coreDI = ":core:di" + const val coreDomain = ":core:domain" + const val coreNetwork = ":core:network" + const val installer = ":installer" +} + +fun DependencyHandlerScope.modules(vararg module: String) { + val modules = module.toList() + modules.forEach { + add("implementation", project(it)) + } +} diff --git a/build-logic/structure/src/main/kotlin/com/looker/droidify/KotlinAndroid.kt b/build-logic/structure/src/main/kotlin/com/looker/droidify/KotlinAndroid.kt new file mode 100644 index 0000000..5fe7266 --- /dev/null +++ b/build-logic/structure/src/main/kotlin/com/looker/droidify/KotlinAndroid.kt @@ -0,0 +1,66 @@ +package com.leos.droidify + +import DefaultConfig +import com.android.build.api.dsl.CommonExtension +import org.gradle.api.JavaVersion +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.provideDelegate +import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +// Taken from NIA sample app by Google + +/** + * Configure base Kotlin with Android options + */ +internal fun Project.configureKotlinAndroid( + commonExtension: CommonExtension<*, *, *, *, *>, +) { + commonExtension.apply { + compileSdk = DefaultConfig.compileSdk + + defaultConfig { + minSdk = DefaultConfig.minSdk + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + // Up to Java 11 APIs are available through desugaring + // https://developer.android.com/studio/write/java11-minimal-support-table + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + isCoreLibraryDesugaringEnabled = true + } + } + + configureKotlin() + + dependencies { + add("coreLibraryDesugaring", libs.getLibrary("android.desugarJdkLibs")) + } +} + +/** + * Configure base Kotlin options + */ +private fun Project.configureKotlin() { + // Use withType to workaround https://youtrack.jetbrains.com/issue/KT-55947 + tasks.withType().configureEach { + kotlinOptions { + // Set JVM target to 11 + jvmTarget = JavaVersion.VERSION_11.toString() + // Treat all Kotlin warnings as errors (disabled by default) + // Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties + val warningsAsErrors: String? by project + allWarningsAsErrors = warningsAsErrors.toBoolean() + freeCompilerArgs = freeCompilerArgs + listOf( + "-opt-in=kotlin.RequiresOptIn", + // Enable experimental coroutines APIs, including Flow + "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + "-opt-in=kotlinx.coroutines.FlowPreview", + "-Xcontext-receivers" + ) + } + } +} diff --git a/build-logic/structure/src/main/kotlin/com/looker/droidify/ProjectExtensions.kt b/build-logic/structure/src/main/kotlin/com/looker/droidify/ProjectExtensions.kt new file mode 100644 index 0000000..936b391 --- /dev/null +++ b/build-logic/structure/src/main/kotlin/com/looker/droidify/ProjectExtensions.kt @@ -0,0 +1,18 @@ +package com.leos.droidify + +import org.gradle.api.Project +import org.gradle.api.artifacts.MinimalExternalModuleDependency +import org.gradle.api.artifacts.VersionCatalog +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.api.provider.Provider +import org.gradle.kotlin.dsl.getByType +import org.gradle.plugin.use.PluginDependency + +val Project.libs + get(): VersionCatalog = extensions.getByType().named("libs") + +fun VersionCatalog.getLibrary(alias: String): Provider = + findLibrary(alias).get() + +fun VersionCatalog.getPlugin(alias: String): Provider = + findPlugin(alias).get() diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..eb89d1e --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.ktlint) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.hilt) apply false + alias(libs.plugins.kotlin.serialization) apply false +} diff --git a/core/common/.gitignore b/core/common/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/core/common/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts new file mode 100644 index 0000000..cf32480 --- /dev/null +++ b/core/common/build.gradle.kts @@ -0,0 +1,58 @@ +import com.android.build.gradle.internal.tasks.factory.dependsOn + +plugins { + alias(libs.plugins.looker.android.library) + alias(libs.plugins.looker.lint) +} + +android { + namespace = "com.leos.core.common" + defaultConfig { + vectorDrawables.useSupportLibrary = true + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) + } + create("alpha") { + initWith(getByName("debug")) + isMinifyEnabled = true + } + } + buildFeatures { + buildConfig = true + } +} + +dependencies { + implementation(libs.kotlinx.coroutines.android) + implementation(libs.android.material) + implementation(libs.androidx.activity.ktx) + implementation(libs.androidx.fragment.ktx) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.viewModel.ktx) + implementation(libs.androidx.recyclerview) + implementation(libs.coil.kt) + implementation(libs.jackson.core) +} + +// using a task as a preBuild dependency instead of a function that takes some time insures that it runs +task("detectAndroidLocals") { + val langsList: MutableSet = HashSet() + + // in /res are (almost) all languages that have a translated string is saved. this is safer and saves some time + fileTree("src/main/res").visit { + if (this.file.path.endsWith("strings.xml") && + this.file.canonicalFile.readText().contains(" + if (size >= BYTE_SIZE) { + Pair(size / BYTE_SIZE, index + 1) + } else { + null + } + }.take(sizeFormats.size).last() + return sizeFormats[index].format(Locale.US, size) + } +} diff --git a/core/common/src/main/java/com/looker/core/common/Deeplinks.kt b/core/common/src/main/java/com/looker/core/common/Deeplinks.kt new file mode 100644 index 0000000..33dd203 --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/Deeplinks.kt @@ -0,0 +1,80 @@ +package com.leos.core.common + +import android.content.Intent +import com.leos.core.common.extension.get + +private const val PERSONAL_HOST = "droidify.eu.org" + +private val httpScheme = setOf("http", "https") +private val fdroidRepoScheme = setOf("fdroidrepo", "fdroidrepos") + +private val supportedExternalHosts = setOf( + "f-droid.org", + "www.f-droid.org", + "staging.f-droid.org", + "apt.izzysoft.de" +) + +val Intent.deeplinkType: DeeplinkType? + get() = when { + data?.scheme == "package" || data?.scheme == "fdroid.app" -> { + val packageName = data?.schemeSpecificPart?.nullIfEmpty() + ?: throw InvalidDeeplink("Invalid packageName: $data") + DeeplinkType.AppDetail(packageName) + } + + data?.scheme in fdroidRepoScheme -> { + val repoAddress = + if (data?.scheme.equals("fdroidrepos")) { + dataString!!.replaceFirst("fdroidrepos", "https") + } else if (data?.scheme.equals("fdroidrepo")) { + dataString!!.replaceFirst("fdroidrepo", "https") + } else { + throw InvalidDeeplink("No repo address: $data") + } + DeeplinkType.AddRepository(repoAddress) + } + + data?.scheme == "market" && data?.host == "details" -> { + val packageName = + data["id"]?.nullIfEmpty() ?: throw InvalidDeeplink("Invalid packageName: $data") + DeeplinkType.AppDetail(packageName) + } + + data != null && data?.scheme in httpScheme -> { + when (data?.host) { + PERSONAL_HOST -> { + val repoAddress = data["repo_address"] + if (data?.path == "/app/") { + val packageName = + data["id"] ?: throw InvalidDeeplink("Invalid packageName: $data") + DeeplinkType.AppDetail(packageName, repoAddress) + } else { + throw InvalidDeeplink("Unknown intent path: ${data?.path}, Data: $data") + } + } + + in supportedExternalHosts -> { + val packageName = data?.lastPathSegment?.nullIfEmpty() + ?: throw InvalidDeeplink("Invalid packageName: $data") + DeeplinkType.AppDetail(packageName) + } + + else -> null + } + } + + else -> null + } + +val Intent.getInstallPackageName: String? + get() = if (data?.scheme == "package") data?.schemeSpecificPart?.nullIfEmpty() else null + +class InvalidDeeplink(override val message: String?) : IllegalStateException(message) + +sealed interface DeeplinkType { + + data class AddRepository(val address: String) : DeeplinkType + + data class AppDetail(val packageName: String, val repoAddress: String? = null) : DeeplinkType +} diff --git a/core/common/src/main/java/com/looker/core/common/Exporter.kt b/core/common/src/main/java/com/looker/core/common/Exporter.kt new file mode 100644 index 0000000..5979cc5 --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/Exporter.kt @@ -0,0 +1,10 @@ +package com.leos.core.common + +import android.net.Uri + +interface Exporter { + + suspend fun export(item: T, target: Uri) + + suspend fun import(target: Uri): T +} diff --git a/core/common/src/main/java/com/looker/core/common/PackageName.kt b/core/common/src/main/java/com/looker/core/common/PackageName.kt new file mode 100644 index 0000000..654b373 --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/PackageName.kt @@ -0,0 +1,6 @@ +package com.leos.core.common + +@JvmInline +value class PackageName(val name: String) + +fun String.toPackageName() = PackageName(this) diff --git a/core/common/src/main/java/com/looker/core/common/SdkCheck.kt b/core/common/src/main/java/com/looker/core/common/SdkCheck.kt new file mode 100644 index 0000000..25c825b --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/SdkCheck.kt @@ -0,0 +1,36 @@ +package com.leos.core.common + +import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast + +@ChecksSdkIntAtLeast(parameter = 0, lambda = 1) +inline fun sdkAbove(sdk: Int, onSuccessful: () -> Unit) { + if (Build.VERSION.SDK_INT >= sdk) onSuccessful() +} + +object SdkCheck { + + val sdk: Int + get() = Build.VERSION.SDK_INT + + // Allows auto install if target sdk of apk is one less then current sdk + fun canAutoInstall(targetSdk: Int) = targetSdk >= sdk - 1 && isSnowCake + + @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.TIRAMISU) + val isTiramisu: Boolean = sdk >= Build.VERSION_CODES.TIRAMISU + + @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.R) + val isR: Boolean = sdk >= Build.VERSION_CODES.R + + @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.P) + val isPie: Boolean = sdk >= Build.VERSION_CODES.P + + @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O) + val isOreo: Boolean = sdk >= Build.VERSION_CODES.O + + @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) + val isSnowCake: Boolean = sdk >= Build.VERSION_CODES.S + + @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.N) + val isNougat: Boolean = sdk >= Build.VERSION_CODES.N +} diff --git a/core/common/src/main/java/com/looker/core/common/Singleton.kt b/core/common/src/main/java/com/looker/core/common/Singleton.kt new file mode 100644 index 0000000..9baf44c --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/Singleton.kt @@ -0,0 +1,13 @@ +package com.leos.core.common + +class Singleton { + private var value: T? = null + + /** + * Updates the [value] if its null else it is returned + */ + fun getOrUpdate(block: () -> T): T = value ?: kotlin.run { + value = block() + value!! + } +} diff --git a/core/common/src/main/java/com/looker/core/common/Text.kt b/core/common/src/main/java/com/looker/core/common/Text.kt new file mode 100644 index 0000000..9e4efec --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/Text.kt @@ -0,0 +1,53 @@ +package com.leos.core.common + +import android.util.Log +import java.util.Locale + +fun T.nullIfEmpty(): T? { + return if (isNullOrBlank()) null else this +} + +/** + * Removes the string between the first [prefix] and last [suffix] + * + * For example: if "xyz_abc_123" is passed with [prefix] = "_" + * + * @return: "xyz_123" + */ +fun String.stripBetween(prefix: String, suffix: String = prefix): String { + val prefixIndex = indexOf(prefix) + val suffixIndex = lastIndexOf(suffix) + val isRangeValid = prefixIndex != -1 && + suffixIndex != -1 && + prefixIndex != suffixIndex + return if (isRangeValid) { + substring(0, prefixIndex + 1) + substring(suffixIndex + 1) + } else { + this + } +} + +private val sizeFormats = listOf("%.0f B", "%.0f kB", "%.1f MB", "%.2f GB") + +fun Long.formatSize(): String { + val (size, index) = generateSequence(Pair(this.toFloat(), 0)) { (size, index) -> + if (size >= 1024f) { + Pair(size / 1024f, index + 1) + } else { + null + } + }.take(sizeFormats.size).last() + return sizeFormats[index].format(Locale.US, size) +} + +fun ByteArray.hex(): String = joinToString(separator = "") { byte -> + "%02x".format(Locale.US, byte.toInt() and 0xff) +} + +fun Any.log( + message: Any?, + tag: String = this::class.java.simpleName + ".DEBUG", + type: Int = Log.DEBUG +) { + Log.println(type, tag, message.toString()) +} diff --git a/core/common/src/main/java/com/looker/core/common/cache/Cache.kt b/core/common/src/main/java/com/looker/core/common/cache/Cache.kt new file mode 100644 index 0000000..27c2bb4 --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/cache/Cache.kt @@ -0,0 +1,243 @@ +package com.leos.core.common.cache + +import android.content.ContentProvider +import android.content.ContentValues +import android.content.Context +import android.content.pm.PackageManager +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.os.Build +import android.os.ParcelFileDescriptor +import android.provider.OpenableColumns +import android.system.Os +import com.leos.core.common.SdkCheck +import com.leos.core.common.sdkAbove +import java.io.File +import java.util.UUID +import kotlin.concurrent.thread +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours + +object Cache { + + private const val RELEASE_DIR = "releases" + private const val PARTIAL_DIR = "partial" + private const val IMAGES_DIR = "images" + private const val TEMP_DIR = "temporary" + + private fun ensureCacheDir(context: Context, name: String): File { + return File( + context.cacheDir, + name + ).apply { isDirectory || mkdirs() || throw RuntimeException() } + } + + private fun applyOrMode(file: File, mode: Int) { + val oldMode = Os.stat(file.path).st_mode and 0b111111111111 + val newMode = oldMode or mode + if (newMode != oldMode) { + Os.chmod(file.path, newMode) + } + } + + private fun subPath(dir: File, file: File): String { + val dirPath = "${dir.path}/" + val filePath = file.path + filePath.startsWith(dirPath) || throw RuntimeException() + return filePath.substring(dirPath.length) + } + + fun getImagesDir(context: Context): File { + return ensureCacheDir(context, IMAGES_DIR) + } + + fun getPartialReleaseFile(context: Context, cacheFileName: String): File { + return File(ensureCacheDir(context, PARTIAL_DIR), cacheFileName) + } + + fun getReleaseFile(context: Context, cacheFileName: String): File { + return File(ensureCacheDir(context, RELEASE_DIR), cacheFileName).apply { + sdkAbove(Build.VERSION_CODES.N) { + // Make readable for package installer + val cacheDir = context.cacheDir.parentFile!!.parentFile!! + generateSequence(this) { it.parentFile!! }.takeWhile { it != cacheDir }.forEach { + when { + it.isDirectory -> applyOrMode(it, 0b001001001) + it.isFile -> applyOrMode(it, 0b100100100) + } + } + } + } + } + + fun getReleaseUri(context: Context, cacheFileName: String): Uri { + val file = getReleaseFile(context, cacheFileName) + val packageInfo = + try { + if (SdkCheck.isTiramisu) { + context.packageManager.getPackageInfo( + context.packageName, + PackageManager.PackageInfoFlags.of(PackageManager.GET_PROVIDERS.toLong()) + ) + } else { + @Suppress("DEPRECATION") + context.packageManager.getPackageInfo( + context.packageName, + PackageManager.GET_PROVIDERS + ) + } + } catch (e: Exception) { + null + } + val authority = + packageInfo?.providers?.find { it.name == Provider::class.java.name }!!.authority + return Uri.Builder() + .scheme("content") + .authority(authority) + .encodedPath(file.path.drop(context.cacheDir.path.length)) + .build() + } + + fun getTemporaryFile(context: Context): File { + return File(ensureCacheDir(context, TEMP_DIR), UUID.randomUUID().toString()) + } + + fun cleanup(context: Context) { + thread { + cleanup( + context, + Pair(IMAGES_DIR, Duration.INFINITE), + Pair(PARTIAL_DIR, 24.hours), + Pair(RELEASE_DIR, 24.hours), + Pair(TEMP_DIR, 1.hours) + ) + } + } + + private fun cleanup(context: Context, vararg dirHours: Pair) { + val knownNames = dirHours.asSequence().map { it.first }.toSet() + val files = context.cacheDir.listFiles().orEmpty() + files.asSequence().filter { it.name !in knownNames }.forEach { + if (it.isDirectory) { + cleanupDir(it, Duration.ZERO) + it.delete() + } else { + it.delete() + } + } + dirHours.forEach { (name, duration) -> + val file = File(context.cacheDir, name) + if (file.exists()) { + if (file.isDirectory) { + cleanupDir(file, duration) + } else { + file.delete() + } + } + } + } + + private fun cleanupDir(dir: File, duration: Duration) { + dir.listFiles()?.forEach { + val older = duration <= Duration.ZERO || run { + val olderThan = System.currentTimeMillis() / 1000L - duration.inWholeSeconds + try { + val stat = Os.lstat(it.path) + stat.st_atime < olderThan + } catch (e: Exception) { + false + } + } + if (older) { + if (it.isDirectory) { + cleanupDir(it, duration) + if (it.isDirectory) { + it.delete() + } + } else { + it.delete() + } + } + } + } + + class Provider : ContentProvider() { + companion object { + private val defaultColumns = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE) + } + + private fun getFileAndTypeForUri(uri: Uri): Pair { + return when (uri.pathSegments?.firstOrNull()) { + RELEASE_DIR -> Pair( + File(context!!.cacheDir, uri.encodedPath!!), + "application/vnd.android.package-archive" + ) + + else -> throw SecurityException() + } + } + + override fun onCreate(): Boolean = true + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String? + ): Cursor { + val file = getFileAndTypeForUri(uri).first + val columns = (projection ?: defaultColumns).mapNotNull { + when (it) { + OpenableColumns.DISPLAY_NAME -> Pair(it, file.name) + OpenableColumns.SIZE -> Pair(it, file.length()) + else -> null + } + }.unzip() + return MatrixCursor(columns.first.toTypedArray()).apply { + addRow( + columns.second.toTypedArray() + ) + } + } + + override fun getType(uri: Uri): String = getFileAndTypeForUri(uri).second + + private val unsupported: Nothing + get() = throw UnsupportedOperationException() + + override fun insert(uri: Uri, contentValues: ContentValues?): Uri = unsupported + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = + unsupported + + override fun update( + uri: Uri, + contentValues: ContentValues?, + selection: String?, + selectionArgs: Array? + ): Int = unsupported + + override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? { + val openMode = when (mode) { + "r" -> ParcelFileDescriptor.MODE_READ_ONLY + "w", "wt" -> + ParcelFileDescriptor.MODE_WRITE_ONLY or ParcelFileDescriptor.MODE_CREATE or + ParcelFileDescriptor.MODE_TRUNCATE + + "wa" -> + ParcelFileDescriptor.MODE_WRITE_ONLY or ParcelFileDescriptor.MODE_CREATE or + ParcelFileDescriptor.MODE_APPEND + + "rw" -> ParcelFileDescriptor.MODE_READ_WRITE or ParcelFileDescriptor.MODE_CREATE + "rwt" -> + ParcelFileDescriptor.MODE_READ_WRITE or ParcelFileDescriptor.MODE_CREATE or + ParcelFileDescriptor.MODE_TRUNCATE + + else -> throw IllegalArgumentException() + } + val file = getFileAndTypeForUri(uri).first + return ParcelFileDescriptor.open(file, openMode) + } + } +} diff --git a/core/common/src/main/java/com/looker/core/common/device/Huawei.kt b/core/common/src/main/java/com/looker/core/common/device/Huawei.kt new file mode 100644 index 0000000..3d5affa --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/device/Huawei.kt @@ -0,0 +1,13 @@ +package com.leos.core.common.device + +object Huawei { + val isHuaweiEmui: Boolean + get() { + return try { + Class.forName("com.huawei.android.os.BuildEx") + true + } catch (e: Exception) { + false + } + } +} diff --git a/core/common/src/main/java/com/looker/core/common/device/Miui.kt b/core/common/src/main/java/com/looker/core/common/device/Miui.kt new file mode 100644 index 0000000..74bf758 --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/device/Miui.kt @@ -0,0 +1,38 @@ +package com.leos.core.common.device + +import android.annotation.SuppressLint +import android.util.Log + +object Miui { + val isMiui by lazy { + getSystemProperty("ro.miui.ui.version.name")?.isNotEmpty() ?: false + } + + @SuppressLint("PrivateApi") + fun isMiuiOptimizationDisabled(): Boolean { + val sysProp = getSystemProperty("persist.sys.miui_optimization") + if (sysProp == "0" || sysProp == "false") { + return true + } + + return try { + Class.forName("android.miui.AppOpsUtils") + .getDeclaredMethod("isXOptMode") + .invoke(null) as Boolean + } catch (e: Exception) { + false + } + } + + @SuppressLint("PrivateApi") + private fun getSystemProperty(key: String?): String? { + return try { + Class.forName("android.os.SystemProperties") + .getDeclaredMethod("get", String::class.java) + .invoke(null, key) as String + } catch (e: Exception) { + Log.e("Miui", "Unable to use SystemProperties.get()", e) + null + } + } +} diff --git a/core/common/src/main/java/com/looker/core/common/extension/Collections.kt b/core/common/src/main/java/com/looker/core/common/extension/Collections.kt new file mode 100644 index 0000000..2b929d9 --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/extension/Collections.kt @@ -0,0 +1,16 @@ +package com.leos.core.common.extension + +inline fun Map.updateAsMutable(block: MutableMap.() -> Unit): Map { + return toMutableMap().apply(block) +} + +inline fun Set.updateAsMutable(block: MutableSet.() -> Unit): Set { + return toMutableSet().apply(block) +} + +inline fun MutableSet.addAndCompute(item: T, block: (isAdded: Boolean) -> Unit): Boolean = + add(item).apply { block(this) } + +inline fun List.updateAsMutable(block: MutableList.() -> Unit): List { + return toMutableList().apply(block) +} diff --git a/core/common/src/main/java/com/looker/core/common/extension/Context.kt b/core/common/src/main/java/com/looker/core/common/extension/Context.kt new file mode 100644 index 0000000..808e52b --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/extension/Context.kt @@ -0,0 +1,84 @@ +package com.leos.core.common.extension + +import android.app.NotificationManager +import android.app.job.JobScheduler +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.drawable.Drawable +import android.net.ConnectivityManager +import android.view.inputmethod.InputMethodManager +import androidx.annotation.AttrRes +import androidx.annotation.DrawableRes +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import com.leos.core.common.R + +inline val Context.clipboardManager: ClipboardManager? + get() = getSystemService() + +inline val Context.connectivityManager: ConnectivityManager? + get() = getSystemService() + +inline val Context.inputManager: InputMethodManager? + get() = getSystemService() + +inline val Context.jobScheduler: JobScheduler? + get() = getSystemService() + +inline val Context.notificationManager: NotificationManager? + get() = getSystemService() + +fun Context.copyToClipboard(clip: String) { + clipboardManager?.setPrimaryClip(ClipData.newPlainText(null, clip)) +} + +val Context.corneredBackground: Drawable + get() = getDrawableCompat(R.drawable.background_border) + +val Context.divider: Drawable + get() = getDrawableFromAttr(android.R.attr.listDivider) + +val Context.homeAsUp: Drawable + get() = getDrawableFromAttr(android.R.attr.homeAsUpIndicator) + +val Context.open: Drawable + get() = getDrawableCompat(R.drawable.ic_launch) + +val Context.selectableBackground: Drawable + get() = getDrawableFromAttr(android.R.attr.selectableItemBackground) + +val Context.camera: Drawable + get() = getDrawableCompat(R.drawable.ic_image) + +val Context.aspectRatio: Float + get() = with(resources.displayMetrics) { + (heightPixels / widthPixels).toFloat() + } + +fun Context.getMutatedIcon(@DrawableRes id: Int): Drawable = getDrawableCompat(id).mutate() + +private fun Context.getDrawableFromAttr(attrResId: Int): Drawable { + val typedArray = obtainStyledAttributes(intArrayOf(attrResId)) + val resId = try { + typedArray.getResourceId(0, 0) + } finally { + typedArray.recycle() + } + return getDrawableCompat(resId) +} + +fun Context.getDrawableCompat(@DrawableRes resId: Int = R.drawable.background_border): Drawable = + requireNotNull(AppCompatResources.getDrawable(this, resId)) { "Cannot find drawable, ID: $resId" } + +fun Context.getColorFromAttr(@AttrRes attrResId: Int): ColorStateList { + val typedArray = obtainStyledAttributes(intArrayOf(attrResId)) + val (colorStateList, resId) = try { + Pair(typedArray.getColorStateList(0), typedArray.getResourceId(0, 0)) + } finally { + typedArray.recycle() + } + return colorStateList ?: ContextCompat.getColorStateList(this, resId)!! +} diff --git a/core/common/src/main/java/com/looker/core/common/extension/Cursor.kt b/core/common/src/main/java/com/looker/core/common/extension/Cursor.kt new file mode 100644 index 0000000..4b287c9 --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/extension/Cursor.kt @@ -0,0 +1,11 @@ +package com.leos.core.common.extension + +import android.database.Cursor + +fun Cursor.asSequence(): Sequence { + return generateSequence { if (moveToNext()) this else null } +} + +fun Cursor.firstOrNull(): Cursor? { + return if (moveToFirst()) this else null +} diff --git a/core/common/src/main/java/com/looker/core/common/extension/DateTime.kt b/core/common/src/main/java/com/looker/core/common/extension/DateTime.kt new file mode 100644 index 0000000..61015e0 --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/extension/DateTime.kt @@ -0,0 +1,18 @@ +package com.leos.core.common.extension + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +private object DateTime { + val HTTP_DATE_FORMAT: SimpleDateFormat + get() = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US).apply { + timeZone = TimeZone.getTimeZone("GMT") + } +} + +fun Date.toFormattedString(): String = DateTime.HTTP_DATE_FORMAT.format(this) + +fun String.toDate(): Date = DateTime.HTTP_DATE_FORMAT.parse(this) + ?: throw IllegalStateException("Wrong Date Format") diff --git a/core/common/src/main/java/com/looker/core/common/extension/Exception.kt b/core/common/src/main/java/com/looker/core/common/extension/Exception.kt new file mode 100644 index 0000000..d32677a --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/extension/Exception.kt @@ -0,0 +1,8 @@ +package com.leos.core.common.extension + +import kotlinx.coroutines.CancellationException + +inline fun Exception.exceptCancellation() { + printStackTrace() + if (this is CancellationException) throw this +} diff --git a/core/common/src/main/java/com/looker/core/common/extension/File.kt b/core/common/src/main/java/com/looker/core/common/extension/File.kt new file mode 100644 index 0000000..61be065 --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/extension/File.kt @@ -0,0 +1,23 @@ +package com.leos.core.common.extension + +import java.io.File +import java.io.InputStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext + +val File.size: Long? + get() = if (exists()) length().takeIf { it > 0L } else null + +suspend infix fun InputStream.writeTo(file: File) = withContext(Dispatchers.IO) { + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytesRead = read(buffer) + file.outputStream().use { output -> + while (bytesRead != -1) { + ensureActive() + output.write(buffer, 0, bytesRead) + bytesRead = read(buffer) + } + output.flush() + } +} diff --git a/core/common/src/main/java/com/looker/core/common/extension/Fingerprint.kt b/core/common/src/main/java/com/looker/core/common/extension/Fingerprint.kt new file mode 100644 index 0000000..0d39fb5 --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/extension/Fingerprint.kt @@ -0,0 +1,27 @@ +package com.leos.core.common.extension + +import com.leos.core.common.hex +import java.security.MessageDigest +import java.security.cert.Certificate +import java.security.cert.CertificateEncodingException + +fun Certificate.fingerprint(): String { + val encoded = try { + encoded + } catch (e: CertificateEncodingException) { + null + } + return encoded?.fingerprint().orEmpty() +} + +fun ByteArray.fingerprint(): String = if (size >= 256) { + try { + val fingerprint = MessageDigest.getInstance("sha256").digest(this) + fingerprint.hex() + } catch (e: Exception) { + e.printStackTrace() + "" + } +} else { + "" +} diff --git a/core/common/src/main/java/com/looker/core/common/extension/Flow.kt b/core/common/src/main/java/com/looker/core/common/extension/Flow.kt new file mode 100644 index 0000000..030f372 --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/extension/Flow.kt @@ -0,0 +1,29 @@ +package com.leos.core.common.extension + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.* +import kotlinx.coroutines.flow.* + +context(ViewModel) +fun Flow.asStateFlow( + initialValue: T, + scope: CoroutineScope = viewModelScope, + started: SharingStarted = SharingStarted.WhileSubscribed(5_000) +): StateFlow = stateIn( + scope = scope, + started = started, + initialValue = initialValue +) + +context(CoroutineScope) +@OptIn(ExperimentalCoroutinesApi::class) +fun ReceiveChannel.filter( + block: suspend (T) -> Boolean +): ReceiveChannel = produce(capacity = Channel.UNLIMITED) { + consumeEach { item -> + if (block(item)) send(item) + } +} diff --git a/core/common/src/main/java/com/looker/core/common/extension/Insets.kt b/core/common/src/main/java/com/looker/core/common/extension/Insets.kt new file mode 100644 index 0000000..de4f115 --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/extension/Insets.kt @@ -0,0 +1,83 @@ +package com.leos.core.common.extension + +import android.view.View +import android.view.ViewGroup +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.marginLeft +import androidx.core.view.marginTop +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import androidx.core.widget.NestedScrollView +import androidx.recyclerview.widget.RecyclerView +import com.leos.core.common.SdkCheck +import com.leos.core.common.extension.InsetSides.BOTTOM +import com.leos.core.common.extension.InsetSides.LEFT +import com.leos.core.common.extension.InsetSides.RIGHT +import com.leos.core.common.extension.InsetSides.TOP + +fun View.systemBarsMargin( + persistentPadding: Int, + allowedSides: List = listOf(LEFT, RIGHT, BOTTOM) +) { + if (SdkCheck.isR) { + ViewCompat.setOnApplyWindowInsetsListener(this) { view, windowInsets -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.updateLayoutParams { + if (TOP in allowedSides) topMargin = insets.top + marginTop + if (LEFT in allowedSides) leftMargin = insets.left + marginLeft + if (BOTTOM in allowedSides) bottomMargin = insets.bottom + persistentPadding + if (RIGHT in allowedSides) rightMargin = insets.right + persistentPadding + } + WindowInsetsCompat.CONSUMED + } + } +} + +fun RecyclerView.systemBarsPadding( + allowedSides: List = listOf(LEFT, RIGHT, BOTTOM), + includeFab: Boolean = true +) { + if (SdkCheck.isR) { + ViewCompat.setOnApplyWindowInsetsListener(this) { view, windowInsets -> + clipToPadding = false + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.updatePadding( + if (LEFT in allowedSides) insets.left else 0, + if (TOP in allowedSides) insets.top else 0, + if (RIGHT in allowedSides) insets.right else 0, + if (BOTTOM in allowedSides) { + insets.bottom + if (includeFab) 88.dp else 0 + } else { + 0 + } + ) + WindowInsetsCompat.CONSUMED + } + } +} + +fun NestedScrollView.systemBarsPadding( + allowedSides: List = listOf(LEFT, RIGHT, BOTTOM) +) { + if (SdkCheck.isR) { + ViewCompat.setOnApplyWindowInsetsListener(this) { view, windowInsets -> + clipToPadding = false + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.updatePadding( + if (LEFT in allowedSides) insets.left else 0, + if (TOP in allowedSides) insets.top else 0, + if (RIGHT in allowedSides) insets.right else 0, + if (BOTTOM in allowedSides) insets.bottom else 0 + ) + WindowInsetsCompat.CONSUMED + } + } +} + +enum class InsetSides { + LEFT, + RIGHT, + TOP, + BOTTOM +} diff --git a/core/common/src/main/java/com/looker/core/common/extension/Intent.kt b/core/common/src/main/java/com/looker/core/common/extension/Intent.kt new file mode 100644 index 0000000..06a1d79 --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/extension/Intent.kt @@ -0,0 +1,23 @@ +package com.leos.core.common.extension + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.core.app.TaskStackBuilder +import com.leos.core.common.SdkCheck + +inline val intentFlagCompat + get() = if (SdkCheck.isSnowCake) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + +fun Intent.toPendingIntent(context: Context): PendingIntent? = + TaskStackBuilder + .create(context) + .addNextIntentWithParentStack(this) + .getPendingIntent(0, intentFlagCompat) + +operator fun Uri?.get(key: String): String? = this?.getQueryParameter(key) diff --git a/core/common/src/main/java/com/looker/core/common/extension/JarFile.kt b/core/common/src/main/java/com/looker/core/common/extension/JarFile.kt new file mode 100644 index 0000000..7aa6f59 --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/extension/JarFile.kt @@ -0,0 +1,19 @@ +package com.leos.core.common.extension + +import java.io.File +import java.security.CodeSigner +import java.security.cert.Certificate +import java.util.jar.JarEntry +import java.util.jar.JarFile + +fun File.toJarFile(verify: Boolean = true): JarFile = JarFile(this, verify) + +@get:Throws(IllegalStateException::class) +val JarEntry.codeSigner: CodeSigner + get() = codeSigners?.singleOrNull() + ?: throw IllegalStateException("index.jar must be signed by a single code signer") + +@get:Throws(IllegalStateException::class) +val CodeSigner.certificate: Certificate + get() = signerCertPath?.certificates?.singleOrNull() + ?: throw IllegalStateException("index.jar code signer should have only one certificate") diff --git a/core/common/src/main/java/com/looker/core/common/extension/Json.kt b/core/common/src/main/java/com/looker/core/common/extension/Json.kt new file mode 100644 index 0000000..00773ed --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/extension/Json.kt @@ -0,0 +1,108 @@ +package com.leos.core.common.extension + +import com.fasterxml.jackson.core.JsonFactory +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParseException +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.JsonToken + +object Json { + val factory = JsonFactory() +} + +interface KeyToken { + val key: String + val token: JsonToken + + fun number(key: String): Boolean = this.key == key && this.token.isNumeric + fun string(key: String): Boolean = this.key == key && this.token == JsonToken.VALUE_STRING + fun boolean(key: String): Boolean = this.key == key && this.token.isBoolean + fun dictionary(key: String): Boolean = this.key == key && this.token == JsonToken.START_OBJECT + fun array(key: String): Boolean = this.key == key && this.token == JsonToken.START_ARRAY +} + +fun JsonParser.illegal(): Nothing { + throw JsonParseException(this, "Illegal state") +} + +fun JsonParser.forEachKey(callback: JsonParser.(KeyToken) -> Unit) { + var passKey = "" + var passToken = JsonToken.NOT_AVAILABLE + val keyToken = object : KeyToken { + override val key: String + get() = passKey + override val token: JsonToken + get() = passToken + } + while (true) { + val token = nextToken() + if (token == JsonToken.FIELD_NAME) { + passKey = currentName + passToken = nextToken() + callback(keyToken) + } else if (token == JsonToken.END_OBJECT) { + break + } else { + illegal() + } + } +} + +fun JsonParser.forEach(requiredToken: JsonToken, callback: JsonParser.() -> Unit) { + while (true) { + val token = nextToken() + if (token == JsonToken.END_ARRAY) { + break + } else if (token == requiredToken) { + callback() + } else if (token.isStructStart) { + skipChildren() + } + } +} + +fun JsonParser.collectNotNull( + requiredToken: JsonToken, + callback: JsonParser.() -> T? +): List { + val list = mutableListOf() + forEach(requiredToken) { + val result = callback() + if (result != null) { + list += result + } + } + return list +} + +fun JsonParser.collectNotNullStrings(): List { + return collectNotNull(JsonToken.VALUE_STRING) { valueAsString } +} + +fun JsonParser.collectDistinctNotEmptyStrings(): List { + return collectNotNullStrings().asSequence().filter { it.isNotEmpty() }.distinct().toList() +} + +fun JsonParser.parseDictionary(callback: JsonParser.() -> T): T { + if (nextToken() == JsonToken.START_OBJECT) { + val result = callback() + if (nextToken() != null) { + illegal() + } + return result + } else { + illegal() + } +} + +inline fun JsonGenerator.writeDictionary(callback: JsonGenerator.() -> Unit) { + writeStartObject() + callback() + writeEndObject() +} + +inline fun JsonGenerator.writeArray(fieldName: String, callback: JsonGenerator.() -> Unit) { + writeArrayFieldStart(fieldName) + callback() + writeEndArray() +} diff --git a/core/common/src/main/java/com/looker/core/common/extension/Locale.kt b/core/common/src/main/java/com/looker/core/common/extension/Locale.kt new file mode 100644 index 0000000..7bcce89 --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/extension/Locale.kt @@ -0,0 +1,17 @@ +package com.leos.core.common.extension + +import java.util.Locale + +fun String.toLocale(): Locale = when { + contains("-r") -> Locale( + substring(0, 2), + substring(4) + ) + + contains("_") -> Locale( + substring(0, 2), + substring(3) + ) + + else -> Locale(this) +} diff --git a/core/common/src/main/java/com/looker/core/common/extension/Network.kt b/core/common/src/main/java/com/looker/core/common/extension/Network.kt new file mode 100644 index 0000000..c07dc23 --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/extension/Network.kt @@ -0,0 +1,6 @@ +package com.leos.core.common.extension + +import androidx.core.net.toUri + +val String.isOnion: Boolean + get() = toUri().host?.endsWith(".onion") == true diff --git a/core/common/src/main/java/com/looker/core/common/extension/Number.kt b/core/common/src/main/java/com/looker/core/common/extension/Number.kt new file mode 100644 index 0000000..82cc095 --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/extension/Number.kt @@ -0,0 +1,25 @@ +package com.leos.core.common.extension + +import android.content.res.Resources +import android.util.TypedValue +import android.view.View +import com.leos.core.common.DataSize +import kotlin.math.roundToInt + +infix fun Long.percentBy(denominator: Long?): Int { + if (denominator == null || denominator < 1) return -1 + return (this * 100 / denominator).toInt() +} + +infix fun DataSize.percentBy(denominator: DataSize?): Int = value percentBy denominator?.value + +val Number.px + get() = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + this.toFloat(), + Resources.getSystem().displayMetrics + ) + +context(View) +val Int.dp: Int + get() = (this * resources.displayMetrics.density).roundToInt() diff --git a/core/common/src/main/java/com/looker/core/common/extension/PackageInfo.kt b/core/common/src/main/java/com/looker/core/common/extension/PackageInfo.kt new file mode 100644 index 0000000..288054a --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/extension/PackageInfo.kt @@ -0,0 +1,133 @@ +package com.leos.core.common.extension + +import android.content.Intent +import android.content.pm.* +import com.leos.core.common.SdkCheck +import com.leos.core.common.hex +import java.security.MessageDigest + +val PackageInfo.singleSignature: Signature? + get() = if (SdkCheck.isPie) { + val signingInfo = signingInfo + if (signingInfo?.hasMultipleSigners() == false) { + signingInfo.apkContentsSigners + ?.let { if (it.size == 1) it[0] else null } + } else { + null + } + } else { + @Suppress("DEPRECATION") + signatures?.let { if (it.size == 1) it[0] else null } + } + +fun Signature.calculateHash() = MessageDigest.getInstance("MD5") + .digest(toCharsString().toByteArray()) + .hex() + +@Suppress("DEPRECATION") +val PackageInfo.versionCodeCompat: Long + get() = if (SdkCheck.isPie) longVersionCode else versionCode.toLong() + +fun PackageManager.isSystemApplication(packageName: String): Boolean = try { + ( + ( + this.getApplicationInfoCompat(packageName) + .flags + ) and ApplicationInfo.FLAG_SYSTEM + ) != 0 +} catch (e: Exception) { + false +} + +fun PackageManager.getLauncherActivities(packageName: String): List> { + return queryIntentActivities( + Intent(Intent.ACTION_MAIN).addCategory( + Intent.CATEGORY_LAUNCHER + ), + 0 + ) + .asSequence() + .mapNotNull { resolveInfo -> resolveInfo.activityInfo } + .filter { activityInfo -> activityInfo.packageName == packageName } + .mapNotNull { activityInfo -> + val label = try { + activityInfo.loadLabel(this).toString() + } catch (e: Exception) { + e.printStackTrace() + null + } + label?.let { labelName -> + activityInfo.name to labelName + } + } + .toList() +} + +fun PackageManager.getApplicationInfoCompat( + filePath: String +): ApplicationInfo = if (SdkCheck.isTiramisu) { + getApplicationInfo( + filePath, + PackageManager.ApplicationInfoFlags.of(0L) + ) +} else { + @Suppress("DEPRECATION") + getApplicationInfo(filePath, 0) +} + +@Suppress("DEPRECATION") +private val signaturesFlagCompat: Int + get() = ( + if (SdkCheck.isPie) { + PackageManager.GET_SIGNING_CERTIFICATES + } else { + 0 + } + ) or PackageManager.GET_SIGNATURES + +fun PackageManager.getPackageInfoCompat( + packageName: String, + signatureFlag: Int = signaturesFlagCompat +): PackageInfo? = try { + if (SdkCheck.isTiramisu) { + getPackageInfo( + packageName, + PackageManager.PackageInfoFlags.of(signatureFlag.toLong()) + ) + } else { + @Suppress("DEPRECATION") + getPackageInfo(packageName, signatureFlag) + } +} catch (e: Exception) { + null +} + +fun PackageManager.getPackageArchiveInfoCompat( + filePath: String, + signatureFlag: Int = signaturesFlagCompat +): PackageInfo? = try { + if (SdkCheck.isTiramisu) { + getPackageArchiveInfo( + filePath, + PackageManager.PackageInfoFlags.of(signatureFlag.toLong()) + ) + } else { + @Suppress("DEPRECATION") + getPackageArchiveInfo(filePath, signatureFlag) + } +} catch (e: Exception) { + null +} + +fun PackageManager.getInstalledPackagesCompat( + signatureFlag: Int = signaturesFlagCompat +): List? = try { + if (SdkCheck.isTiramisu) { + getInstalledPackages(PackageManager.PackageInfoFlags.of(signatureFlag.toLong())) + } else { + @Suppress("DEPRECATION") + getInstalledPackages(signatureFlag) + } +} catch (e: Exception) { + null +} diff --git a/core/common/src/main/java/com/looker/core/common/extension/SQLiteDatabase.kt b/core/common/src/main/java/com/looker/core/common/extension/SQLiteDatabase.kt new file mode 100644 index 0000000..006ae97 --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/extension/SQLiteDatabase.kt @@ -0,0 +1,7 @@ +package com.leos.core.common.extension + +import android.database.sqlite.SQLiteDatabase + +fun SQLiteDatabase.execWithResult(sql: String) { + rawQuery(sql, null).use { it.count } +} diff --git a/core/common/src/main/java/com/looker/core/common/extension/Service.kt b/core/common/src/main/java/com/looker/core/common/extension/Service.kt new file mode 100644 index 0000000..1f6b3c9 --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/extension/Service.kt @@ -0,0 +1,30 @@ +package com.leos.core.common.extension + +import android.app.Service +import android.content.Intent +import com.leos.core.common.SdkCheck + +fun Service.startSelf() { + val intent = Intent(this, this::class.java) + if (SdkCheck.isOreo) { + startForegroundService(intent) + } else { + startService(intent) + } +} + +fun Service.stopForegroundCompat(removeNotification: Boolean = true) { + @Suppress("DEPRECATION") + if (SdkCheck.isNougat) { + stopForeground( + if (removeNotification) { + Service.STOP_FOREGROUND_REMOVE + } else { + Service.STOP_FOREGROUND_DETACH + } + ) + } else { + stopForeground(removeNotification) + } + stopSelf() +} diff --git a/core/common/src/main/java/com/looker/core/common/extension/View.kt b/core/common/src/main/java/com/looker/core/common/extension/View.kt new file mode 100644 index 0000000..34f28ff --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/extension/View.kt @@ -0,0 +1,54 @@ +package com.leos.core.common.extension + +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import coil.request.ImageRequest +import kotlin.math.min +import kotlin.math.roundToInt +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.* + +fun ImageRequest.Builder.authentication(base64: String) { + addHeader("Authorization", base64) +} + +fun TextView.setTextSizeScaled(size: Int) { + val realSize = (size * resources.displayMetrics.scaledDensity).roundToInt() + setTextSize(TypedValue.COMPLEX_UNIT_PX, realSize.toFloat()) +} + +fun ViewGroup.inflate(layoutResId: Int): View { + return LayoutInflater.from(context).inflate(layoutResId, this, false) +} + +val RecyclerView.firstItemPosition: Flow + get() = callbackFlow { + val listener = object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + val position = (recyclerView.layoutManager as LinearLayoutManager) + .findFirstVisibleItemPosition() + trySend(position) + } + } + addOnScrollListener(listener) + awaitClose { removeOnScrollListener(listener) } + }.distinctUntilChanged().conflate() + +val RecyclerView.isFirstItemVisible: Flow + get() = firstItemPosition.map { it == 0 }.distinctUntilChanged() + +val View.minDimension: Int + get() = ( + min( + layoutParams.width, + layoutParams.height + ) / resources.displayMetrics.density + ).roundToInt() + +val View.dpi: Int + get() = (context.resources.displayMetrics.densityDpi * minDimension) / 48 diff --git a/core/common/src/main/java/com/looker/core/common/result/Result.kt b/core/common/src/main/java/com/looker/core/common/result/Result.kt new file mode 100644 index 0000000..fc020d2 --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/result/Result.kt @@ -0,0 +1,10 @@ +package com.leos.core.common.result + +sealed interface Result { + data class Success(val data: T) : Result + + data class Error( + val exception: Throwable? = null, + val data: T? = null + ) : Result +} diff --git a/core/common/src/main/java/com/looker/core/common/signature/FileValidator.kt b/core/common/src/main/java/com/looker/core/common/signature/FileValidator.kt new file mode 100644 index 0000000..3623e99 --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/signature/FileValidator.kt @@ -0,0 +1,11 @@ +package com.leos.core.common.signature + +import java.io.File + +interface FileValidator { + + // Throws error if not valid + suspend fun validate(file: File) +} + +class ValidationException(override val message: String) : Exception(message) diff --git a/core/common/src/main/java/com/looker/core/common/signature/HashChecker.kt b/core/common/src/main/java/com/looker/core/common/signature/HashChecker.kt new file mode 100644 index 0000000..d207fe3 --- /dev/null +++ b/core/common/src/main/java/com/looker/core/common/signature/HashChecker.kt @@ -0,0 +1,70 @@ +package com.leos.core.common.signature + +import com.leos.core.common.extension.exceptCancellation +import com.leos.core.common.hex +import java.io.File +import java.security.MessageDigest +import kotlinx.coroutines.* + +suspend fun File.verifyHash(hash: Hash): Boolean { + return try { + if (!hash.isValid() || !exists()) return false + calculateHash(hash.type) + ?.equals(hash.hash, true) + ?: false + } catch (e: Exception) { + e.exceptCancellation() + false + } +} + +suspend fun File.calculateHash(hashType: String): String? { + return try { + if (hashType.isBlank() || !exists()) return null + MessageDigest + .getInstance(hashType) + .readBytesFrom(this) + ?.hex() + } catch (e: Exception) { + e.exceptCancellation() + null + } +} + +private suspend fun MessageDigest.readBytesFrom( + file: File +): ByteArray? = withContext(Dispatchers.IO) { + try { + if (file.length() < DIRECT_READ_LIMIT) return@withContext digest(file.readBytes()) + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + file.inputStream().use { input -> + var bytesRead = input.read(buffer) + while (bytesRead >= 0) { + ensureActive() + update(buffer, 0, bytesRead) + bytesRead = input.read(buffer) + } + digest() + } + } catch (e: Exception) { + e.exceptCancellation() + null + } +} + +// 25 MB +private const val DIRECT_READ_LIMIT = 25 * 1024 * 1024 + +@Suppress("FunctionName") +data class Hash( + val type: String, + val hash: String +) { + + companion object { + fun SHA256(hash: String) = Hash(type = "sha256", hash) + fun MD5(hash: String) = Hash(type = "md5", hash) + } + + fun isValid(): Boolean = type.isNotBlank() && hash.isNotBlank() +} diff --git a/core/common/src/main/res/color/favourite_icon_color.xml b/core/common/src/main/res/color/favourite_icon_color.xml new file mode 100644 index 0000000..8c3e81a --- /dev/null +++ b/core/common/src/main/res/color/favourite_icon_color.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/core/common/src/main/res/color/switch_thumb_tint.xml b/core/common/src/main/res/color/switch_thumb_tint.xml new file mode 100644 index 0000000..2ba4d62 --- /dev/null +++ b/core/common/src/main/res/color/switch_thumb_tint.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/core/common/src/main/res/color/switch_track_tint.xml b/core/common/src/main/res/color/switch_track_tint.xml new file mode 100644 index 0000000..a089985 --- /dev/null +++ b/core/common/src/main/res/color/switch_track_tint.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/core/common/src/main/res/drawable/arrow_up.xml b/core/common/src/main/res/drawable/arrow_up.xml new file mode 100644 index 0000000..76f4227 --- /dev/null +++ b/core/common/src/main/res/drawable/arrow_up.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/drawable/background_border.xml b/core/common/src/main/res/drawable/background_border.xml new file mode 100644 index 0000000..a8856e9 --- /dev/null +++ b/core/common/src/main/res/drawable/background_border.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/core/common/src/main/res/drawable/ic_add.xml b/core/common/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000..28e44fc --- /dev/null +++ b/core/common/src/main/res/drawable/ic_add.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/core/common/src/main/res/drawable/ic_apk_install.xml b/core/common/src/main/res/drawable/ic_apk_install.xml new file mode 100644 index 0000000..2bffe3f --- /dev/null +++ b/core/common/src/main/res/drawable/ic_apk_install.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_arrow_down.xml b/core/common/src/main/res/drawable/ic_arrow_down.xml new file mode 100644 index 0000000..1992fdb --- /dev/null +++ b/core/common/src/main/res/drawable/ic_arrow_down.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/core/common/src/main/res/drawable/ic_bug_report.xml b/core/common/src/main/res/drawable/ic_bug_report.xml new file mode 100644 index 0000000..b584779 --- /dev/null +++ b/core/common/src/main/res/drawable/ic_bug_report.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_cancel.xml b/core/common/src/main/res/drawable/ic_cancel.xml new file mode 100644 index 0000000..1307bb2 --- /dev/null +++ b/core/common/src/main/res/drawable/ic_cancel.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_cannot_load.xml b/core/common/src/main/res/drawable/ic_cannot_load.xml new file mode 100644 index 0000000..62027e7 --- /dev/null +++ b/core/common/src/main/res/drawable/ic_cannot_load.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/core/common/src/main/res/drawable/ic_check.xml b/core/common/src/main/res/drawable/ic_check.xml new file mode 100644 index 0000000..7f3b4e0 --- /dev/null +++ b/core/common/src/main/res/drawable/ic_check.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_code.xml b/core/common/src/main/res/drawable/ic_code.xml new file mode 100644 index 0000000..a322be7 --- /dev/null +++ b/core/common/src/main/res/drawable/ic_code.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_copyright.xml b/core/common/src/main/res/drawable/ic_copyright.xml new file mode 100644 index 0000000..060299f --- /dev/null +++ b/core/common/src/main/res/drawable/ic_copyright.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_delete.xml b/core/common/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000..d8a5a00 --- /dev/null +++ b/core/common/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_donate.xml b/core/common/src/main/res/drawable/ic_donate.xml new file mode 100644 index 0000000..aadb09d --- /dev/null +++ b/core/common/src/main/res/drawable/ic_donate.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_donate_bitcoin.xml b/core/common/src/main/res/drawable/ic_donate_bitcoin.xml new file mode 100644 index 0000000..08f3181 --- /dev/null +++ b/core/common/src/main/res/drawable/ic_donate_bitcoin.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_donate_flattr.xml b/core/common/src/main/res/drawable/ic_donate_flattr.xml new file mode 100644 index 0000000..62de4a6 --- /dev/null +++ b/core/common/src/main/res/drawable/ic_donate_flattr.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/core/common/src/main/res/drawable/ic_donate_liberapay.xml b/core/common/src/main/res/drawable/ic_donate_liberapay.xml new file mode 100644 index 0000000..eeeaa6d --- /dev/null +++ b/core/common/src/main/res/drawable/ic_donate_liberapay.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/core/common/src/main/res/drawable/ic_donate_litecoin.xml b/core/common/src/main/res/drawable/ic_donate_litecoin.xml new file mode 100644 index 0000000..baae98c --- /dev/null +++ b/core/common/src/main/res/drawable/ic_donate_litecoin.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/core/common/src/main/res/drawable/ic_donate_opencollective.xml b/core/common/src/main/res/drawable/ic_donate_opencollective.xml new file mode 100644 index 0000000..29411bb --- /dev/null +++ b/core/common/src/main/res/drawable/ic_donate_opencollective.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/core/common/src/main/res/drawable/ic_download.xml b/core/common/src/main/res/drawable/ic_download.xml new file mode 100644 index 0000000..da9de2d --- /dev/null +++ b/core/common/src/main/res/drawable/ic_download.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_email.xml b/core/common/src/main/res/drawable/ic_email.xml new file mode 100644 index 0000000..6d82495 --- /dev/null +++ b/core/common/src/main/res/drawable/ic_email.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_favourite.xml b/core/common/src/main/res/drawable/ic_favourite.xml new file mode 100644 index 0000000..6d49705 --- /dev/null +++ b/core/common/src/main/res/drawable/ic_favourite.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_favourite_checked.xml b/core/common/src/main/res/drawable/ic_favourite_checked.xml new file mode 100644 index 0000000..68b0857 --- /dev/null +++ b/core/common/src/main/res/drawable/ic_favourite_checked.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_gitlab.xml b/core/common/src/main/res/drawable/ic_gitlab.xml new file mode 100644 index 0000000..a4089ea --- /dev/null +++ b/core/common/src/main/res/drawable/ic_gitlab.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_history.xml b/core/common/src/main/res/drawable/ic_history.xml new file mode 100644 index 0000000..025ffd8 --- /dev/null +++ b/core/common/src/main/res/drawable/ic_history.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_image.xml b/core/common/src/main/res/drawable/ic_image.xml new file mode 100644 index 0000000..ded47ba --- /dev/null +++ b/core/common/src/main/res/drawable/ic_image.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_kde.xml b/core/common/src/main/res/drawable/ic_kde.xml new file mode 100644 index 0000000..ffd419f --- /dev/null +++ b/core/common/src/main/res/drawable/ic_kde.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_language.xml b/core/common/src/main/res/drawable/ic_language.xml new file mode 100644 index 0000000..02a15bb --- /dev/null +++ b/core/common/src/main/res/drawable/ic_language.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_launch.xml b/core/common/src/main/res/drawable/ic_launch.xml new file mode 100644 index 0000000..b6c297f --- /dev/null +++ b/core/common/src/main/res/drawable/ic_launch.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_new_releases.xml b/core/common/src/main/res/drawable/ic_new_releases.xml new file mode 100644 index 0000000..1506f84 --- /dev/null +++ b/core/common/src/main/res/drawable/ic_new_releases.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/core/common/src/main/res/drawable/ic_perm_device_information.xml b/core/common/src/main/res/drawable/ic_perm_device_information.xml new file mode 100644 index 0000000..8d1a0a0 --- /dev/null +++ b/core/common/src/main/res/drawable/ic_perm_device_information.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_person.xml b/core/common/src/main/res/drawable/ic_person.xml new file mode 100644 index 0000000..cb043c9 --- /dev/null +++ b/core/common/src/main/res/drawable/ic_person.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_proxy.xml b/core/common/src/main/res/drawable/ic_proxy.xml new file mode 100644 index 0000000..9a89c87 --- /dev/null +++ b/core/common/src/main/res/drawable/ic_proxy.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_public.xml b/core/common/src/main/res/drawable/ic_public.xml new file mode 100644 index 0000000..e58467d --- /dev/null +++ b/core/common/src/main/res/drawable/ic_public.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_save.xml b/core/common/src/main/res/drawable/ic_save.xml new file mode 100644 index 0000000..51670ff --- /dev/null +++ b/core/common/src/main/res/drawable/ic_save.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_search.xml b/core/common/src/main/res/drawable/ic_search.xml new file mode 100644 index 0000000..dcb3083 --- /dev/null +++ b/core/common/src/main/res/drawable/ic_search.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_share.xml b/core/common/src/main/res/drawable/ic_share.xml new file mode 100644 index 0000000..10efc62 --- /dev/null +++ b/core/common/src/main/res/drawable/ic_share.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_sort.xml b/core/common/src/main/res/drawable/ic_sort.xml new file mode 100644 index 0000000..077f1d9 --- /dev/null +++ b/core/common/src/main/res/drawable/ic_sort.xml @@ -0,0 +1,11 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_source_code.xml b/core/common/src/main/res/drawable/ic_source_code.xml new file mode 100644 index 0000000..d721608 --- /dev/null +++ b/core/common/src/main/res/drawable/ic_source_code.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/core/common/src/main/res/drawable/ic_sync.xml b/core/common/src/main/res/drawable/ic_sync.xml new file mode 100644 index 0000000..c4f554e --- /dev/null +++ b/core/common/src/main/res/drawable/ic_sync.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_sync_type.xml b/core/common/src/main/res/drawable/ic_sync_type.xml new file mode 100644 index 0000000..95d0933 --- /dev/null +++ b/core/common/src/main/res/drawable/ic_sync_type.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_themes.xml b/core/common/src/main/res/drawable/ic_themes.xml new file mode 100644 index 0000000..60c05a2 --- /dev/null +++ b/core/common/src/main/res/drawable/ic_themes.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_time.xml b/core/common/src/main/res/drawable/ic_time.xml new file mode 100644 index 0000000..1c918c3 --- /dev/null +++ b/core/common/src/main/res/drawable/ic_time.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/drawable/ic_tune.xml b/core/common/src/main/res/drawable/ic_tune.xml new file mode 100644 index 0000000..3672e27 --- /dev/null +++ b/core/common/src/main/res/drawable/ic_tune.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/common/src/main/res/values-ar/strings.xml b/core/common/src/main/res/values-ar/strings.xml new file mode 100644 index 0000000..64d0842 --- /dev/null +++ b/core/common/src/main/res/values-ar/strings.xml @@ -0,0 +1,246 @@ + + + العنوان + تعذر تنزيل %s + الإشادات + الوصف + الحد الأدنى لإصدار واجهة برمجة التطبيقات هو %d. + فاتح + تحريكات القائمة + حسنًا + ليس موقعًا. تعذر التحقق من قائمة التطبيقات. كن حذرًا عند تنزيل التطبيقات من مستودعات غير موقعة. + زامن المستودعات + قيد المزامنة + غير معروف: %s + لم يُوقع + تحديثات غير مستقرة + اعرض أقل + الأحدث + استكشف + فشل الإجراء + أضف مستودعًا + كل التطبيقات + كل التطبيقات مُحدثة + متوفر بالفعل + دائمًا + أسود + مميزات غير مرغوبة + التطبيق + لم يُعثر على ذلك التطبيق + بريد المطور + صفحة المطور + استكشف + لا يمكن تحرير المستودع لأنه قيد المزامنة. + سجل التغييرات + متعقب العلل + ألغِ + التغييرات + جارٍ التحقق من المستودع… + جُمّع من أجل التصحيح + تأكيد + قيد التوصيل… + يحتوي على وسائط غير حرة + تعذرت مزامنة %s + تعذر التحقق من %s + داكن + احذف + حذف المستودع؟ + التفاصيل + التبرع + تم تنزيل %s + قيد التنزيل + قيد تنزيل %s… + حرر المستودع + صيغة الملف غير صالحة. + البصمة + يحتوي على إعلانات + يحتوي على تبعيات غير حرة + يحتوي على ثغرات أمنية + استجابة الخادم غير صحيحة. + تجاهل جميع الإصدارات الجديدة + تجاهل هذا الإصدار + %1$s (إصدار واجهة برمجة التطبيقات %2$d) غير مدعوم. %3$s + الحد الأقصى لإصدار واجهة برمجة التطبيقات هو %d. + مميزات مفقودة. + هذا الإصدار أقدم من ذلك المثبت على جهازك، ألغِ تثبيته أولًا. + وكيل HTTP + منصتك %1$s ليست مدعومة. المنصات المدعومة: %2$s. + هذا الإصدار مُوقَّع بشهادة مختلفة عن تلك المثبَّتة على جهازك. ألغِ تثبيت تلك أولًا. + إصدار غير متوافق + إصدارات غير متوافقة + أظهر إصدارات التطبيقات الغير متوافقة مع الجهاز + غير متوافق مع %s + ثبِّت + طرق التثبيت + المثبِّت + المثبِّت القديم + مثبِّت الجلسة + مثبِّت الجذر + مثبِّت شيزوكو + مثبَّت + تعذر التحقق من التكامل. + عنوان خاطئ + صيغة البصمة خاطئة + بيانات وصفية خاطئة. + صلاحيات خاطئة. + توقيع خاطئ. + صيغة اسم المستخدم خاطئة + شغل + الرخصة + رخصة %s + نُسِخ الرابط + الروابط + أظهر تحريكات القائمة على الصفحة الرئيسة + قيد دمج %s + الاسم + خطأ في الشبكة + أبدًا + توجد إصدارات جديدة + + ليس لدى أيِّ تطبيق إصدار جديد. + لدى تطبيق إصدار جديد. + لدى تطبيقين إصداران جديدان. + لدى %d تطبيقات إصدارات جديدة. + لدى %d تطبيق إصدارات جديدة. + لدى %d تطبيق إصدارات جديدة. + + لا توجد تطبيقات متاحة + لا توجد تطبيقات مثبَّتة + لا يوجد وصف متاح + لم يُعثر على تطبيق كهذا + بدون وسيط + نبِّه بوجود التحديثات + اعرض تنبيهًا عند توفر إصدارات جديدة + عدد التطبيقات + متوافق فقط مع %s + فقط عبر شبكة واي-فاي + افتح %s ؟ + آخر + تعذر تحليل ملف الفهرس. + كلمة المرور + كلمة المرور مفقودة + الصلاحيات + +%d أخرى + الإعدادات + قيد معالجة %1$s… + موقع المشروع + يروج لخدمات شبكة غير حرة + يروج لبرمجيات غير حرة + مقدم بواسطة %s + وسيط + مضيف الوسيط + منفذ الوسيط + نوع الوسيط + حُدث مؤخرًا + يتطلب %s + المستودعات + المستودع + هذا المستودع لم يُستخدم بعد. مكنه لعرض التطبيقات التي يحتويها. + تثبيت صامت + اسمح لصلاحية الجذر لتمكين التثبيت الصامت + احفظ + قيد حفظ التفاصيل… + لقطات الشاشة + ابحث + اختر مرآة + شارك + اعرض المزيد + اعرض إصدارات أقدم + التوقيع %s + مُوَقع باستخدام خوارزمية غير آمنة + الحجم + تخطَّ + وسيط سوكس + ترتيب الفرز + الكود المصدري + الكود المصدري لم يعد متوفرًا + مقترح + زامن المستودعات تلقائيًا + قيد مزامنة %s… + النظام + اضغط للتثبيت. + الهدف + السمة + السمات + يتعقب أو يرفق نشاطك + ألغِ التثبيت + لم يُتحقق منه + حدث + التحديثات + غير معروف + خطأ غير معروف. + اقترح تثبيت التطبيقات غير المستقرة + الكود المصدري ليس حرًا + اسم المستخدم + اسم المستخدم مفقود + تعذر التحقق من صحة الفهرس. + الإصدار + الإصدار %s + الإصدارات + قيد انتظار بدء التنزيل… + ما الجديد + الموقع الإلكتروني + اللغة + التفضيلات + حدث الكل + التطبيقات المثبَّتة + افرز وصفِّ + التطبيقات جديدة + الزمن المار قبل فحص وإزالة الملفات المنزَّلة + مدة تنظيف ملفات APK + + أقل من يوم + يوم + يومان + أيام + يومًا + يوم + + + أقل من ساعة + ساعة + ساعتان + ساعات + ساعةً + ساعة + + فقط على شبكة واي-فاي وعند الشحن + تعذر أداء بعض الإجراءات. + ليس لديك اتصال بالإنترنت + اسمح بتوسيع شريط التطبيقات العلوي + اسمح لشريط التطبيقات العلوي بالتوسع والطي + مَتيريَل يو + استخدم سمة ألوان مَتيريَل يو + المفضَّلات + تعذَّر الوصول للمستودع + افرض التنظيف + ينظِّف الملفَّات المتكرِّرة + مكِّن المستودع + أعد تشغيل LeOS-Droid لرؤية التغييرات + يثبّت + في انتظار بدء التثبيت… + حدِّث التطبيقات تلقائيًّا + حاول تثبيت التحديثات تلقائيًّا + يحتوي على مكونات غير حرة + فشل الخادوم في توفير حزمة جديدة. + لا يمكن الاتصال بالخادم + يحتوي على محتوى غير آمن للعمل + شيزوكو لا يعمل + شيزوكو غير مثبت + شكر خاص + ايماءات الشاشة الرئيسة + اسمح للمستخدم بالتمرير بين الصفحات في الشاشة الرئيسة + انسخ + يجب أن يكون منفذ الوكيل رقمًا صحيحًا + لم يُعثَر على المستودع + استورد إعدادات + استورد\\صدِّر + استورد الإعدادات والمفضلات من ملف + صدِّر الإعدادات + صدِّر كل المستودعات في هيئة ملف + استورد مستودعات + صدِّر الإعدادات والمفضلات في هيئة ملف + صدِّر المستودعات + استورد كل المستودعات التي في ملف + تعذَّر فتح الرابط + \ No newline at end of file diff --git a/core/common/src/main/res/values-az/strings.xml b/core/common/src/main/res/values-az/strings.xml new file mode 100644 index 0000000..1a6040a --- /dev/null +++ b/core/common/src/main/res/values-az/strings.xml @@ -0,0 +1,212 @@ + + + Yeni qaynaq əlavə et + Ünvan + Bütün applikasiyalar + Üst çubuğun genişlədilməsinə izn ver + Üst çubuğun genişləndirilməsinə və sıxılmasına izn ver + Zatən mövcuddur + Hər zaman + Anti-özəlliklər + Applikasiya + Bu applikasiya tapılmadı + Yazarın e-mail ünvanı + Yazarıb vebsaytı + Applikasiyaları avtomatik güncəllə + Kəşf et + Xəta izləmə + Ləğv et + Qaynaq hal-hazırda sinxronizasiya edildiyi üçün düzənlənmir. + Dəyişim günlüyü + Dəyişikliklər + APK cleanup interval + Period to check and remove downloaded files + Xəta tapmaq üçün kompayl edildi + Təsdiq + Bağlanılır… + %s yüklənmədi + %s sinxronizasiya edilə bilmədi + %s doğrulanmadı + Qatqı Edənlər + Tünd + + Gün + Günlər + + Sil + Qaynaq silinsinmi\? + Açıqlama + Detallar + Yardım + Yüklənir + %s yüklənir… + Qaynağı düzənlə + Qaynağı aktivləşdir + Barmaq izi + Təmizlənməyə məcbur et + Lazımsız faylları təmizləyir + Tərkibində reklam var + Azad olmayan qaynaqları var + Güvənlik açığına sahibdir + HTTP proxy + Bütün yeni versiyaları yox say + Bu versiyanı yox say + Sizin %1$s (API versiyası %2$d) dəstəkləmir. %3$s + Minimum API versiyası %d. + Özəlliklər çatışmır. + Bu versiya telefonunuzda olan versiyadan daha köhnədir. Öncə onu silin. + Bu versiya cihazınızda mövcud olan versiyadan fərqli bir sertifikat ilə imzalanmışdır. Öncə onu ləğv edin. + Uyğunsuz versiya + Uyğunsuz versiyalar + Cihazla uyğun olmayan applikasiya versiyalarını göstər + %s ilə uyğunsuzdur + Quraşdırılma Növləri + Quraşdırıcı + Hesab Quraşdırıcısı + Shizuku Quraşdırıcısı + Quraşdırılıb + Quraşdırılır + Bütünlük yoxlanılmadı. + Uyğunsuz ünvan + Uyğunsuz meta bilgisi. + Uyğunsuz icazələr. + Uyğunsuz imza. + Uyğunsuz istifadəçi adı formatı + Bəlirli hadisələr gerçəkləşdirilə bilmədi. + Başlat + Lisenziya + %s lisenziyası + Açıq + Linklər + Siyahı Animasiyaları + Siyahı canlandırmasını ana səhifədə göstər + Material You + Material you rəng temasını istifadə et + %s birləşdirilir + Ad + İnternet xətası + Heç vaxt + + %d applikasiyanın yeni bir versiyası var. + %d applikasiyanın yeni versiyaları var. + + İstifadə edilə bilən applikasiya yoxdur + Açıqlama yoxdur + İnternet bağlantınız yoxdur + Proxy yoxdur + Applikasiyanın yeni versiyaları haqqında bilgiləndir + Applikasiya sayı + Tamam + Sadəcə %s ilə uyğundur + Sadəcə Wi-Fi + Sadəcə Wi-Fi açıq ikən & Batareya Doldurularkən + %s açılsınmı\? + Digər + İndex faylı çözümlənə bilmədi. + Şifrə + İcazələr + +%d daha + %1$s işlənir… + Hadisə uğursuz oldu + Qara + Bütün applikasiyalarınız güncəldir + Güncəlləmələri avtomatik etməyə çalış + Qaynaq yoxlanılır… + Tərkibində azad olmayan media var + %s yükləndi + Sevimlilər + Uyğunsuz fayl formatı. + + Saat + Saatlar + + Uyğunsuz server cavabı. + Maksimum API versiyası %d. + Sizin %1$s cihazınız dəstəklənməməkdədir. Dəstəklənən cihazlar: %2$s. + Quraşdır + Köhnə Quraşdırıcı + Root Quraşdırıcısı + Uyğunsuz barmaq izi forması + Applikasiyanın yeni versiyaları var + Link kopyalandı + Quraşdırılmış applikasiya yoxdur + Belə bir applikasiya tapılmadı + Layihə vebsaytı + Yeni versiyalar istifadə edilə bilən olduğu zaman bildiriş göstər + Şifrə əksikdir + Pulsuz olmayan şəbəkə xidmətlərini təşviq edir + Azad olmayan proqram təminatını təbliğ edir + %s tərəfindən təmin edilmişdir + Proksi + Proksi host + Proksi port + Proksi növü + Bu yaxınlarda yeniləndi + Anbarlar + Repozitoriya + Bu anbardan hələ istifadə olunmayıb. İçindəki proqramlara baxmaq üçün onu yandırın. + İmzasız. Tətbiq siyahısını yoxlamaq mümkün olmadı. İmzasız depolardan proqramları endirərkən diqqətli olun. + Repozitoriya əlçatmazdır + %s tələb edir + Dəyişiklikləri görmək üçün LeOS-Droid-ı yenidən başladın + Səssiz Quraşdırma + Səssiz quraşdırmalar üçün kök icazəsinə icazə verin + Yadda saxla + Detallar yadda saxlanılır… + Ekran görüntüləri + Axtar + Güzgü seçin + Parametrlər + Paylaşın + Daha çox göstər + Köhnə versiyaları göstərin + İmza %s + Təhlükəsiz alqoritmdən istifadə edərək imzalanıb + Ölçü + Keç + SOCKS proksi + Çeşidləmə qaydası + Mənbə kodu + Mənbə kodu artıq mövcud deyil + Təklif olunur + Repozitoriyaları sinxronlaşdırın + Sinxronizasiya + %s sinxronizasiya edilir… + Sistem + Hədəf + Mövzu + Mövzular + Fəaliyyətinizi izləyir və ya hesabat verir + Naməlum + Naməlum xəta. + Naməlum: %s + İmzasız + Qeyri-sabit yeniləmələr + Qeyri-sabit versiyaların quraşdırılmasını təklif edin + Doğrulanmamış + Yeniləyin + Yeniləmələr + İstifadəçi adı + İstifadəçi adı yoxdur + Versiya + Versiya %s + Versiyalar + Endirməyə başlamaq gözlənilir… + Quraşdırmanın başlaması gözlənilir… + Yeniliklər + Veb sayt + Dil + Fərdiləşdirmə + Ən son + Araşdırın + Hamısını yeniləyin + Quraşdırılmış proqramlar + Çeşidləyin və Filtr edin + Yeni tətbiqlər + Daha az göstər + Repozitoriyaları avtomatik sinxronlaşdırın + Silin + Yuxarı mənbə kodu pulsuz deyil + İndeksi doğrulamaq mümkün olmadı. + Quraşdırmaq üçün toxunun. + \ No newline at end of file diff --git a/core/common/src/main/res/values-be/strings.xml b/core/common/src/main/res/values-be/strings.xml new file mode 100644 index 0000000..312f2f5 --- /dev/null +++ b/core/common/src/main/res/values-be/strings.xml @@ -0,0 +1,240 @@ + + + Не атрымалася выканаць дзеянне + Дазволіць верхняй панэлі праграм пашырацца + Не ўдалося знайсці гэтую праграму + Сайт аўтара + Аўтаабнаўленне праграм + Дадаць рэпазітар + Адрас + Усе праграмы + Дазволіць верхнюю панэль праграмы пашыраць і згортваць + Ужо існуе + Заўсёды + Чорная + Антыфункцыі + Праграма + Электронная пошта аўтара + Спрабаваць усталяваць абнаўленні аўтаматычна + Агляд + Адсочванне памылак + Адмяніць + Усе вашыя праграмы абноўлены + Выдаліць + Перыяд для праверкі і выдалення спампаваных файлаў + Апісанне + Дэталі + Спампоўка %s… + Рэдагаваць сховішча + Змены + Інтэрвал ачысткі APK + Праверка сховішча… + Не атрымалася сінхранізаваць %s + + Дзень + дзён + дзён + дзён + + Цёмны + Ахвяраваць + Спампавана %s + Ідзе загрузка + Выбранае + Адбітак пальца + Уключыць рэпазітар + Прымусовая ачыстка + Ачышчае лішнія файлы + Мае рэкламу + Немагчыма рэдагаваць сховішча, бо яно зараз сінхранізуецца. + Журнал змяненняў + Скампілявана для адладкі + Пацверджанне + Падключэнне… + Змяшчае несвабодныя носьбіты + Немагчыма спампаваць %s + Немагчыма праверыць %s + Крэдыты + Выдаліць сховішча\? + Няправільны фармат файла. + Мае несвабодныя залежнасці + Мае ўразлівасці ў бяспецы + Ліцэнзія %s + Несумяшчальная версія + Гэтая версія падпісана іншым сертыфікатам, чым той, які ўсталяваны на вашай прыладзе. Спачатку выдаліце ўсталяваную версію. + Гэтая версія старэйшая за ўсталяваную на вашай прыладзе. Спачатку выдаліце старую версію. + Root ўсталёўшчык + Усталёўшчык + Няправільныя метадзеныя. + Толькі па Wi-Fi + Пароль + Без подпісу. Не ўдалося праверыць спіс прыкладанняў. Будзьце асцярожныя, загружаючы прыкладанні з непадпісаных рэпазітароў. + Мае несвабодныя кампаненты + Рэкламуе несвабодныя сеткавыя сэрвісы + Няправільны подпіс. + Няма ўсталяваных прыкладанняў + Некарэктны адказ сервера. + Паказваць апавяшчэнне, калі з\'явяцца новыя версіі + Правы доступу + Даступныя новыя версіі прыкладанняў + Ўсталяванне + Ваша платформа %1$s не падтрымліваецца. Падтрымліваюцца платформы: %2$s. + Рэпазітар недасяжны + Перазапусціце LeOS-Droid, каб убачыць змены + Адсутныя функцыі. + Няма даступных прыкладанняў + Пароль адсутнічае + Усталявана + Імя + Немагчыма падключыцца да сервера + Порт проксі + Патрабуецца %s + Сумяшчальна толькі з %s + Серверу не ўдалося адправіць новы пакет. + Добра + Немагчыма выканаць пэўныя дзеянні. + Іншае + Спасылка скапіявана + Апрацоўваецца %1$s… + Капіяваць + У вас няма падключэння да інтэрнэту + Порт проксі можа быць толькі цэлым лікам + Колькасць прыкладанняў + Несумяшчальна з %s + Памылка сеткі + Ліцэнзія + Ігнараваць гэтую версію + HTTP проксі + Апісанне адсутнічае + Спасылкі + Адключаныя правы доступу. + Рэкламуе несвабоднае праграмнае забеспячэнне + Запусціць + Віды ўстаноўкі + Састарэлы ўсталёўшчык + + %d прыкладанне мае новую версію. + %d прыкладання маюць новую версію. + %d прыкладанняў маюць новую версію. + %d прыкладанняў маюць новую версію. + + Тып проксі + Сайт праекта + Ніколі + Не ўдалося знайсці такіх прыкладанняў + Мінімальная версія API %d. + Няправільны адрас + Несумяшчальныя версіі + Максімальная версія API %d. + Не ўдалося разабраць індэксны файл. + Усталяваць + Наступны рэпазіторый не знойдзены + Няправільны фармат імя карыстальніка + Паведамляць аб абнаўленнях + Прадастаўлена %s + Паказаць версіі прыкладанняў, несумяшчальныя з прыладай + Хост проксі + Ігнараваць усе новыя версіі + Не атрымалася праверыць цэласнасць. + Проксі + Рэпазіторыі + Абнаўленні + Памер + Сесійны ўсталёўшчык + + Гадзіна + Гадзіны + Гадзін + Гадзін + + Зыходны код больш не даступны + Захаваць + Аўтасінхранізацыя рэпазітараў + Абнавіць усё + Жэсты на галоўным экране + Выкарыстоўваць каляровую тэму Material You + Толькі пры Wi-Fi і зарадцы + Сінхранізацыя рэпазітараў + +%d больш + Налады імпарту + Ваш %1$s (версія API %2$d) не падтрымліваецца. %3$s + Без подпісу + Змяшчае NSFW кантэнт + Выберыце люстэрка + Паказаць анімацыю спісу на галоўнай старонцы + Сінхранізацыя %s… + Імпарт/Экспарт + Адсочвае або паведамляе аб вашай дзейнасці + Вэб-сайт + Аб\'яднанне %s + Чаканне спампоўкі… + Агляд + Рэкамендуецца + Сартаваць і фільтраваць + Адкрыць %s? + Не атрымалася праверыць індэкс. + Shizuku не працуе + Невядомы + Імя карыстальніка адсутнічае + Абнавіць + Паказаць больш + Тэма + SOCKS проксі + Material You + Мэтавы + Чаканне пачатку ўстаноўкі… + Падпісана з выкарыстаннем небяспечнага алгарытму + Зыходны код + Імя карыстальніка + Версія %s + Светлая + Усталяваныя праграмы + Імпарт налад і абранага з файла + Рэпазітар + Прапанова ўсталяваць нестабільныя версіі + Версія + Персаналізацыя + Подпіс %s + Shizuku ўсталёўшчык + Новыя праграмы + Выдаліць + Прапусціць + Налады экспарту + Экспарт усіх рэпазітараў з файла + Імпарт рэпазітараў + Скрыншоты + Нядаўна абноўлены + Парадак сартавання + Неправераныя + Ціхая ўстаноўка + Няправільны фармат адбітка + Захаванне даных… + Што новага + Націсніце, каб усталяваць. + Няма проксі + Сінхранізацыя + Экспарт налад і абранага ў файл + Мова + Пошук + Невядомая памылка. + Тэмы + Анімацыя спісаў + Дазволіць карыстачу гартаць старонкі на галоўным экране + Экспарт рэпазітараў + Паказваць менш + Налады + Зыходны код бацькоўскай праграмы зачынены + Версіі + Гэты рэпазітар яшчэ не выкарыстоўваўся. Вам трэба ўключыць яго для прагляду прымянення ў ім. + Імпарт усіх рэпазітараў з файла + Як у сістэме + Асобнае дзякуй + Падзяліцца + Паказаць старыя версіі + Shizuku не ўстаноўлена + Нестабільнае абнаўленне + Апошнія + Дайце правы root, каб уключыць бясшумную ўстаноўку + Невядома: %s + Немагчыма адкрыць спасылку + \ No newline at end of file diff --git a/core/common/src/main/res/values-bg/strings.xml b/core/common/src/main/res/values-bg/strings.xml new file mode 100644 index 0000000..08b5c95 --- /dev/null +++ b/core/common/src/main/res/values-bg/strings.xml @@ -0,0 +1,234 @@ + + + Неуспешна операция + Добави хранилище + Адрес + Всички Приложения + Всички приложения са актуални + Вече съществува + Винаги + Чернa + Приложение + Приложението не може да бъде намерено + Ел. поща на автора + Налични + Тракер за грешки + Отказ + Списък на промените + Промени + Проверка на хранилището… + Потвърждение + Свързване… + Съдържа несвободна медия + Неуспешно синхронизиране на %s + Неуспешна валидиция на %s + Доброволци + Изтрий + Изтриване на хранилището\? + Описание + Детайли + Дарения + Изтегли се %s + Изтегля се + Изтегля се %s… + Редактирай + Има реклами + Има уязвимости в сигурността + Невалиден отговор от сървъра. + HTTP прокси + Игнорирай всички нови версии + Максималната АПИ версия е %d. + Минималната АПИ версия е %d. + Липсващи функции. + Вашата %1$s платформа не се поддържа. Поддържани платформи: %2$s. + Несъвместима версия + Несъвместими версии + Несъвместим с %s + Инсталирай + Начини на инсталиране + Инсталирани + Невалиден адрес + Невалидни разрешения. + Невалиден подпис. + Стартирай + Лиценз + %s лиценз + Светла + Линкове + Анимирай списъка на главната страница + Сливане на %s + Име + Мрежова грешка + Никога + Няма инсталирани приложения + Няма налично описание + Без прокси + Уведомления за актуализации + Брой приложения + Окей + Само на Wi-Fi + Отвори %s\? + Други + Не може да се прочете индекс файла. + Парола + Липсваща парола + Прокси + Прокси хост + Прокси порт + Прокси тип + Наскоро обновени + Хранилища + Хранилище + Това хранилище все още не е използвано.Включете го, за да видите приложенията в него. + Неподписано. Не може да провери списъка с неподписаните приложения. Внимавайте с тегленето на приложения от неподписани хранилища. + Изисква %s + Безшумна Инсталация + За безшумни инсталации дайте root разрешение + Запази + Запазване на подробности… + Екранни снимки + Избери източник + Сподели + Покажи повече + Покажи по-стари версии + Подпис %s + Подписано с несигурен алгоритъм + Размер + Пропусни + SOCKS прокси + Ред на сортиране + Предложено + Автоматично синхронизиране на хранилищата + Синхронизиране + Цел + Тема + Проследява или отчита вашата дейност + Неизвестно + Неизвестно: %s + Непроверено + Актуализация + Актуализации + Потребителско име + Версия + Версии + В очакване да започне изтеглянето… + Какво е новото + Покажи по-малко + Последни + Разглеждане + Инсталирай всички + Инсталирани приложения + Сортиране & Филтриране + Нови приложения + Антифункции + Уебстраница на автора + Не може да се редактират синхронизиращи се хранилища. + Неуспешно изтегляне на %s + Компилирано за отстраняване на грешки + Тъмнa + Невалиден файлов формат. + Отпечатък + Невалиден формат на отпечатъка + Има несвободни зависимости + Игнорирай тази версия + Вашата %1$s (АПИ версия %2$d) се поддържа. %3$s + Тази версия е по-стара от инсталираната на вашето устройство. Деинсталирайте първо нея. + Невалиден формат на потребителското име + Анимации на списъците + Нестабилни актуализации + Тази версия е подписана със сертификат, различен от този, инсталиран на вашето устройство. Деинсталирайте първо нея. + Показване на версии, несъвместими с устройството + Не може да се провери целостта. + Невалидни метаданни. + +%d повече + Насърчава несвободни мрежови услуги + Търсене + Линка е копиран + Налични нови версии на приложението + Няма налични приложения + + %d приложение има нова версия. + %d приложения имат нова версия. + + Настройки + Не могат да бъдат намерени такива приложения + Покажи известие, при налични нови версии + Съвместим само с %s + Разрешения + Обработка на %1$s… + Уебстраница на проекта + Насърчава несвободен софтуер + Предоставени от %s + Теми + Деинсталиране + Програмен код + Програмният код вече не е наличен + Синхронизиране на %s… + Версия %s + Синхронизирай хранилищата + Системна + Докосни за инсталиране. + Неизвестна грешка. + Предложи инсталирането на нестабилни версии + Неподписано + Актуалният програмен код вече не е със свободен лиценз + Потребителско име липсва + Не може да валидира индексът. + Уебстраница + Език + Персонализация + Инсталатор + Стар Инсталатор + Session Инсталатор + Root Инсталатор + Shizuku Инсталатор + + Ден + Дни + + Само при Wi-Fi и зареждане + Интервал за почистване на APK + Период за проверка и премахване на изтеглените файлове + + Час + Часове + + Нямате интернет връзка + Разрешете горната лента на приложението да се разшири + Невъзможност за извършване на определени действия. + Разрешете горната лента на приложението да се разширява и свива + Използвайте material you цветова тема + Материални Вие + Автоматично актуализиране на приложения + Инсталиране + Опитайте се да инсталирате актуализации автоматично + Рестартирайте LeOS-Droid, за да видите промените + Изчакване за стартиране на инсталацията… + Любими + Активирайте хранилището + Принудително почистване + Почиства излишните файлове + Хранилището е недостъпно + Сървърът не успя да предостави нов пакет. + Има несвободни компоненти + Неуспешно свързване със сървъра + Плъзгане на началния екран + Съдържа неподходящо за работа съдържание + Shizuku не работи + Копирай + Прокси портът може да бъде само цяло число + Позволете на потребителя да плъзга между страниците в началния екран + Следното хранилище не бе намерено + Специални благодарности + Shizuku не е инсталиран + Импортиране/Експортиране + Импортирай Настройки + Импортиране на настройки и любими от файл + Експортирай Настройки + Експортирай всички хранилища във файл + Импортирай хранилища + Експортиране на настройки и любими във файл + Експортирай хранилища + Линкът не може да се отвори + Импортирай всички хранилища от файл + \ No newline at end of file diff --git a/core/common/src/main/res/values-bn/strings.xml b/core/common/src/main/res/values-bn/strings.xml new file mode 100644 index 0000000..4f3c4bc --- /dev/null +++ b/core/common/src/main/res/values-bn/strings.xml @@ -0,0 +1,212 @@ + + + এপিকে পরিষ্কারকরণ বিরতি + নিশ্চিতকরণ + সংযোগ দেওয়া হচ্ছে… + বিনামূল্য নয় এমন ছবি/অডিও/ভিডিও রয়েছে + %s ডাউনলোড করা সম্ভব হয়নি + ক্রেডিট + বিস্তারিত + অকার্যকর ফাইলের ধরণ। + ফিঙ্গারপ্রিন্ট + + দিন + দিন + + ক্রিয়া ব্যর্থ + ভাণ্ডার যোগ করো + ঠিকানা + সব অ্যাপ্লিকেশন + তোমার সব অ্যাপ্লিকেশন হালনাগাদকৃত + আগে থেকেই আছে + সর্বদা + অন্ধকার + অ্যাপের টপ বার সম্প্রসারিত হতে দাও + %s সত্যায়িত করা সম্ভব হয়নি + অনুদান + বর্ণনা + + ঘণ্টা + ঘণ্টা + + সর্বোচ্চ এপিআই সংস্করণ %d। + অ্যাপের টপ বার সম্প্রসারিত ও সংকুচিত হতে দাও + অপবৈশিষ্ট্য + বিজ্ঞাপন আছে + HTTP প্রক্সি + ডিলিট করুন + বিনামূল্য নয় এমন ডিপেন্ডেন্সি আছে + নিরাপত্তার ঝুঁকি রয়েছে + অ্যাপ্লিকেশন + অ্যাপ্লিকেশনটি খুঁজে পাওয়া যায়নি + লেখকের ইমেইল + লেখকের ওয়েবসাইট + %s ডাউনলোড সম্পন্ন হয়েছে + সার্ভারের অকার্যকর প্রতিক্রিয়া। + সকল নতুন সংস্করণ অগ্রাহ্য করুন + অন্বেষণ + বাতিল + আপনার %1$s (এপিআই সংস্করণ %2$d) সাপোর্টেড না। %3$s + সর্বনিম্ন এপিআই সংস্করণ %d। + অনুপস্থিত বৈশিষ্ট্যসমূহ। + এখন রিপোজিটরি সম্পাদনা করা সম্ভব নয় কারণ এটি সিনক্রোনাইজ করা হচ্ছে। + পরিবর্তনসমূহের তালিকা + পরিবর্তনসমূহ + রিপোজিটরি যাচাই করা হচ্ছে… + %s সিনক্রোনাইজ করা সম্ভব হয়নি + গাঢ় + ডাউনলোড করা হচ্ছে + %s ডাউনলোড করা হচ্ছে… + রিপোজিটরি ডিলিট করতে চান\? + রিপোজিটরি সম্পাদনা করুন + এই সংস্করণটি অগ্রাহ্য করো + ডাউনলোড করা নথিগুলো পরীক্ষা এবং অপসারণের সময়কাল + ডিবাগিংয়ের জন্য কম্পাইল করা হয়েছে + বাগ ট্র্যাকার + এই সংস্করণটি আপনার ডিভাইসে ইনস্টল করা একটি থেকে একটি ভিন্ন শংসাপত্রের সাথে স্বাক্ষরিত৷ প্রথমে এটি আনইনস্টল করুন। + বেমানান সংস্করণ + বেমানান সংস্করণ + ডিভাইসের সাথে বেমানান অ্যাপ্লিকেশন সংস্করণগুলি দেখান + ইনস্টলার + Shizuku ইনস্টলার + ইনস্টল করা হয়েছে + সততা পরীক্ষা করা যায়নি. + ভুল ঠিকানা + অবৈধ আঙ্গুলের ছাপ বিন্যাস + ক্লিপবোর্ডে লিঙ্ক কপি করা হয়েছে + লিঙ্ক + লিগ্যাসি ইনস্টলার + অবৈধ অনুমতি. + আপনার রঙ থিম উপাদান ব্যবহার করুন + রুট ইনস্টলার + অবৈধ মেটাডেটা। + নাম + এই সংস্করণটি আপনার ডিভাইসে ইনস্টল করা সংস্করণের চেয়ে পুরানো। প্রথমে এটি আনইনস্টল করুন। + আপনার %1$s প্ল্যাটফর্ম সমর্থিত নয়৷ সমর্থিত প্ল্যাটফর্ম: %2$s। + %s এর সাথে বেমানান + ইনস্টল করুন + ইনস্টলেশন প্রকার + নির্দিষ্ট কর্ম সম্পাদন করতে অক্ষম. + শুরু করা + লাইসেন্স + %s লাইসেন্স + আলো + তালিকা অ্যানিমেশন + প্রধান পৃষ্ঠায় তালিকা অ্যানিমেশন দেখান + %s মার্জ করা হচ্ছে + উপাদান আপনি + সেশন ইনস্টলার + অবৈধ ব্যবহারকারীর নাম বিন্যাস + অবৈধ স্বাক্ষর। + সংগ্রহস্থল সক্রিয় করুন + কোন বর্ণনা নাই + শুধুমাত্র Wi-Fi-এ + অন্যান্য + স্বয়ংক্রিয়ভাবে আপডেট ইনস্টল করার চেষ্টা করুন + + %d অ্যাপ্লিকেশনটির একটি নতুন সংস্করণ রয়েছে৷ + নতুন সংস্করণ সহ %dটি অ্যাপ্লিকেশন। + + কোনো ইনস্টল করা অ্যাপ্লিকেশন নেই + আপনার কোন ইন্টারনেট সংযোগ নেই + এই ধরনের কোনো অ্যাপ্লিকেশন খুঁজে পাওয়া যায়নি + আবেদনের সংখ্যা + স্বয়ংক্রিয়ভাবে অ্যাপ্লিকেশন হালনাগাদ + ইনস্টল করা হচ্ছে + নেটওয়ার্ক ত্রুটি + কখনই না + কোন উপলব্ধ অ্যাপ্লিকেশন + উপলব্ধ অ্যাপ্লিকেশনের নতুন সংস্করণ + শুধুমাত্র %s এর সাথে সামঞ্জস্যপূর্ণ + শুধুমাত্র ওয়াইফাই এবং চার্জিং এ + %s খুলবেন\? + ইনডেক্স ফাইল পার্স করা যায়নি. + অ-মুক্ত সফ্টওয়্যার প্রচার করে + ভান্ডার + %s দ্বারা সরবরাহ করা হয়েছে + প্রক্সি হোস্ট + প্রক্সি টাইপ + প্রক্সি পর্ট + সম্প্রতি আপডেট করা হয়েছে + ভান্ডার + এই সংগ্রহস্থল এখনও ব্যবহার করা হয় নি. এটিতে থাকা অ্যাপ্লিকেশনগুলি দেখতে এটি চালু করুন। + পরিবর্তনগুলি দেখতে LeOS-Droid পুনরায় চালু করুন + পুরানো সংস্করণ দেখান + +%d আরো + প্রিয় + কোনো প্রক্সি নেই + প্রক্সি + জোর করে পরিষ্কার করুন + অপ্রয়োজনীয় ফাইলগুলি পরিষ্কার করে + অ্যাপ্লিকেশনের নতুন সংস্করণ সম্পর্কে অবহিত করুন + নতুন সংস্করণ উপলব্ধ হলে একটি বিজ্ঞপ্তি দেখান + ঠিক আছে + অ-মুক্ত নেটওয়ার্ক পরিষেবা প্রচার করে + পাসওয়ার্ড + পাসওয়ার্ড অনুপস্থিত + অনুমতি + %1$s প্রক্রিয়া করা হচ্ছে… + প্রকল্প ওয়েবসাইট + রিপোজিটরি পৌঁছানো যায় না + %s প্রয়োজন + নীরব ইনস্টল + বিবরণ সংরক্ষণ করা হচ্ছে… + স্ক্রিনশট + নীরব ইনস্টলেশনের জন্য রুট অনুমতির অনুমতি দিন + সংরক্ষণ + অনুসন্ধান করুন + একটি আয়না নির্বাচন করুন + শেয়ার করুন + আরো দেখুন + সেটিংস + স্বাক্ষরবিহীন। আবেদন তালিকা যাচাই করা যায়নি. স্বাক্ষরবিহীন সংগ্রহস্থল থেকে অ্যাপ্লিকেশন ডাউনলোড করার বিষয়ে সতর্ক থাকুন। + স্বাক্ষর %s + যায়গা + SOCKS প্রক্সি + অনিরাপদ উপায়ে স্বাক্ষর করা হয়েছে + এড়িয়ে যান + সাজানোর ক্রম + সোর্স কোড + সোর্স কোড আর উপলব্ধ নেই + সংগ্রহস্থলগুলি স্বয়ংক্রিয়ভাবে সিঙ্ক করুন + সিঙ্ক হচ্ছে + পদ্ধতি + ইনস্টল করতে আলতো চাপুন। + টার্গেট + আনইনস্টল করুন + অজানা + অজানা ত্রুটি. + অজানা: %s + স্বাক্ষরবিহীন + যাচাই করা হয়নি + ব্যবহারকারীর নাম + সংস্করণ %s + ওয়েবসাইট + ব্যক্তিগতকরণ + প্রদর্শন কম + সর্বশেষ + বাছাই এবং ফিল্টার + সিঙ্ক রিপোজিটরি + আপনার কার্যকলাপ ট্র্যাক বা রিপোর্ট + সংস্করণ + অন্বেষণ + %s সিঙ্ক হচ্ছে… + নতুন কি + রঙ + রঙ + সংস্করণ + সব আপডেট + আপডেট + প্রস্তাবিত + অস্থির আপডেট + ব্যবহারকারীর নাম অনুপস্থিত + অস্থির সংস্করণ ইনস্টল করার পরামর্শ দিন + ইনস্টলেশন শুরু করার জন্য অপেক্ষা করা হচ্ছে… + আপস্ট্রিম সোর্স কোড বিনামূল্যে নয় + সূচক যাচাই করা যায়নি. + ইনস্টল করা অ্যাপ্লিকেশন + হালনাগাদ + ডাউনলোড শুরু করার জন্য অপেক্ষা করা হচ্ছে… + ভাষা + নতুন অ্যাপ্লিকেশন + \ No newline at end of file diff --git a/core/common/src/main/res/values-ca/strings.xml b/core/common/src/main/res/values-ca/strings.xml new file mode 100644 index 0000000..5971d33 --- /dev/null +++ b/core/common/src/main/res/values-ca/strings.xml @@ -0,0 +1,212 @@ + + + Acció fallida + Afegeix repositori + Adreça + Ja existeix + Sempre + Negre (AMOLED) + Anti-característiques + No s\'ha pogut trobar aquesta aplicació + Correu electrònic de l\'autor + Lloc web d\'autor + Explorar + Anul·la + No pot editar el repositori des d\'ell perquè està sincronitzant ara mateix. + Canvis + Comprovant repositori… + Interval de neteja de l\'APK + Període per comprovar i eliminar els fitxers descarregats + Compilat per a depuració + Confirmació + Connectant… + Registre de canvis + No podria validar %s + Crèdits + + Dia + Dies + + Elimina + Elimina el repositori\? + Descripció + Detalls + Donar + Descarregat %s + Té dependènciesnolliures + Té vulnerabilitats de seguretat + + Hora + Hores + + Resposta de servidor nul. + proxy d\'HTTP + El max API la versió és %d. + Versió incompatible + Versions incompatibles + Versions d\'aplicació de l\'espectacle incompatibles amb el dispositiu + Incompatible amb %s + Instal·la + Tipus d\'instal·lació + Instal·lador + Instal·lador de llegat + Instal·lador de sessió + Instal·lador d\'arrel + Shizuku Instal·lador + Instal·lat + No podria comprovar enteresa. + Adreça nul·la + Format d\'empremta digital nul·la + Metadada nul·la. + Permisos nuls. + Signatura nul·la. + Nom + Número d\'aplicacions + D\'acord + Només compatible amb %s + Només amb Wi-Fi + Només amb Wi-Fi i carregant + Vols obrir %s\? + Altres + No s\'ha pogut analitzar l\'arxiu d\'índex. + Contrasenya + Falta la contrasenya + Permisos + +%d més + Paràmetres + Processant %1$s… + Lloc web del projecte + Promou serveis de xarxa no lliures + Promou programari no lliure + Proporcionat per %s + Proxy + Amfitrió de proxy + Port de proxy + Tipus de proxy + Recentment actualitzat + Repositoris + Repositori + Aquest repositori no ha estat utilitzat tot i així. Gira\'l damunt per veure les aplicacions en ell. + Sense signar. No podria verificar la llista d\'aplicació. Ser prudent descarregar aplicacions des de repositoris sense signar. + Requereix %s + Silenciós Instal·lar + Permet permís d\'arrel per silenciós instal·la + Salva + Recerca + Selecciona un mirall + Compartir + Mostrar més + Signatura %s + Signat utilitzant un algoritme insegur + Omet + Proxy SOCKS + Ordenant ordre + Codi de font + Codi de font ja no disponible + Sincronitza repositoris + Sincronitza repositoris automàticament + Sincronitzant %s… + Sistema + Copet per instal·lar. + Desconegut + Error desconegut. + Desconegut: %s + Sense signar + Actualitzacions inestables + Suggereix instal·lant versions inestables + Sense verificar + Actualització + Actualitzacions + El codi font no és lliure + Nom d\'usuari + Falta el nom d\'usuari + L\'índex no va poder ser validat. + Versió + Versió %s + Versions + Lloc web + Llengua + Personalització + Mostrar menys + Més recent + Explora + Actualitzar tot + Aplicacions instal·lades + Ordenar i filtrar + Aplicacions noves + Empremta digital + El vostre %1$s el programa no és recolzat. Programes recolzats: %2$s. + Totes les aplicacions + Totes les vostres aplicacions s\'han actualitzat + Aplicació + Rastrejador d\'incidències + Descarregant + No podria descarregar %s + Fosca + Edita repositori + Té publicitat + Ignora aquesta versió + Temes + Segueix o reporta la vostra activitat + Desinstal·la + Conté mitjans de comunicaciónolliures + No podria sincronitzar %s + Esperar per arrencar descàrrega… + Descarregant %s… + Format de fitxer nul. + Ignora tot versions noves + El min API la versió és %d. + Novetats + El vostre %1$s (API versió %2$d) no és recolzat. %3$s + Perdent característiques. + Aquesta versió és més vella que l\'instal·lat en el vostre dispositiu. Uninstall que primer. + Aquesta versió és signada amb un certificat diferent que l\'instal·lat en el vostre dispositiu. Uninstall que primer. + %s llicència + Cap proxy + El nexe va copiar a portapapers + Objectiu + Llicència + Tema + Nul username format + Llanxa + Llum + Nexes + Animacions de llista + Mida + Aplicacions instal·lades no + L\'espectacle llista animació en la pàgina major + Fusionant %s + Error de xarxa + Versions noves de les aplicacions disponibles + Mai + + %d l\'aplicació té una versió nova. + %d aplicacions amb versions noves. + + Cap aplicació disponible + Cap descripció disponible + Mostra una notificació quan les versions noves són disponibles + Salvant detalls… + Captures de pantalla + No podria trobar qualsevol tals aplicacions + Notifica sobre versions noves d\'aplicacions + Mostrar versions més velles + Suggerit + Sincronitzant + No teniu cap connexió a Internet + Permet que la barra d\'aplicacions superior s\'expandeixi i es col·lapsi + Permet que la barra d\'aplicacions superior s\'expandeixi + No es pot realitzar determinades accions. + Actualització automàtica d\'aplicacions + Intenta instal·lar les actualitzacions automàticament + Material tu + Utilitzeu el material del vostre tema + Preferits + Repositori inaccessible + Neteja forçada + Neteja els fitxers redundants + Habiliteu el repositori + S\'està esperant per iniciar la instal·lació… + Reinicieu LeOS-Droid per veure els canvis + Instal·lació + \ No newline at end of file diff --git a/core/common/src/main/res/values-cs/strings.xml b/core/common/src/main/res/values-cs/strings.xml new file mode 100644 index 0000000..96ec1ec --- /dev/null +++ b/core/common/src/main/res/values-cs/strings.xml @@ -0,0 +1,237 @@ + + + Přidat zdroj + Adresa + Všechny vaše aplikace jsou aktuální + Nezdařilo se najít tuto aplikaci + Procházet + Sledování chyb + Zrušit + Instalováno + Tato verze je starší než ta instalovaná na vašem zařízení. Nejdříve odinstalujte ji. + Zobrait nekompatibilní verze aplikace s vaším zařízením + Neplatný podpis. + Odkaz zkopírován + Světlé + Počet aplikací + Zpracovávání %1$s… + Nezdařilo se zpracovat soubor indexu. + Heslo + Chybí heslo + Web projektu + Hostitel proxy + Nedávno aktualizované + Zdroj + Port proxy + Typ proxy + Zdroje + Anti-funkce + Již existuje + Vždy + Nezdařilo se upravit zdroj protože se právě synchronizuje. + Změny + Seznam změn + Kontroluji zdroj… + Potvrzení + Spojuji… + Obsahuje ne-svobodná média + Nepodařilo se stáhnout %s + Nepodařilo se synchronizovat %s + Nepodařilo se ověřit %s + Tmavé + Smazat + Smazat zdroj\? + Popis + Detaily + Přispět + Staženo %s + Stahuji + Stahuji %s… + Upravit zdroj + Neplatný formát souboru. + Otisk prstu + Obsahuje reklamy + Obsahuje nesvobodné závislosti + Obsahuje bezpečnostní zranitelnosti + Neplatná odpověď serveru. + HTTP proxy + Ignorovat všechny nové verze + Ignorovat tuto verzi + Váš %1$s (verze API %2$d) není podporován. %3$s + Maximální verze API je %d. + Minimální verze API je %d. + Kredity + Chybějící funkce. + Vaše %1$s platforma není podporována. Podporované platformy: %2$s. + Nekompatibilní verze + Nekompatibilní verze + Nekompatibilní s %s + Instalovat + Typy Instalace + Nezdařilo se zkontrolovat integritu. + Neplatná adresa + Neplatný formát otisku prstu + Neplatná metadata. + Neplatná oprávnění. + Neplatný formát uživatelského jména + Spustit + Licence + %s licence + Odkazy + List Animací + Zobrazit animaci listu na hlavní stránce + Slučování %s + Název + Chyba sítě + Nikdy + Jsou dostupné nové verze aplikací + + %d aplikace má novou verzi. + %d aplikace mají novou verzi. + %d aplikací má novou verzi. + + Žádné dostupné aplikace + Žádné instalované aplikace + Žádný dostupný popis + Nepodařilo se najít žádné takové aplikace + Žádná proxy + Oznámení o aktualizacích + Zobrazit oznámení když jsou dostupné nové verze + OK + Kompatibilní pouze s %s + Pouze na Wi-Fi + Otevřít %s\? + Ostatní + Tato verze je podepsána jiným certifikátem než ta instalována na vašem zařízení. Nejdříve odisntalujte ji. + Oprávnění + +%d více + Nastavení + Propaguje nesvobodné internetové služby + Propaguje ne-svobodný software + Poskytuje %s + Proxy + Tento zdroj zatím použit. Zapněte jej pro zobrazení aplikací na něm. + Nepodepsáno. Nezdařilo se ověřit seznam aplikací. Buďte opatrní při stahování aplikací z nepodepsaných zdrojů. + Vyžaduje %s + Tichá instalace + Povolit root oprávnění pro tiché instalace + Uložit + Ukládám detaily… + Snímky obrazovky + Hledat + Sdílet + Zobrazit více + Zobrazit starší verze + Podpis %s + Podepsáno za použití nebezpečného algoritmu + Velikost + Přeskočit + Proxy SOCKS + Pořadí řazení + Zdrojový kód + Zdrojový kód již není dostupný + Doporučené + Synchronizovat zdroje + Synchronizovat zdroje automaticky + Synchronizuji + Synchronizuji %s… + Systém + Klikněte pro instalaci. + Cíl + Téma + Témata + Sleduje nebo hlásí vaší aktivitu + Odinstalovat + Neznámé + Neznámá chyba. + Neznámé: %s + Nepodepsáno + Nestabilní aktualizace + Neověřeno + Aktualizovat + Aktualizace + Originální zdrojový kód není svobodný + Uživatelské jméno + Chybí uživatelské jméno + Index nemohl být ověřen. + Verze + Verze %s + Verze + Čekám na zahájení stahování… + Co je nového + Web + Jazyk + Personalizace + Zobrazit méně + Nejnovější + Prozkoumat + Aktualizovat vše + Instalované aplikace + Třídit & Filtrovat + Nové aplikace + Akce se nezdařila + Všechny aplikace + Černá + Aplikace + E-mail autora + Web autora + Zkompilováno pro ladění + Instalátor + Původní instalátor + Instalátor pomocí relací + Root instalátor + Instalátor Shizuku + Navrhnout instalaci nestabilních verzí + Vybrat mirror + + den + dny + dní + + + hodina + hodiny + hodin + + Interval čištění APK + Období pro kontrolu a odstranění stažených souborů + Pouze na Wi-Fi a při nabíjení + Nepodařilo se vykonat některé akce. + Nejste připojeni k internetu + Povolení rozšíření horního panelu aplikací + Povolení rozbalování a sbalování horního panelu aplikace + Použít barevný motiv Material You + Material You + Oblíbené + Vyčistí přebytečné soubory + Vynutit vyčištění + Repozitář nedostupný + Povolit repozitář + Pro zobrazení změn restartujte LeOS-Droid + Čekání na spuštění instalace… + Instalace + Automatická aktualizace aplikací + Pokusit se automaticky nainstalovat aktualizace + Obsahuje nesvobodné součásti + Server neodeslal nový paket. + Nepodařilo se připojit k serveru + Shizuku není spuštěno + Shizuku není nainstalováno + Obsahuje obsah nevhodný do práce + Speciální poděkování + Posouvání na domovské stránce + Umožnit uživateli posouvat mezi stránkami na domovské stránce + Kopírovat + Následující repozitář nebyl nalezen + Port proxy smí být pouze celé číslo + Importovat nastavení + Import/export + Importovat nastavení a oblíbené ze souboru + Exportovat nastavení + Exportovat všechny repozitáře do souboru + Importovat repozitáře + Exportovat nastavení a oblíbené do souboru + Exportovat repozitáře + Importovat všechny repozitáře ze souboru + Nelze otevřít odkaz + \ No newline at end of file diff --git a/core/common/src/main/res/values-de/strings.xml b/core/common/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..4096220 --- /dev/null +++ b/core/common/src/main/res/values-de/strings.xml @@ -0,0 +1,233 @@ + + + Paketquelle hinzufügen + Alle Anwendungen + All deine Anwendungen sind aktuell + Bereits vorhanden + Immer + Adresse + Vorgang fehlgeschlagen + Schwarz + Anwendung + Unerwünschte Merkmale + Entdecken + Abbrechen + Diese Anwendung konnte nicht gefunden werden + Fehlerverwaltung + Änderungsprotokoll + Die Paketquelle kann nicht bearbeitet werden, da sie gerade synchronisiert wird. + Paketquelle wird abgefragt … + Kompiliert für die Fehlersuche + Verbinde … + Bestätigung + Konnte %s nicht validieren + Dunkel + Enthält nicht-freie Medien + Mitwirkende + Beschreibung + Spenden + %s heruntergeladen + Details + Fingerabdruck + Enthält Werbung + Enthält Sicherheitslücken + Ungültige Antwort des Servers. + HTTP-Proxy + Die maximale API-Version ist %d. + Fehlende Funktionen. + Deine %1$s-Plattform wird nicht unterstützt. Unterstützte Plattformen: %2$s. + Installiert + Mit dem Gerät inkompatible Anwendungsversionen anzeigen + Installationstypen + Installieren + Integrität konnte nicht überprüft werden. + Ungültige Metadaten. + Ungültige Signatur. + Öffnen + Hell + Listenanimation auf der Hauptseite anzeigen + Neue Anwendungsversionen verfügbar + Nie + Netzwerkfehler + Über neue Versionen benachrichtigen + Keine Beschreibung vorhanden + Keine derartigen Anwendungen konnten gefunden werden + Keine installierten Anwendungen + Nur bei Wi-Fi + Öffne %s\? + Andere + Anzahl der Anwendungen + OK + Berechtigungen + +%d mehr + Einstellungen + Verarbeitung %1$s … + Bewirbt unfreie Software + Proxy + Paketquellen + Benötigt %s + Stumme Installation + Speichern + Details werden gespeichert … + Bildschirmfotos + Überspringen + Empfohlen + Synchronisierung + Themen + Ziel + Unbekannt + Deinstallation + Unsigniert + Aktualisierung + Version + Versionen + Was gibt es Neues + Warten auf den Downloadbeginn … + Der Index konnte nicht validiert werden. + Webseite + Änderungen + Autor-E-Mail-Adresse + Konnte %s nicht herunterladen + Autor-Webseite + Löschen + Zuletzt aktualisiert + Lizenz + Konnte %s nicht synchronisieren + Nur kompatibel mit %s + %s-Lizenz + Link kopiert + Projekt-Website + Proxy Typ + Paketquelle + Die Indexdatei konnte nicht geparst werden. + Passwort + Eine Benachrichtigung anzeigen, wenn neue Versionen verfügbar sind + Bereitgestellt von %s + Quellcode nicht mehr verfügbar + Ungültiges Benutzernamen-Format + Kein Proxy + Passwort fehlt + Quellcode + Paketquellen synchronisieren + Paketquellen automatisch synchronisieren + Diese Paketquelle wurde noch nicht verwendet. Aktivieren Sie es, um die darin enthaltenen Anwendungen anzuzeigen. + Signatur %s + Enthält nicht-freie Abhängigkeiten + Inkompatible Version + System + Thema + Alle neuen Versionen ignorieren + Diese Version ignorieren + Größe + Aktualisierungen + Benutzername + Version %s + Herunterladen + Teilen + Zeige mehr + Ältere Versionen zeigen + Ungeprüft + Benutzername fehlt + Paketquelle bearbeiten + Ungültiges Dateiformat. + %s wird heruntergeladen … + Inkompatibel mit %s + Inkompatible Versionen + Ungültige Adresse + Ungültiges Fingerabdruckformat + Ungültige Berechtigungen. + Bewirbt unfreie Netzwerkdienste + Unbekannt: %s + Unbekannter Fehler. + Synchronisierung %s … + Diese Version ist mit einem anderen Zertifikat signiert, als die auf Deinem Gerät installierte. Deinstalliere diese zuerst. + Die Paketquelle löschen\? + Diese Version ist älter als die auf deinem Gerät installierte. Deinstalliere diese zuerst. + Deine %1$s (API-Version %2$d) wird nicht unterstützt. %3$s + Die minimale API-Version ist %d. + Nicht signiert. Die Anwendungsliste konnte nicht verifiziert werden. Sei vorsichtig beim Herunterladen von Anwendungen aus nicht signierten Paketquellen. + Installation von instabilen Versionen vorschlagen + Instabile Aktualisierungen + Root-Rechte für stille Installationen zulassen + Proxy Host + Tippe um zu installieren. + Verfolgt oder erfasst deine Aktivitäten + Proxy Port + Suche + Sortierreihenfolge + SOCKS Proxy + Keine Anwendungen verfügbar + + %d Anwendung hat eine neue Version. + %d Anwendungen haben eine neue Version. + + Mit einem unsicheren Algorithmus signiert + Wähle einen Spiegel + Animationen anzeigen + Links + Führe %s zusammen + Name + Der Upstream-Quellcode ist nicht frei + Sprache + Personalisierung + Weniger anzeigen + Neueste + Entdecken + Alle aktualisieren + Installierte Anwendungen + Sortieren und filtern + Neue Anwendungen + + Tag + Tage + + + Stunde + Stunden + + Nur während des Ladevorgangs und aktiviertem WLAN + Installationsmethode + Zeitraum zum Prüfen und Entfernen heruntergeladener Dateien + APK-Bereinigungsintervall + Root-Installation + Alte Installationsmethode + Sitzungs-Installation + Shizuku-Installation + Bestimmte Aktionen können nicht durchgeführt werden. + Sie haben keine Internetverbindung + Erweiterung der oberen Anwendungsleiste zulassen + Das Erweitern und Reduzieren der oberen Anwendungsleiste erlauben + Favoriten + Material You + Material You-Farbschema verwenden + Repository unerreichbar + Aufräumen erzwingen + Repository aktivieren + Entfernt doppelte Dateien + Installation + Starten Sie LeOS-Droid neu, um die Änderungen zu sehen + Warten auf den Beginn der Installation … + Apps automatisch aktualisieren + Versuche, Updates automatisch zu installieren + Hat nicht-freie Komponenten + Server konnte kein neues Datenpaket liefern. + Shizuku läuft nicht + Enthält für den Arbeitsplatz unangemessene Inhalte + Verbindung zum Server nicht möglich + Shizuku ist nicht installiert + Wischgesten + Dem Benutzer erlauben, auf dem Startbildschirm zwischen den Seiten zu wischen + Besonderer Dank + Kopieren + Proxy-Port muss eine natürliche Zahl sein + Folgende Repos konnten nicht gefunden werden + Einstellungen importieren + Importieren/Exportieren + Importiere Einstellung und Favoriten von Datei + Exportiere Einstellungen + Exportiere alle Sammlungen in Datei + Importiere eine Sammlung + Exportiere Einstellungen und Favoriten in Datei + Exportiere Sammlungen + Importiere alle Sammlungen aus Datei + \ No newline at end of file diff --git a/core/common/src/main/res/values-el/strings.xml b/core/common/src/main/res/values-el/strings.xml new file mode 100644 index 0000000..f1bc84e --- /dev/null +++ b/core/common/src/main/res/values-el/strings.xml @@ -0,0 +1,233 @@ + + + Προσθήκη αποθετηρίου + Διεύθυνση + Όλες οι εφαρμογές + Όλες οι εφαρμογές είναι ενημερωμένες + Υπάρχει ήδη + Πάντα + Ανεπιθύμητα χαρακτηριστικά + Εφαρμογή + Η εφαρμογή δε βρέθηκε + Διεύθυνση ηλεκτρονικού ταχυδρομείου συντάκτη + Ιστοσελίδα συντάκτη + Εξερευνήστε + Ινχηλάτης προβλημάτων + Ακύρωση + Δεν μπορείτε να τροποποιήσετε το αποθετήριο καθώς συγχρονίζεται αυτή τη στιγμή. + Κατάλογος αλλαγών + Αλλαγές + Έλεγχος αποθετηρίου… + Προορίζεται για αποσφαλμάτωση + Επιβεβαίωση + Συνδέεται… + Περιέχει μη ελεύθερα μέσα + Αδυναμία λήψης %s + Αδυναμία συγχρονισμού %s + Αδυναμία επικύρωσης %s + Ευχαριστίες + Σκούρο + Διαγραφή αποθετηρίου; + Περιγραφή + Λεπτομέρειες + Δωρεά + Έγινε λήψη %s + Γίνεται λήψη + Γίνεται λήψη %s… + Επεξεργασία αποθετηρίου + Μη έγκυρος τύπος αρχείου. + Αποτύπωμα + Περιέχει διαφημίσεις + Έχει μη ελεύθερες εξαρτήσεις + Μη έγκυρη απόκριση διακομιστή. + HTTP διακομιστής μεσολάβησης + Αγνόηση όλων των ενημερώσεων + Αγνόηση αυτής της ενημέρωσης + Η μέγιστη έκδοση API είναι %d. + Η ελάχιστη έκδοση API είναι %d. + Λείπουν χαρακτηριστικά. + Ασύμβατη έκδοση + Ασύμβατες εκδόσεις + Εμφάνιση εκδόσεων εφαρμογών ασύμβατων με τη συσκευή + Ασύμβατο με %s + Εγκατάσταση + Τύποι Εγκατάστασης + Εγκατεστημένα + Μη έγκυρη διεύθυνση + Μη έγκυρη μορφή αποτυπώματος + Μη έγκυρα μεταδεδομένα. + Μη έγκυρες άδειες. + Μη έγκυρη μορφή ονόματος χρήστη + Εκκίνηση + Άδεια + Άδεια %s + Φωτεινό + Ο σύνδεσμος αντιγράφηκε + Σύνδεσμοι + Κινήσεις Λίστας + Εμφάνιση κινήσεων λίστας στην αρχική σελίδα + Συγχώνευση %s + Όνομα + Σφάλμα δικτύου + Ποτέ + Νέες διαθέσιμες ενημερώσεις + + %d νέα ενημέρωση. + %d νέες ενημερώσεις. + + Δεν υπάρχουν διαθέσιμες εφαρμογές + Δεν υπάρχουν εγκατεστημένες εφαρμογές + Δεν υπάρχει διαθέσιμη περιγραφή + Δε βρέθηκαν αντίστοιχες εφαρμογές + Κανένας διακομιστής μεσολάβησης + Ειδοποίηση για ενημερώσεις + Εμφάνιση ειδοποίησης όταν υπάρχουν διαθέσιμες ενημερώσεις + Αριθμός εφαρμογών + Εντάξει + Συμβατό μόνο με %s + Μόνο με Wi-Fi + Άνοιγμα %s; + Άλλα + Αδυναμία ανάλυσης του αρχείου δείκτη. + Κωδικός + Απουσία κωδικού + Άδειες + Ρυθμίσεις + Επεξεργασία %1$s… + Ιστοσελίδα του πρότζεκτ + Προωθεί μη ελεύθερες υπηρεσίες δικτύου + Προωθεί μη ελεύθερο λογισμικό + Παρέχεται από %s + Διακομιστής μεσολάβησης + +%d περισσότερα + Proxy host + Θύρα διακομιστή μεσολάβησης + Τύπος διακομιστή μεσολάβησης + Αποθετήρια + Αποθετήριο + Μη υπογεγραμμένο. Αδυναμία επαλήθευσης της λίστας εφαρμογών. Προσέχετε όταν κατεβάζετε εφαρμογές από μη υπογεγραμμένα αποθετήρια. + Απαιτεί %s + Σιωπηλή Εγκατάσταση + Αποθήκευση + Αποθήκευση λεπτομερειών… + Στιγμιότυπα οθόνης + Αναζήτηση + Επιλέξτε ένα mirror + Κοινοποίηση + Εμφάνιση περισσοτέρων + Εμφάνιση παλαιοτέρων εκδόσεων + Υπογραφή %s + Μέγεθος + Παράβλεψη + Σειρά ταξινόμησης + Πηγαίος κώδικας + Ο πηγαίος κώδικας δεν είναι πλέον διαθέσιμος + Προτεινόμενο + Συγχρονισμός αποθετηρίων + Αυτόματος συγχρονισμός αποθετηρίων + Συγχρονισμός + Συγχρονισμός %s… + Σύστημα + Πατήστε για εγκατάσταση. + Θέμα + Θέματα + Απεγκατάσταση + Άγνωστο + Άγνωστο σφάλμα. + Άγνωστο: %s + Ασταθείς ενημερώσεις + Πρόταση για εγκατάσταση ασταθών εκδόσεων + Ενημέρωση + Ενημερώσεις + Μη υπογεγραμμένο + Μη επιβεβαιωμένο + Διαγραφή + Πρόσφατα ενημερωμένα + Στόχος + Τι νέο υπάρχει + Upstream source code is not free + Όνομα χρήστη + Απουσία ονόματος χρήστη + Έκδοση + Έκδοση %s + Εκδόσεις + Αναμονή για λήψη… + Ιστοσελίδα + Έχει ευπάθειες ασφαλείας + Το %1$s σας (έκδοση API %2$d) δεν υποστηρίζεται. %3$s + Αυτή η έκδοση είναι παλαιότερη από αυτήν που είναι εγκατεστημένη στη συσκευή σας. Απεγκαταστήστε εκείνη πρώτα. + Η %1$s πλατφόρμα σας δεν υποστηρίζεται. Υποστηριζόμενες πλατφόρμες: %2$s. + Η ενέργεια απέτυχε + Αυτή η έκδοση είναι υπογεγραμμένη με ένα διαφορετικό πιστοποιητικό από αυτή που είναι εγκατεστημένη στη συσκευή σας. Απεγκαταστήστε εκείνη πρώτα. + Αδυναμία ελέγχου ακεραιότητας. + Μη έγκυρη υπογραφή. + Αυτό το αποθετήριο δεν έχει χρησιμοποιηθεί ακόμα. Χρειάζεται να το ενεργοποιήσετε για να δείτε τις εφαρμογές που παρέχει. + Έχει υπογραφεί χρησιμοποιώντας έναν μη ασφαλή αλγόριθμο + SOCKS διακομιστής μεσολάβησης + Καταγράφει ή αναφέρει τη δραστηριότητά σας + Αδυναμία επαλήθευσης δείκτη. + Επιτρέψτε την άδεια root για σιωπηλή εγκατάσταση + Amoled + Εξατομίκευση + Γλώσσα + Πρόσφατα + Ταξινόμηση & Φιλτράρισμα + Εγκατεστημένες εφαρμογές + Πρόγραμμα Εγκατάστασης + Παλιό πρόγραμμα Εγκατάστασης + Πρόγραμμα Εκατάστασης Συνεδρίας + Πρόγραμμα Εγκατάστασης Root + Πρόγραμμα Εγκατάστασης Shizuku + Εμφάνιση Λιγότερων + Εξερεύνηση + Ενημέρωση όλων + Νέες εφαρμογές + Period to check and remove downloaded files + APK cleanup interval + + Ημέρα + Ημέρες + + + Ώρα + Ώρες + + Μόνο σε Wi-Fi και Φόρτιση + Δεν είναι δυνατή η εκτέλεση ορισμένων ενεργειών. + Δεν έχετε σύνδεση στο διαδίκτυο + Να επιτρέπεται η Επέκταση της Γραμμής Κορυφαίων Εφαρμογών + Να επιτρέπεται η επέκταση και σύμπτυξη της επάνω γραμμής εφαρμογών + Χρησιμοποιήστε material you με θέμα το χρώμα σας + Material You + Αγαπημένα + Αποθετήριο μη προσβάσιμο + Αναγκαστική εκκαθάριση + Καθαρίζει τα περιττά αρχεία + Ενεργοποιήστε το αποθετήριο + Επανεκκινήστε το LeOS-Droid για να δείτε αλλαγές + Εγκατάσταση + Αναμονή για έναρξη εγκατάστασης… + Αυτόματη ενημέρωση εφαρμογών + Προσπαθήστε να εγκαταστήσετε αυτόματα ενημερώσεις + Διαθέτει μη-ελεύθερα στοιχεία + Ο διακομιστής απέτυχε να παράσχει νέο πακέτο. + Δε μπόρεσε να συνδεθεί με τον διακομιστή + Περιέχει μη ασφαλές για εργασία περιεχόμενο + Το Shizuku δεν εκτελείται + Ειδικές Πιστώσεις + Το Shizuku δεν είναι εγκατεστημένο + Σύρσιμο Αρχικής Οθόνης + Επιτρέψτε στον χρήστη να συρθεί μεταξύ σελίδων στην αρχική οθόνη + Αντιγραφή + Το παρακάτω αποθετήριο δεν βρέθηκε + Η θύρα Proxy μπορεί να είναι μόνο Ακέραιος + Εισαγωγή Ρυθμίσεων + Εισαγωγή/Εξαγωγή + Εισαγωγή ρυθμίσεων και αγαπημένων από το αρχείο + Εξαγωγή Ρυθμίσεων + Εξαγωγή όλων των αποθετηρίων σε αρχείο + Εισαγωγή Αποθετηρίων + Εξαγωγή ρυθμίσεων και αγαπημένων σε αρχείο + Εξαγωγή Αποθετηρίων + Εισαγωγή όλων των αποθετηρίων από το αρχείο + \ No newline at end of file diff --git a/core/common/src/main/res/values-eo/strings.xml b/core/common/src/main/res/values-eo/strings.xml new file mode 100644 index 0000000..518bed2 --- /dev/null +++ b/core/common/src/main/res/values-eo/strings.xml @@ -0,0 +1,234 @@ + + + Ĝisdatigoj + Grandeco + %s licenco + Sesia Instalilo + Fingrospuro + Nekongrua versio + + Horo + Horoj + + Ne eblis elŝuti %s + Fontkodo ne plu disponebla + Ĉi tiu versio estas subskribita per malsama atestilo ol tiu instalita sur via aparato. Unue malinstalu tion. + Konservi + Sinkronigi deponejojn aŭtomate + Ĝisdatigi ĉion + Hejmekrana Glitado + Ĉi tiu versio estas pli malnova ol tiu instalita sur via aparato. Unue malinstalu tion. + Uzi Material You kolorhaŭton + Nur ĉe Wi-Fi ⳤ Ŝargado + Sinkronigi deponejojn + Radika Instalilo + Ago malsukcesis + +%d pli + Instalilo + Nevalidaj metadatenoj. + Via %1$s (API-versio %2$d) ne estas subtenata. %3$s + Nur sur Wi-Fi + Pasvorto + Nesubskribita + Intervalo de purigado de APK + Nesubskribita. Ne eblis kontroli la aplikliston. Atentu elŝutante aplikaĵojn el nesubskribitaj deponejoj. + Enhavas enhavon ne taŭgan por laboro + Elekti spegulon + Havas neliberajn komponantojn + Promocias ne-liberajn retservojn + Montri listanimacion sur la ĉefpaĝo + Sinkroniganta %s… + Favoratoj + Spuras aŭ raportas vian agadon + Nevalida subskribo. + Retejo + Neniuj instalitaj aplikaĵoj + Kontraŭ-funkcioj + Nevalida servila respondo. + Kunfandi %s + Atendante komenci elŝuton… + Provi instali ĝisdatigojn aŭtomate + Elŝutanta + Esplori + Montri sciigon kiam novaj versioj estas disponebla + Sugestita + Permesoj + Ordigi ⳤ Filtri + Ĉu malfermi %s\? + Indekso ne povis esti validigita. + Novaj versioj de aplikaĵoj disponeblaj + Ĉiuj viaj aplikaĵoj estas ĝisdatigitaj + Havas neliberajn dependecojn + Ŝanĝprotokolo + Shizuku ne kuras + Nekonata + + Tago + Tagoj + + Ne eblis sinkronigi %s + Instalante + Via %1$s platformo ne estas subtenata. Subtenataj platformoj: %2$s. + Deponejo neatingebla + Rekomenci LeOS-Droid por vidi ŝanĝojn + Uzantnomo mankas + Konektanta… + Ĝisdatigi + Montri pli + Mankas funkcioj. + Neniuj disponeblaj aplikaĵoj + Altrudi purigi + Haŭto + SOCKS prokurilo + Pasvorto mankas + Priskribo + Material You + Celo + Atendante komenci instaladon… + Instalita + Nomo + Subskribite per nesekura algoritmo + Ne eblis konekti al servilo + Fontkodo + Uzantnomo + Versio %s + Hela + Ĉiuj aplikaĵoj + Instalitaj aplikaĵoj + Prokura haveno + Postulas %s + Forigi + Deponejo + Nur kongrua kun %s + Servilo malsukcesis provizi novan pakaĵon. + Sugesti instali malstabilajn versiojn + Versio + OK + Ne eblas fari iujn agojn. + Personigo + Havas sekurecajn vundeblecojn + Alia + Subskribo %s + Ligilo kopiita + Prilaboranta %1$s… + Shizuku Instalilo + Novaj aplikaĵoj + Kopii + Malinstali + Vi ne havas interretan konekton + Preterpasi + Elŝutis %s + Ne povas redakti deponejon ĉar ĝi nun sinkronigas. + Prokura haveno povas nur esti Entjero + Nombro de aplikaĵoj + Nekongrua kun %s + Reteraro + Licenco + Aŭtomate ĝisdatigi aplikaĵoj + Ekrankopioj + Lastatempe ĝisdatigita + Jam ekzistas + Aŭtora retejo + Detaloj + Redakti deponejon + Ordo de ordigo + Ignori ĉi tiun version + Nekontrolita + HTTP prokurilo + Aplikaĵo + Ĉiam + Adreso + Silenta Instalo + Neniu priskribo disponebla + Nevalida fingrospura formato + Konservanta detalojn… + Ligiloj + Havas reklamon + Malhela + Kio novas + Premu por instali. + Nuligi + Cimspurilo + Nevalidaj permesoj. + Enhavas neliberajn amaskomunikilojn + Purigas redundajn dosierojn + Promocias neliberan programaron + Lanĉi + Neniu prokurilo + Nigra + Sinkroniganta + Lingvo + Instalaj Tipoj + Malnova-funkcia instalilo + Serĉi + Nekonata eraro. + Haŭtoj + Enlistigi Animacioj + Nevalida dosierformato. + + %d aplikaĵo havas novan version. + %d aplikaĵoj kun novaj versioj. + + Permesi al la uzanto gliti inter paĝoj en la hejmekrano + Prokura tipo + Ĉu forigi la deponejon\? + Ebligi la deponejon + Projekto retejo + Donaci + Aldoni deponejo + Permesi al Supra Aplika Breto Etendi + Ne eblis validigi %s + Esplori + Montri Malpli + Neniam + Ne povis trovi tiajn aplikaĵojn + La minimuma API-versio estas %d. + Permesi al supra aplika breto etendi kaj maletendi + Periodo por kontroli kaj forigi elŝutitajn dosierojn + Agordoj + Nevalida adreso + La kontraŭflua fontkodo ne estas libera + Nekongruaj versioj + Aŭtora retpoŝto + Kontrolanta deponejon… + Versioj + La maksimuma API-versio estas %d. + Ĉi tiu deponejo ankoraŭ ne estis uzata. Ŝaltu ĝin por vidi la aplikaĵojn en ĝi. + Ne eblis analizi la indeksan dosieron. + Instali + Sistemo + Konfirmo + Sekva deponejo ne estis trovita + Specialaj Kreditoj + Kunhavigi + Ŝanĝoj + Nevalida uzantnomo formato + Sciigi pri ĝisdatigoj + Provizite de %s + Montri aplikaĵajn versiojn nekongruajn kun la aparato + Montri malnovajn versiojn + Shizuku ne estas instalita + Malstabilaj ĝisdatigoj + Prokura ĉefkomputilo + Ignori ĉiujn novajn versiojn + Elŝutanta %s… + Ne eblis kontroli integrecon. + Ne eblis trovi tiun aplikaĵon + Prokurilo + Plej lasta + Kreditoj + Deponejoj + Permesi radikan permeson por silenta instalo + Kompilita por sencimigado + Nekonata: %s + Importi Agordojn + Importi/Eksporti + Importi agordojn kaj favoratojn el dosiero + Eksporti Agordojn + Eksporti ĉiujn deponejojn al dosiero + Importi Deponejojn + Eksporti agordojn kaj favoratojn al dosiero + Eksporti Deponejojn + Importi ĉiujn deponejojn el dosiero + Ne povas malfermi ligilon + \ No newline at end of file diff --git a/core/common/src/main/res/values-es/strings.xml b/core/common/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..7cc0f02 --- /dev/null +++ b/core/common/src/main/res/values-es/strings.xml @@ -0,0 +1,237 @@ + + + Acción fallida + Agregar repositorio + Dirección + Todas las aplicaciones + Todas sus aplicaciones están actualizadas + Ya existe + Siempre + Negro (AMOLED) + Anti-funciones + Aplicación + No se pudo encontrar esa aplicación + Correo electrónico del autor + Sitio web del autor + Explorar + Registro de incidencias + Cancelar + No se puede editar el repositorio ya que se está sincronizando ahora mismo. + Registro de cambios + Cambios + Comprobando repositorio… + Compilado para la depuración + Confirmación + Conectando… + Contiene medios no libres + No se ha podido descargar %s + No se ha podido sincronizar %s + No se ha podido validar %s + Créditos + Oscuro + Eliminar + ¿Eliminar repositorio\? + Descripción + Detalles + Donar + %s descargado + Descargando + Descargando %s… + Editar repositorio + Formato de archivo no válido. + Huella digital + Contiene publicidad + Contiene dependencias no libres + Contiene vulnerabilidades de seguridad + Respuesta del servidor no válida. + Proxy HTTP + Ignorar todas las nuevas versiones + Ignorar esta versión + Tu %1$s (versión de la aplicación %2$d) no es compatible. %3$s + La versión máxima de la API es %d. + La versión mínima de la API es %d. + Características que faltan. + Esta versión es más antigua que la instalada en su dispositivo. Desinstala esa primero. + Tu plataforma %1$s no está soportada. Plataformas soportadas: %2$s. + Esta versión está firmada con un certificado diferente al instalado en tu dispositivo. Desinstala ese primero. + Versión incompatible + Versiones incompatibles + Mostrar versiones de aplicaciones incompatibles con el dispositivo + Incompatible con %s + Instalar + Tipos de Instalación + Instalado + No se ha podido comprobar la integridad. + Dirección no válida + Formato de huella digital no válido + Metadatos no válidos. + Permisos no válidos. + Firma inválida. + Formato del nombre de usuario inválido + Ejecutar + Licencia + Licencia %s + Claro + Enlace copiado + Enlaces + Animaciones de la lista + Mostrar la animación de la lista en la página principal + Uniendo %s + Nombre + Error en la red + Nunca + Nuevas versiones de aplicaciones disponibles + No hay aplicaciones disponibles + No hay aplicaciones instaladas + Sin ninguna descripción + No se ha podido encontrar ninguna aplicación de este tipo + Sin proxy + Notificar las actualizaciones + Mostrar una notificación cuando haya nuevas versiones disponibles + Número de aplicaciones + Aceptar + Solo compatible con %s + Solo con Wi-Fi + ¿Quieres abrir %s\? + Otros + No se ha podido analizar el archivo de índice. + Contraseña + Falta la contraseña + Permisos + +%d más + Ajustes + Procesando %1$s… + Página web del proyecto + Promueve servicios de red no libres + Promueve el software no libre + Proporcionado por %s + Proxy + Host del proxy + Puerto del proxy + Tipo de proxy + Actualizado recientemente + Repositorios + Repositorio + Este repositorio no se ha utilizado todavía. Actívalo para ver las aplicaciones que contiene. + Sin firmar. No se ha podido verificar la lista de aplicaciones. Ten cuidado al descargar aplicaciones de repositorios no firmados. + Requiere %s + Instalación silenciosa + Conceder el permiso de root para las instalaciones silenciosas + Guardar + Guardando detalles… + Capturas de pantalla + Buscar + Selecciona un espejo + Compartir + Mostrar más + Mostrar versiones anteriores + Firma %s + Firmado con un algoritmo no seguro + Tamaño + Omitir + Proxy SOCKS + Ordenado + Código fuente + El código fuente ya no está disponible + Sugerencias + Sincronizar repositorios + Sincronización automática de repositorios + Sincronización + Sincronizando %s… + Sistema + Pulse para instalar. + Objetivo + Tema + Temas + Rastrea o informa de tu actividad + Desinstalar + Desconocido + Error desconocido. + Desconocido: %s + No firmado + Actualizaciones inestables + Sugerir la instalación de versiones inestables + No verificado + Actualizar + Actualizaciones + El código fuente no es libre + Nombre de usuario + Falta el nombre de usuario + El índice no pudo ser validado. + Versión + Versión %s + Versiones + A la espera de iniciar la descarga… + Novedades + Página web + + La aplicación %d tiene una nueva versión. + Las aplicaciones %d tienen nuevas versiones. + Las aplicaciones %d tienen nuevas versiones. + + Idioma + Personalización + Mostrar menos + Lo más reciente + Explorar + Aplicaciones instaladas + Ordenar y filtrar + Actualizar todo + Nuevas aplicaciones + Instalador de root + Instalador + Instalador de sesión + Instalador heredado + Instalador de Shizuku + Intervalo de limpieza del APK + Periodo para comprobar y eliminar los archivos descargados + + Día + Días + Días + + + Hora + Horas + Horas + + Solo en Wi-Fi y cargando + No se pueden realizar ciertas acciones. + No hay conexion a internet + Permitir que la barra superior de aplicaciones se expanda + Permitir que la barra superior de la aplicación se expanda y se contraiga + Utiliza el tema de color Material You + Material You + Favoritas + Repositorio inaccesible + Forzar la limpieza + Limpiar archivos redundantes + Habilitar el repositorio + Instalando + Reinicia LeOS-Droid para ver los cambios + Esperando para iniciar la instalación… + Actualización automática de aplicaciones + Intentar instalar actualizaciones automáticamente + Contiene componentes no libres + El servidor no ha podido proporcionar un nuevo paquete. + No se ha podido conectar con el servidor + Shizuku no se está ejectando + Contenido no apto para el trabajo + Shizuku no está instalado + Gracias a + Deslizamiento por la pantalla de inicio + Permitir al usuario pasar de una página a otra en la pantalla de inicio + Copiar + No pudimos encontrar el siguiente repositorio + El puerto proxy sólo puede ser un número entero + Configuración de la importación + Importar/Exportar + Importar los ajustes y los favoritos desde un archivo + Ajustes de la exportación + Exportar todos los repositorios a un archivo + Importar los repositorios + Exportar los ajustes y los favoritos a un archivo + Exportación de los repositorios + Importar todos los repositorios del archivo + No se puede abrir el enlace + \ No newline at end of file diff --git a/core/common/src/main/res/values-fa/strings.xml b/core/common/src/main/res/values-fa/strings.xml new file mode 100644 index 0000000..b4dc18b --- /dev/null +++ b/core/common/src/main/res/values-fa/strings.xml @@ -0,0 +1,212 @@ + + + ناموفق بود + افزودن مخزن + آدرس + همهٔ برنامه‌ها + همهٔ برنامه‌های شما به‌روز هستند + از قبل وجود دارد + سیاه + برنامه + آن برنامه پیدا نشد + همیشه + ایمیل سازنده + وبسایت سازنده + کاوش + ردیاب باگ + لغو + تغییرات + توضیحات + تغییرات + پادویژگی‌ها + بررسی مخزن … + تاییدیه + درحال‌اتصال… + دانلود ناموفق %s + کامپایل‌شده جهت خطایابی + اعتبارسنجی ناموفق %s + دست‌اندرکاران + تاریک + حذف + تبادل ناموفق %s + جزییات + دانلود شده %s + درحال‌دانلود + درحال‌دانلود %s … + فرمت فایل نادرست است. + دارای تبلیغات + ویرایش مخزن + اثرانگشت + پروکسی HTTP + بیخیال این نسخه + چیزت %1$s (نسخهAPIت %2$d) پشتیبانی نمیشه. %3$s + حداکثر نسخهAPI اینه %d. + حداقل نسخهAPI اینه %d. + قابلیت‌هایی را ندارد. + این نسخه قدیمی‌تر از اونی که روی دستگاه شما نصب هست. اول اونو حذف کن. + این نسخه با کلید متفاوتی امضاء شده نسبت به نسخه‌ای که در دستگاه شما نصب شده. اول اونو حذف کن. + انواع نصب + نصب + فراداده نادرست. + دسترسی‌های نادرست. + لینک‌ها + نمایش لیست انیمیشن در صفحه‌اصلی + ادغام %s + نام + خطای شبکه + هرگز + نسخه‌های جدیدی از اپلیکیشن‌ها موجوده + خبرم کن درمورد نسخه جدید برنامه‌ها + تایید + تنها با %s سازگار است + فقط با وایفای + دیگر + فایل ایندکس قابل تحلیل نیست. + باز کردن %s ؟ + این مخزن هنوز استفاده نشده. فعالش کن تا اپلیکیشن‌های داخلشو ببینی. + ناشناس + لغونصب + منتظر شروع دانلود… + نسخه‌ها + وقفه پاکسازی APK + دوره بررسی و حذف فایل های دانلود شده + دارای حفره‌های امنیتی + پاسخ نادرست از مرکز. + نسخه‌های ناسازگار + ناسازگاری با %s + امضای نادرست. + لیست انیمیشن‌ها + هیچ اپلیکیشنی فراهم نیست + هیچ اپلیکیشنی نصب نیست + تعداد اپلیکیشن‌ها + وایفای یا درحال‌شارژ + کلمه‌عبور + کلمه‌عبور نیست + %d+ بیشتر + تنظیمات + درحال‌پردازش %1$s … + اجازه دسترسی روت برای نصب بدون‌پرسش + امضاء %s + با الگوریتم ناامن امضاء شده + حجم + بیخیال + پروکسی socks + سورس کد + پیشنهادات + آپدیت مخازن + آپدیت خودکار مخازن + درحال‌آپدیت + سیستم + بزن نصب شه. + آپدیت + سورس‌کد اصلی رایگان نیست + زبان + شخصی‌سازی + نمایش کمتر + آخرین + اکتشاف + آپدیت همه + ترتیب و فیلتر + نصاب روت + نصاب + نصاب قدیمی + نصاب نشست + نصاب شیزوکو + نصب‌شده + اجرا + ساختار نادرست نام‌کاربری + لایسنس + روشن + %s جواز + پروکسی + ارتقاء خدمات پولی شبکه + ارتقاء برنامه پولی + سرور پروکسی + پورت پروکسی + مخزن‌ها + مخزن + نیازمند %s + نصب بدون‌پرسش + اسکرین‌شات‌ها + جستجو + از کجا دانلود کنم + اشتراک + ذخیره + درحال‌ذخیره جزییات… + نمایش بیشتر + نمایش نسخه‌های قدیمی + هدف + قالب + قالب‌ها + رهگیری یا گزارش فعالیت شما + خطای ناشناخته. + ناشناخته: %s + امضاءنشده + آپدیت‌های ناپایدار + تایید نشده + نام‌کاربری + ایندکس قابل تایید شدن نیست. + چه خبر + وبسایت + نام‌کاربری نیست + + بروزرسانی برای %d برنامه موجود می باشد. + بروزرسانی برای %d برنامه موجود می باشد. + + + روز + روزها + + + ساعت + ساعات + + حمایت‌مالی + این مخزن حذف شود؟ + هنگام یکپارچه‌سازی مخزن نمی‌توان آن را ویرایش کرد. + حاوی محتوای پولی + دارای وابستگی‌های پولی + بیخیال همه نسخه‌های جدید + نسخه ناسازگار + پلتفرم %1$s پشتیبانی نمیشه. پلتفرم پشتیبانی شده: %2$s. + نمایش نسخه‌هایی که با دستگاه من ناسازگار هستند + بررسی صحت ناموفق بود. + ساختار نادرست اثرانگشت + آدرس ناردست + لینک کپی شد + بدون پروکسی + هیچ توضیحاتی موجود نیست + چنین اپلیکیشنی یافت نشد + وقتی نسخه جدیدی منتشر شد، اطلاع بده + دسترسی‌ها + وبسایت پروژه + نوع پروکسی + ارائه توسط %s + اخیرا آپدیت شده + امضاءنشده. لیست اپلیکیشن قابل اعتماد نیست. حین دانلود اپ از مخازن بدون‌امضاء احتیاط کنید. + اولویت ترتیب + پیشنهاد نصب نسخه‌های آزمایشی + سورس‌کد دیگه موجود نیست + درحال‌آپدیت %s … + اپلیکیشن‌های نصب شده + آپدیت‌ها + برنامه‌های جدید + نسخه + نسخه %s + شما هیچ اتصال اینترنتی ندارید + قادر به انجام برخی اقدامات خاص نیست. + به نوار بالای برنامه اجازه دهید تا گسترش یابد + به نوار بالای برنامه اجازه دهید تا گسترده و فشرده شود + Material You + از تم رنگی material you استفاده کنید + مخزن را فعال کنید + پاکسازی اجباری + برای مشاهده تغییرات، LeOS-Droid را مجددا راه اندازی کنید + موارد دلخواه + مخزن قابل دسترسی نیست + به‌روز رسانی خودکار برنامه‌ها + فایل های اضافی را پاک می کند + سعی می‌کند به‌روزرسانی‌ها را به صورت خودکار نصب کند + در حال نصب + در انتظار شروع نصب… + \ No newline at end of file diff --git a/core/common/src/main/res/values-fi/strings.xml b/core/common/src/main/res/values-fi/strings.xml new file mode 100644 index 0000000..26ac1d7 --- /dev/null +++ b/core/common/src/main/res/values-fi/strings.xml @@ -0,0 +1,233 @@ + + + Osoite + Kaikki sovellukset + Kaikki sovelluksesi ovat ajan tasalla + On jo olemassa + Aina + Musta + Anti-ominaisuudet + Sovellus + Toiminta epäonnistui + Lisää ohjelmalähde + Tätä sovellusta ei löytynyt + Tekijän sähköposti + Tekijän verkkosivusto + Tutustu + Vikojen jäljitin + Poista + Tumma + Ei voitu validoida %s + Käännetty virheenkorjausta varten + Vahvistus + Yhdistetään… + Sisältää ei-vapaata mediaa + Ei voitu ladata %s + Ei voitu synkronoida %s + Kiitokset + Peruuta + Arkistoa ei voi muokata, koska se synkronoidaan juuri nyt. + Muutosloki + Muutokset + Tarkistetaan ohjelmavarastoa… + Sormenjälki + Väärä tiedostomuoto. + Muokkaa ohjelmalähdettä + Ladataan %s… + Ladataan + Ladattu %s + Lahjoita + Tiedot + Kuvaus + Poista ohjelmalähde\? + Sisältää mainoksia + Ei-vapaita riippuvuuksia + Sisältää tietoturva-aukkoja + Palvelimen vastaus on virheellinen. + HTTP-välityspalvelin + Jätä kaikki uudet versiot huomiotta + Jätä tämä versio huomiotta + Sinun %1$s (API-versio %2$d) ei ole tuettu. %3$s + Suurin API-versio on %d. + Vähimmäis API-versio on %d. + Puuttuvat ominaisuudet. + Tämä versio on vanhempi kuin laitteeseesi asennettu versio. Poista se ensin. + Yhteensopimaton versio + Yhteensopimattomat versiot + Näytä sovellusversiot, jotka eivät ole yhteensopivia laitteen kanssa + Yhteensopimaton %s + Asenna + Asennustyypit + Asennettu + Eheyttä ei voitu tarkistaa. + Virheellinen osoite + Virheellinen sormenjälkimuoto + Virheelliset metatiedot. + Virheelliset käyttöoikeudet. + Virheellinen allekirjoitus. + Virheellinen käyttäjänimen muoto + Avaa + Lisenssi + %s -lisenssi + Vaalea + Linkki kopioitu + Linkit + Luettelon animaatiot + Näytä luettelon animaatio pääsivulla + Yhdistetään %s + Nimi + Verkkovirhe + Ei koskaan + Uusia versioita sovelluksista saatavilla + + %d sovelluspäivitys saatavilla. + %d sovelluspäivitystä saatavilla. + + Ei saatavilla olevia sovelluksia + Ei asennettuja sovelluksia + Kuvausta ei ole saatavilla + Tällaisia sovelluksia ei löydy + Toimittanut %s + Välityspalvelimen isäntä + Välityspalvelimen portti + Välityspalvelimen tyyppi + Äskettäin päivitetty + Ohjelmavarastot + Ohjelmavarasto + Vaatii %s + Hiljainen asennus + Salli pääkäyttäjän oikeudet hiljaisiin asennuksiin + Tallenna + Tallennetaan tietoja… + Kuvakaappaukset + Vain Wi-Fi verkossa + Avaa %s\? + Muut + Indeksitiedostoa ei voitu jäsentää. + Salasana + Salasana puuttuu + Käyttöoikeudet + +%d lisää + Asetukset + Käsitellään %1$s… + Projektin verkkosivusto + Edistää ei-vapaita verkkopalveluja + Haku + Valitse peilipalvelin + Ei proxya + Ilmoita sovellusten uusista versioista + Näytä ilmoitus, kun uusia versioita on saatavilla + Yhteensopiva vain %s + Jaa + Näytä lisää + Näytä vanhemmat versiot + OK + Seuraa tai raportoi toiminnastasi + Poista + Tuntematon + Tuntematon virhe. + Tuntematon: %s + Allekirjoittamaton + Epävakaat päivitykset + Vahvistamaton + Alkuperäinen lähdekoodi ei ole vapaa + Verkkosivusto + Mitä uutta + Odotetaan latauksen aloittamista… + Käyttäjätunnus puuttuu + Käyttäjänimi + Allekirjoitus %s + Allekirjoitettu turvattomalla algoritmilla + Koko + Ohita + SOCKS-välityspalvelin + Lajittelujärjestys + Lähdekoodi + Lähdekoodia ei ole enää saatavilla + Suositeltu + Synkronoi ohjelmalähteet + Synkronoi ohjelmalähteet automaattisesti + Synkronoidaan + Synkronoidaan %s… + Teemat + Teema + Kohde + Napauta asentaaksesi. + Järjestelmä + Päivitys + Päivitykset + Edistää ei-vapaita ohjelmia + Tätä ohjelmavarastoa ei ole vielä käytetty. Ota se käyttöön nähdäksesi siinä olevat sovellukset. + Välityspalvelin + Allekirjoittamaton. Sovellusluetteloa ei voitu tarkistaa. Ole varovainen ladatessasi sovelluksia allekirjoittamattomista arkistoista. + Versio %s + Indeksiä ei voitu vahvistaa. + Ehdota epävakaiden versioiden asentamista + Tämä versio on allekirjoitettu eri varmenteella kuin laitteeseesi asennettu versio. Poista se ensin. + Alustasi %1$s ei ole tuettu. Tuetut alustat: %2$s. + Versiot + Versio + Sovellusten määrä + Mukauttaminen + Kieli + Näytä vähemmän + Uusimmat + Tutustu + Päivitä kaikki + Asennetut sovellukset + Uudet sovellukset + Lajittele ja suodata + Asentaja + Vanha asentaja + Sessioasentaja + Root-asentaja + Shizuku-asentaja + APK:n puhdistusväli + Aika ladattujen tiedostojen tarkistamiseen ja poistamiseen + + Päivä + Päivää + + + tunti + tuntia + + Vain Wi-Fi verkossa latauksessa ollessa + Tiettyjä toimia ei voida suorittaa. + Salli yläosan sovelluspalkin laajeneminen + Salli yläosan sovelluspalkin laajentua ja tiivistyä + Ei internet yhteyttä + Suosikit + Material You + Käytä Material You -väriteemaa + Ota ohjelmalähde käyttöön + Pakota puhdistus + Puhdistaa tarpeettomat tiedostot + Ohjelmavarasto ei ole tavoitettavissa + Asentaa + Käynnistä LeOS-Droid uudelleen nähdäksesi muutokset + Odotetaan asennuksen aloittamista… + Päivitä sovelluksia automaattisesti + Yritä asentaa päivitykset automaattisesti + Aloitusnäytön pyyhkäisy + Sisältää NSFW-sisältöä + Sisältää ei-vapaita komponentteja + Shizuku ei ole käynnissä + Palvelimeen ei saatu yhteyttä + Palvelin ei pystynyt toimittamaan uutta pakettia. + Kopioi + Välityspalvelimen portti voi olla vain kokonaisluku + Salli käyttäjän pyyhkäistä sivujen välillä aloitusnäytössä + Seuraavaa ohjelmavarastoa ei löytynyt + Erityiskiitokset + Shizuku ei ole asennettu + Tuontiasetukset + Tuo/Vie + Tuo asetukset ja suosikit tiedostosta + Vie asetukset + Vie kaikki tietovarastot tiedostoon + Tuo tietovarastoja + Vie asetukset ja suosikit tiedostoon + Vie tietovarastot + Tuo kaikki tietovarastot tiedostosta + \ No newline at end of file diff --git a/core/common/src/main/res/values-fr/strings.xml b/core/common/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..91d0544 --- /dev/null +++ b/core/common/src/main/res/values-fr/strings.xml @@ -0,0 +1,237 @@ + + + Adresse + L’action a échoué + Existe déjà + Anti-fonctionnalités + Compilé pour le débogage + Noir + Impossible de trouver cette application + Courriel de l’auteur + Site web de l’auteur + Explorer + Traqueur de bogues + Annuler + Impossible de modifier le dépôt car il est en cours de synchronisation. + Journal des modifications + Changements + Vérification du dépôt… + Confirmation + Connexion… + Contient des médias non libres + Impossible de synchroniser %s + Sombre + Supprimer + Supprimer le dépôt \? + Description + Détails + Faire un don + Téléchargé %s + Téléchargement en cours + Téléchargement %s… + Modifier le dépôt + Format de fichier non valide. + Empreinte + Licence %s + Lancer + Adresse invalide + Installer + Ignorer toutes les nouvelles versions + Ignorer cette version + Autorisations + +%d plus + Paramètres + Ouvrir %s \? + OK + + %d application a une nouvelle version. + %d applications avec de nouvelles versions. + %d applications avec de nouvelles versions. + + Aucune application disponible + Liens + Nom + Erreur réseau + Jamais + Nouvelles versions des applications disponibles + Suit ou signale votre activité + Le code source n’est plus disponible + Suggérée + Synchroniser les dépôts + Code source + Taille + Ignorer + Captures d’écran + Chercher + Sélectionnez un miroir + Partager + Afficher plus + Enregistrer + Enregistrement des détails… + Récemment mis à jour + Dépôts + Inconnu + Toujours + Application + Version + Version %s + Versions + En attente du lancement du téléchargement… + Quoi de neuf + Site web + Mettre à jour + Mises à jour + Non vérifié + Inconnu : %s + Erreur inconnue. + Ajouter un dépôt + Toutes vos applications sont à jour + Dépôt + Promeut des services réseau non libres + Promeut des logiciels non libres + Toutes les applications + Appuyez pour installer. + Fourni par %s + Site web du projet + Impossible de télécharger %s + Thème + Désinstaller + Impossible de valider %s + Système + Cible + Mot de passe manquant + Crédits + Thèmes + Clair + Versions incompatibles + Afficher les versions d’applis incompatibles avec l’appareil + Licence + Mot de passe + Présente des failles de sécurité + Lien copié + Version incompatible + Contient des annonces + Réponse du serveur non valide. + Proxy HTTP + Fonctionnalités manquantes. + Cette version est plus ancienne que celle qui est installée sur votre appareil. Désinstallez-la d’abord. + Afficher une notification quand de nouvelles versions sont disponibles + Ce dépôt n’a pas encore été utilisé. Activez-le pour voir les applications qu’il contient. + Aucune application installée + Non signé. Impossible de vérifier la liste d’applis. Soyez prudents lorsque vous téléchargez des applis de dépôts non signés. + Dépend d’applications qui ne sont pas libres + La version maximale de l’API est %d. + La version minimale de l’API est %d. + Votre plateforme %1$s n’est pas prise en charge. Plateformes prises en charge : %2$s. + Cette version est signée avec un certificat différent de celle installée sur votre appareil. Désinstallez-la d’abord. + Incompatible avec %s + Types d’installation + Installé + Métadonnées invalides. + Permissions invalides. + Signature invalide. + Format du nom d’utilisateur non valide + Aucune description disponible + Impossible de trouver de telles applications + Aucun proxy + Notifier les mises à jour + Nombre d’applications + Compatible seulement avec %s + Seulement avec Wi-Fi + Autre + Proxy + Adresse du proxy + Port du proxy + Type de proxy + Installation silencieuse + Autoriser la permission de l’utilisateur root pour les installations silencieuses + Afficher les versions plus anciennes + Signature %s + Signé avec un algorithme qui n’est pas sécurisé + Proxy SOCKS + Synchroniser les dépôts automatiquement + Synchronisation en cours + Synchronisation de %s en cours… + Non-signé + Mises à jour instables + Le code source n’est pas entièrement libre + Nom d’utilisateur + Nom d’utilisateur manquant + L’index n’a pas pu être validé. + Votre %1$s (API version %2$d) n’est pas pris en charge. %3$s + Impossible de vérifier l’intégrité. + Format d’empreinte digitale non valide + Animations de listes + Traitement de %1$s… + Impossible d’analyser le fichier d’index. + Fusionner %s + Afficher l’animation de la liste sur la page principale + Ordre de tri + Requiert %s + Suggérer l’installation de versions instables + Langue + Personnalisation + Afficher moins + Le plus récent + Trier et filtrer + Nouvelles applications + Explorer + Tout mettre à jour + Applications installées + Installateur de session + Installateur hérité + Installateur + Installateur racine + Installateur Shizuku + Intervalle de nettoyage APK + Période de vérification et de suppression des fichiers téléchargés + + Jour + Journées + Journées + + + Heure + Heures + Heures + + Uniquement sur Wi-Fi et charge + Impossible d’effectuer certaines actions. + Vous n’avez pas de connexion internet + Autoriser l\'expansion et la réduction de la barre supérieure de l\'appli + Autoriser l\'expansion de la barre supérieure de l\'appli + Utiliser le thème couleur Material You + Material You + Favoris + Dépôt inaccessible + Nettoyage forcer + Nettoyer les fichiers redondants + Activer le dépôt + Installation + Redémarrez LeOS-Droid pour voir les changements + En attente du démarrage de l\'installation… + Mise à jour automatique des applis + Essayez d\'installer les mises à jour automatiquement + A des composants non libres + Le serveur n\'a pas fourni de nouveau paquet. + Impossible de se connecter au serveur + Shizuku n\'est pas en fonction + Shizuku n\'est pas installé + Contient des contenus non adaptés pour le travail + Crédits spéciaux + Balayage de l\'écran d\'accueil + Permettre à l\'utilisateur de passer d\'une page à l\'autre dans l\'écran d\'accueil + Copie + Le dépôt suivant n\'a pas été trouvé + Le port proxy ne peut être qu\'un entier + Importer les paramètres + Import/Export + Importer les paramètres et favoris à partir d\'un fichier + Exporter les paramètres + Exporter tous les dépôts vers un fichier + Importer les dépôts + Exporter les paramètres et favoris vers un fichier + Exporter les dépôts + Importer tous les dépôts à partir d\'un fichier + Impossible d\'ouvrir le lien + \ No newline at end of file diff --git a/core/common/src/main/res/values-gl/strings.xml b/core/common/src/main/res/values-gl/strings.xml new file mode 100644 index 0000000..4437fea --- /dev/null +++ b/core/common/src/main/res/values-gl/strings.xml @@ -0,0 +1,216 @@ + + + Acción errada + Todas as túas aplicacións estopanse o día + Sempre + Sitio web do autor + Engadir repositorio + Enderezo + Rastreador de erros + Cancelar + Non se pode editar o repositorio xa que se está sincronizando agora mesmo. + Rexistro de cambios + Cambios + Comprobando o repositorio… + APK cleanup interval + Period to check and remove downloaded files + Compilado para a depuración + Confirmación + Conectando… + Contén medios non ceibes + Non se puido descargar %s + Non se puido sincronizar %s + Créditos + Escuro + + Día + Días + + Eliminar + Queres eliminar o repositorio\? + Descrición + Descargado %s + Baixando + Baixando %s… + Instalar + Formato de impresión dixital non válido + Licenza %s + Claro + A ligazón copiouse no portapapeis + Ligazóns + Lista de animacións + Mostra a animación da lista na páxina principal + Fusionando %s + Nome + Erro na rede + Xamáis + Novas versións das aplicacións dispoñibles + + %d aplicación ten unha nova versión. + %d aplicacións con versións novas. + + Non hai aplicacións dispoñibles + Non hai aplicacións instaladas + Non ten descrición dispoñible + Non se puido atopar ningunha aplicación deste tipo + Sen proxy + Notificar sobre novas versións das aplicacións + Número de aplicacións + Abrir %s\? + Outra + Non se puido analizar o ficheiro de índice. + Contrasinal + Falta o contrasinal + Permisos + +%d máis + Configuración + Procesando %1$s… + Páxina web do proxecto + Promove servizos de rede non gratuítos + Proporcionado por %s + Host proxy + Porto proxy + Tipo de proxy + Actualizado recentemente + Repositorios + Repositorio + Este repositorio aínda non se utilizou. Accéndeo para ver as aplicacións nel. + Require %s + Capturas de pantalla + Procurar + Seleccione un espello + Compartir + Mostrar máis + Sinatura %s + Asinado mediante un algoritmo non seguro + Tamaño + Saltar + Proxy SOCKS + Suxerido + Sincronizar repositorios + Sincronizar repositorios automaticamente + Sincronización + Sistema + Obxectivo + Tema + Temas + Rastrexa ou informa da túa actividade + Desinstalar + Descoñecido + Erro descoñecido. + Descoñecido: %s + Actualizacións inestables + Sen verificar + Actualizar + Actualizacións + O código fonte non é ceibe + Nome de usuario + Non se atopa o nome de usuario + Versión %s + Versións + Agardando para comezar a descarga… + Que hai de novo + Páxina web + Lingua + Personalización + Mostrar menos + Derradeiro + Explora + Actualiza todo + Aplicacións instaladas + Ordear e filtrar + Novas aplicacións + Todas as aplicacións + Negro + Xa existe + Correo electrónico do autor + Anti-características + Aplicacions + Explorar + Versión non valida + Non se puido atopar esa aplicación + Non se puido validar %s + Detalles + Resposta do servidor non válida. + Instalado + Doar + Editar fonte + Formato do ficheiro non válido. + Ten publicidade + Ten dependencias non ceives + + Hora + Horas + + Proxy HTTP + Ten vulnerabilidades de seguridade + Pegada dixital + Ignora esta versión + Faltan funcións. + Ignorar todas as novas versións + A tua %1$s (versión da API %2$d) non é compatible. %3$s + Esta versión é máis antiga que a instalada no teu dispositivo. Desinstala iso primeiro. + A túa plataforma %1$s non é compatible. Plataformas compatibles: %2$s. + Lanzamento + A versión máxima da API é %d. + A versión mínima da API é %d. + Versións non validas + Mostra versións de aplicacións incompatibles co dispositivo + Incompatible con %s + Tipos de instalación + Instalador + Instalador de sesións + Instalador raíz + Instalador Shizuku + Esta versión está asinada cun certificado diferente ao instalado no teu dispositivo. Desinstala iso primeiro. + Instalador Legado + Non se puido comprobar a integridade. + Enderezo non válido + Permisos non válidos. + Sinatura non válida. + Formato do nome do usuario non válido + Metadatos errados. + Licenza + Promove o software non libre + Ok + Mostra unha notificación cando hai novas versións dispoñibles + Só compatible con %s + Só con wifi + Só con wifi e carga + Proxy + Instalación silenciosa + Permitir permiso de root para instalacións silenciosas + Sen asinar. Non se puido verificar a lista de solicitudes. Teña coidado ao descargar aplicacións desde repositorios sen asinar. + Gardar + Orde de clasificación + O código fonte xa non está dispoñible + Sincronizando %s… + Gardando detalles… + Mostrar versións antigas + Código fonte + Preme para instalar. + Sen asinar + Suxire instalar versións inestables + Non se puido validar o índice. + Versión + Non se poden realizar determinadas accións. + Non tes conexión a internet + Permitir ca barra das aplicacións superior se amplíe + Permitir cas barras das aplicacións superior se amplíe e contraiga + Material You + Usalo material que coloree o tema + Favoritas + Repositorio inalcanzable + Limpa ficheiros redundantes + Forzala limpeza + Activalo repositorio + Instalando + Reinicia LeOS-Droid para velos cambios + Agardando para iniciala instalación… + Actualizacións automática das aplicacións + Tenta instalar actualizacións automaticamente + Importalos axustes + Importación/exportación + Importalos axustes e favoritos dende un fixeiro + Non se pode abrir a ligazón + \ No newline at end of file diff --git a/core/common/src/main/res/values-hi/strings.xml b/core/common/src/main/res/values-hi/strings.xml new file mode 100644 index 0000000..f285773 --- /dev/null +++ b/core/common/src/main/res/values-hi/strings.xml @@ -0,0 +1,234 @@ + + + कार्य असफल + पता + रिपोसिट्री जोड़ें + रिपोसिट्री एडिट नहीं कर सकते क्योंकि वह अभी सिंक्रनाइज़ हो रही है। + हमेशा + %s सिंक्रनाइज़ नहीं हो सका + कनेक्ट हो रहा है… + %s मान्य नहीं हो सका + सभी एप्लीकेशन + आपके सभी एप्लीकेशन नवीनतम हैं + पहले से ही मौजूद है + काला + विरोधी-विशेषताएं + एप्लीकेशन + ऑथर वेबसाइट + उपलब्ध + बग ट्रैकर + रद्द करें + बदलाव सूची + बदलाव + रिपोसिट्री की जाँच की जा रही है… + डीबॅग करने के लिए कम्पाईल किया + पुष्टीकरण + गैर-नि: शुल्क मीडिया शामिल है + %s डाउनलोड नहीं कर सका + वह एप्लीकेशन नहीं मिली + ऑथर ई-मेल + श्रेय + गहरा + हटाएं + रिपोसिट्री हटाएं\? + विवरण + उल्लेख + डोनेट + %s डाउनलोड किया + डाउनलोड हो रहा है + डाउनलोड हो रहा है %s… + रिपोसिट्री एडिट करें + अमान्य फाइल प्रारूप। + फिंगरप्रिंट + विज्ञापन हैं + अधिकतम API वर्शन %d है। + न्यूनतम API वर्शन %d है। + नामौजूद विशेषताएं। + इसमें सुरक्षा कमजोरियां हैं + अमान्य सर्वर प्रतिक्रिया। + HTTP प्रॉक्सी + सभी नए वर्शन पर ध्यान न दें + इस वर्शन पर ध्यान न दें + आपका %1$s (API वर्शन %2$d) सपोर्टेड नहीं है। %3$s + यह वर्शन आपके डिवाइस पर इंस्टॉल किए गए वर्शन से पुराना है। पहले उसे अनइंस्टॉल कर दें। + आपका %1$s प्लेटफॉर्म सपोर्टेड नहीं है। सपोर्टेड प्लैटफ़ॉर्म: %2$s. + गैर-मुक्त निर्भरताएँ हैं + यह संस्करण आपके डिवाइस पर इंस्टॉल किए गए प्रमाणपत्र से भिन्न प्रमाणपत्र के साथ हस्ताक्षरित है। उसे पहले अनइंस्टॉल करें। + असंगत संस्करण + डिवाइस के साथ असंगत एप्लिकेशन संस्करण दिखाएं + %s . के साथ असंगत + स्थापित करें + स्थापना प्रकार + अनुचित संस्करण + केवल वाई-फ़ाई पर + %s को खोले\? + इस रिपॉजिटरी का अभी तक उपयोग नहीं किया गया है। इसमें एप्लिकेशन देखने के लिए इसे चालू करें। + सहेजें + साइलेंट इंस्टाल के लिए रूट अनुमति दें + स्क्रीनशॉट + ब्यौरा सहेजा जा रहा है… + छोड़ें + सिंक्रनाइज़ किए जा रहे + %s समन्वयित किया जा रहा है… + व्यवस्था + स्थापित करने के लिए टैप करें। + अपस्ट्रीम सोर्स कोड फ्री नहीं है + अपडेट्स + संस्करण + %s संस्करण + संस्करणों + पर्सनलाइजेशन + भाषा + कम दिखाएं + नए एप्लीकेशंस + इंस्टालर + लीगेसी इंस्टालर + सत्र इंस्टॉलर + रूट इंस्टालर + शिज़ुकु इंस्टालर + स्थापित + अखंडता की जांच नहीं कर सका। + अमान्य मेटाडेटा. + अमान्य अनुमतियां। + अमान्य हस्ताक्षर। + अमान्य उपयोगकर्ता नाम प्रारूप + लांच करें + लाइसेंस + %s लाइसेंस + हल्की + लिंक कॉपी किया गया + एनिमेशन की सूची दिखाए + मुख्य पृष्ठ पर एनीमेशन की सूची दिखाएं + %s . को मर्ज कर रहा है + नाम + नेटवर्क त्रुटि + कभी नहीँ + लिंक्स + एप्लिकेशनस के नए संस्करण उपलब्ध हैं + कोई उपलब्ध एप्लिकेशन नहीं + ऐसा कोई एप्लिकेशन नहीं मिला + कोई प्रॉक्सी नहीं + + %d एप्लिकेशन का एक नया संस्करण है। + %d एप्लीकेशंस के नए संस्करण हैं। + + अपडेटस के बारे में सूचित करें + नए संस्करण उपलब्ध होने पर सूचना दिखाएं + अप्लिकेशन संख्या + ठीक है + केवल %s . के साथ संगत + अनुक्रमणिका फ़ाइल को पार्स नहीं कर सका। + पासवर्ड + पासवर्ड गायब + अनुमतियां + +%d अधिक + सेटिंग + %1$s संसाधित किया जा रहा है… + परियोजना वेबसाइट + गैर-मुक्त नेटवर्क सेवाओं को बढ़ावा देता है + गैर-मुक्त सॉफ़्टवेयर को बढ़ावा देता है + प्रॉक्सी होस्ट + प्रॉक्सी पोर्ट + प्रॉक्सी + प्रॉक्सी प्रकार + हाल ही में अपडेट किया + रिपॉजिटरी + %s . की आवश्यकता है + खोज + शेयर + और दिखाओ + पुराने संस्करण दिखाएं + रिपोजिटरीज + एक मिरर चुनें + %s हस्ताक्षर + असुरक्षित एल्गोरिथम का उपयोग करके हस्ताक्षर किए गए + आकार + सॉक्स प्रॉक्सी + सोर्स कोड + स्रोत कोड अब उपलब्ध नहीं है + सॉर्टिंग क्रम + सुझाव + सिंक रिपॉजिटरी + रिपोजिटरी स्वचालित रूप से सिंक्रनाइज़ करें + लक्ष्य + थीम + स्थापना रद्द करें + अनजान + अज्ञात त्रुटि। + अज्ञात: %s + अहस्ताक्षरित + अस्थिर संस्करण स्थापित करने का सुझाव दें + अपडेट + उपयोगकर्ता नाम + उपयोगकर्ता नाम अनुपलब्ध + रूपरंग + डाउनलोड शुरू होने की प्रतीक्षा की जा रही है… + नया क्या है + वेबसाइट + नवीनतम + इंस्टॉल किए गए एप्लिकेशन + सभी अपडेट करें + एक्सप्लोर करें + कोई इंस्टॉल किए गए एप्लिकेशंनस नहीं + गलत पता + अमान्य फ़िंगरप्रिंट प्रारूप + कोई विवरण उपलब्ध नहीं + अस्थिर अद्यतन + अन्य + अहस्ताक्षरित। आवेदन सूची का सत्यापन नहीं किया जा सका। अहस्ताक्षरित रिपॉजिटरी से एप्लिकेशन डाउनलोड करने में सावधानी बरतें। + %s . द्वारा प्रदान किया गया + साइलेंट इंस्टाल + आपकी गतिविधि को ट्रैक या रिपोर्ट करता है + अनुक्रमणिका सत्यापित नहीं की जा सकी। + असत्यापित + छाँटें और फ़िल्टर करें + एपीके सफाई अंतराल + डाउनलोड की गई फ़ाइलों को जांचने और निकालने की अवधि + केवल वाई-फ़ाई व च्राजिंग पर + + दिन + दिन + + + घंटा + घंटे + + कुछ क्रियाएं करने में असमर्थ। + आपका इंटरनैट कनेक्शन चालू नहीं है + टॉप ऐप बार को विस्तृत होने दें + टॉप ऐप बार को विस्तृत और संक्षिप्त होने दें + पसंदीदा + जबरी साफ करो + अनावश्यक फाइलों को साफ करता है + रिपॉजिटरी तक पहुंच नहीं + मटीरियल यू + मटीरियल यू रंग थीम का प्रयोग करें + स्थापना प्रारंभ करने की प्रतीक्षा की जा रही है… + ऐप्स को ऑटो अपडेट करें + अपडेटस को स्वचालित रूप से इंस्टॉल करने का प्रयास करें + रिपॉजिटरी को सक्षम करें + बदलाव देखने के लिए LeOS-Droid को रीस्टार्ट करें + इंस्टॉल कर रहा है + गैर-मुक्त घटक हैं + सर्वर नया पैकेट प्रदान करने में विफल रहा। + सर्वर से कनेक्ट नहीं हो सका + इसमें कार्य के लिए असुरक्षित सामग्री है + Shizuku नहीं चल रहा है + Shizuku इंस्टाल नहीं है + विशेष श्रेय + होम स्क्रीन स्वाइपिंग + उपयोगकर्ता को होम स्क्रीन में पृष्ठों के बीच स्वाइप करने की अनुमति दें + कापी करें + प्रॉक्सी पोर्ट केवल पूर्णांक हो सकता है + निम्नलिखित रिपॉजिटरी नहीं मिली + आयात सेटिंग्स + आयात निर्यात + फ़ाइल से सेटिंग्स और पसंदीदा आयात करें + निर्यात सेटिंग्स + फ़ाइल में सभी रिपॉजिटरी निर्यात करें + रिपॉजिटरी आयात करें + सेटिंग्स और पसंदीदा फ़ाइल में निर्यात करें + रिपॉजिटरीआं निर्यात करें + फ़ाइल से सभी रिपॉजिटरी आयात करें + लिंक नहीं खुल सका + \ No newline at end of file diff --git a/core/common/src/main/res/values-hr/strings.xml b/core/common/src/main/res/values-hr/strings.xml new file mode 100644 index 0000000..a3c2b6d --- /dev/null +++ b/core/common/src/main/res/values-hr/strings.xml @@ -0,0 +1,236 @@ + + + Ova inačica je starija od one instalirane na Vašem uređaju. Potrebno je deinstalirati onu inačicu na Vašem uređaju kako biste mogli instalirati ovu. + Inačice nisu kompatibilne + Prikaži inačice aplikacije koje nisu kompatibilne s mojim uređajem + Zastarjeo instalacijski program + Instalacijski program sesije + Instalacijski program u Root načinu + Instalacijski program Shizuku + Neispravni metapodaci. + Licenca + Nije moguće provjeriti integritet. + Poveznice + Novije inačice aplikacija su dostupne + Neispravan oblik otiska + Neispravne dozvole. + %s licenca + Svijetlo + Poveznica je kopirana + Opis nije dostupan + Nema instaliranih aplikacija + Nije moguće pronaći slične aplikacije + Bez proxy + Obavijesti za ažuriranja + Samo na Wi-Fi mreži + Samo na Wi-Fi mreži i tijekom Punjenja + Zaporka + Postavke + Promovira mrežne usluge koje nisu besplatne + Još niste koristili ovaj repozitorij. Omogućite ga kako biste vidjeli aplikacije koje sadrži. + Prikaži starije inačice + Nije potpisano. Nemoguće potvrditi liste aplikacija. Budite oprezni prilikom preuzimanja aplikacija s nepotpisanih repozitorija. + SOCKS proxy + Omogućite root dozvole kako bi neometana instalacija funkcionirala + Prati i prijavljuje Vašu aktivnost + Nepoznato: %s + Prikaži više + Potpis %s + Nepotpisano + Potpisano nesigurnosnim algoritmom + Veličina + Preskoči + Način sortiranja + Sinkronizacija %s… + Teme + Predloži instalaciju nestabilnih inačica + Korisničko ime + Inačica + Index nije bilo moguće potvrditi. + Inačice + Internet stranica + Inačica %s + Prilagodba + Dodaj repozitorij + Web adresa + Već postoji + Uvijek + Crna + Anti-značajke + Aplikacija + Nije bilo moguće pronaći tu aplikaciju + E-mail adresa autora + Web stranica autora + Alat za praćenje grešaka + Otkaži + Nije moguće urediti repozitorij jer je u tijeku njegova sinkronizacija. + Popis promjena + Promjene + Provjeravam repozitorij… + APK interval čišćenja + Razdoblje za provjeru i uklanjanje preuzetih datoteka + Stvoreno za ispravljanje pogrešaka + Povezujem… + Sadrži medije koji nisu besplatni + Nemoguće preuzeti %s + Nemoguće sinkronizirati %s + Nemoguće potvrditi ispravnost %s + Zasluge + Tamno + + Dan + Dana + Dana + + Opis + Preuzimanje %s… + Neispravan oblik datoteke. + Otisak + Sadrži reklame + Sadrži ovisnosti koje nisu besplatne + + Sat + Sati + Sati + + Zanemari sve novije inačice + Vaš %1$s (API inačica %2$d) nije podržana. %3$s + Najnovije moguća inačica API-ja je %d. + Najstarije moguća inačica API-ja je %d. + Vaša %1$s platforma nije podržana. Podržane platforme: %2$s. + Ova inačica je potpisana drukčijim certifikatom u odnosu na onu instaliranu na Vašem uređaju. Izbrišite ju kako biste mogli instalirati ovu inačicu. + Inačica nije kompatibilna + Nije kompatibilno s %s + Instaliraj + Načini Instalacije + Instalirano + Neispravna web adresa + Neispravan potpis. + Neispravan oblik korisničkog imena + Pokreni + Animacije popisa + Prikaži animacije popisa na početnoj stranici + Stapanje %s + Naziv + Mrežna greška + + %d aplikacija ima noviju inačicu. + %d aplikacije imaju novije inačice. + %d aplikacije imaju novije inačice. + + Prikaži obavijest kada nove inačice postanu dostupne + Broj aplikacija + U redu + Kompatibilno samo s %s + Otvoriti %s\? + Ostalo + Nemoguće otvoriti index datoteku. + Nedostaje zaporka + Dozvole + +%d više + Procesiramb%1$s… + Web stranica projekta + Promovira program koji nije besplatan + Omogućio/la %s + Proxy + Proxy poslužitelj + Proxy priključak + Proxy vrsta + Nedavno ažurirano + Repozitoriji + Repozitorij + Potrebno %s + Neometana instalacija + Spremi + Spremam detalje… + Snimke zaslona + Traži + Odaberite poslužitelj + Podijeli + Izvorni kod + Izvorni kod više nije dostupan + Predloženo + Sinkroniziraj repozitorije + Automatski sinkroniziraj repozitorije + Sinkroniziram + Sistem + Kliknite kako biste instalirali. + Cilj + Tema + Deinstaliraj + Nepoznato + Nepoznata greška. + Nestabilna ažuriranja + Nepotvrđeno + Ažuriraj + Ažuriranja + Glavni izvorni kod nije besplatan + Nedostaje korisničko ime + Čekam na pokretanje preuzimanja… + Što je Novo + Jezik + Prikaži manje + Najnovije + Otkrij + Ažuriraj sve + Instalirane aplikacije + Sortiraj i filtriraj + Sve Vaše aplikacije su ažurne + Nove aplikacije + Radnja neuspjela + Sve aplikacije + Otkrij + Izbriši + Doniraj + Preuzimanje + Potvrda + Uredi repozitorij + Izbrisati repozitorij\? + Zanemariraj ovu inačicu + Detalji + Instalacijski program + Preuzeto %s + Značajke koje nedostaju. + Nema dostupnih aplikacija + Sadrži sigurnosne probleme + Neispravan odgovor poslužitelja. + HTTP proxy + Nikad + Nije moguće izvršiti određene radnje. + Nemate internetsku vezu + Dozvoli rasklapanje i sklapanje gornje trake aplikacije + Dozvoli rasklapanje gornje trake aplikacije + Instaliranje + Material You + Upotrijebite Material You temu boja + Favoriti + Omogućite repozitorij + Prisiliti + Čisti suvišne datoteke + Repozitorij je nedostupan + Automatsko ažuriranje aplikacija + Pokušajte automatski instalirati ažuriranja + Ponovo pokrenite droid-ify da biste vidjeli promjene + Čekajući da započnete instalaciju … + Sadrži komponente koje nisu besplatne + Nije bilo moguće spojiti se s poslužiteljem + Poslužitelj nije uspio dostaviti novi paket. + Prevlačenje početnim zaslonom + Sadrži sadržaj koji nije siguran za rad + Shizuku nije pokrenut + Kopiraj + Proxy port može biti samo cijeli broj + Dopušta korisniku da prelazi između stranica na početnom zaslonu + Sljedeći repozitorij nije pronađen + Posebne zasluge + Shizuku nije instaliran + Uvezi postavke + Uvezi/Izvezi + Uvezi postavke i favorite iz datoteke + Izvezi postavke + Izvezi sve repozitorije u datoteku + Uvezi repozitorije + Izvezi postavke i favorite u datoteku + Izvezi repozitorije + Uvezi sve repozitorije iz datoteke + \ No newline at end of file diff --git a/core/common/src/main/res/values-hu/strings.xml b/core/common/src/main/res/values-hu/strings.xml new file mode 100644 index 0000000..32383dd --- /dev/null +++ b/core/common/src/main/res/values-hu/strings.xml @@ -0,0 +1,224 @@ + + + Művelet sikertelen + Tároló hozzáadása + Cím + Összes alkalmazás + Fekete + Köszönet + Adományozás + Telepítési típusok + Listaanimációk + + %d alkalmazásnak új verziója van. + %d alkalmazásnak új verziója van. + + Beállítások + Nemrég frissített + Néma telepítés + Engedélyezze a rendszergazdai jogosultságot a néma telepítés érdekében + Megosztás + Méret + Cél + Témák + Frissítések + Verzió + Újdonságok + Nyelv + Az összes alkalmazása naprakész + Már létezik + Mindig + Előnytelen funkciók + Alkalmazás + Az alkalmazás nem található + Szerző e-mail-címe + Szerző weboldala + Elérhető + Hibakövető + Mégse + A tároló most nem szerkeszthető, mert épp szinkronizál. + Változásnapló + Változások + Hibakereséshez fordítva + Megerősítés + Nem szabad médiát tartalmaz + Nem tölthető le: %s + Nem szinkronizálható: %s + Nem ellenőrizhető: %s + Sötét + Törlés + Biztos, hogy törli a tárolót\? + Leírás + Részletek + %s letöltve + Letöltés + Tároló szerkesztése + Ujjlenyomat + Reklámot tartalmaz + Nem szabad függőségei vannak + Biztonsági sérülékenységei vannak + Érvénytelen kiszolgálóválasz. + HTTP proxy + Összes új verzió mellőzése + Ezen verzió mellőzése + A következő verzió nem támogatott: %1$s (API verzió: %2$d). %3$s + A maximális API verzió: %d. + A minimális API verzió: %d. + Hiányzó funkciók. + Nem kompatibilis verzió + Nem kompatibilis verziók + Az eszközzel nem kompatibilis alkalmazásverziók megjelenítése + Nem kompatibilis ezzel: %s + Telepítés + Telepítve + Az épségének ellenőrzése sikertelen. + Érvénytelen cím + Érvénytelen ujjlenyomat-formátum + Érvénytelen metaadatok. + Érvénytelen engedélyek. + Érvénytelen aláírás. + Érvénytelen felhasználónév-formátum + Indítás + Licenc + %s licenc + Világos + Hivatkozás másolva + Hivatkozások + Egyesítés: %s + Név + Hálózati hiba + Soha + Új alkalmazásverziók érhetők el + Nem érhetők el alkalmazások + Nincsenek telepített alkalmazások + Nem érhető el leírás + Nem találhatók ilyen alkalmazások + Nincs proxy + Értesítés a frissítésekről + Értesítés megjelenítése, ha új verziók érhetők el + Alkalmazások száma + OK + Csak ezzel kompatibilis: %s + Csak Wi-Fin + %s megnyitása\? + Egyéb + Az indexfájl nem értelmezhető. + Jelszó + Hiányzik a jelszó + Engedélyek + +%d további + Projekt weboldala + Nem szabad hálózati szolgáltatásokat támogat + Nem szabad szoftvereket támogat + Szállító: %s + Proxy + Proxy kiszolgáló + Proxy port + Proxy típus + Tárolók + Tároló + Szükséges: %s + Mentés + Képernyőképek + Keresés + Válasszon egy tükröt + Továbbiak megjelenítése + Régebbi verziók megjelenítése + Aláírás: %s + Nem biztonságos algoritmussal aláírva + Kihagyás + SOCKS proxy + Rendezési sorrend + Forráskód + A forráskód már nem érhető el + Javasolt + Tárolók szinkronizálása + Tárolók automatikus szinkronizálása + Szinkronizálás + Rendszer + Koppintson a telepítéshez. + Téma + Követi vagy jelenti a tevékenységét + Eltávolítás + Ismeretlen + Ismeretlen hiba. + Ismeretlen: %s + Nem aláírt + Nem stabil frissítések + Nem stabil verziófrissítések javaslata + Nem ellenőrzött + Frissítés + Az upstream forráskód nem szabad + Felhasználónév + Hiányzik a felhasználónév + Az index nem ellenőrizhető. + Verzió: %s + Verziók + Weboldal + Listaanimációk megjelenítése a főoldalon + Érvénytelen fájlformátum. + Testreszabás + Kapcsolódás… + Tároló ellenőrzése… + %s letöltése… + Ez a verzió régebbi mint az eszközre telepített. Előbb távolítsa el azt. + A platform nem támogatott: %1$s. Támogatott platformok: %2$s. + Részletek mentése… + Várakozás a letöltés elindítására… + Ez a verzió a jelenleg az eszközére telepített verziójától eltérő tanúsítvánnyal rendelkezik. Előbb távolítsa el azt. + %1$s feldolgozása… + %s szinkronizálása… + Ez a tároló még nem volt használva. Kapcsolja be, hogy megtekintse a benne lévő alkalmazásokat. + Nincs aláírva. Nem sikerült ellenőrizni az alkalmazáslistát. Legyen óvatos amikor nem aláírt tárolókból tölt le alkalmazásokat. + Kevesebb megjelenítése + Rendezés és szűrés + Csak Wi-Fin és töltés közben + Felfedezés + Összes frissítése + Telepített alkalmazások + Új alkalmazások + A telepítő + Hagyományos telepítő + Munkamenet-alapú telepítő + Rendszergazdai telepítő + Shizuku telepítő + Legújabb + APK takarítás időköze + Letöltött fájlok ellenőrzésének és törlésének időköze + + óra + óra + + + nap + nap + + Felső alkalmazássáv kibontása + Bizonyos műveletek végrehajtása sikertelen. + Felső alkalmazássáv kibontása és összecsukása + Nincs internet-hozzáférés + Próbálja automatikusan telepíteni a frissítéseket + Telepítés… + Indítsa újra a LeOS-Droid-t a változások megtekintéséhez + Várakozás a telepítés megkezdésére … + Kedvencek + Eltakarítja a redundáns fájlokat + Kényszerített takarítás + Material You + A tároló elérhetetlen + Alkalmazások automatikus frissítése + A tároló engedélyezése + A Material You színtéma használata + Nem szabad összetevői vannak + Kezdőképernyő lapozása + Érzékeny tartalmat tartalmaz + A Shizuku nem fut + Nem sikerült kapcsolódni a kiszolgálóhoz + A kiszolgáló nem tudott új csomagot biztosítani. + Másolás + A proxy portja csak egész szám lehet + Engedélyezés, hogy a felhasználó lapozzon a kezdőképernyő lapjai közt + A következő tároló nem található + Külön köszönet + A Shizuku nincs telepítve + \ No newline at end of file diff --git a/core/common/src/main/res/values-ia/strings.xml b/core/common/src/main/res/values-ia/strings.xml new file mode 100644 index 0000000..c1d5467 --- /dev/null +++ b/core/common/src/main/res/values-ia/strings.xml @@ -0,0 +1,169 @@ + + + Non es possibile discargar %s + Falleva le action + Essayar le installation de actualisationes automaticamente + Tote le applicationes es actualisate + + Die + Dies + + Connectente… + Description + Tote le applicationes + Deler + Actualisation automatic de applicationes + Jam existe + Application + Sempre + Adresse + Obscur + Cancellar + Nigre + Deler le repositorio\? + Adder repositorio + Creditos + Actualisationes + Actualisar toto + Non signate + Necun applicationes installate + Discargante + Explorar + Monstrar un notification quando il ha nove versiones disponibile + Nove versiones de applicationes disponibile + Incognite + Repositorio inaccessibile + Actualisation + Installate + Applicationes installate + Repositorio + Suggerer le installation de versiones instabile + Personalisation + Disinstallar + %s discargate + Recentemente actualisate + Modificar repositorio + Ignorar iste version + Non verificate + Lingua + Error incognite. + Themas + Formato de file non valide. + + %d application ha un nove version. + %d applicationes con nove versiones. + + Activar le repositorio + Explorar + Verificante repositorio… + Systema + Notificar le actualisationes + Shizuku non es installate + Actualisationes instabile + Ignorar tote le nove versiones + Discargante %s… + Incognite: %s + Dimension + Le codice fonte jam non es disponibile + Thema + Codice fonte + Parametros + Sito web + Nomine de usator + Version %s + Version + Nove applicationes + Tocca pro installar. + Versiones + Le plus recente + Licentia %s + Salvar + Usar le thema de color Material You + Parametros de importation + Tu %1$s (version de API %2$d) non es supportate. %3$s + Contrasigno + Favoritos + Importar/Exportar + Anti-functionalitates + Suggestiones + Permissiones + Ha dependentias non libere + Monstrar plus + Necun applicationes disponibile + Material You + Nomine + Clar + Importar parametros e favoritos ab un file + Require %s + Ha vulnerabilitates de securitate + Alteres + Ligamine copiate + Copiar + Parametros de exportation + Numero de applicationes + Error de rete + Licentia + Exportar tote le repositorios a un file + Importar le repositorios + Capturas de schermo + Sito web del autor + Detalios + Proxy HTTP + Necun description disponibile + Ligamines + Contine publicitate + Permissiones non valide. + Exportar parametros e favoritos a un file + Sito web del projecto + Facer un donation + Exportar repositorios + Nunquam + e-mail del autor + Importar tote le repositorios ab un file + Creditos special + Compartir + Non poteva trovar ille application + Repositorios + +%d plus + Solmente con Wi-Fi + Aperir %s? + Proxy SOCKS + Solmente compatibile con %s + OK + Tu non ha connexion a Internet + Ignorar + Salvante detalios… + Necun proxy + Typos de installation + Typo de proxy + Monstrar minus + Installar + Monstrar le versiones ancian + + Installante + Adresse non valide + Ordinar e filtrar + Novitates + Lancear + Le codice fonte non es libere + Proxy + Installator de session + Version incompatibile + + Hora + Horas + + Synchronisar repositorios automaticamente + Synchronisar repositorios + Installator + Synchronisante %s… + Responsa de servitor non valide. + Attendente pro le initio del discargamento… + Attendente pro le initio del installation… + Non poteva connecter al servitor + Installator Shizuku + Incompatibile con %s + Synchronisante + Versiones incompatibile + Monstrar versiones de application incompatibile con le apparato + \ No newline at end of file diff --git a/core/common/src/main/res/values-in/strings.xml b/core/common/src/main/res/values-in/strings.xml new file mode 100644 index 0000000..e8271da --- /dev/null +++ b/core/common/src/main/res/values-in/strings.xml @@ -0,0 +1,231 @@ + + + Perubahan + Detail + Format file tidak valid. + Tipe Pemasangan + Semua aplikasi + Memeriksa repositori… + Sudah ada + Hitam + Anti-fitur + Aplikasi + E-mail pengembang + Website pengembang + Jelajah + Pelacak bug + Batal + Tidak dapat mengedit repositori karena sedang disinkronkan sekarang. + Log Perubahan + Tindakan gagal + Semua aplikasimu sudah yang terbaru + Berisi media tidak-bebas + Tidak bisa mengunduh %s + Tidak bisa menyinkronkan %s + Kredit + Gelap + Hapus + Hapus repositori ini\? + Deskripsi + Donasi + Menghubungkan… + Berisi dependensi tidak-bebas + Berisi kerentanan keamanan + Respon server tidak valid. + Proksi HTTP + Abaikan semua versi baru + Abaikan versi ini + %1$smu (versi API %2$d) tidak didukung. %3$s + Versi API minimum adalah %d. + Versi API maksimum adalah %d. + Fitur yang hilang. + Versi ini lebih lama dari yang terpasang di perangkatmu. Copot aplikasi tersebut terlebih dahulu. + Versi ini ditandatangani dengan sertifikat yang berbeda dari yang terpasang di perangkatmu. Copot aplikasi tersebut terlebih dahulu. + Platform %1$smu tidak didukung. Platform yang didukung: %2$s. + Versi tidak kompatibel + Versi tidak kompatibel + Pemasang Sesi + Pemasang Root + Pemasang Shizuku + Terpasang + Tidak bisa memeriksa integritas. + Alamat tidak valid + Format sidik jari tidak valid + Metadata tidak valid. + Perizinan tidak valid. + Tanda tangan tidak valid. + Format nama pengguna tidak valid + Luncurkan + Lisensi + Tampilkan versi aplikasi yang tidak kompatibel dengan perangkatmu + Animasi Daftar + Tampilkan animasi daftar pada halaman utama + Menggabungkan %s + Nama + Tidak pernah + Versi baru aplikasi telah tersedia + + %d aplikasi dengan versi baru. + + Tidak ada aplikasi yang tersedia + Tidak ada aplikasi yang terpasang + Deskripsi tidak tersedia + Tidak bisa menemukan aplikasi tersebut + Tanpa proksi + Beri tahu tentang pembaruan + Tampilkan notifikasi saat versi baru telah tersedia + Jumlah aplikasi + OKE + Hanya kompatibel dengan %s + Buka %s\? + Lainnya + Tidak bisa mengurai berkas indeks. + Sandi + Perizinan + +%d lainnya + Pengaturan + Memproses %1$s… + Situs web proyek + Mempromosikan layanan jaringan tidak-bebas + Mempromosikan perangkat lunak tidak-bebas + Disediakan oleh %s + Proksi + Hos proksi + Porta proksi + Tipe proksi + Baru-baru ini diperbarui + Repositori + Repositori + Repositori ini belum digunakan. Aktifkan untuk melihat aplikasi yang tersedia. + Tanpa tanda tangan. Tidak bisa memverifikasi daftar aplikasi. Hati-hati saat mengunduh aplikasi dari repositori tanpa tanda tangan. + Membutuhkan %s + Pasang Diam-diam + Izinkan akses root untuk pemasangan secara diam-diam + Simpan + Tangkapan Layar + Tanda tangan %s + Ukuran + Lewati + Proksi SOCKS + Urutan penyortiran + Kode sumber + Kode sumber tidak lagi tersedia + Disarankan + Sinkron repositori + Menyinkronkan + Sistem + Tidak diketahui + Galat tidak diketahui. + Tidak diketahui: %s + Tanpa tanda tangan + Pembaruan tidak stabil + Sarankan memasang versi tidak stabil + Tidak terverifikasi + Pembaruan + Kode sumber upstream tidak bebas + Nama pengguna + Nama pengguna tidak ada + Indeks tidak bisa divalidasi. + Perbarui + Yang Baru + Situs Web + Bahasa + Tambah repositori + Alamat + Selalu + Terbaru + Urutan & Filter + Aplikasi baru + Tidak bisa menemukan aplikasi tersebut + Konfirmasi + Tidak bisa memvalidasi %s + Mengunduh + Pemasang Lama + Mengunduh %s… + Tanda tangan + Edit repositori + Berisi iklan + Tidak kompatibel dengan %s + Pasang + Jaringan error + Lisensi %s + Pemasang + Tautan disalin + Terang + Tautan + Hanya pada Wi-Fi + Sandi tidak ada + Tampilkan lebih banyak + Ditanda tangani menggunakan algoritma tidak aman + Menyimpan detail… + Cari + Pilih mirror + Bagikan + Tampilkan versi lama + Menyinkronkan %s… + Sinkron repositori secara otomatis + Ketuk untuk pasang. + Sasaran + Copot + Tema + Tema + Melacak atau melaporkan aktivitas Anda + Tampilkan Sedikit + Jelajahi + Versi + Versi %s + Menunggu memulai mengunduh… + Personalisasi + Versi + Perbarui semua + Aplikasi terpasang + + Hari + + + Jam + + Hanya pada Wi-Fi & Mengisi Daya + Dikompilasi untuk debugging + Tidak bisa melaksanakan tindakan tertentu. + Kamu tidak memiliki akses internet + Izinkan Bilah Atas Apl untuk Diperluas + Izinkan bilah atas aplikasi untuk diperluas dan dikecilkan + Interval pembersihan APK + Interval pengecekan dan penghapusan berkas terunduh + %s Terdownload + Favorit + Material You + Gunakan tema warna Material You + Aktifkan repositori + Paksa pembersihan + Bersihkan file tidak penting + Repositori tidak terjangkau + Memasang + Menunggu untuk memulai pemasangan… + Perbarui aplikasi secara otomatis + Cobalah untuk menginstal pembaruan secara otomatis + Ulang LeOS-Droid untuk melihat perubahan + Memiliki komponen tidak terbuka + Server gagal menyediakan paket baru. + Tidak dapat terhubung ke server + Geser Layar Beranda + Mengandung konten khusus dewasa + Shizuku tidak berjalan + Izinkan pengguna menggeser antar halaman di layar beranda + Kredit Spesial + Shizuku tidak terpasang + Salin + Porta proksi harus bilangan bulat + Repositori berikut tidak ditemukan + Impor Pengaturan + Impor/Ekspor + Impor pengaturan dan favorit dari berkas + Ekspor Pengaturan + Ekspor semua repositori ke berkas + Impor Repositori + Ekspor pengaturan dan favorit ke berkas + Ekspor Repositori + Impor semua repositori dari berkas + Tidak Bisa Membuka Tautan + \ No newline at end of file diff --git a/core/common/src/main/res/values-it/strings.xml b/core/common/src/main/res/values-it/strings.xml new file mode 100644 index 0000000..5369a43 --- /dev/null +++ b/core/common/src/main/res/values-it/strings.xml @@ -0,0 +1,238 @@ + + + Azione fallita + Aggiungi repository + Indirizzo + Tutte le applicazioni + Tutte le applicazioni sono aggiornate + Già esistente + Sempre + Nero + Anti-caratteristiche + Applicazione + Impossibile trovare quell\'applicazione + E-mail dell\'autore + Sito web dell\'autore + Esplora + Tracciamento dei bug + Annulla + Non è possibile modificare il repository perché è in corso la sincronizzazione. + Registro delle modifiche + Cambiamenti + Controllo repository… + Compilato per il debug + Convalida + Connessione in corso… + Contiene media non liberi + Impossibile scaricare %s + Impossibile sincronizzare %s + Impossibile convalidare %s + Crediti + Scuro + Elimina + Eliminare il repository\? + Descrizione + Dettagli + Dona + Scaricato %s + Scaricamento in corso + Scaricamento di %s in corso… + Modifica repository + Formato file non valido. + Fingerprint + Contiene annunci + Contiene dipendenze non libere + Contiene vulnerabilità + Risposta del server non valida. + Proxy HTTP + Ignora tutte le nuove versioni + Ignora questa versione + Il proprio %1$s (versione API %2$d) non è supportato. %3$s + La versione massima API è %d. + La versione minima API è %d. + Caratteristiche mancanti. + Questa versione è più vecchia di quella installata su questo dispositivo. + Elimina prima quella. + La piattaforma %1$s non è supportata. Piattaforme supportate: %2$s. + Questa versione ha una firma diversa da quella installata sul proprio dispositivo. Disinstalla prima quella. + Versione non compatibile + Versioni non compatibili + Mostra le versioni delle applicazioni incompatibili con il dispositivo + Non compatibile con %s + Installa + Tipi di installazione + Installato + Impossibile verificare l\'integrità. + Indirizzo non valido + Fingerprint in un formato non valido + Metadata non validi. + Permessi non validi. + Firma non valida. + Formato nome utente non valido + Avvia + Licenze + Licenza %s + Chiaro + Link copiato + Collegamenti + Elenco animazioni + Mostra l\'animazione della lista nella pagina principale + Unione di %s + Nome + Errore di rete + Mai + Nuove versioni di applicazioni disponibili + + %d applicazione ha una nuova versione. + %d applicazioni hanno una nuova versione. + %d applicazioni hanno una nuova versione. + + Nessuna applicazione disponibile + Nessuna applicazione installata + Nessuna descrizione disponibile + Impossibile trovare tali applicazioni + Senza proxy + Avvisa per gli aggiornamenti + Mostra una notifica quando ci sono nuove versioni disponibili + Numero di applicazioni + OK + Compatibile solo con %s + Solo con Wi-Fi + Aprire %s? + Altro + Impossibile analizzare il file di indice. + Password + Password mancante + Permessi + +%d altro + Impostazioni + Elaborazione %1$s… + Sito web del progetto + Promuove servizi di rete non liberi + Promuove software non liberi + Fornito da %s + Proxy + Host proxy + Porta proxy + Tipo proxy + Aggiornato di recente + Repositories + Repository + Questo repository non è stato ancora utilizzato. Attivalo per vedere le applicazioni in esso contenute. + Senza firma. Impossibile verificare la lista delle applicazioni. È importante stare attenti quando si scaricano le applicazioni dai repository senza firma. + Richiede %s + Installazione silenziosa + Consenti permesso di Root per abilitare installazioni silenziose + Salva + Salvataggio dei dettagli… + Schermate + Cerca + Seleziona alternativa + Condividi + Mostra dettagli + Mostra versioni precedenti + Firma %s + Firmato usando un algoritmo non sicuro + Dimensione + Salta + Proxy SOCKS + Ordinamento + Codice sorgente + Codice sorgente non più disponibile + Suggerito + Sincronizza repository + Sincronizza automaticamente i repository + Sincronizzazione + Sincronizzazione di %s in corso… + Sistema + Tocca per installare. + Obiettivo + Tema + Temi + Traccia o riferisce le tue attività + Disinstalla + Sconosciuto + Errore sconosciuto. + Sconosciuto: %s + Non firmato + Aggiornamenti instabili + Suggerisci l\'installazione di versioni instabili + Non verificato + Aggiorna + Aggiornamenti + Il codice sorgente aggiornato non libero + Nome utente + Nome utente mancante + Non è stato possibile convalidare l\'indice. + Versione + Versione %s + Versioni + In attesa dell\'inizio dello scaricamento… + Novità + Sito web + Lingua + Personalizzazione + Aggiorna tutto + Ordina e filtra + Ultime novità + Mostra meno + Nuove applicazioni + Esplora + Applicazioni installate + Installatore + Installatore Shizuku + Installatore di sessione + Installatore legacy + Installatore root + Periodo per controllare e rimuovere i file scaricati + + Giorno + Giorni + Giorni + + + Ora + Ore + Ore + + Intervallo di pulizia APK + Solo su Wi-Fi e ricarica + Impossibile eseguire determinate azioni. + Non hai connessione internet + Consenti l\'espansione della barra superiore dell\'app + Consenti alla barra superiore dell\'app di espandersi e comprimersi + Material You + Preferiti + Usa il tema material you + Repository irraggiungibile + Pulisce i file ridondanti + Pulizia forzata + Abilita il repository + Installazione + In attesa di avviare l\'installazione… + Riavvia LeOS-Droid per vedere le modifiche + Aggiornamento automatico delle app + Prova a installare gli aggiornamenti automaticamente + Ha componenti non liberi + Impossibile connettersi al server + Contiene contenuti non sicuri per il lavoro + Il server non è riuscito a fornire un nuovo pacchetto. + Importa impostazioni + Importa/Esporta + Importa impostazioni e preferiti da file + Esporta impostazioni + Esporta tutti i repository in file + Importa repository + Esporta impostazioni e preferiti in file + Esporta repository + Importare tutti i repository da file + Shizuku non è in esecuzione + Copia + La porta proxy può essere solo un numero intero + La seguente repository non è stata trovata + Menzione Speciale + Shizuku non è installato + Scorri schermata home + Consente di scorrere (swiping) le pagine nella schermata home + Impossibile aprire il link + \ No newline at end of file diff --git a/core/common/src/main/res/values-iw/strings.xml b/core/common/src/main/res/values-iw/strings.xml new file mode 100644 index 0000000..f82aa05 --- /dev/null +++ b/core/common/src/main/res/values-iw/strings.xml @@ -0,0 +1,218 @@ + + + הצג רשימת אנימציית בעמוד הראשי + כתובות + אנטי-תכונות + בטל + כבר קיים + תמיד + שחור + יישום + לא ניתן היה למצוא את היישום הזה + דואר אלקטרוני של היוצר + עיין + עוקב אחר באגים + לא ניתן לערוך את המאגר מכיוון שהוא מסתנכרן כעת. + יומן שינויים + שינויים + בודק מאגר… + APK cleanup interval + Period to check and remove downloaded files + אישורים + לא ניתן היה להוריד את %s + לא ניתן היה לסנכרן את %s + + יום + ימים + יום + ימים + + מחק + למחוק את המאגר\? + תיאור + פרטים + תרום + מוריד %s + מוריד + ערוך מאגר + מכיל פרצות אבטחה + פרוקסי HTTP + התעלם מהגרסה הזו + ה-%1$s שלך (גרסת API %2$d) אינו נתמך. %3$s + גרסת ה-API המקסימלית היא %d. + גרסת ה-API המינימלית היא %d. + תכונות חסרות. + פלטפורמת %1$s שלך אינה נתמכת. פלטפורמות נתמכות: %2$s. + התקן + מתקין הפעלה + הותקן + מטא נתונים לא חוקיים. + הרשאות לא חוקיות. + חתימה לא חוקית. + פורמט שם משתמש לא חוקי + פתח + רשיון + %s רשיון + מואר + הקישור הועתק ללוח + קישורים + רשימת אנימציות + ממזג %s + היי, זמינות גרסאות חדשות של יישומים + + %d לאפליקציה יש גרסה חדשה. + %d לאפליקציות יש גרסאות חדשות. + %d לאפליקציה יש גרסה חדשה. + %d לאפליקציות יש גרסאות חדשות. + + אין אפליקציות זמינות + אין יישומים מותקנים + אין תיאור זמין + לא הצלחתי למצוא אפליקציות כאלה + אין פרוקסי + הודע על גרסאות חדשות של יישומים + הצג התראה כאשר גרסאות חדשות זמינות + מספר אפליקציות + אוקיי + תואם רק עם %s + רק על וואי-פי + רק ב-Wifi וטעינה + לפתוח %s\? + אחר + לא ניתן לנתח את קובץ האינדקס. + סיסמא + חסרה סיסמא + +%d יותר + מעבד %1$s… + אתר הפרויקט + מקדם שירותי רשת שאינם חופשיים + מקדם תוכנות שאינן חופשיות + מסופק על ידי %s + יציאת פרוקסי + סוג פרוקסי + מאגרים + מאגר + לא חתום. לא ניתן היה לאמת את רשימת היישומים. היזהר בהורדת יישומים ממאגרים לא חתומים. + דורש %s + שומר פרטים… + גודל + דלג + פרוקסי SOCKS + סדר מיון + קוד מקור + קוד המקור אינו זמין יותר + מוצע + סנכרון מאגרים + סנכרון מאגרים באופן אוטומטי + מסנכרן + מסנכרן את %s… + מערכת + הקש כדי להתקין. + יעד + ערכת נושא + ערכות נושא + עוקב או מדווח על הפעילות שלך + לא ידוע + לא מאומת + קוד המקור המלא אינו חינמי + שם משתמש + שם משתמש חסר + שפה + עיין + עדכן הכל + אפליקציות מותקנות + מיון וסינון + יישומים חדשים + פעולה נכשל + הוסף מאגר + כל האפליקציות + כל האפליקציות שלך מעודכנות + אתר היוצר + מכיל קוד שאינו חופשי + לא ניתן היה לאמת את %s + אפל + פורמט קובץ לא חוקי. + הידור עבור ניפוי באגים + מתחבר… + קרדיטים + מוריד את %s… + טביעת אצבע + תלוי בתוספים לא חופשיים + תגובת שרת לא חוקית. + מכיל פרסום + גרסה זו ישנה יותר מזו המותקנת במכשיר שלך. הסר את החדשה קודם. + + שעה + שעות + שעה + שעות + + התעלם מכל הגרסאות החדשות + גרסאות לא תואמות + הצג גרסאות אפליקציה שאינן תואמות למכשיר + מתקין + כתובת לא חוקית + גרסה זו חתומה עם אישור שונה מזה המותקן במכשיר שלך. הסר את המותקן קודם. + גרסה לא תואמת + לא תואם עם %s + סוגי התקנה + מתקין מדור קודם + מתקין שורש + מתקין שיזוקו + שם + לא ניתן היה לבדוק תקינות. + פורמט טביעת אצבע לא חוקי + הצג גירסאות ישנות + גירסא + שגיאת רשת + לעולם לא + הגדרות + ‌פרוקסי + עודכן לאחרונה + עדיין לא נעשה שימוש במאגר זה. הפעל אותו כדי להציג את היישומים שבו. + הרשאות + מארח פרוקסי + צילומי מסך + אפשר הרשאת רוט עבור התקנות שקטות + שמור + התקנה שקטה (רוט) + חתום באמצעות אלגוריתם. לא בטוח + שתף + הצג יותר + חיפוש + בחר מראה + חתימה %s + לא חתום + הצע להתקין גרסאות לא יציבות + עידכונים + גרסה %s + הסר התקנה + גירסאות + שגיאה לא ידועה. + עדכונים לא יציבים + לא ניתן היה לאמת את האינדקס. + לא ידוע: %s + ‌עדכן + מה חדש + אתר אינטרנט + ממתין לתחילת ההורדה… + התאמה אישית + הצג פחות + הכי מאוחר + אפשר לסרגל האפליקציות העליון להתרחב + אפשר לסרגל האפליקציות העליון להתרחב ולהתמוטט + אין לך חיבור לאינטרנט + לא ניתן לבצע פעולות מסוימות. + התקנה + חומר אתה + השתמש בחומר אתה נושא צבע + הפעל מחדש את Droid-iim כדי לראות שינויים + מחכה להתחיל בהתקנה … + מועדפים + מנקה קבצים מיותרים + אפשר את המאגר + כוח לנקות + מאגר בלתי ניתן להשגה + אפליקציות לעדכון אוטומטי + נסה להתקין עדכונים באופן אוטומטי + \ No newline at end of file diff --git a/core/common/src/main/res/values-ja/strings.xml b/core/common/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..f34a502 --- /dev/null +++ b/core/common/src/main/res/values-ja/strings.xml @@ -0,0 +1,230 @@ + + + 変更履歴 + リポジトリをチェックしています… + %s を検証できませんでした + 概要 + %s をダウンロードしました + このリポジトリを削除しますか? + 詳細 + 不自由な依存関係を含む + ダウンロード中 + %s をダウンロード中… + リポジトリを編集 + 無効なファイル形式です。 + フィンガープリント + 広告を含む + 名前 + ネットワークエラー + %s をマージ中 + しない + アプリの新しいバージョンが利用可能です + リポジトリ + 不明 + 操作に失敗しました + リポジトリを追加 + アドレス + すべてのアプリ + すべてのアプリが最新です + すでに存在します + 常に + ブラック + 好ましくない可能性のある機能 + アプリ + アプリが見つかりませんでした + 作者のメールアドレス + 作者のウェブサイト + バグトラッカー + キャンセル + 変更点 + デバッグ用にコンパイルされています + 確認 + 接続中… + 不自由なメディアを含みます + %s をダウンロードできませんでした + %s を同期できませんでした + ダーク + 削除 + 寄付 + セキュリティ上の脆弱性があります + + 時間 + + サーバーからの応答が無効です。 + HTTP プロキシ + すべての新しいバージョンを無視 + このバージョンを無視 + お使いの %1$s (API バージョン %2$d) はサポートされていません。%3$s + 最大 API バージョンは %d です。 + 最小 API バージョンは %d です。 + このバージョンは、お使いのデバイスにインストール済みのものよりも古いバージョンです。先にアンインストールしてください。 + %1$s プラットフォームはサポートされていません。サポートされているプラットフォーム: %2$s + このバージョンは、お使いのデバイスにインストールされているものと異なる証明書で署名されています。先にアンインストールしてください。 + + + + 互換性のないバージョン + 互換性のないバージョン + デバイスと互換性のないアプリのバージョンを表示する + %s とは互換性がありません + インストール + インストール方式 + インストーラー + インストール済み + 整合性を確認できませんでした。 + 無効なアドレス + 無効なフィンガープリント形式 + 無効なメタデータです。 + 無効な権限です。 + 無効な署名です。 + 無効なユーザー名形式 + 起動 + ライセンス + %s ライセンス + ライト + リンクがコピーされました + リンク + リストのアニメーション + メインページでリストをアニメーションする + + %d 個のアプリに新しいバージョンがあります。 + + 利用可能なアプリはありません + インストール済みのアプリはありません + 説明がありません + そのようなアプリは見つかりませんでした + プロキシなし + アップデート通知 + 新しいバージョンが利用可能になったときに通知を表示する + アプリの数 + OK + %s とのみ互換性があります + Wi-Fi 接続時のみ + Wi-Fi 接続時と充電時のみ + %s を開きますか? + その他 + インデックスファイルを解析できませんでした。 + パスワード + パスワードがありません + 権限 + 設定 + %1$s を処理中… + プロジェクトのウェブサイト + 不自由なネットワークサービスを推奨します + 不自由なソフトウェアを推奨します + 提供: %s + プロキシ + 最近の更新 + リポジトリ + %s が必要 + サイレントインストール + 保存 + 詳細を保存しています… + スクリーンショット + 検索 + ミラーを選択 + 共有 + さらに表示 + 未署名です。アプリのリストを検証できませんでした。署名されていないリポジトリからアプリをダウンロードする際は注意してください。 + 古いバージョンを表示 + 署名 %s + 安全でないアルゴリズムを使用して署名されています + サイズ + スキップ + SOCKS プロキシ + 並べ替え + ソースコード + ソースコードは利用できません + 推奨 + リポジトリを同期 + 自動的にリポジトリを同期 + 同期中 + %s を同期中… + システム + タップしてインストール + ターゲット + テーマ + テーマ + あなたのアクティビティを追跡または報告します + アンインストール + 不明なエラーです。 + 不明: %s + 署名なし + 不安定なアップデート + 不安定なバージョンのインストールを提案する + 未検証 + アップデート + アップデート + アップストリームのソースコードは不自由です + ユーザー名 + ユーザー名がありません + インデックスを検証できませんでした。 + バージョン + バージョン %s + バージョン + ダウンロード開始を待機中… + 最新情報 + ウェブサイト + 言語 + 個人用設定 + 表示を減らす + 最新 + 探索 + すべてアップデート + インストールされているアプリ + 新しいアプリ + 探索 + APK の自動クリーンアップ間隔 + ダウンロード済みファイルの確認と削除をする間隔 + 機能が不足しています。 + 現在同期中のため、リポジトリを編集できません。 + クレジット + レガシーインストーラー + セッションインストーラー + Root インストーラー + Shizuku インストーラー + さらに %d 件 + プロキシホスト + プロキシポート + プロキシタイプ + このリポジトリはまだ使用されていません。アプリケーションを表示するには、オンにしてください。 + サイレントインストールのための root 権限を許可する + 並べ替えとフィルター + トップアプリバーの拡張を許可する + トップアプリバーの拡大・縮小を可能にする + 特定のアクションを実行できません。 + インターネット接続がありません + アプリの自動更新 + アップデートを自動的にインストールします + インストール中 + インストール開始を待機中… + お気に入り + Material You + リポジトリに到達できません + リポジトリを有効化 + 強制的にクリーンアップ + 冗長ファイルをクリーンアップする + Material You カラーテーマを使用する + LeOS-Droid を再起動して変更を確認する + 不自由なコンポーネントを含む + ホーム画面のスワイプ + コンテンツには安全ではないものが含まれています + Shizuku が動作していません + サーバーに接続できませんでした + サーバーから新しいパケットが提供されませんでした。 + コピー + プロキシポートは整数のみです + ホーム画面でページをスワイプして移動する + 指定されたリポジトリは見つかりませんでした + スペシャルクレジット + Shizuku はインストールされていません + 設定をインポート + インポート / エクスポート + ファイルから設定とお気に入りをインポートする + 設定をエクスポート + 全てのリポジトリをファイルにエクスポートする + リポジトリをインポート + 設定とお気に入りをファイルにエクスポートする + リポジトリをエクスポート + ファイルから全てのリポジトリをインポートする + \ No newline at end of file diff --git a/core/common/src/main/res/values-kn/strings.xml b/core/common/src/main/res/values-kn/strings.xml new file mode 100644 index 0000000..f68c825 --- /dev/null +++ b/core/common/src/main/res/values-kn/strings.xml @@ -0,0 +1,212 @@ + + + ಕ್ರಿಯೆ ವಿಫಲವಾಗಿದೆ + ರೆಪೊಸಿಟರಿಯನ್ನು ಸೇರಿಸಿ + ವಿಳಾಸ + ಎಲ್ಲಾ ಅಪ್ಲಿಕೇಶನ್‌ಗಳು + ಈಗಾಗಲೇ ಇದೆ + ಯಾವಾಗಲೂ + ಕಪ್ಪು + ವಿರೋಧಿ ವೈಶಿಷ್ಟ್ಯಗಳು + ಅಪ್ಲಿಕೇಶನ್ + ಡೆವಲಪರ್‌ನ ಇಮೇಲ್ + ಡೆವಲಪರ್ ವೆಬ್‌ಸೈಟ್ + ಅನ್ವೇಷಿಸಿ + ಬಗ್ ಟ್ರ್ಯಾಕರ್ + ರದ್ದುಮಾಡು + ಬದಲಾವಣೆಗಳ ಟಿಪ್ಪಣಿ + ಬದಲಾವಣೆಗಳು + APK cleanup interval + Period to check and remove downloaded files + ಡೀಬಗ್ ಮಾಡಲು ಸಂಕಲಿಸಲಾಗಿದೆ + ದೃಢೀಕರಣ + ಮುಕ್ತವಲ್ಲದ ಮಾಧ್ಯಮವನ್ನು ಒಳಗೊಂಡಿದೆ + %s ಅನ್ನು ಸಿಂಕ್ ಮಾಡಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ + ಕ್ರೆಡಿಟ್‌ಗಳು + ಕತ್ತಲು + + ದಿನ + ದಿನಗಳು + + ಅಳಿಸಿ + ರೆಪೊಸಿಟರಿಯನ್ನು ಅಳಿಸುವುದೇ\? + ವಿವರಣೆ + ವಿವರಗಳು + ಕಾಣಿಕೆ ನೀಡಿ + ಡೌನ್‌ಲೋಡ್ ಮಾಡಲಾಗುತ್ತಿದೆ + %s ಡೌನ್‌ಲೋಡ್ ಮಾಡಲಾಗುತ್ತಿದೆ… + ರೆಪೊಸಿಟರಿಯನ್ನು ಪರಿಷ್ಕರಿಸು + ಬೆರಳಚ್ಚು + ಜಾಹೀರಾತು ಹೊಂದಿದೆ + ಭದ್ರತಾ ದೋಷಗಳನ್ನು ಹೊಂದಿದೆ + + ಘಂಟೆ + ಘಂಟೆಗಳು + + ಅಮಾನ್ಯ ಸರ್ವರ್ ಪ್ರತಿಕ್ರಿಯೆ. + HTTP ಪ್ರಾಕ್ಸಿ + ಗರಿಷ್ಠ API ಆವೃತ್ತಿಯು %d ಆಗಿದೆ. + ಕನಿಷ್ಠ API ಆವೃತ್ತಿಯು %d ಆಗಿದೆ. + ಕಾಣೆಯಾದ ವೈಶಿಷ್ಟ್ಯಗಳು. + ಈ ಆವೃತ್ತಿಯು ನಿಮ್ಮ ಸಾಧನದಲ್ಲಿ ಸ್ಥಾಪಿಸಲಾದ ಆವೃತ್ತಿಗಿಂತ ಹಳೆಯದಾಗಿದೆ. ಅದನ್ನು ಮೊದಲು ಅನ್‌ಇನ್‌ಸ್ಟಾಲ್ ಮಾಡಿ. + ಹೊಂದಾಣಿಕೆಯಾಗದ ಆವೃತ್ತಿ + ಹೊಂದಾಣಿಕೆಯಾಗದ ಆವೃತ್ತಿಗಳು + ಸಾಧನದೊಂದಿಗೆ ಹೊಂದಿಕೆಯಾಗದ ಅಪ್ಲಿಕೇಶನ್ ಆವೃತ್ತಿಗಳನ್ನು ತೋರಿಸಿ + %s ಗೆ ಹೊಂದಿಕೆಯಾಗುವುದಿಲ್ಲ + ಅನುಸ್ಥಾಪಕ + ಲೆಗಸಿ ಸ್ಥಾಪಕ + ಸೆಷನ್ ಸ್ಥಾಪಕ + ರೂಟ್ ಸ್ಥಾಪಕ + ಸ್ಥಾಪಿಸಲಾಗಿದೆ + ಸಮಗ್ರತೆಯನ್ನು ಪರಿಶೀಲಿಸಲಾಗಲಿಲ್ಲ. + ಅಮಾನ್ಯವಾದ ವಿಳಾಸ + ಅಮಾನ್ಯ ಮೆಟಾಡೇಟಾ. + ಅಮಾನ್ಯ ಅನುಮತಿಗಳು. + ಅಮಾನ್ಯವಾದ ಸಹಿ. + ಅಮಾನ್ಯ ಬಳಕೆದಾರಹೆಸರು ಫಾರ್ಮ್ಯಾಟ್ + ಲಾಂಚ್ + %s ಪರವಾನಗಿ + ಬೆಳಕು + ಲಿಂಕ್‌ಗಳು + ಅನಿಮೇಷನ್ ಗಳ ಪಟ್ಟಿ + ಮುಖ್ಯ ಪುಟದಲ್ಲಿ ಅನಿಮೇಷನ್ ಪಟ್ಟಿ ತೋರಿಸಿ + ಹೆಸರು + ನೆಟ್‌ವರ್ಕ್ ದೋಷ + ಎಂದಿಗೂ + + %d ಅಪ್ಲಿಕೇಶನ್ ಹೊಸ ಆವೃತ್ತಿಯನ್ನು ಹೊಂದಿದೆ. + ಹೊಸ ಆವೃತ್ತಿಗಳೊಂದಿಗೆ %d ಅಪ್ಲಿಕೇಶನ್‌ಗಳು. + + ಸ್ಥಾಪಿಸಲಾದ ಅಪ್ಲಿಕೇಶನ್‌ಗಳಿಲ್ಲ + ಅಂತಹ ಯಾವುದೇ ಅಪ್ಲಿಕೇಶನ್‌ಗಳನ್ನು ಕಂಡುಹಿಡಿಯಲಾಗಲಿಲ್ಲ + ಪ್ರಾಕ್ಸಿ ಇಲ್ಲ + ಅಪ್ಲಿಕೇಶನ್ ಗಳ ಸಂಖ್ಯೆ + ಸರಿ + ನಿಮ್ಮ ಎಲ್ಲಾ ಅಪ್ಲಿಕೇಶನ್‌ಗಳು ನವೀಕೃತವಾಗಿವೆ + ಸಂಪರ್ಕಿಸಲಾಗುತ್ತಿದೆ… + ಆ ಅಪ್ಲಿಕೇಶನ್ ಅನ್ನು ಕಂಡುಹಿಡಿಯಲಾಗಲಿಲ್ಲ + ಹೊಸ ಆವೃತ್ತಿಗಳು ಲಭ್ಯವಿದ್ದಾಗ ಅಧಿಸೂಚನೆಯನ್ನು ತೋರಿಸಿ + ರೆಪೊಸಿಟರಿಯನ್ನು ಪರಿಶೀಲಿಸಲಾಗುತ್ತಿದೆ… + ಇದೀಗ ಸಿಂಕ್ ಆಗುತ್ತಿರುವ ಕಾರಣ ರೆಪೊಸಿಟರಿಯನ್ನು ಪರಿಷ್ಕರಿಸಲು ಸಾಧ್ಯವಿಲ್ಲ. + %s ಅನ್ನು ಡೌನ್‌ಲೋಡ್ ಮಾಡಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ + %s ಅನ್ನು ದೃಢೀಕರಿಸಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ + %s ಅನ್ನು ಡೌನ್‌ಲೋಡ್ ಮಾಡಲಾಗಿದೆ + ಅಮಾನ್ಯ ಫೈಲ್ ಫಾರ್ಮ್ಯಾಟ್. + ಈಗಿನ ಆವೃತ್ತಿಯನ್ನು ನಿರ್ಲಕ್ಷಿಸಿ + ಮುಕ್ತವಲ್ಲದ ಅವಲಂಬನೆಗಳನ್ನು ಹೊಂದಿದೆ + ಎಲ್ಲಾ ಹೊಸ ಆವೃತ್ತಿಗಳನ್ನು ನಿರ್ಲಕ್ಷಿಸಿ + ನಿಮ್ಮ %1$s (API ಆವೃತ್ತಿ %2$d) ಬೆಂಬಲಿತವಾಗಿಲ್ಲ. %3$s + ನಿಮ್ಮ %1$s ಪ್ಲಾಟ್‌ಫಾರ್ಮ್ ಬೆಂಬಲಿತವಾಗಿಲ್ಲ. ಬೆಂಬಲಿತ ಪ್ಲಾಟ್‌ಫಾರ್ಮ್‌ಗಳು: %2$s. + ಈ ಆವೃತ್ತಿಯು ನಿಮ್ಮ ಸಾಧನದಲ್ಲಿ ಸ್ಥಾಪಿಸಲಾದ ಪ್ರಮಾಣಪತ್ರಕ್ಕಿಂತ ವಿಭಿನ್ನ ಪ್ರಮಾಣಪತ್ರದೊಂದಿಗೆ ಸಹಿ ಮಾಡಲಾಗಿದೆ. ಅದನ್ನು ಮೊದಲು ಅನ್‌ಇನ್‌ಸ್ಟಾಲ್ ಮಾಡಿ. + ಅನುಸ್ಥಾಪನೆಯ ವಿಧಗಳು + ಸ್ಥಾಪಿಸಿ + ಶಿಜುಕು ಸ್ಥಾಪಕ + %s ಅನ್ನು ವಿಲೀನಗೊಳಿಸಲಾಗುತ್ತಿದೆ + ಅಮಾನ್ಯ ಫಿಂಗರ್‌ಪ್ರಿಂಟ್ ಫಾರ್ಮ್ಯಾಟ್ + ಪರವಾನಗಿ + ಲಿಂಕ್ ಅನ್ನು ಕ್ಲಿಪ್‌ಬೋರ್ಡ್‌ಗೆ ನಕಲಿಸಲಾಗಿದೆ + ಯಾವುದೇ ಅಪ್ಲಿಕೇಶನ್‌ಗಳು ಲಭ್ಯವಿಲ್ಲ + ಅಪ್ಲಿಕೇಶನ್‌ಗಳ ಹೊಸ ಆವೃತ್ತಿಗಳು ಲಭ್ಯವಿದೆ + ಅಪ್ಲಿಕೇಶನ್‌ಗಳ ಹೊಸ ಆವೃತ್ತಿಗಳ ಕುರಿತು ಸೂಚಿಸಿ + ಯಾವುದೇ ವಿವರಣೆ ಲಭ್ಯವಿಲ್ಲ + ಆವೃತ್ತಿ %s + ರೆಪೊಸಿಟರಿಗಳು + ಅನುಮತಿಗಳು + ಯೋಜನೆಯ ಜಾಲತಾಣ + ಪ್ರಾಕ್ಸಿ ಪೋರ್ಟ್ + ಇತ್ತೀಚೆಗೆ ನವೀಕರಿಸಲಾಗಿದೆ + ಭಂಡಾರ + %s ಅಗತ್ಯವಿದೆ + ಹುಡುಕಿ + ಈ ಭಂಡಾರವನ್ನು ಇನ್ನೂ ಬಳಸಲಾಗಿಲ್ಲ. ಅದರಲ್ಲಿರುವ ಅಪ್ಲಿಕೇಶನ್‌ಗಳನ್ನು ವೀಕ್ಷಿಸಲು ಅದನ್ನು ಆನ್ ಮಾಡಿ. + ಸಹಿ ಮಾಡಿಲ್ಲ. ಅಪ್ಲಿಕೇಶನ್ ಪಟ್ಟಿಯನ್ನು ಪರಿಶೀಲಿಸಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ. ಸಹಿ ಮಾಡದ ರೆಪೊಸಿಟರಿಗಳಿಂದ ಅಪ್ಲಿಕೇಶನ್‌ಗಳನ್ನು ಡೌನ್‌ಲೋಡ್ ಮಾಡುವಾಗ ಜಾಗರೂಕರಾಗಿರಿ. + ಸೈಲೆಂಟ್ ಇನ್‌ಸ್ಟಾಲ್ + ನಿಶ್ಯಬ್ದ ಸ್ಥಾಪನೆಗಳಿಗೆ ರೂಟ್ ಅನುಮತಿಯನ್ನು ಅನುಮತಿಸಿ + ಉಳಿಸಿ + ವಿವರಗಳನ್ನು ಉಳಿಸಲಾಗುತ್ತಿದೆ… + ಸ್ಕ್ರೀನ್‌ಶಾಟ್‌ಗಳು + ಹಳೆಯ ಆವೃತ್ತಿಗಳನ್ನು ತೋರಿಸಿ + ಕನ್ನಡಿಯನ್ನು ಆಯ್ಕೆಮಾಡಿ + ಹಂಚಿಕೊಳ್ಳಿ + ಇನ್ನು ಹೆಚ್ಚು ತೋರಿಸು + ಸಹಿ %s + ಬಿಟ್ಟುಬಿಡಿ + ಅಸುರಕ್ಷಿತ ಅಲ್ಗಾರಿದಮ್ ಬಳಸಿ ಸಹಿ ಮಾಡಲಾಗಿದೆ + ಗಾತ್ರ + ಮೂಲ ಕೋಡ್ + ಸಾಕ್ಸ್ ಪ್ರಾಕ್ಸಿ + ವಿಂಗಡಿಸುವ ಕ್ರಮ + ಸಿಂಕ್ ಮಾಡಲಾಗುತ್ತಿದೆ + ಮೂಲ ಕೋಡ್ ಇನ್ನು ಮುಂದೆ ಲಭ್ಯವಿಲ್ಲ + %s ಸಿಂಕ್ ಮಾಡಲಾಗುತ್ತಿದೆ… + ವ್ಯವಸ್ಥೆ + ಗುರಿ + ಥೀಮ್ + ಥೀಮ್ಗಳು + ಸ್ಥಾಪಿಸಲು ಟ್ಯಾಪ್ ಮಾಡಿ. + ಅಜ್ಞಾತ + ಅಸ್ಥಿರ ಆವೃತ್ತಿಗಳನ್ನು ಸ್ಥಾಪಿಸಲು ಸಲಹೆ ನೀಡಿ + ನಿಮ್ಮ ಚಟುವಟಿಕೆಯನ್ನು ಟ್ರ್ಯಾಕ್ ಮಾಡುತ್ತದೆ ಅಥವಾ ವರದಿ ಮಾಡುತ್ತದೆ + ಅಪ್‌ಸ್ಟ್ರೀಮ್ ಮೂಲ ಕೋಡ್ ಉಚಿತವಲ್ಲ + ಅನ್‌ಇನ್‌ಸ್ಟಾಲ್ ಮಾಡಿ + ಅಸ್ಥಿರ ನವೀಕರಣಗಳು + ಅಜ್ಞಾತ ದೋಷ. + ಅಜ್ಞಾತ: %s + ಸಹಿ ಮಾಡಿಲ್ಲ + ಪರಿಶೀಲಿಸಲಾಗಿಲ್ಲ + ನವೀಕರಿಸಿ + ನವೀಕರಣಗಳು + ಆವೃತ್ತಿ + ಬಳಕೆದಾರ ಹೆಸರು + ಬಳಕೆದಾರಹೆಸರು ಕಾಣೆಯಾಗಿದೆ + ಸೂಚ್ಯಂಕವನ್ನು ಮೌಲ್ಯೀಕರಿಸಲಾಗಲಿಲ್ಲ. + ಕಡಿಮೆ ತೋರಿಸು + ಆವೃತ್ತಿಗಳು + ಡೌನ್‌ಲೋಡ್ ಪ್ರಾರಂಭಿಸಲು ನಿರೀಕ್ಷಿಸಲಾಗುತ್ತಿದೆ… + ಇತ್ತೀಚಿನ + ಅನ್ವೇಷಿಸಿ + ಎಲ್ಲವನ್ನು ಆಧುನೀಕರಿಸು + ವೈ-ಪೈ ನಲ್ಲಿ ಮಾತ್ರ + ತೆರೆ %s\? + ಪಾಸ್ವರ್ಡ್ + ಪಾಸ್ವರ್ಡ ಅಗತ್ಯವಿದೆ + ಸಂಸ್ಕರಣೆ %1$s… + ಬದಲಿ + +%d ಇನ್ನಷ್ಟುಡ + ಅಳವಡಿಕೆಗಳು + ಉಚಿತವಿಲ್ಲದ ನೆಟ್ವರ್ಕ್ ಸೇವೆಯನ್ನು ಉತ್ತೇಜಿಸುತ್ತದೆ + ಉಚಿತವಿಲ್ಲದ ಸಾಪ್ಟವೇರನ್ನು ಉತ್ತೇಜಿಸುತ್ತದೆ + %s ಇಂದ ಒದಗಿಸಲಾಗಿದೆ + ಬದಲಿ ಹೋಸ್ಟ್ + ಬದಲಿ ಮಾದರಿ + ಕೆಲವು ಕ್ರಿಯೆಗಳನ್ನು ಮಾಡಲು ಸಾಧ್ಯವಿಲ್ಲ. + ಇತರೆ + ಇಂಡೆಕ್ಸ್ ಫೈಲ್ ಅನ್ನು ಪಾರ್ಸ್ ಮಾಡಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ. + ನೀವು ಯಾವುದೇ ಇಂಟರ್ನೆಟ್ ಸಂಪರ್ಕವನ್ನು ಹೊಂದಿಲ್ಲ + %s ಗೆ ಮಾತ್ರ ಹೊಂದಾಣಿಕೆಯಾಗುತ್ತದೆ + ಉನ್ನತ ಅಪ್ಲಿಕೇಶನ್ ಬಾರ್ ಅನ್ನು ವಿಸ್ತರಿಸಲು ಮತ್ತು ಕುಗ್ಗಿಸಲು ಅನುಮತಿಸಿ + ವೈ-ಪೈ ಮತ್ತು ಚಾರ್ಜಿಂಗ್ ನಲ್ಲಿ ಮಾತ್ರ + ಸೂಚಿಸಲಾಗಿದೆ + ರೆಪೊಸಿಟರಿಗಳನ್ನು ಸಿಂಕ್ ಮಾಡಿ + ರೆಪೊಸಿಟರಿಗಳನ್ನು ಸ್ವಯಂಚಾಲಿತವಾಗಿ ಸಿಂಕ್ ಮಾಡಿ + ಟಾಪ್ ಅಪ್ಲಿಕೇಶನ್ ಬಾರ್ ಅನ್ನು ವಿಸ್ತರಿಸಲು ಅನುಮತಿಸಿ + ಹೊಸತೇನಿದೆ + ಜಾಲತಾಣ + ಭಾಷೆ + ವೈಯಕ್ತೀಕರಣ + ಸ್ಥಾಪಿಸಲಾದ ಅಪ್ಲಿಕೇಶನ್‌ಗಳು + ವಿಂಗಡಿಸಿ ಮತ್ತು ಫಿಲ್ಟರ್ ಮಾಡಿ + ಹೊಸ ಅಪ್ಲಿಕೇಶನ್‌ಗಳು + ಅಪ್ಲಿಕೇಶನ್‌ಗಳನ್ನು ಸ್ವಯಂಚಾಲಿತವಾಗಿ ನವೀಕರಿಸಿ + ನವೀಕರಣಗಳನ್ನು ಸ್ವಯಂಚಾಲಿತವಾಗಿ ಸ್ಥಾಪಿಸಲು ಪ್ರಯತ್ನಿಸಿ + ಸ್ಥಾಪಿಸಲಾಗುತ್ತಿದೆ + ಅನುಸ್ಥಾಪನೆಯನ್ನು ಪ್ರಾರಂಭಿಸಲು ನಿರೀಕ್ಷಿಸಲಾಗುತ್ತಿದೆ… + ಮೆಚ್ಚಿನವುಗಳು + ರೆಪೊಸಿಟರಿಯನ್ನು ಸಕ್ರಿಯಗೊಳಿಸಿ + ಬಲವಂತವಾಗಿ ಸ್ವಚ್ಛಗೊಳಿಸಿ + ಅನಗತ್ಯ ಫೈಲ್‌ಗಳನ್ನು ಸ್ವಚ್ಛಗೊಳಿಸುತ್ತದೆ + ವಸ್ತು ನೀವು + ವಸ್ತು ನೀವು ಬಣ್ಣದ ಥೀಮ್ ಬಳಸಿ + ರೆಪೊಸಿಟರಿಯನ್ನು ತಲುಪಲಾಗುವುದಿಲ್ಲ + ಬದಲಾವಣೆಗಳನ್ನು ನೋಡಲು LeOS-Droid ಅನ್ನು ಮರುಪ್ರಾರಂಭಿಸಿ + \ No newline at end of file diff --git a/core/common/src/main/res/values-ko/strings.xml b/core/common/src/main/res/values-ko/strings.xml new file mode 100644 index 0000000..3012aa7 --- /dev/null +++ b/core/common/src/main/res/values-ko/strings.xml @@ -0,0 +1,230 @@ + + + 앱 자동 업데이트 + 자동으로 업데이트 설치 시도 + 이 버전은 기기에 설치된 버전보다 이전 버전입니다. 먼저 제거하십시오. + 호환되지 않는 버전 + 호환되지 않는 버전 + 설치 유형 + 레거시 설치 관리자 + 무결성을 확인하지 못했습니다. + 즐겨찾기 + 특정 작업을 수행할 수 없습니다. + 열기 + Material You + %s 병합 + 사용 안 함 + + %d개 애플리케이션을 업데이트할 수 있습니다. + + 사용 가능한 애플리케이션 없음 + 설치된 애플리케이션 없음 + 네트워크 오류 + HTTP 프록시 + 모든 새 버전 무시 + %1$s 플랫폼이 지원되지 않습니다. 지원되는 플랫폼: %2$s. + %s와 호환되지 않음 + 설치 + Shizuku 설치 관리자 + 설치됨 + 잘못된 메타데이터입니다. + 잘못된 권한입니다. + 잘못된 서명입니다. + 잘못된 주소 + 잘못된 지문 형식 + 메인 페이지에 목록 애니메이션 표시 + 프록시 없음 + 새 버전을 사용할 수 있을 때 알림 표시 + 업데이트 알림 + 강제 정리 + 저장소 켜기 + 보안 취약점이 있음 + + 시간 + + 이름 + 이 버전 무시 + 중복 파일 정리 + 잘못된 서버 응답입니다. + 귀하의 %1$s(API 버전 %2$d)는 지원되지 않습니다. %3$s + 기능이 없습니다. + 최대 API 버전은 %d입니다. + 최소 API 버전은 %d입니다. + 이 버전은 기기에 설치된 것과 다른 인증서로 서명되었습니다. 먼저 제거하십시오. + 기기와 호환되지 않는 애플리케이션 버전 표시 + 설치 관리자 + 세션 설치 관리자 + 루트 설치 관리자 + 잘못된 사용자 이름 형식 + Material You 색상 테마 사용 + 인터넷에 연결되어 있지 않습니다 + 새로운 버전의 애플리케이션 사용 가능 + 가능한 설명이 없습니다 + 지원하는 애플리케이션을 찾지 못했습니다. + + + + 삭제 + 저장소를 삭제하시겠습니까\? + 설명 + 애플리케이션 정보 + 기부 + %s 앱을 다운로드했습니다. + 상단 앱 바 확장 허용 + 상단 앱 바 확장 및 축소 허용 + 다운로드한 파일을 확인하고 제거할 시간 간격 + 디버깅용으로 컴파일됨 + 연결 중… + 비자유 미디어 포함 + %s 항목을 다운로드하지 못했습니다. + %s 항목을 동기화하지 못했습니다. + %s 항목을 검증하지 못했습니다. + 크레딧 + 다크 + 다운로드 중 + %s 다운로드 중… + 개발자 이메일 + 개발자 웹사이트 + 둘러보기 + 확인 + 버그 신고 + 취소 + 작업 실패 + 저장소 추가 + 주소 + 모든 애플리케이션 + 모든 애플리케이션이 최신 버전입니다. + 이미 존재함 + 항상 + 블랙 + 안티 기능 + 애플리케이션 + 해당 애플리케이션을 찾지 못했습니다. + 지금은 동기화 중이므로 저장소를 편집할 수 없습니다. + 변경 사항 + 변경 사항 + 저장소 확인 중… + APK 정리 간격 + 저장소 편집 + 잘못된 파일 형식입니다. + 지문 + 광고 있음 + 자유롭지 않은 종속성 있음 + 특허 + %s 라이선스 + 라이트 + 링크를 복사했습니다. + 연결 + 애니메이션 나열 + 설치 중 + %s와만 호환 가능 + Wi-Fi에서만 + 건너뛰기 + 소스 코드 + 더 이상 사용할 수 없는 소스 코드 + 제안 + 저장소 자동 동기화 + 동기화 중 + 설치하려면 탭하세요. + 테마 + 테마 + 활동을 추적하거나 보고합니다 + 알 수 없는 오류가 발생했습니다. + 알 수 없음: %s + 서명되지 않은 + 불안정한 버전 업데이트 + 설치 시작 대기 중… + 개인화 + 모두 업데이트 + 설치된 애플리케이션 + 공유 + 이전 버전 표시 + 정렬 순서 + 알 수 없음 + 버전 %s + 버전 + 간단히 보기 + 정렬 및 필터 + 새로운 애플리케이션 + 확인 + Wi-Fi 및 충전 중일 때만 + 저장 + 스크린샷 + 검색 + 미러 선택 + 서명 %s + 자동 설치를 위해 루트 권한을 허용합니다. + 세부 정보 저장 중… + 크기 + 표적 + 불안정한 버전의 업데이트 설치 제안 + 업데이트 + 업데이트 + 업스트림 소스 코드는 무료가 아닙니다 + 사용자 이름 + 미확인 + 사용자 이름이 비어 있습니다. + 색인을 검증하지 못했습니다. + 버전 + 언어 + 신청 수 + 이 저장소는 아직 사용한 적이 없습니다. 저장소에서 제공하는 애플리케이션을 확인하려면 이 저장소를 켜세요. + 저장소 + 더 보기 + SOCKS 프록시 + 설치 제거 + 새로 등록된 앱 + 다운로드 시작 대기 중… + 웹사이트 + 프로젝트 웹사이트 + 무료가 아닌 네트워크 서비스 홍보 + %s 제공 + 프록시 호스트 + 프록시 포트 + 프록시 유형 + 최근 업데이트 + 저장소 + 저장소에 연결할 수 없음 + %s 필요 + %1$s 처리 중… + 비자유 소프트웨어 홍보 + 최신 + %s를 여시겠습니까\? + 다른 + 비밀번호 누락 + 프록시 + %s 동기화 중… + 서명되지 않았습니다. 애플리케이션 목록을 확인하지 못했습니다. 서명되지 않은 저장소에서 애플리케이션을 다운로드할 때는 주의하세요. + 저장소 동기화 + 시스템 + 탐색 + 색인 파일 구문을 분석하지 못했습니다. + 비밀번호 + 권한 + +%d개 더 + LeOS-Droid를 다시 시작하여 변경 사항 확인 + 자동 설치 + 설정 + 안전하지 않은 알고리즘을 사용하여 서명됨 + 자유롭지 않은 구성 요소를 포함하고 있습니다 + 홈 화면에서 스와이프 사용 + NSFW 컨텐츠를 포함하고 있음 + Shizuku가 실행 중이 아님 + 서버에 연결하지 못했습니다. + 서버에서 새 패킷을 가져오지 못했습니다. + 복사 + 프록시 포트에는 숫자만 입력할 수 있습니다. + 홈 화면에서 스와이프하여 페이지 전환 + 다음 저장소를 찾지 못했습니다. + 스페셜 크레딧 + Shizuku가 설치되어 있지 않음 + 설정 가져오기 + 가져오기/내보내기 + 설정 및 즐겨찾기를 파일에서 가져옵니다. + 설정 내보내기 + 저장소 목록을 파일로 내보냅니다. + 저장소 가져오기 + 설정 및 즐겨찾기를 파일로 내보냅니다. + 저장소 내보내기 + 저장소 목록을 파일에서 가져옵니다. + \ No newline at end of file diff --git a/core/common/src/main/res/values-lt/strings.xml b/core/common/src/main/res/values-lt/strings.xml new file mode 100644 index 0000000..d19910f --- /dev/null +++ b/core/common/src/main/res/values-lt/strings.xml @@ -0,0 +1,215 @@ + + + Veiksmas nepavyko + Adresas + Visos programėlės + Visos jūsų programėlės yra atnaujintos + Juoda + Autoriaus el. paštas + Pakeitimų žurnalas + Pakeitimai + Tamsi + + Diena + Dienos + Dienų + + Ištrinti + Aprašymas + Išsami informacija + Ignoruoti visas naujas versijas + Ignoruoti šią versiją + Ši versija pasirašyta kitu sertifikatu nei jūsų įrenginyje įdiegtas sertifikatas. Pirmiausia jį pašalinkite. + Pranešti apie naujas programėlių versijas + Išsaugoti + Rodyti senesnes versijas + Pirminis kodas + Nepasirašyta + Versijos + Naujausia + Atnaujinti viską + Įdiegtos programėlės + Rūšiavimas ir filtravimas + Naujos programėlės + Rodyti daugiau + Pasirašyta naudojant nesaugų algoritmą + Dalintis + Dydis + Sinchronizuojama + Nežinoma klaida. + Ieškoti + Parašas %s + Praleisti + Sistemos + Temos + Rūšiavimo tvarka + Šaltinio kodas nebepasiekiamas + Siūloma + Naujiniai + Sinchronizuojama %s… + Stebi arba praneša apie jūsų veiklą + Nežinoma + Nežinoma: %s + Nestabilūs atnaujinimai + Siūlyti įdiegti nestabilias versijas + Nepatvirtinta + Atnaujinti + Trūksta vartotojo vardo + Indekso patvirtinti nepavyko. + Versija + Kas naujo + Kalba + Tema + Pašalinti + Vartotojo vardas + Versija %s + Laukiama, kol bus pradėtas atsisiuntimas… + Interneto svetainė + Personalizavimas + Rodyti mažiau + Anti-ypatybės + Nepavyko sinchronizuoti %s + Rodyti pranešimą, kai bus pasiekiamos naujos versijos + Programėlių skaičius + Išsaugoma informacija… + Suderinama tik su %s + Nepavyko išanalizuoti indekso failo. + Apdorojama %1$s… + Projekto svetainė + Ekrano nuotraukos + Visada + Klaidų sekiklis + Patvirtinimas + Jungiamasi… + Nepavyko atsisiųsti %s + Programėlė + Nepavyko rasti šios programėlės + Atsisiunčiama + Netinkamas failo formatas. + Atšaukti + Paaukoti + Kita + Leidimai + Nustatymai + Neseniai atnaujinta + Tylusis diegimas + Suteikti šakninį leidimą tyliajam diegimui + Atsisiųsta %s + Atidaryti %s\? + Slaptažodis + Atsisiunčiama %s… + Turi reklamų + Turi saugumo spragų + + Valanda + Valandos + Valandų + + Gerai + Tik per Wi-Fi + Jūsų %1$s (API versija %2$d) nepalaikoma. %3$s + Minimali API versija yra %d. + Nesuderinama versija + Ši versija yra senesnė už jūsų įrenginyje įdiegtą versiją. Pirmiausia ją pašalinkite. + Maksimali API versija yra %d. + Nesuderinamos versijos + Jau egzistuoja + Autoriaus svetainė + Nepavyko patvirtinti %s + Jūsų %1$s platforma nepalaikoma. Palaikomos platformos: %2$s. + Trūksta slaptažodžio + +%d daugiau + Pridėti repozitoriją + Naršyti + Negalima redaguoti repozitorijos, nes ji dabar sinchronizuojama. + APK cleanup interval + Tikrinama repozitorija… + Ištrinti repozitoriją\? + Nepavyko patikrinti vientisumo. + Neteisingas adresas + Netinkamas pirštų atspaudų formatas + Netinkami metaduomenys. + Netinkami leidimai. + Netinkamas parašas. + Netinkamas vartotojo vardo formatas + Paleisti + Licencija + %s licencija + Šviesi + Nuorodos + Sąrašo animacijos + Rodyti sąrašo animaciją pagrindiniame puslapyje + Sujungiama %s + Pavadinimas + Tinklo klaida + Niekada + Yra naujų programėlių versijų + + %d programėlė turi naują versiją. + %d programėlės turi naujas versijas. + %d programėlių turi naujas versijas. + + Nėra pasiekiamų programėlių + Nėra įdiegtų programėlių + Aprašymo nėra + Nepavyko rasti tokių programėlių + Repozitorija + Nepasirašyta. Nepavyko patikrinti programėlių sąrašo. Būkite atsargūs atsisiųsdami programėles iš nepasirašytų repozitorijų. + Sinchronizuoti repozitorijas + Automatiškai sinchronizuoti repozitorijas + Bakstelėkite, kad įdiegtumėte. + Repozitorijos + Ši repozitorija dar nebuvo naudojama. Įjunkite ją, kad peržiūrėtumėte joje esančias programėles. + Reikalauja %s + Parengta derinimui + Padėkos + Turi ne „libre“ medijos + Period to check and remove downloaded files + Redaguoti repozitoriją + Pirštų atspaudai + Turi ne „libre“ priklausomybių + Netinkamas serverio atsakymas. + HTTP įgaliotasis serveris + Trūkstamos funkcijos. + Įdiegti + Diegimo tipai + Diegimo priemonė + Sesijos diegimo programa + Šakninė diegimo programa + Shizuku diegimo programa + Įdiegta + Rodyti su įrenginiu nesuderinamas programėlės versijas + Nesuderinama su %s + Senoji diegimo programa + Nuoroda nukopijuota į iškarpinę + Nėra įgaliotojo serverio + Tik naudojant Wi-Fi ir prijungus krauti + Skatina ne „libre“ tinklo paslaugas + Įgaliotasis serveris + Įgaliotojo serverio adresas + Įgaliotojo serverio prievadas + Pateikė %s + Skatina ne „libre“ programinę įrangą + Pasirinkite alternatyvų šaltinį + SOCKS įgaliotasis serveris + Tikslas + Pradinis pirminis kodas nėra visiškai „libre“ + Naršyti + Įgaliotojo serverio tipas + Nepavyko atlikti tam tikrų veiksmų. + Neturite interneto ryšio + Leisti viršutinei programėlių juostai išsiskleisti + Leisti viršutinei programėlių juostai išsiskleisti ir susiskleisti + Automatinis programų atnaujinimas + Pabandykite automatiškai įdiegti naujinimus + Diegimas + Iš naujo paleiskite LeOS-Droid, kad pamatytumėte pakeitimus + Laukiama, kol bus pradėtas diegimas… + Mėgstamiausi + Saugykla nepasiekiama + Priversti valyti + Išvalo perteklinius failus + Įgalinti saugyklą + Medžiaga Tu + Naudokite medžiaga tu spalvų temą + \ No newline at end of file diff --git a/core/common/src/main/res/values-lv/strings.xml b/core/common/src/main/res/values-lv/strings.xml new file mode 100644 index 0000000..72a0eed --- /dev/null +++ b/core/common/src/main/res/values-lv/strings.xml @@ -0,0 +1,215 @@ + + + Šī versija ir parakstīta ar citu sertifikātu, nevis jūsu ierīcē instalēto. Vispirms atinstalējiet to. + Instalēšanas veidi + Rādīt paziņojumu, kad ir pieejamas jaunas versijas + Nederīga adrese + Nederīgs pirkstu nospiedumu formāts + Tīkla kļūda + Vai atvērt %s\? + Apvieno %s + Apraksts nav pieejams + Nesen atjaunināts + Meklēt + Nepieciešams %s + Parādīt vairāk + Šis repozitorijs vēl nav izmantota. Ieslēdziet to, lai skatītu tajā esošās lietojumprogrammas. + Atļaut root atļauju klusai instalēšanai + Saglabāt + Ekrānuzņēmumi + Parakstīts, izmantojot nedrošu algoritmu + Paraksts %s + Indeksu nevarēja apstiprināt. + Pievienot repozitoriju + Adrese + Visas aplikācijas + Visas jūsu aplikācijas ir atjauninātas + Jau eksistē + Vienmēr + Melns + Pretīpašības + Aplikācija + Nevarēja atrast šo aplikāciju + Autora e-pasts + Autora vietne + Izpētīt + Kļūdu izsekotājs + Atcelt + Izmaiņu žurnāls + Izmaiņas + Notiek repozitorija pārbaude… + Sastādīts atkļūdošanai + Apstiprinājums + Notiek savienojuma izveide… + Satur nebrīvus medijus + Nevarēja sinhronizēt %s + Nevarēja apstiprināt %s + Kredīts + Tumšs + + dienas + diena + dienas + + Dzēst + Apraksts + Ziedot + Lejupielādēts %s + Lejupielādē + Notiek %s lejupielāde… + Rediģēt repozitoriju + Pirkstu nospiedums + Ir nebrīvas atkarības + Ir drošības ievainojamības + Nederīga servera atbilde. + + Stundas + Stundu + Stundas + + Ignorēt šo versiju + Jūsu %1$s (API versija %2$d) netiek atbalstīta. %3$s + Maksimālā API versija ir %d. + Minimālā API versija ir %d. + Trūkst funkcijas. + Jūsu %1$s platforma netiek atbalstīta. Atbalstītās platformas: %2$s. + Nesaderīga versija + Nesaderīgas versijas + Rādīt aplikācijas versijas, kas nav saderīgas ar ierīci + Nesaderīgs ar %s + Instalēt + Instalētājs + Mantotais instalētājs + Sesijas instalētājs + Sakņu instalētājs + Shizuku instalētājs + Instalēts + Nevarēja pārbaudīt integritāti. + Nederīgi metadati. + Nederīgas atļaujas. + Nederīgs paraksts. + Nederīgs lietotājvārda formāts + Palaist + Licence + %s licence + Gaisma + Saite ir kopēta starpliktuvē + Saites + Animāciju saraksts + Rādīt saraksta animāciju galvenajā lapā + Vārds + Nekad + Pieejamas jaunas aplikācijas versijas + + %d aplikācijām ir jaunas versijas. + %d aplikācijai ir jauna versija. + %d aplikācijām ir jaunas versijas. + + Nav pieejamu aplikāciju + Nav instalētas aplikācijas + Nevarēja atrast nevienu šādu aplikāciju + Nav starpniekservera + Paziņot par jaunām aplikāciju versijām + Aplikāciju skaits + Labi + Saderīgs tikai ar %s + Tikai Wi-Fi tīklā + Tikai Wi-Fi un uzlādes režīmā + Cits + Nevarēja parsēt indeksa failu. + Parole + Trūkst paroles + Atļaujas + +%d vairāk + Iestatījumi + Notiek %1$s apstrāde… + Projekta vietne + Veicina maksas tīkla pakalpojumus + Reklamē maksas programmatūru + Nodrošina %s + Starpniekserveris + Starpniekservera saimniekdators + Starpniekservera ports + Starpniekservera veids + Repozitorijas + Repozitorijs + Neparakstīts. Nevarēja pārbaudīt lietojumprogrammu sarakstu. Esiet piesardzīgs, lejupielādējot lietojumprogrammas no neparakstītām repozitorijiem. + Klusā instalēšana + Notiek informācijas saglabāšana… + Izvēlieties spoguli + Dalīties + Rādīt vecākas versijas + Izmērs + Izlaist + SOCKS starpniekserveris + Šķirošanas secība + Avota kods + Avota kods vairs nav pieejams + Ieteikts + Sinhronizēt repozitorijus + Automātiski sinhronizēt repozitorijus + Sinhronizē + Notiek %s sinhronizēšana… + Sistēma + Pieskarieties, lai instalētu. + Mērķis + Tēma + Tēmas + Izseko vai ziņo par jūsu aktivitātēm + Atinstalēt + Nezināms + Nezināma kļūda. + Nezināms: %s + Neparakstīts + Nestabili atjauninājumi + Ieteikt instalēt nestabilas versijas + Nepārbaudīts + Atjaunināt + Atjauninājumi + Augšējā avota kods nav bezmaksas + Lietotājvārds + Trūkst lietotājvārda + Versija + Versija %s + Versijas + Gaida lejupielādes sākšanu… + Kas jauns + Tīmekļa vietne + Valoda + Personalizēšana + Rādīt mazāk + Jaunākais + Izpētīt + Atjaunināt visu + Instalētās aplikācijas + Kārtot & filtrēt + Jaunas aplikācijas + Darbība neizdevās + Nevar rediģēt repozitoriju, jo tas pašlaik tiek sinhronizēts. + APK cleanup interval + Nevarēja lejupielādēt %s + Period to check and remove downloaded files + Vai dzēst repozitoriju\? + Sīkāka informācija + Šī versija ir vecāka par jūsu ierīcē instalēto. Vispirms atinstalējiet to. + Nederīgs faila formāts. + Ir reklāmas + HTTP starpniekserveris + Ignorēt visas jaunās versijas + Ļaujiet paplašināties augšējā lietotņu joslā + Nespēj veikt noteiktas darbības. + Ļaujiet augšējā lietotņu joslā paplašināties un sabrukt + Jums nav interneta savienojuma + Automātiski atjaunināt lietotnes + Mēģiniet automātiski instalēt atjauninājumus + Instalēšana + Restartējiet LeOS-Droid, lai redzētu izmaiņas + Gaida instalēšanas sākšanu… + Izlase + Iespējot repozitoriju + Piespiedu tīrīšana + Materiāls Tu + Notīra liekos failus + Izmantojiet materiāls tu krāsu motīvu + Repozitorijs nav sasniedzams + \ No newline at end of file diff --git a/core/common/src/main/res/values-ml/strings.xml b/core/common/src/main/res/values-ml/strings.xml new file mode 100644 index 0000000..11ce0da --- /dev/null +++ b/core/common/src/main/res/values-ml/strings.xml @@ -0,0 +1,212 @@ + + + റദ്ദാക്കുക + വിലാസം + + ദിവസം + ദിവസങ്ങൾ + + വിശദാംശങ്ങൾ + സംഭാവനചെയ്യുക + കറുപ്പ് + പര്യവേക്ഷണം ചെയ്യുക + എപ്പോഴും + പ്രവർത്തനം പരാജയപ്പെട്ടു + ഇല്ലാതാക്കുക + ഇരുട്ട് + വിവരണം + സ്ഥിരീകരണം + ബന്ധിപ്പിക്കുന്നു… + മാറ്റങ്ങൾ + റെപ്പോസിറ്റോറി ചേർക്കുക + എല്ലാ ആപ്പ്ലിക്കേഷനുകളും + നിങ്ങളുടെ എല്ലാ അപ്പ്ലിക്കേഷനുകളും പുതിയവ ആണ് + പുതിയ അപ്പ്ലിക്കേഷനുകൾ + ആപ്പുകൾ സ്വയമേവ അപ്ഡേറ്റ് ചെയ്യുക + ബഗ് ട്രാക്കർ + ചേഞ്ച്ലോഗ് + ശേഖരം പരിശോധിക്കുന്നു… + APK cleanup interval + %s സാധൂകരിക്കാൻ കഴിഞ്ഞില്ല + + മണിക്കൂർ + മണിക്കൂറുകൾ + + HTTP പ്രോക്സി + സെഷൻ ഇൻസ്റ്റാളർ + റൂട്ട് ഇൻസ്റ്റാളർ + Shizuku ഇൻസ്റ്റാളർ + ഇൻസ്റ്റാൾ ചെയ്യുന്നു + അസാധുവായ അനുമതികൾ. + അസാധുവായ ഒപ്പ്. + പ്രിയപ്പെട്ടവ + ഇതിനകം നിലവിലുണ്ട് + വിരുദ്ധ സവിശേഷതകൾ + അപേക്ഷ + ആ ആപ്ലിക്കേഷൻ കണ്ടെത്താൻ കഴിഞ്ഞില്ല + ശേഖരം ഇപ്പോൾ സമന്വയിപ്പിക്കുന്നതിനാൽ എഡിറ്റ് ചെയ്യാൻ കഴിയില്ല. + സ്വതന്ത്രമല്ലാത്ത മാധ്യമങ്ങൾ അടങ്ങിയിരിക്കുന്നു + ഡീബഗ്ഗിംഗിനായി സമാഹരിച്ചത് + ശേഖരം ഇല്ലാതാക്കണോ\? + %s ഡൗൺലോഡ് ചെയ്തു + ഡൗൺലോഡ് ചെയ്യുന്നു + അസാധുവായ ഫയൽ ഫോർമാറ്റ്. + പരസ്യമുണ്ട് + വിരലടയാളം + നോൺ-ഫ്രീ ഡിപൻഡൻസികൾ ഉണ്ട് + ഏറ്റവും കുറഞ്ഞ API പതിപ്പ് %d ആണ്. + ഇൻസ്റ്റാൾ ചെയ്തു + സമഗ്രത പരിശോധിക്കാൻ കഴിഞ്ഞില്ല. + മെറ്റാഡാറ്റ അസാധുവാണ്. + അസാധുവായ ഉപയോക്തൃനാമ ഫോർമാറ്റ് + ചില പ്രവർത്തനങ്ങൾ നടത്താൻ കഴിയുന്നില്ല. + ലോഞ്ച് + ലൈസൻസ് + ശേഖരം പ്രവർത്തനക്ഷമമാക്കുക + നിർബന്ധിച്ച് വൃത്തിയാക്കുക + അനാവശ്യ ഫയലുകൾ വൃത്തിയാക്കുന്നു + ഈ പതിപ്പ് നിങ്ങളുടെ ഉപകരണത്തിൽ ഇൻസ്റ്റാൾ ചെയ്തതിനേക്കാൾ പഴയതാണ്. ആദ്യം അത് അൺഇൻസ്റ്റാൾ ചെയ്യുക. + നിങ്ങളുടെ %1$s പ്ലാറ്റ്‌ഫോം പിന്തുണയ്ക്കുന്നില്ല. പിന്തുണയ്ക്കുന്ന പ്ലാറ്റ്‌ഫോമുകൾ: %2$s. + ഈ പതിപ്പ് നിങ്ങളുടെ ഉപകരണത്തിൽ ഇൻസ്‌റ്റാൾ ചെയ്‌തതിൽ നിന്ന് വ്യത്യസ്തമായ ഒരു സർട്ടിഫിക്കറ്റ് ഉപയോഗിച്ചാണ് ഒപ്പിട്ടിരിക്കുന്നത്. ആദ്യം അത് അൺഇൻസ്റ്റാൾ ചെയ്യുക. + അനുയോജ്യമല്ലാത്ത പതിപ്പ് + പൊരുത്തപ്പെടാത്ത പതിപ്പുകൾ + എല്ലാ പുതിയ പതിപ്പുകളും അവഗണിക്കുക + ഈ പതിപ്പ് അവഗണിക്കുക + നിങ്ങളുടെ %1$s (API പതിപ്പ് %2$d) പിന്തുണയ്ക്കുന്നില്ല. %3$s + പരമാവധി API പതിപ്പ് %d ആണ്. + സവിശേഷതകൾ നഷ്‌ടമായി. + %s-മായി പൊരുത്തപ്പെടുന്നില്ല + ഉപകരണവുമായി പൊരുത്തപ്പെടാത്ത ആപ്ലിക്കേഷൻ പതിപ്പുകൾ കാണിക്കുക + ഇൻസ്റ്റാൾ ചെയ്യുക + ഇൻസ്റ്റലേഷൻ തരങ്ങൾ + ഇൻസ്റ്റാളർ + ലെഗസി ഇൻസ്റ്റാളർ + %s സമന്വയിപ്പിക്കാൻ കഴിഞ്ഞില്ല + ക്രെഡിറ്റുകൾ + ശേഖരം എഡിറ്റ് ചെയ്യുക + സുരക്ഷാ പാളിച്ചകൾ ഉണ്ട് + അസാധുവായ സെർവർ പ്രതികരണം. + ടോപ്പ് ആപ്പ് ബാർ വികസിപ്പിക്കാൻ അനുവദിക്കുക + മുകളിലെ ആപ്പ് ബാർ വികസിപ്പിക്കാനും ചുരുക്കാനും അനുവദിക്കുക + രചയിതാവിന്റെ ഇമെയിൽ + രചയിതാവിന്റെ വെബ്സൈറ്റ് + Period to check and remove downloaded files + %s ഡൗൺലോഡ് ചെയ്യാനായില്ല + അപ്ഡേറ്റുകൾ സ്വയമേവ ഇൻസ്റ്റാൾ ചെയ്യാൻ ശ്രമിക്കുക + %s ഡൗൺലോഡ് ചെയ്യുന്നു… + അസാധുവായ വിലാസം + വിരലടയാള ഫോർമാറ്റ് അസാധുവാണ് + ലഭ്യമായ അപ്ലിക്കേഷനുകളൊന്നുമില്ല + പാസ്‌വേഡ് കാണുന്നില്ല + അനുമതികൾ + %s ആവശ്യമാണ് + മാറ്റങ്ങൾ കാണുന്നതിന് LeOS-Droid പുനരാരംഭിക്കുക + സോക്സ് പ്രോക്സി + അടുക്കൽ ക്രമം + സിസ്റ്റം + ഇൻസ്റ്റാൾ ചെയ്യാൻ ടാപ്പ് ചെയ്യുക. + നിങ്ങളുടെ പ്രവർത്തനം ട്രാക്ക് ചെയ്യുകയോ റിപ്പോർട്ടുചെയ്യുകയോ ചെയ്യുന്നു + അജ്ഞാത പിശക്. + അജ്ഞാതം: %s + ഒപ്പിടാത്തത് + ഇൻസ്റ്റാളേഷൻ ആരംഭിക്കാൻ കാത്തിരിക്കുന്നു… + ആരായുക + എല്ലാം അപ്ഡേറ്റ് ചെയ്യുക + ശരി + രക്ഷിക്കും + Material You + വൈ-ഫൈയിൽ മാത്രം + സോഴ്സ് കോഡ് + നിർദ്ദേശിച്ചു + അൺഇൻസ്റ്റാൾ ചെയ്യുക + അസ്ഥിരമായ അപ്ഡേറ്റുകൾ + വെബ്സൈറ്റ് + വ്യക്തിഗതമാക്കൽ + ലിങ്കുകൾ + ലിസ്റ്റ് ആനിമേഷനുകൾ + ലിങ്ക് ക്ലിപ്പ്ബോർഡിലേക്ക് പകർത്തി + പ്രധാന പേജിൽ ലിസ്റ്റ് ആനിമേഷൻ കാണിക്കുക + + %d അപ്ലിക്കേഷനും ഒരു പുതിയ പതിപ്പ് ഉണ്ട്. + %d അപ്ലിക്കേഷനും പുതിയ പതിപ്പുകൾ ഉണ്ട്. + + അപ്ലിക്കേഷനുകളുടെ പുതിയ പതിപ്പുകളെക്കുറിച്ച് അറിയിക്കുക + %s എന്നതുമായി പൊരുത്തപ്പെടുന്നു + ഇൻസ്റ്റാളുചെയ്ത അപ്ലിക്കേഷനുകളൊന്നുമില്ല + പ്രോക്സി ഇല്ല + പുതിയ പതിപ്പുകൾ ലഭ്യമാകുമ്പോൾ ഒരു അറിയിപ്പ് കാണിക്കുക + വൈഫൈയിലും ചാർജിംഗിലും മാത്രം + അടുത്തിടെ അപ്ഡേറ്റ് ചെയ്തത് + ഈ ശേഖരം ഇതുവരെ ഉപയോഗിച്ചിട്ടില്ല. ഇതിലെ ആപ്ലിക്കേഷനുകൾ കാണുന്നതിന് അത് ഓണാക്കുക. + പങ്കിടുക + റിപ്പോസിറ്ററികൾ സ്വയമേവ സമന്വയിപ്പിക്കുക + ഒപ്പിടാത്തത്. ആപ്ലിക്കേഷൻ ലിസ്റ്റ് പരിശോധിക്കാൻ കഴിഞ്ഞില്ല. ഒപ്പിടാത്ത റിപ്പോസിറ്ററികളിൽ നിന്ന് ആപ്ലിക്കേഷനുകൾ ഡൗൺലോഡ് ചെയ്യുന്നത് ശ്രദ്ധിക്കുക. + അജ്ഞാതം + നിശബ്ദ ഇൻസ്റ്റാളേഷൻ + ലക്ഷ്യം + അസ്ഥിരമായ പതിപ്പുകൾ ഇൻസ്റ്റാൾ ചെയ്യാൻ നിർദ്ദേശിക്കുക + നിശബ്ദ ഇൻസ്റ്റാളുകൾക്ക് റൂട്ട് അനുമതി അനുവദിക്കുക + കൂടുതൽ കാണിക്കുക + വിശദാംശങ്ങൾ സംരക്ഷിക്കുന്നു… + തിരയുക + ക്രമീകരണങ്ങൾ + ഒപ്പ് %s + സുരക്ഷിതമല്ലാത്ത അൽഗോരിതം ഉപയോഗിച്ചാണ് ഒപ്പിട്ടത് + ഒരു കണ്ണാടി തിരഞ്ഞെടുക്കുക + പഴയ പതിപ്പുകൾ കാണിക്കുക + ഒഴിവാക്കുക + ഉറവിട കോഡ് ഇനി ലഭ്യമല്ല + റിപ്പോസിറ്ററികൾ സമന്വയിപ്പിക്കുക + %s സമന്വയിപ്പിക്കുന്നു… + തീം + തീമുകൾ + സൂചിക സാധൂകരിക്കാനായില്ല. + അപ്ഡേറ്റ് ചെയ്യുക + അപ്ഡേറ്റുകൾ + പതിപ്പ് + പതിപ്പ് %s + പതിപ്പുകൾ + ഭാഷ + ഇൻസ്റ്റാൾ ചെയ്ത ആപ്ലിക്കേഷനുകൾ + അടുക്കുക & ഫിൽട്ടർ ചെയ്യുക + %s ലൈസൻസ് + ഭാരംകുറഞ്ഞ + മറ്റേതായ + സൂചിക ഫയൽ പാഴ്സുചെയ്യാൻ കഴിഞ്ഞില്ല. + +%d കൂടുതൽ + സ്ക്രീൻഷോട്ടുകൾ + ഡൗൺലോഡ് ആരംഭിക്കാൻ കാത്തിരിക്കുന്നു… + പുതിയതെന്താണ് + വലിപ്പം + സമന്വയിപ്പിക്കുന്നു + അപ്സ്ട്രീം സോഴ്സ് കോഡ് സൗജന്യമല്ല + ഉപയോക്തൃനാമം + ഉപയോക്തൃനാമം കാണുന്നില്ല + Material you കളർ തീം ഉപയോഗിക്കുക + %1$s പ്രോസസ്സ് ചെയ്യുന്നു… + പദ്ധതി വെബ്സൈറ്റ് + നോൺ-ഫ്രീ നെറ്റ്‌വർക്ക് സേവനങ്ങൾ പ്രോത്സാഹിപ്പിക്കുന്നു + സ്വതന്ത്രമല്ലാത്ത സോഫ്റ്റ്‌വെയർ പ്രോത്സാഹിപ്പിക്കുന്നു + %s നൽകിയത് + പ്രോക്സി + പ്രോക്സി ഹോസ്റ്റ് + പ്രോക്സി പോർട്ട് + പ്രോക്സി തരം + ശേഖരങ്ങൾ + സംഭരണിയാണ് + ശേഖരം ലഭ്യമല്ല + കുറവ് കാണിക്കുക + ഏറ്റവും പുതിയ + %s തുറക്കുന്നു\? + %s ലയിപ്പിക്കുന്നു + പേര് + നെറ്റ്വർക്ക് പിശക് + ഒരിക്കലും + ലഭ്യമായ അപ്ലിക്കേഷനുകളുടെ പുതിയ പതിപ്പുകൾ + വിവരണം ലഭ്യമല്ല + നിങ്ങൾക്ക് ഇന്റർനെറ്റ് കണക്ഷനില്ല + അത്തരം അപ്ലിക്കേഷനുകൾ കണ്ടെത്താൻ കഴിഞ്ഞില്ല + അപ്ലിക്കേഷനുകളുടെ എണ്ണം + പാസ്വേഡ് + പരിശോധിച്ചുറപ്പിച്ചിട്ടില്ല + \ No newline at end of file diff --git a/core/common/src/main/res/values-nb-rNO/strings.xml b/core/common/src/main/res/values-nb-rNO/strings.xml new file mode 100644 index 0000000..1ba4d5b --- /dev/null +++ b/core/common/src/main/res/values-nb-rNO/strings.xml @@ -0,0 +1,224 @@ + + + Finnes allerede + Alltid + Svart + Utviklerens e-postadresse + Utviklerens nettside + Endringslogg + Kan ikke redigere pakkebrønnen siden den synkroniseres akkurat nå. + Kontrollerer depotet… + Kompilert for avlusing + Bekreftelse + Kunne ikke laste ned %s + Kobler til… + Inneholder ufri media + Slett depotet\? + Lastet ned %s + Laster ned + Fingeravtrykk + Har reklame + Har ufrie avhengigheter + Har sikkerhetssårbarheter + Ugyldig tjenersvar. + Ignorer alle nye versjoner + Ignorer denne versjonen + Din %1$s (API-versjon %2$d) støttes ikke. %3$s + Maksimal API-versjon er %d. + Denne versjonen er eldre enn den som er installert på enheten din. Avinstaller den først. + Minste tillatte API-versjon er %d. + Din %1$s-plattform støttes ikke. Støttede plattformer er %2$s. + Ukompatible versjoner + Ukompatibelt med %s + Kunne ikke sjekke filens gyldighet. + Lys + Ugyldig brukernavnsformat + %s-lisens + Lenke kopiert + Listeanimasjoner + Vis liste animasjon på hovedsiden + Nye versjoner av programmer tilgjengelig + Ingen programmer tilgjengelig + Ingen programmer installert + Ingen beskrivelse tilgjengelig + Åpne %s\? + Fant ingen slike programmer + Gi merknad om nye versjoner av programmer + Antall programmer + Kun kompatibelt med %s + Andre + Passord mangler + Tilganger + +%d til + Innstillinger + Behandler %1$s … + Promoterer ufrie nettverkstjenester + Tilbudt av %s + Mellomtjenervert + Mellomtjenerport + Mellomtjenertype + Nylig oppdatert + Pakkebrønn + Krever %s + Usignert. Kunne ikke bekrefte programlisten. Vær forsiktig med å laste ned programmer fra usignerte pakkebrønner. + Stille installasjon + Tillat rot-tilganger for stille installasjoner + Skjermavbildninger + Søk + Velg et speil + Del + Vis mer + Vis eldre versjoner + Lagre + Hopp over + SOCKS-mellomtjener + Kildekode + Foreslått + Signert ved bruk av en utrygg algoritme + Synkroniserer + Synkroniserer %s… + System + Mål + Synkroniser pakkebrønner automatisk + Drakter + Sporer eller rapporterer din aktivitet + Ukjent feil. + Ukjent: %s + Usignert + Foreslå installasjon av ustabile versjoner + Ubekreftet + Brukernavn + Kildekoden oppstrøms er ikke helt fri + Nettside + Kunne ikke bekrefte indeksen. + Versjon %s + Venter på å laste ned… + Handlingen mislyktes + Alle appene dine er oppdatert + Kjør + Vis en merknad når nye versjoner er tilgjengelig + Legg til pakkebrønn + Bidragsytere + Laster ned %s … + Nettverksfeil + Ingen mellomtjener + Kun på Wi-Fi + Mellomtjener + Versjon + Versjoner + Detaljer + Manglende funksjoner. + Finner ikke det programmet + Endringer + Kunne ikke synkronisere %s + Mørk + Beskrivelse + Doner + Rediger pakkebrønn + Vis programversjoner som ikke er kompatible med enheten + Installert + Ugyldig metadata. + + %d program har en ny versjon. + %d programmer har nye versjoner. + + Prosjektnettside + Ukjent + Brukernavn mangler + Alle programmer + Feilsporer + Avbryt + Installer + Ugyldig fingeravtrykksformat + Ugyldige tilganger. + Ugyldig signatur. + Lisens + Lenker + Navn + Passord + Adresse + Anti-funksjoner + Program + Utforsk + Kunne ikke bekrefte %s + Slett + Ugyldig filformat. + HTTP-mellomtjener + Ukompatibel versjon + Ugyldig adresse + Fletter %s + Aldri + Installasjonstyper + Sorteringsrekkefølge + Synkroniser kodelagre + Pakkebrønner + Lagrer detaljer … + Størrelse + Trykk for å installere. + Drakt + Avinstaller + %s-signatur + Ustabile oppgraderinger + Denne versjonen er signert med et annet sertifikat enn det som er installert på enheten din. Avinstaller det først. + OK + Kunne ikke tolke indeksfilen. + Promoterer ufri programvare + Kildekoden er ikke lenger tilgjengelig + Oppgraderinger + Seneste + Denne pakkebrønnen har ikke blitt brukt enda. Skru den på for å vise programmene i den. + Oppgradering + Språk + Personalisering + Vis mindre + Sorter og filtrer + Utforsk + APK cleanup interval + Period to check and remove downloaded files + + Dag + Dager + + + Time + Timer + + Installasjonsprogram + Gammeldags + Økt + Rot-tilgang + Shizuku + Kun på Wi-Fi og når innplugget + Nyeste + Oppdater alle + Installerte + Nye + Tillat utvidelse av toppfelt + Du mangler tilkobling til Internett + Noen av handlingene kunne ikke utføres. + Tillat toppfeltet å utvide og folde seg sammen + Bruk «materiell deig»-fargedrakt + Materiell deig + Favoritter + Pakkebrønn kan ikke nås + Skru på pakkebrønnen + Påtving opprenskning + Fjerner overflødige filer + Prøv å installere nye versjoner automatisk + Installer nye versjoner av programmer automatisk + Venter på å starte installasjon … + Installerer + Start LeOS-Droid på ny for å ta i bruk endringene + Har ufrie komponenter + Hjemmeskjermsdragning + Inneholder sensurerbart innhold + Shizuku kjører ikke + Kunne ikke koble til tjener + Tjeneren kunne ikke tilby ny pakke. + Kopier + Mellomtjenerport må være et heltall + Tillat bruker å dra mellom sider på hjemmeskjermen + Følgende pakkebrønner ble funnet + Spesiell takk til + Shizuku er ikke installert + \ No newline at end of file diff --git a/core/common/src/main/res/values-nl/strings.xml b/core/common/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000..1225c15 --- /dev/null +++ b/core/common/src/main/res/values-nl/strings.xml @@ -0,0 +1,215 @@ + + + Adres + Alle apps zijn de nieuwste versie + Bestaat al + Alle apps + Actie mislukt + Bronopslagruimte toevoegen + Altijd + AMOLED + Antifuncties + Applicatie + Website van auteur + Beschikbaar + Gecompileerd voor debugging + Bevestiging + Bijwerken + Niet gecontroleerd + Gebruikersnaam + Upstream broncode is niet vrij + Wachten om te beginnen met downloaden… + Wat is nieuw + Webside + App niet gevonden + E-mail van de auteur + Versies + Bijwerkingen + Annuleer + Kan het archief niet bewerken, omdat het nu aan het synchroniseren is. + Ik controleer de opslagplaats… + Wijzigingen + Wijzigingsoverzicht + Versie + Versie %s + Gebruikersnaam ontbreekt + Stel voor onstabiele versies te installeren + De index kon niet worden gevalideerd. + Onbekend: %s + Niet tekend + Niet stabiele updates + Foutspeurder (bug tracker) + Verbinden… + Bevat niet-vrije media + Kon %s niet opslaan + Kon %s niet gelijkschakelen + Kon %s niet vergunnen + Dankbetuigingen + Donker + Verwijderen + Opslagplaats verwijderen\? + Beschrijving + Details + Vingerafdruk + Heeft reclames + Heeft niet-vrije afhankelijkheden + Heeft kwetsbaarheden in beveiliging + Ongeldige terugkoppeling van server. + HTTP proxy + Alle nieuwe versies negeren + Deze versie overslaan + Uw %1$s (API versie %2$d) wordt niet ondersteund. %3$s + De hoogte API versie is %d. + De laagste API versie is %d. + Ontbrekende onderdelen. + Deze versie is ouder dan die ingestald op uw toestel. Verwijder die eerst. + Deze versie is met een ander certificaat ondertekend dan de versie op uw toestel. Verwijder die eerst. + Onverenigbare versie + Onverenigbare versies + Toon versies die onverenigbaar zijn met dit toestel + Onverenigbaar met %s + Installeren + Installatietypen + Geïnstalleerd + Kon de echtheid niet nagaan. + Ongeldig adres + Ongeldige metadata. + Ongeldig type van vingerafdruk + Ongeldige toestemmingen. + Ongeldige ondertekening. + Opstarten + Licentie + %s licentie + Licht + Link gekopieerd naar klembord + Toon animaties + %s samenvoegen + Nooit + Nieuwe versies van toepassingen beschikbaar + Geen toepassingen beschikbaar + Geen toepassingen geïnstalleerd + Geen beschrijving beschikbaar + Geen proxy + Waarschuw bij nieuwe versies van applicaties + Verzend een vermelding wanneer nieuwe versies beschikbaar zijn + Aantal applicaties + Oké + Werkt alleen met %s + Alleen bij Wi-Fi + Andere + Kon het indexbestand niet ontleden. + Wachtwoord + Wachtwoord ontbreekt + Toestemmingen + %1$s aan het verwerken… + Project webstede + Gebruikt niet-vrije netwerk diensten + Proxy type + Pas bijgewerkt + Opslagruimtes + Opslagplaats + Vereist %s + Geef root-toestemming voor stille installaties + Opslaan + Details worden opgeslagen… + Schermafbeeldingen + Delen + Toon meer + Ondertekening %s + Ondertekend met een onveilig algoritme + SOCKS proxy + Volgorde van rangschikken + Broncode + Broncode niet meer beschikbaar + Voorgesteld + Opslagplaatsen gelijkschakelen + Gelijkschakelen + %s aan het gelijkschakelen… + Systeem + Klik om te installeren. + Doel + Deelt uw gedrag of houdt het bij + Verwijderen + Onbekend + Onbekende fout. + Taal + Eigen voorkeuren + Minder tonen + Geïnstalleerde applicaties + Doneren + Opslagplaats bewerken + Ongeldig bestandstype. + Aan het opslaan + Uw %1$s platform wordt niet ondersteund. Dit zijn de ondersteunde platforms: %2$s. + Koppelingen + + %d toepassing heeft een nieuwe versie. + %d toepassingen hebben nieuwe versies. + + Animatie inschakelen op lijst op voorblad + Kon zulke toepassingen niet vinden + +%d meer + Instellingen + Toon oudere versies + Naam + Grootte + Netwerk fout + %s openen\? + Proxy poort + Stille installatie + Aangeboden door %s + Gebruikt niet-vrije software + Proxy + Proxy gastheer + Overslaan + Niet ondertekend. Kon de applicatie lijst niet bevestigen. Wees voorzichtig met het installeren van applicaties van niet ondertekende opslagplaatsen. + Thema\'s + Verkennen + Ordenen en filteren + Zoeken + Opslagplaatsen vanzelf gelijkschakelen + Thema + Nieuwste + Nieuwe applicaties + Alles bijwerken + Deze opslagplaats is nog niet gebruikt. Schakel het in om de applicaties erin te bekijken. + Ongeldige vorm van gebruikersnaam + Kies een spiegel + %s aan het opslaan… + %s opgeslagen + Shizuku Installatie + Root Installatie + Installatie + Verouderde Installatie + Sessie Installatie + APK cleanup interval + Period to check and remove downloaded files + + Uur + Uren + + + Dag + Dagen + + Je hebt geen internetverbinding + Alleen bij Wi-Fi & Opladen + Niet in staat om bepaalde acties uit te voeren. + Laat de topbalk uitbreiden + Laat de topbalk uitbreiden en instorten + Apps automatisch bijwerken + Installeren + Start LeOS-Droid opnieuw om de wijzigingen te zien + Favorieten + Materiaal jij + Gebruik materiaal jij kleurthema + Schakel de opslagplaats in + Forceer opruimen + Probeer updates automatisch te installeren + Ruimt overbodige bestanden op + Opslagplaats onbereikbaar + Wachten om installatie te starten… + Server heeft nieuw pakket niet geleverd. + Kan geen verbinding maken met server + Heeft niet-vrije afhankelijkheden + \ No newline at end of file diff --git a/core/common/src/main/res/values-nn/strings.xml b/core/common/src/main/res/values-nn/strings.xml new file mode 100644 index 0000000..76f23a1 --- /dev/null +++ b/core/common/src/main/res/values-nn/strings.xml @@ -0,0 +1,217 @@ + + + Adresse + Alle appar + Finst alt + Legg til ei samling + App + Fann ikkje den appen + Ufunksjonar + Stadig + Mistaksporar + Utgjevarnettstad + Gransker samlinga… + Bind saman… + Kunne ikkje stadfeste %s + Om + Gje bort + Henta %s + Slett samlinga\? + Småting + Urett filformat. + Fingermerke + Har reklame + + Time + Timar + + Urett tenarsvar. + Brigde samling + Hopp over nye utgåver + Har ufrie trongar + Hopp over denne utgåva + Høgste stødd API-utgåve er %d. + Lægst stødd API-utgåve er %d. + Saknande funksjonar. + Innleggingsslag + Innleggingsvis + Urett adresse + Urett fingermerkeformat + Gamaldags + Økt + Rot + Shizuku + Innlagd + Kunne ikkje granske filas gildskap. + Urette tilgjenge. + Urett underskrift. + Køyr + Løyve + %s-løyve + Ljos + Lenkja skriven av til utklippstavla + Lenkjer + Listerørsler + Flettar %s + Sambandsmistak + Aldri + Nye utgåver av appar er tilgjengelege + + %d app har ei ny utgåve. + %d appar har nye utgåver. + + Fann ingen slike appar + Mengd appar + Greitt + Høver berre med %s + Berre på Wi-Fi + Berre på Wi-Fi og medan eininga lader + Saknar passord + Tilgjenge + +%d til + Innstillingar + Handsamar %1$s… + Prosjektnettstad + Fremjar ufrie sambandstenester + Fremjar ufri programvare + Tilboden av %s + Mellomtenarvert + Mellomtenarport + Mellomtenarslag + Samlingar + Samling + Opphav ikkje stadfesta. Kunne ikkje stadfeste applista. Ver varsam med å hente appar ifrå samlingar med ustadfesta opphav. + Hugs + Tyst innlegging + Gje rot-tilgjenge for tyste innleggingar + Hugsar småting… + Skjermbilete + Søk + Vis meir + Vis eldre utgåver + Del + Vel ein spegel + Storleik + Hopp over + SOCKS-mellomtenar + Ordningsrekkjefylgd + Kjeldekode + Ikkje stadfesta + Kjeldekoden er ikkje lenger tilgjengeleg + Synkroniser samlingar + Synkroniserer + Synkroniserer %s… + System + Trykk for å leggja inn. + Mål + Utsjånad + Utsjånadar + Slett + Ukjend + Ukjend mistak. + Ukjend: %s + Opphav ikkje stadfesta + Ustø oppdateringar + Gje framlegg om å leggja inn ustø oppdateringar + Oppstraumskjeldekoden er ikkje fri + Tilrådt + Lat samlingar synkronisera av seg sjølv + Sporar eller seier ifrå om kva du gjer + Innlagde appar + Nyaste + Vis mindre + Skriftmål + Nytt + Ventar på å hente… + Utgåve + Utgåve %s + Utgåver + Stell til + Oppdater alle + Sjå omkring + Gjerda mislukkast + Svart + Utgjevar e-post + Avbryt + Slett apk-filer etter + Stadfesting + Inneheld ufri media + Urett brukarnamnsformat + Mørk + + Dag + Dagar + + Slett + Utgreiing + Hentar + Hentar %s … + Nye appar + Skil ut & Sil ut + Brukarnamn + Nettstad + Saknar brukarnamn + Urett metadata. + Gransk + Kan ikkje brigde samlinga, då ho synkroniserer no. + Brigdelogg + Brigde + Kompilert for lysking + Slett henta appinnleggingsfiler etter ei viss stund + Kunne ikkje synkronisera %s + Kunne ikkje hente %s + Har tryggleikshòl + HTTP-mellomtenar + Nyleg oppdaterte + Din %1$s (API-utgåve %2$d) er ikkje stødd. %3$s + Din %1$s-bygnad er ikkje stødd. Stødde bygnadar: %2$s. + Utgåver som ikkje høver + Denne utgåva er eldre enn ho som er innlagd på eininga di. Slett ho først. + Opphavet til denne utgåva er stadfesta med eit anna løyve enn ho som alt er lagd inn på eininga di. Slett ho først. + Utgåva høver ikkje + Oppdateringar + Oppdater + Vis apputgåver som ikkje høver i eininga + Høver ikkje med %s + Legg inn + Vis listerørsler på hovudsida + Namn + Ingen appar er tilgjengelege + Inga utgreiing tilgjengeleg + Ingen mellomtenar + Ingen innlagde appar + Sei ifrå om nye apputgåver + Vis ein merknad når nye utgåver er tilgjengelege + Opne %s\? + Andre + Kunne ikkje granske indeksfila. + Krev %s + Passord + %s-opphavsstadfesting + Mellomtenar + Denne samlinga har ikkje vorte nytta endå. Slå ho på for å visa hennar appar. + Opphav stadfesta ved bruk av ein utrygg algoritme + Alle appane dine er av nyaste utgåve + Kunne ikkje stadfeste indeksen. + Kan ikkje gjere somme gjerder. + Du har inkje internettsamband + Lat applinja få utvide seg + Lat den øvste applinja få utvide og minke seg + Likar + Reins no + Slett uturvande filer + Når ikkje samlinga + Slå på samlinga + Nytt «Material You»-letar + Material You + Byrje om LeOS-Droid for å sjå brigde + Legg inn + Ventar på å leggja inn … + Oppdater appane sjølvverkande + Røyn å leggja inn oppdateringar sjølvverkande + Har ufrie delar + Shizuku køyrer ikkje + Shizuku er ikkje lagt inn + Har vakse innhald + Greidde ikkje å binda saman med tenaren + \ No newline at end of file diff --git a/core/common/src/main/res/values-or/strings.xml b/core/common/src/main/res/values-or/strings.xml new file mode 100644 index 0000000..7b66b20 --- /dev/null +++ b/core/common/src/main/res/values-or/strings.xml @@ -0,0 +1,233 @@ + + + ଠିକଣା + ସମସ୍ତ ଆପ୍ଲିକେସନ୍ ଗୁଡିକ + ସେହି ଅନୁପ୍ରୟୋଗ ଖୋଜି ପାଇଲା ନାହିଁ + ପରିବର୍ତ୍ତନ ଗୁଡିକ + ଅନ୍ଧାର + ଆଙ୍ଗୁଠି ଛାପ + %sମିଶ୍ରଣ ହେଉଛି + ନାମ + ଠିକ ଅଛି + କେବଳ %sସହିତ ସୁସଙ୍ଗତ + କେବଳ ୱାଇ-ଫାଇରେ + +%d ଅଧିକ + ପ୍ରକ୍ସି ପୋର୍ଟ + ସଂଗ୍ରହାଳୟ ଗୁଡ଼ିକ + ନୀରବ ସଂସ୍ଥାପନ ପାଇଁ ମୂଳ ଅନୁମତି ଦିଅନ୍ତୁ + ସ୍କ୍ରିନସଟ୍ + ପଠାନ୍ତୁ + ପୁରାତନ ସଂସ୍କରଣଗୁଡିକ ଦେଖାନ୍ତୁ + ଏକ ଅସୁରକ୍ଷିତ ଆଲଗୋରିଦମ ବ୍ୟବହାର କରି ଦସ୍ତଖତ + ଛାଡିଦିଅ + ପ୍ରସ୍ତାବିତ + ଥିମ୍ ଗୁଡ଼ିକ + ଅଣସଂସ୍ଥାପନ କରନ୍ତୁ + ଅଜଣା + ଉପଯୋଗକର୍ତ୍ତା ନାମ + ଉପଯୋଗକର୍ତ୍ତା ନାମ ନିଖୋଜ + ସଂସ୍କରଣ + ୱେବସାଇଟ୍ + କମ୍ ଦେଖନ୍ତୁ + ଅନୁସନ୍ଧାନ କରନ୍ତୁ + ସମସ୍ତ ଅଦ୍ୟତନ କରନ୍ତୁ + ସଂସ୍ଥାପିତ ପ୍ରୟୋଗଗୁଡ଼ିକ + ଫିଲ୍ଟର୍ ଏବଂ ସର୍ଟ କରନ୍ତୁ + ନୂତନ ପ୍ରୟୋଗଗୁଡ଼ିକ + ବିବରଣୀ + କାର୍ଯ୍ୟ ବିଫଳ ହେଲା + ସଂଗ୍ରହାଳୟ ଯୋଡନ୍ତୁ + ଆପଣଙ୍କର ସମସ୍ତ ଅନୁପ୍ରୟୋଗଗୁଡିକ ଅଦ୍ୟତନ ଅଟେ + ଆଗରୁ ଅଛି + ସର୍ବଦା + କଳା + ଆପ୍ଲିକେସନ୍ + ଖରାପ୍ ବୈଶିଷ୍ଟ୍ୟ ଗୁଡ଼ିକ + ଲେଖାକଙ୍କ ଇମେଲ୍ + ଲେଖକ ଙ୍କ ୱେବସାଇଟ୍ + ସନ୍ଧାନ + ବଗ୍ ଟ୍ରାକର୍ + ସଂଗ୍ରହାଳୟ ସଂପାଦନ କରିପାରିବ ନାହିଁ କାରଣ ଏହା ବର୍ତ୍ତମାନ ସିଙ୍କ କରୁଛି । + ବାତିଲ କରନ୍ତୁ + ବଦଳିଛି + APK କ୍ଲିନଅପ ଅନ୍ତରାଳ + ସଂଗ୍ରହାଳୟ ଯାଞ୍ଚ୍ ହେଉଛି… + ଆହରଣ ହୋଇଥିବା ଫାଇଲଗୁଡ଼ିକୁ ଯାଞ୍ଚ ଏବଂ ଅପସାରଣ କରିବା ପାଇଁ ଅବଧି + ଅଣ-ମୁକ୍ତ ମିଡିଆ ଧାରଣ କରେ + ବର୍ଣ୍ଣନା + ତ୍ରୁଟି ନିବାରଣ ପାଇଁ ସଂକଳିତ + ନିଶ୍ଚିତ କରନ୍ତୁ + ସଂଯୋଗ କରୁଛି … + %s ସଞ୍ଚୟ ହୋଇପାରୁନାହିଁ + ଡାଉନଲୋଡ୍ ଚାଲିଛି + %sସିଙ୍କ ହୋଇପାରୁନାହିଁ + %s ବାଇଧତା ହୋଇପାରିଲା ନାହିଁ + କ୍ରେଡିଟ୍ + + ଦିନ + ଦିନ ଗୁଡ଼ିକ + + ବିଲୋପ + ସଂଗ୍ରହାଳୟ ବିଲୋପ କରନ୍ତୁ \? + %s ଡାଉନଲୋଡ ହୋଇସାରିଛି + ଇଁବେଲିଡ ଫାଇଲ୍ ଫର୍ମାଟ୍ । + ଦାନ କରନ୍ତୁ + ସଂଗ୍ରହାଳୟ ସଂପାଦନ କରନ୍ତୁ + %s ଡାଉନଲୋଡ୍ ଚାଲିଛି… + ବିଜ୍ଞାପନ ଅଛି + ମାଗଣା ମୁକ୍ତ ନିର୍ଭରଶୀଳତା ଅଛି + ସୁରକ୍ଷା ଦୁର୍ବଳତା ଅଛି + + ଘଣ୍ଟାଏ + ଘଣ୍ଟା + + ଇନଭେଲିଡ ସର୍ଭର ପ୍ରତିକ୍ରିୟା । + HTTP ପ୍ରକ୍ସି + ସମସ୍ତ ନୂତନ ସଂସ୍କରଣକୁ ଉପେକ୍ଷା କରନ୍ତୁ + ଏହି ସଂସ୍କରଣକୁ ଅଗ୍ରାହ୍ୟ କରନ୍ତୁ + ଏହି ଡିଭାଇସ୍ ଆପଣଙ୍କ ଡିଭାଇସରେ ସଂସ୍ଥାପିତ ହୋଇଥିବା ଠାରୁ ପୁରାତନ ଅଟେ । ପ୍ରଥମେ ଏହାକୁ ସଂସ୍ଥାପନ କରନ୍ତୁ । + ତୁମର %1$s (API ସଂସ୍କରଣ %2$d) ସମର୍ଥନ କରୁନାହିଁ । %3$s + ସର୍ବାଧିକ API ସଂସ୍କରଣ ହେଉଛି %d । + ସର୍ବନିମ୍ନ API ସଂସ୍କରଣ ହେଉଛି %d । + ନଥିବା ବୈଶିଷ୍ଟ୍ୟଗୁଡିକ । + ତୁମର %1$s ପ୍ଲାଟଫର୍ମ ସମର୍ଥିତ ନୁହେଁ । ସମର୍ଥିତ ପ୍ଲାଟଫର୍ମ:%2$s। + ଏହି ଡିଭାଇସ୍ ଆପଣଙ୍କ ଡିଭାଇସରେ ସଂସ୍ଥାପିତ ହୋଇଥିବା ତୁଳନାରେ ଏକ ଭିନ୍ନ ସାର୍ଟିଫିକେଟ୍ ସହିତ ସାଇନ୍ ହୋଇଛି । ପ୍ରଥମେ ଏହାକୁ ସଂସ୍ଥାପନ କରନ୍ତୁ । + ପୁରୁଣା ସଂସ୍ଥାପକ + ସଂଯୋଗ ନକଲ ହୋଇଛି + ସମ୍ପ୍ରତି ଅଦ୍ୟତନ ହୋଇଛି + ଅସଙ୍ଗତ ସଂସ୍କରଣ + ଡିଭାଇସ୍ ସହିତ ଅସଙ୍ଗତ ପ୍ରୟୋଗ ସଂସ୍କରଣଗୁଡିକ ଦେଖାନ୍ତୁ + ସଂସ୍ଥାପକ + ଅଧିବେଶନ ସଂସ୍ଥାପକ + ସିଜୁକୁ ସଂସ୍ଥାପକ + ଇନଭେଲିଡ ମେଟାଡାଟା । + ଇନଭେଲିଡ ଅନୁମତି। + ଇନଭେଲିଡ ଦସ୍ତଖତ । + ଉଜ୍ଜଳ + ଅସଙ୍ଗତ ସଂସ୍କରଣ ଗୁଡ଼ିକ + %s ସହ ଅସଙ୍ଗତ + ସଂସ୍ଥାପନ କରନ୍ତୁ + ସଂସ୍ଥାପନ ପ୍ରକାର + ମୂଳ ସଂସ୍ଥାପକ + ସଂସ୍ଥାପିତ + ଅଖଣ୍ଡତା ଯାଞ୍ଚ କରିପାରିଲା ନାହିଁ । + ଇନଭେଲିଡ ଠିକଣା + ଇନଭେଲିଡ ଆଙ୍ଗୁଠି ଛାପ + ଆରମ୍ଭ + ଇନଭେଲିଡ ଉପଯୋଗକର୍ତ୍ତା ନାମ ଫର୍ମାଟ୍ + ଲାଇସେନ୍ସ + ମୁଖ୍ୟ ପୃଷ୍ଠାରେ ତାଲିକା ଆନିମେସନ୍ ଦେଖାନ୍ତୁ + %s ଲାଇସେନ୍ସ + ଆନିମେସନ୍ ତାଲିକା କର + ଲିଙ୍କ୍ ଗୁଡ଼ିକ + ନେଟୱର୍କ ତ୍ରୁଟି + କେବେନାହିଁ + ପ୍ରୟୋଗଗୁଡ଼ିକର ନୂତନ ସଂସ୍କରଣ ଉପଲବ୍ଧ + କୌଣସି ସଂସ୍ଥାପିତ ପ୍ରୟୋଗ ନାହିଁ + + %dପ୍ରୟୋଗର ଏକ ନୂତନ ସଂସ୍କରଣ ଅଛି । + %dପ୍ରୟୋଗ ଗୁଡ଼ିକର ନୂତନ ସଂସ୍କରଣ ଅଛି । + + କୌଣସି ଉପଲବ୍ଧ ପ୍ରୟୋଗ ନାହିଁ + କେବଳ ୱାଇ-ଫାଇ ଏବଂ ଚାର୍ଜିଂ ବେଳେ + କୌଣସି ବର୍ଣ୍ଣନା ଉପଲବ୍ଧ ନାହିଁ + ଏହିପରି କୌଣସି ପ୍ରୟୋଗ ଖୋଜି ପାଇଲା ନାହିଁ + କୌଣସି ପ୍ରକ୍ସି ନାହିଁ + ନୂତନ ସଂସ୍କରଣ ଉପଲବ୍ଧ ହେଲେ ଏକ ବିଜ୍ଞପ୍ତି ଦେଖାନ୍ତୁ + ପ୍ରୟୋଗ ସଂଖ୍ୟା + ଅନ୍ୟ + ଅଦ୍ୟତନ ପାଇଁ ବିଜ୍ଞପ୍ତି + ଇଣ୍ଡେକ୍ସ ଫାଇଲ୍ ପାର୍ସ କରିପାରିଲା ନାହିଁ । + %s କୁ ଖୋଲନ୍ତୁ \? + ପାସୱାର୍ଡ + ପାସୱାର୍ଡ ନିଖୋଜ + ଅନୁମତି ଗୁଡ଼ିକ + ସେଟିଂ ଗୁଡ଼ିକ + ପ୍ରକ୍ରିଆରତ %1$s… + ପ୍ରୋଜେକ୍ଟ ୱେବସାଇଟ୍ + ଅଣ-ମୁକ୍ତ ନେଟୱାର୍କ ସେବାକୁ ପ୍ରୋତ୍ସାହିତ କରେ + ଅଣ ମାଗଣା ସଫ୍ଟୱେର୍ କୁ ପ୍ରୋତ୍ସାହିତ କରେ + ଯାଞ୍ଚ ହୋଇନାହିଁ + ପ୍ରକ୍ସି ହୋଷ୍ଟ + %s ଦ୍ଵାରା ପ୍ରଦାନ କରାଯାଇଛି + ପ୍ରକ୍ସି + ପ୍ରକ୍ସି ପ୍ରକାର + ଏହି ସଂଗ୍ରହାଳୟ ଏପର୍ଯ୍ୟନ୍ତ ବ୍ୟବହୃତ ହୋଇନାହିଁ । ଏଥିରେ ଥିବା ପ୍ରୟୋଗଗୁଡ଼ିକୁ ଦେଖିବା ପାଇଁ ଏହାକୁ ଟର୍ନ୍ ଅନ୍ କରନ୍ତୁ । + ଦସ୍ତଖତ ନୁହେଁ । ଆବେଦନ ତାଲିକା ଯାଞ୍ଚ କରିପାରିଲା ନାହିଁ । ସାକ୍ଷରିତ ହୋଇନଥିବା ସଂଗ୍ରହାଳୟରୁ ପ୍ରୟୋଗଗୁଡ଼ିକୁ ଡାଉନଲୋଡ୍ କରିବାକୁ ସାବଧାନ । + ସଞ୍ଚୟ କରନ୍ତୁ + ସଂଗ୍ରହାଳୟ + %s ଦରକାର କରେ + ନୀରବ ସଂସ୍ଥାପନ + ବିବରଣୀ ଗୁଡ଼ିକ ସଞ୍ଚିତ ହେଉଛି… + ସନ୍ଧାନ କରନ୍ତୁ + ଏକ ଦର୍ପଣ ଚୟନ କରନ୍ତୁ + ଅସ୍ଥିର ସଂସ୍କରଣ ସଂସ୍ଥାପନ କରିବାକୁ ପରାମର୍ଶ ଦିଅନ୍ତୁ + ଅଧିକ ଦେଖାନ୍ତୁ + ଦସ୍ତଖତ ନୁହେଁ + ଦସ୍ତଖତ %s + ଆକାର + ଉତ୍ସ କୋଡ୍ + SOCKS ପ୍ରକ୍ସି + ସଜାଇବା କ୍ରମ + ତୁମର କାର୍ଯ୍ୟକଳାପକୁ ଟ୍ରାକ୍ କିମ୍ବା ରିପୋର୍ଟ କରେ + ଉତ୍ସ କୋଡ୍ ଆଉ ଉପଲବ୍ଧ ନାହିଁ + ସଂଗ୍ରହାଳୟଗୁଡିକ ସିଙ୍କ କରନ୍ତୁ + ସିଙ୍କ୍ହେ ହେଉଛି + ସଂସ୍ଥାପନ କରିବାକୁ ଟ୍ୟାପ୍ କରନ୍ତୁ । + ଲକ୍ଷ୍ୟ + ସଂଗ୍ରହାଳୟଗୁଡ଼ିକ ସ୍ୱୟଂଚାଳିତ ଭାବରେ ସିଙ୍କ କରନ୍ତୁ + ସିଷ୍ଟମ୍ + ଅଜ୍ଞାତ ତ୍ରୁଟି । + %s ସିଂଙ୍କ ହେଉଛି… + ଥିମ୍ + ଅଜଣା:%s + ସଂସ୍କରଣ %s + ସଂସ୍କରଣ ଗୁଡ଼ିକ + ଅସ୍ଥିର ଅଦ୍ୟତନଗୁଡ଼ିକ + ଅଦ୍ୟତନ ଗୁଡିକ + ଅଦ୍ୟତନ କରନ୍ତୁ + ଅପଷ୍ଟ୍ରିମ ଉତ୍ସ କୋଡ୍ ମାଗଣା ନୁହେଁ + ସୂଚକାଙ୍କ ବୈଧ ହୋଇପାରିଲା ନାହିଁ । + ଡାଉନଲୋଡ୍ ଆରମ୍ଭ କରିବାକୁ ଅପେକ୍ଷା କରିଛି… + ନୁଆ କଣ + ଭାଷା + ବ୍ୟକ୍ତିଗତକରଣ + ସର୍ବଶେଷ + କିଛି କାର୍ଯ୍ୟ କରିବାକୁ ଅସମର୍ଥ । + ଆପଣଙ୍କର କୌଣସି ଇଣ୍ଟରନେଟ୍ ସଂଯୋଗ ନାହିଁ + ଟପ୍ ଆପ୍ ବାର୍ କୁ ବିସ୍ତାର କରିବାକୁ ଅନୁମତି ଦିଅନ୍ତୁ + ଟପ୍ ଆପ୍ ବାର୍ କୁ ବିସ୍ତାର ଏବଂ ଭୁଶୁଡ଼ିବାକୁ ଅନୁମତି ଦିଅନ୍ତୁ + ତୁମେ ଥିମ୍ ରଙ୍ଗ କରୁଥିବା ପଦାର୍ଥ ବ୍ୟବହାର କର + ତୁମର ରଙ୍ଗ ସାମଗ୍ରୀ + ଅନାବଶ୍ୟକ ଫାଇଲଗୁଡିକ ସଫା କରନ୍ତୁ + ପସନ୍ଦ ଗୁଡିକ + ବଳ ପୂର୍ବକ ସଫା କରନ୍ତୁ + ସଂଗ୍ରହାଳୟ ଅପହଞ୍ଚ ଅଟେ + ସଂଗ୍ରହାଳୟ ସକ୍ଷମ କରନ୍ତୁ + ଅଟୋ ଅପଡେଟ୍ ଆପ୍ସ + ସ୍ୱୟଂଚାଳିତ ଭାବରେ ଅଦ୍ୟତନଗୁଡିକ ସଂସ୍ଥାପନ କରିବାକୁ ଚେଷ୍ଟା କରନ୍ତୁ + ସଂସ୍ଥାପନ କରୁଅଛି + ପରିବର୍ତ୍ତନଗୁଡିକ ଦେଖିବାକୁ LeOS-Droid ପୁନ Rest ଆରମ୍ଭ କରନ୍ତୁ + ସ୍ଥାପନ ଆରମ୍ଭ କରିବାକୁ ଅପେକ୍ଷା କରିଛି… + ଅନାବଶ୍ୟକ ଉପାଦାନଗୁଡ଼ିକ ଅଛି + ସର୍ଭର ନୂତନ ପ୍ୟାକେଟ ପ୍ରଦାନ କରିବାରେ ବିଫଳ ହୋଇଛି । + ସର୍ଭର ସହିତ ସଂଯୋଗ କରିପାରିଲା ନାହିଁ + ମୂଳପୃଷ୍ଠା ପରଦା ସ୍ୱିପିଂ + କାର୍ଯ୍ୟ ବିଷୟବସ୍ତୁ ପାଇଁ ସୁରକ୍ଷିତ ନୁହଁ + Shizuku ଚାଲୁ ନାହିଁ + ହୋମ ସ୍କ୍ରିନରେ ପୃଷ୍ଠାଗୁଡ଼ିକ ମଧ୍ୟରେ ଚାଳକକୁ ସ୍ୱାଇପ୍ କରିବାକୁ ଅନୁମତି ଦିଅନ୍ତୁ + ସ୍ୱତନ୍ତ୍ର କ୍ରେଡିଟ୍ + Shizuku ସ୍ଥାପିତ ହୋଇନାହିଁ + ନକଲ କରନ୍ତୁ + ପ୍ରକ୍ସି ପୋର୍ଟ କେବଳ ଗୋଟିଏ ପୂର୍ଣ୍ଣସଂଖ୍ୟା ହୋଇପାରେ + ନିମ୍ନଲିଖିତ ଭଣ୍ଡାର ମିଳିଲା ନାହିଁ + ସେଟିଂସମୂହ ଆମଦାନୀ କରନ୍ତୁ + ଆମଦାନୀ ଏବଂ ରପ୍ତାନି + ଫାଇଲରୁ ସେଟିଂସମୂହ ଏବଂ ପସନ୍ଦଗୁଡିକ ଆମଦାନୀ କରନ୍ତୁ + ସେଟିଂସମୂହ ରପ୍ତାନି କରନ୍ତୁ + ସମସ୍ତ ସଂଗ୍ରହାଳୟ ଫାଇଲ୍ କରିବାକୁ ରପ୍ତାନି କରନ୍ତୁ + ସଂଗ୍ରହାଳୟ ଆମଦାନି କରନ୍ତୁ + ଫାଇଲ୍ ପାଇଁ ସେଟିଂସମୂହ ଏବଂ ପସନ୍ଦଗୁଡିକ ରପ୍ତାନି କରନ୍ତୁ + ସଂଗ୍ରହାଳୟ ରପ୍ତାନି କରନ୍ତୁ + ଫାଇଲରୁ ସମସ୍ତ ସଂଗ୍ରହାଳୟ ଆମଦାନି କରନ୍ତୁ + \ No newline at end of file diff --git a/core/common/src/main/res/values-pa/strings.xml b/core/common/src/main/res/values-pa/strings.xml new file mode 100644 index 0000000..cc0ae6e --- /dev/null +++ b/core/common/src/main/res/values-pa/strings.xml @@ -0,0 +1,234 @@ + + + ਤੁਹਾਡੀ ਗਤੀਵਿਧੀ ਨੂੰ ਟ੍ਰੈਕ ਜਾਂ ਰਿਪੋਰਟ ਕਰਦਾ ਹੈ + ਇੰਡੈਕਸ ਨੂੰ ਪ੍ਰਮਾਣਿਤ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਿਆ। + ਅਸਥਿਰ ਅੱਪਡੇਟ + ਅਸਥਿਰ ਸੰਸਕਰਣਾਂ ਨੂੰ ਇੰਸਟਾਲ ਕਰਨ ਦਾ ਸੁਝਾਅ ਦਿਓ + ਡਾਊਨਲੋਡ ਸ਼ੁਰੂ ਕਰਨ ਦੀ ਉਡੀਕ ਕੀਤੀ ਜਾ ਰਹੀ ਹੈ… + ਰਿਪੋਜ਼ਟਰੀ ਨੂੰ ਸੰਪਾਦਿਤ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਦਾ ਕਿਉਂਕਿ ਇਹ ਇਸ ਸਮੇਂ ਸਿੰਕ੍ਰਨਾਈਜ਼ ਹੋ ਰਹੀ ਹੈ। + ਪੁਸ਼ਟੀ + ਡਾਊਨਲੋਡ ਨਹੀਂ ਹੋ ਸਕਿਆ %s + ਅਵੈਧ ਫਾਈਲ ਫਾਰਮੈਟ। + ਰਿਪੋਜ਼ਟਰੀ ਮਿਟਾਓ\? + ਡਾਊਨਲੋਡ ਹੋ ਰਿਹਾ %s… + ਰਿਪੋਜ਼ਟਰੀ ਸੰਪਾਦਿਤ ਕਰੋ + ਸੁਰੱਖਿਆ ਕਮਜ਼ੋਰੀਆਂ ਹਨ + ਸਾਰੇ ਨਵੇਂ ਸੰਸਕਰਣਾਂ ਨੂੰ ਅਣਡਿੱਠ ਕਰੋ + ਇਹ ਸੰਸਕਰਣ ਤੁਹਾਡੀ ਡਿਵਾਈਸ \'ਤੇ ਇੰਸਟਾਲ ਕੀਤੇ ਗਏ ਸੰਸਕਰਣ ਤੋਂ ਪੁਰਾਣਾ ਹੈ। ਪਹਿਲਾਂ ਇਸਨੂੰ ਅਣਇੰਸਟਾਲ ਕਰੋ। + ਨਿਊਨਤਮ API ਸੰਸਕਰਣ %d ਹੈ। + ਅਸੰਗਤ ਸੰਸਕਰਣ + ਇੰਸਟਾਲਰ + ਸ਼ਿਜ਼ੂਕੂ ਇੰਸਟਾਲਰ + ਕੋਈ ਇੰਸਟਾਲ ਕੀਤੀਆਂ ਐਪਲੀਕੇਸ਼ਨਾਂ ਨਹੀਂ ਹਨ + ਪਤਾ + ਸਾਰੀਆਂ ਐਪਲੀਕੇਸ਼ਨਜ਼ + ਪਹਿਲਾਂ ਤੋਂ ਹੀ ਮੌਜੂਦ ਹੈ + ਹਮੇਸ਼ਾਂ + ਕਾਲ੍ਹਾ + ਐਪਲੀਕੇਸ਼ਨ + ਉਹ ਐਪਲੀਕੇਸ਼ਨ ਨਹੀਂ ਲੱਭ ਸਕੀ + ਲੇਖਕ ਈ-ਮੇਲ + ਬੱਗ ਟਰੈਕਰ + ਬਦਲਾਅ-ਲੇਖਾ + ਬਦਲਾਅ + ਰਿਪੋਜ਼ਟਰੀ ਦੀ ਜਾਂਚ ਕੀਤੀ ਜਾ ਰਹੀ ਹੈ… + APK ਕਲੀਨਅੱਪ ਅੰਤਰਾਲ + ਡਾਊਨਲੋਡ ਕੀਤੀਆਂ ਫਾਈਲਾਂ ਦੀ ਜਾਂਚ ਕਰਨ ਅਤੇ ਹਟਾਉਣ ਦੀ ਮਿਆਦ + ਡੀਬੱਗਿੰਗ ਲਈ ਕੰਪਾਇਲ ਕੀਤਾ + ਜੁੜ ਰਿਹਾ ਹੈ… + ਗੈਰ-ਮੁਕਤ ਮੀਡੀਆ ਸ਼ਾਮਲ + ਸਿੰਕ ਨਹੀਂ ਕਰ ਸਕਿਆ %s + ਪ੍ਰਮਾਣਿਤ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਿਆ %s + ਕ੍ਰੈਡਿਟ + ਗਹਿਰਾ + + ਦਿਨ + ਦਿਨ + + ਮਿਟਾਓ + ਵਰਣਨ + ਵੇਰਵੇ + ਦਾਨ + ਡਾਊਨਲੋਡ ਹੋਇਆ %s + ਡਾਊਨਲੋਡ ਹੋ ਰਿਹਾ + ਫਿੰਗਰਪ੍ਰਿੰਟ + ਗੈਰ-ਮੁਕਤ ਨਿਰਭਰਤਾ ਹੈ + + ਘੰਟਾ + ਘੰਟੇ + + ਅਵੈਧ ਸਰਵਰ ਜਵਾਬ। + HTTP ਪਰੌਕਸੀ + ਅਧਿਕਤਮ API ਸੰਸਕਰਣ %d ਹੈ। + ਲੁਪਤ ਵਿਸ਼ੇਸ਼ਤਾਵਾਂ। + ਤੁਹਾਡਾ %1$s (API ਸੰਸਕਰਣ %2$d) ਸਮਰਥਿਤ ਨਹੀਂ ਹੈ %3$s + ਤੁਹਾਡਾ %1$s ਪਲੇਟਫਾਰਮ ਸਮਰਥਿਤ ਨਹੀਂ ਹੈ। ਸਮਰਥਿਤ ਪਲੇਟਫਾਰਮ: %2$s। + ਇਸ ਸੰਸਕਰਣ ਨੂੰ ਅਣਡਿੱਠ ਕਰੋ + ਵਿਗਿਆਪਨ ਹਨ + ਲੀਗੇਸੀ ਇੰਸਟਾਲਰ + ਅਸੰਗਤ ਸੰਸਕਰਣ + ਡਿਵਾਈਸ ਦੇ ਨਾਲ ਅਸੰਗਤ ਐਪਲੀਕੇਸ਼ਨ ਸੰਸਕਰਣ ਦਿਖਾਓ + ਅਸੰਗਤ ਹੈ %s + ਇੰਸਟਾਲ + ਇੰਸਟਾਲੇਸ਼ਨ ਦੀਆਂ ਕਿਸਮਾਂ + ਸੈਸ਼ਨ ਇੰਸਟਾਲਰ + ਰੂਟ ਇੰਸਟਾਲਰ + ਇੰਸਟਾਲਡ + ਅਵੈਧ ਪਤਾ + ਅਵੈਧ ਫਿੰਗਰਪ੍ਰਿੰਟ ਫਾਰਮੈਟ + ਅਵੈਧ ਅਨੁਮਤੀਆਂ। + ਅਵੈਧ ਹਸਤਾਖ਼ਰ। + ਅਵੈਧ ਉਪਭੋਗਤਾ ਨਾਮ ਫਾਰਮੈਟ + ਲਾਂਚ ਕਰੋ + ਲਾਈਸੈਂਸ + %s ਲਾਈਸੈਂਸ + ਸਫ਼ੈਦ + ਲਿੰਕ ਕਾਪੀ ਹੋਇਆ + ਲਿੰਕ + ਐਨੀਮੇਸ਼ਨਾਂ ਦੀ ਸੂਚੀ ਵਿਖਾਓ + ਮੁੱਖ ਪੰਨੇ \'ਤੇ ਸੂਚੀ ਐਨੀਮੇਸ਼ਨ ਦਿਖਾਓ + %s ਨੂੰ ਮਿਲਾਇਆ ਜਾ ਰਿਹਾ ਹੈ + ਨਾਮ + ਨੈੱਟਵਰਕ ਤਰੁੱਟੀ + ਕਦੇ ਨਹੀਂ + ਐਪਲੀਕੇਸ਼ਨਾਂ ਦੇ ਨਵੇਂ ਸੰਸਕਰਣ ਉਪਲਬਧ ਹਨ + + %d ਐਪਲੀਕੇਸ਼ਨ ਦਾ ਨਵਾਂ ਸੰਸਕਰਣ ਹੈ। + %d ਐਪਲੀਕੇਸ਼ਨਾਂ ਦਾ ਨਵਾਂ ਸੰਸਕਰਣ ਹੈ। + + ਕੋਈ ਉਪਲਬਧ ਐਪਲੀਕੇਸ਼ਨ ਨਹੀਂ + ਕੋਈ ਵਿਵਰਣ ਉਪਲਬਧ ਨਹੀਂ ਹੈ + ਅਜਿਹੀ ਕੋਈ ਐਪਲੀਕੇਸ਼ਨ ਨਹੀਂ ਲੱਭ ਸਕੀ + ਕੋਈ ਪ੍ਰੌਕਸੀ ਨਹੀਂ + ਅੱਪਡੇਟਾਂ ਬਾਰੇ ਸੂਚਿਤ ਕਰੋ + ਨਵੇਂ ਸੰਸਕਰਣ ਉਪਲਬਧ ਹੋਣ \'ਤੇ ਇੱਕ ਸੂਚਨਾ ਦਿਖਾਓ + ਐਪਲੀਕੇਸ਼ਨਾਂ ਦੀ ਗਿਣਤੀ + ਠੀਕ ਹੈ + ਸਿਰਫ਼ %s ਨਾਲ ਅਨੁਕੂਲ + ਸਿਰਫ਼ ਵਾਈ-ਫਾਈ \'ਤੇ + ਸਿਰਫ਼ ਵਾਈ-ਫਾਈ ਅਤੇ ਚਾਰਜਿੰਗ \'ਤੇ + %s ਨੂੰ ਖੋਲ੍ਹਣਾ ਹੈ\? + ਹੋਰ + ਇੰਡੈਕਸ ਫਾਈਲ ਨੂੰ ਪਾਰਸ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਿਆ। + ਪਾਸਵਰਡ + ਪਾਸਵਰਡ ਗੁੰਮ ਹੈ + ਇਜਾਜ਼ਤਾਂ + +%d ਹੋਰ + ਸੈਟਿੰਗਾਂ + %1$s \'ਤੇ ਪ੍ਰਕਿਰਿਆ ਹੋ ਰਹੀ ਹੈ… + ਪ੍ਰੋਜੈਕਟ ਵੈਬਸਾਈਟ + ਗੈਰ-ਮੁਫ਼ਤ ਨੈੱਟਵਰਕ ਸੇਵਾਵਾਂ ਦਾ ਪ੍ਰਚਾਰ ਕਰਦਾ ਹੈ + ਗੈਰ-ਮੁਫ਼ਤ ਸਾਫਟਵੇਅਰ ਦਾ ਪ੍ਰਚਾਰ ਕਰਦਾ ਹੈ + %s ਦੁਆਰਾ ਪ੍ਰਦਾਨ ਕੀਤਾ ਗਿਆ + ਪ੍ਰੌਕਸੀ + ਪ੍ਰੌਕਸੀ ਹੋਸਟ + ਪ੍ਰੌਕਸੀ ਪੋਰਟ + ਰਿਪੋਜ਼ਟਰੀਆਂ + ਰਿਪੋਜ਼ਟਰੀ + %s ਦੀ ਲੋੜ ਹੈ + ਮੂਕ ਇੰਸਟਾਲ + ਵੇਰਵੇ ਸੁਰੱਖਿਅਤ ਕੀਤੇ ਜਾ ਰਹੇ ਹਨ… + ਸੁਰੱਖਿਅਤ ਕਰੋ + ਸਕਰੀਨਸ਼ਾਟ + ਖੋਜ + ਮੂਕ ਇੰਸਟਾਲ ਲਈ ਰੂਟ ਅਨੁਮਤੀ ਦਿਓ + ਇੱਕ ਮਿਰਰ ਚੁਣੋ + ਸ਼ੇਅਰ ਕਰੋ + ਹੋਰ ਦਿਖਾਓ + ਪੁਰਾਣੇ ਸੰਸਕਰਣ ਦਿਖਾਓ + ਹਸਤਾਖ਼ਰ %s + ਅਸੁਰੱਖਿਅਤ ਐਲਗੋਰਿਦਮ ਦੀ ਵਰਤੋਂ ਕਰਕੇ ਦਸਤਖਤ ਕੀਤੇ + ਆਕਾਰ + ਛੱਡੋ + SOCKS ਪ੍ਰੌਕਸੀ + ਛਾਂਟੀ ਦਾ ਕ੍ਰਮ + ਸਰੋਤ ਕੋਡ ਹੁਣ ਉਪਲਬਧ ਨਹੀਂ ਹੈ + ਸਰੋਤ ਕੋਡ + ਸੁਝਾਏ ਗਏ + ਰਿਪੋਜ਼ਟਰੀਆਂ ਨੂੰ ਸਿੰਕ ਕਰੋ + ਰਿਪੋਜ਼ਟਰੀਆਂ ਨੂੰ ਆਟੋਮੈਟਿਕਲੀ ਸਿੰਕ ਕਰੋ + ਸਿੰਕ ਹੋ ਰਿਹਾ ਹੈ + %s ਨੂੰ ਸਿੰਕ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ… + ਇੰਸਟਾਲ ਕਰਨ ਲਈ ਟੈਪ ਕਰੋ। + ਟੀਚਾ + ਥੀਮ + ਥੀਮ + ਅਣਇੰਸਟਾਲ ਕਰੋ + ਅਗਿਆਤ + ਅਗਿਆਤ ਤਰੁੱਟੀ। + ਅਗਿਆਤ: %s + ਬਿਨਾਂ-ਦਸਤਖਤ + ਅਸਪਸ਼ਟ + ਅੱਪਡੇਟ + ਅੱਪਡੇਟਾਂ + ਅੱਪਸਟ੍ਰੀਮ ਸਰੋਤ ਕੋਡ ਮੁਫ਼ਤ ਨਹੀਂ ਹੈ + ਵਰਤੋਂਕਾਰ ਦਾ ਨਾਮ + ਵਰਤੋਂਕਾਰ ਨਾਮ ਗੁੰਮ ਹੈ + ਨਵੀਆਂ ਐਪਲੀਕੇਸ਼ਨਾਂ + ਸੰਸਕਰਣ + ਸੰਸਕਰਣ %s + ਸੰਸਕਰਣਾਂ + ਨਵਾਂ ਕੀ ਹੈ + ਵੈੱਬਸਾਈਟ + ਭਾਸ਼ਾ + ਵਿਅਕਤੀਗਤਕਰਨ + ਥੋੜਾ ਦਿਖਾਓ + ਨਵੀਨਤਮ + ਪੜਚੋਲ ਕਰੋ + ਸਭ ਨੂੰ ਅੱਪਡੇਟ ਕਰੋ + ਕ੍ਰਮਬੱਧ ਅਤੇ ਫਿਲਟਰ ਕਰੋ + ਕਾਰਜ ਅਸਫਲ + ਰਿਪੋਜ਼ਟਰੀ ਸ਼ਾਮਿਲ ਕਰੋ + ਤੁਹਾਡੀਆਂ ਸਾਰੀਆਂ ਐਪਲੀਕੇਸ਼ਨਾਂ ਅੱਪਡੇਟ ਹਨ + ਵਿਰੋਧੀ ਵਿਸ਼ੇਸ਼ਤਾਵਾਂ + ਲੇਖਕ ਵੈੱਬਸਾਈਟ + ਅਖੰਡਤਾ ਦੀ ਜਾਂਚ ਨਹੀਂ ਕਰ ਸਕਿਆ। + ਅਵੈਧ ਮੈਟਾਡਾਟਾ। + ਕੁਝ ਖਾਸ ਕਾਰਵਾਈਆਂ ਕਰਨ ਵਿੱਚ ਅਸਮਰੱਥ। + ਸਿਸਟਮ + ਬਿਨਾਂ-ਦਸਤਖਤ। ਐਪਲੀਕੇਸ਼ਨ ਸੂਚੀ ਦੀ ਪੁਸ਼ਟੀ ਨਹੀਂ ਕੀਤੀ ਜਾ ਸਕੀ। ਬਿਨਾਂ-ਦਸਤਖਤ ਕੀਤੇ ਰਿਪੋਜ਼ਟਰੀਆਂ ਤੋਂ ਐਪਲੀਕੇਸ਼ਨਾਂ ਨੂੰ ਡਾਊਨਲੋਡ ਕਰਨ ਵਿੱਚ ਸਾਵਧਾਨ ਰਹੋ। + ਰੱਦ ਕਰੋ + ਪੜਚੋਲ + ਹਾਲ ਹੀ ਵਿੱਚ ਅੱਪਡੇਟ ਕੀਤਾ ਗਿਆ + ਪ੍ਰੌਕਸੀ ਦੀ ਕਿਸਮ + ਇਹ ਰਿਪੋਜ਼ਟਰੀ ਅਜੇ ਤੱਕ ਵਰਤੀ ਨਹੀਂ ਗਈ ਹੈ। ਇਸ ਵਿੱਚ ਐਪਲੀਕੇਸ਼ਨਾਂ ਨੂੰ ਦੇਖਣ ਲਈ ਇਸਨੂੰ ਚਾਲੂ ਕਰੋ। + ਇੰਸਟਾਲ ਕੀਤੀਆਂ ਐਪਲੀਕੇਸ਼ਨਾਂ + ਇਹ ਸੰਸਕਰਣ ਤੁਹਾਡੀ ਡਿਵਾਈਸ \'ਤੇ ਇੰਸਟਾਲ ਕੀਤੇ ਗਏ ਸਰਟੀਫਿਕੇਟ ਨਾਲੋਂ ਵੱਖਰੇ ਪ੍ਰਮਾਣ ਪੱਤਰ ਨਾਲ ਹਸਤਾਖਰਿਤ ਕੀਤਾ ਗਿਆ ਹੈ। ਪਹਿਲਾਂ ਇਸਨੂੰ ਅਣਇੰਸਟਾਲ ਕਰੋ। + ਤੁਹਾਡਾ ਇੰਟਰਨੈੱਟ ਕੁਨੈਕਸ਼ਨ ਜੁੜਿਆ ਹੋਇਆ ਨਹੀਂ ਹੈ + ਸਿਖਰ ਐਪ ਬਾਰ ਦਾ ਵਿਸਤਾਰ ਕਰਨ ਦਿਓ + ਸਿਖਰ ਦੀ ਐਪ ਬਾਰ ਨੂੰ ਫੈਲਾਉਣ ਅਤੇ ਸਮੇਟਣ ਦਿਓ + Material You + Material you ਰੰਗ ਥੀਮ ਦੀ ਵਰਤੋਂ ਕਰੋ + ਮਨਪਸੰਦ + ਜਬਰੀ ਸਾਫ਼ ਕਰੋ + ਰਿਪੋਜ਼ਟਰੀ ਪਹੁੰਚਯੋਗ ਨਹੀਂ ਹੈ + ਬੇਲੋੜੀਆਂ ਫਾਈਲਾਂ ਨੂੰ ਸਾਫ਼ ਕਰਦਾ ਹੈ + ਐਪਸ ਨੂੰ ਆਟੋ ਅੱਪਡੇਟ ਕਰੋ + ਇੰਸਟਾਲ ਕਰ ਰਿਹਾ ਹੈ + ਤਬਦੀਲੀਆਂ ਦੇਖਣ ਲਈ LeOS-Droid ਨੂੰ ਰੀਸਟਾਰਟ ਕਰੋ + ਰਿਪੋਜ਼ਟਰੀ ਨੂੰ ਸਮਰੱਥ ਬਣਾਓ + ਅੱਪਡੇਟਾਂ ਨੂੰ ਸਵੈਚਲਿਤ ਤੌਰ \'ਤੇ ਇੰਸਟਾਲ ਕਰਨ ਦੀ ਕੋਸ਼ਿਸ਼ ਕਰੋ + ਇੰਸਟਾਲੇਸ਼ਨ ਸ਼ੁਰੂ ਕਰਨ ਦੀ ਉਡੀਕ ਕੀਤੀ ਜਾ ਰਹੀ ਹੈ… + ਗੈਰ-ਮੁਫ਼ਤ ਭਾਗ ਹਨ + ਸਰਵਰ ਨਾਲ ਕਨੈਕਟ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਿਆ + ਸਰਵਰ ਨਵਾਂ ਪੈਕੇਟ ਪ੍ਰਦਾਨ ਕਰਨ ਵਿੱਚ ਅਸਫਲ। + ਇਸ ਵਿੱਚ ਕੰਮ ਦੇ ਲਈ ਅਸੁਰੱਖਿਅਤ ਸਮੱਗਰੀ ਹੈ + ਸ਼ਿਜ਼ੂਕੂ ਨਹੀਂ ਚੱਲ ਰਿਹਾ ਹੈ + ਸ਼ਿਜ਼ੂਕੂ ਇੰਸਟਾਲ ਨਹੀਂ ਕੀਤਾ ਗਿਆ ਹੈ + ਵਿਸ਼ੇਸ਼ ਕ੍ਰੈਡਿਟ + ਹੋਮ ਸਕ੍ਰੀਨ ਸਵਾਈਪਿੰਗ + ਉਪਭੋਗਤਾ ਨੂੰ ਹੋਮ ਸਕ੍ਰੀਨ ਵਿੱਚ ਪੰਨਿਆਂ ਦੇ ਵਿਚਕਾਰ ਸਵਾਈਪ ਕਰਨ ਦਿਓ + ਕਾਪੀ ਕਰੋ + ਪ੍ਰੌਕਸੀ ਪੋਰਟ ਸਿਰਫ਼ ਪੂਰਨ ਅੰਕ ਹੋ ਸਕਦਾ ਹੈ + ਹੇਠ ਦਿੱਤੀ ਰਿਪੋਜ਼ਟਰੀ ਨਹੀਂ ਮਿਲੀ + ਆਯਾਤ ਸੈਟਿੰਗਾਂ + ਆਯਾਤ/ਨਿਰਯਾਤ + ਫ਼ਾਈਲ ਤੋਂ ਸੈਟਿੰਗਾਂ ਅਤੇ ਮਨਪਸੰਦ ਆਯਾਤ ਕਰੋ + ਨਿਰਯਾਤ ਸੈਟਿੰਗਾਂ + ਸਾਰੀਆਂ ਰਿਪੋਜ਼ਟਰੀਆਂ ਨੂੰ ਫ਼ਾਈਲ ਵਿੱਚ ਐਕਸਪੋਰਟ ਕਰੋ + ਰਿਪੋਜ਼ਟਰੀਆਂ ਆਯਾਤ ਕਰੋ + ਸੈਟਿੰਗਾਂ ਅਤੇ ਮਨਪਸੰਦ ਫ਼ਾਈਲ ਵਿੱਚ ਐਕਸਪੋਰਟ ਕਰੋ + ਰਿਪੋਜ਼ਟਰੀਆਂ ਨਿਰਯਾਤ ਕਰੋ + ਫ਼ਾਈਲ ਤੋਂ ਸਾਰੀਆਂ ਰਿਪੋਜ਼ਟਰੀਆਂ ਆਯਾਤ ਕਰੋ + ਲਿੰਕ ਖੋਲ੍ਹਿਆ ਨਹੀਂ ਜਾ ਸਕਦਾ + \ No newline at end of file diff --git a/core/common/src/main/res/values-pl/strings.xml b/core/common/src/main/res/values-pl/strings.xml new file mode 100644 index 0000000..ddcbb53 --- /dev/null +++ b/core/common/src/main/res/values-pl/strings.xml @@ -0,0 +1,239 @@ + + + Czarny + Tarcza + Wersja + Indeks nie mógł zostać zatwierdzony. + Strona + Nowe aplikacje + Wersja %s + Brak nazwy użytkownika + Oczekiwanie na rozpoczęcie pobierania… + Niezweryfikowany + Nazwa użytkownika + Aktualizacje + Sugeruj instalację niestabilnych wersji + Niestabilne aktualizacje + Nieoznaczony + Nieznany błąd. + Odinstaluj + Śledzenie lub raportowanie aktywności użytkownika + Motyw + Dodaj repozytorium + Zawsze + Niepożądane funkcje + Nadrzędny kod źródłowy nie jest wolny + Aktualizacja + Nieznany: %s + Adres + Wszystkie twoje aplikacje są aktualne + Już istnieje + Nieznany + Motywy + Stuknij , aby zainstalować. + Automatyczna synchronizacja repozytoriów + Synchronizacja + Synchronizacja %s… + Wersje + System + Działanie nieudane + Wszystkie aplikacje + Podłączenie… + Zawiera media niezawierające substancji wolnych + Nie można zsynchronizować %s + Brak hasła + Obróbka %1$s… + Port proxy + Host proxy + Proxy + Dostarczony przez %s + Promuje niewolne oprogramowanie + Promuje niewolne usługi sieciowe + Typ proxy + Repozytorium + Ostatnio uaktualnione + Cicha instalacja + Wymaga %s + Zrzuty ekranu + Kod źródłowy + Porządek sortowania + Proxy SOCKS + Pomiń + Synchronizacja repozytoriów + Nieważny podpis. + Niezgodna wersja + Niewłaściwa odpowiedź serwera. + Anuluj + Posiada niewolne zależności + Darowizna + Pobieranie %s… + Nieważny format pliku. + Odcisk palca + posiada reklamę + Ignoruj wszystkie aktualizacje + Zignoruj tę aktualizację + Minimalna wersja API to %d. + Powiadamiaj o aktualizacjach + Pobrano %s + Pobieranie + Edytuj repozytorium + Posiada luki w zabezpieczeniach + Twój %1$s (wersja API %2$d) nie jest obsługiwany. %3$s + Przeglądaj + Śledzenie błędów + Maksymalna wersja API to %d. + Nie znaleziono takiej aplikacji + E-mail autora + Strona internetowa autora + Opis + Brak zainstalowanych aplikacji + Podpisane przy użyciu niebezpiecznego algorytmu + Aplikacja + Nie można edytować repozytorium, ponieważ jest ono w tej chwili synchronizowane. + Lista zmian + Ciemny + Brakujące funkcje. + Ta wersja jest starsza niż ta zainstalowana na Twoim urządzeniu. Odinstaluj ją najpierw. + Twoja platforma %1$s nie jest obsługiwana. Obsługiwane platformy: %2$s. + Jasny + Animacje listy + Łączenie %s + Brak dostępnych aplikacji + Nie znaleziono pasujących aplikacji + Zmiany + Sprawdzanie repozytorium… + Skompilowane do debugowania + Potwierdzenie + Nie można pobrać %s + Nie można zatwierdzić %s + Podziękowania + Usuń + Czy na pewno chcesz usunąć repozytorium\? + Szczegóły + Ta wersja jest podpisana innym certyfikatem niż ten, który jest zainstalowany na Twoim urządzeniu. Najpierw odinstaluj ten certyfikat. + Nieprawidłowy format nazwy użytkownika + Licencja %s + Linki + Dostępne są nowe aktualizacje + Brak proxy + Pokaż powiadomienie, gdy dostępne są aktualizacje + Tylko na Wi-Fi + Uruchom + Licencja + Skopiowano link + Włącz animację listy na stronie głównej + Według nazwy + Błąd sieci + Nigdy + Brak dostępnego opisu + Rozmiar + Pokaż starsze wersje + Wybierz lustro + Liczba aplikacji + Zgodne tylko z %s + Zalecana + Kod źródłowy już niedostępny + Pokaż więcej + Szukaj + Zapisywanie danych… + Zapisz + Inne + Nie można sprawdzić spójności. + Podpis %s + Udostępnij + To repozytorium nie jest jeszcze używane. Musisz je włączyć, aby zobaczyć aplikacje, które udostępnia. + OK + Nieprawidłowe uprawnienia. + Niekompatybilne wersje + Pokaż wersje aplikacji niekompatybilne z urządzeniem + Typy instalacji + Zainstalowano + Nieprawidłowy adres + Nieważne metadane. + + %d nowe aktualizacje. + %d nowe aktualizacje. + %d nowych aktualizacji. + %d nowych aktualizacji. + + Zezwól na uprawnienia roota dla cichych instalacji + Repozytoria + Niekompatybilne z %s + Zainstaluj + Hasło + Niepodpisane. Nie można zweryfikować listy aplikacji. Zachowaj ostrożność podczas pobierania aplikacji z niepodpisanych repozytoriów. + Strona projektu + Ustawienia + +%d więcej + Uprawnienia + Nie można przetworzyć pliku indeksu. + Otworzyć %s\? + Nieważny format odcisku palca + Proxy HTTP + Język + Personalizacja + Pokaż mniej + Zainstalowane aplikacje + Sortowanie i filtrowanie + Nowe aplikacje + Instalator + Native (starszy) + Session (nowszy) + ROOT + Shizuku + Najnowsza + Poznaj + Aktualizuj wszystkie + Interwał oczyszczania plików apk + Okres sprawdzania i usuwania pobranych plików + + dzień + dni + dni + dni + + Tylko na Wi-Fi i podczas ładowania + + godzina + godziny + godzin + godzin + + Nie można wykonać niektórych działań. + Nie masz połączenia z internetem + Rozwijalny górny pasek aplikacji + Zezwalaj na rozwijanie i zwijanie górnego paska aplikacji + Motyw kolorystyczny Material You + Silnik motywu Monet + Włącz repozytorium + Instalowanie + Uruchom LeOS-Droid ponownie, aby zastosować zmiany + Oczekiwanie na rozpoczęcie instalacji… + Ulubione + Wymuś oczyszczenie + Repozytorium nieosiągalne + Aktualizuj aplikacje automatycznie + Spróbuj zainstalować aktualizacje automatycznie + Czyści zbędne pliki + Posiada niewolne komponenty + Shizuku nie działa + Shizuku nie jest zainstalowany + Specjalne podziękowania + Zawiera treści NSFW + Serwer nie mógł dostarczyć nowego pakietu. + Nie udało się połączyć z serwerem + Przewijanie ekranu głównego + Pozwala użytkownikowi na przesuwanie palcem między stronami na ekranie głównym + Kopiuj + Port proxy może być tylko liczbą naturalną + Nie znaleziono następującego repozytorium + Przywróć ustawienia + Import/Eksport + Przywróć ustawienia i ulubione z pliku + Eksportuj ustawienia + Zapisz wszystkie repozytoria do pliku + Importuj repozytoria + Zapisz ustawienia i ulubione do pliku + Eksportuj repozytoria + Przywróć wszystkie repozytoria z pliku + \ No newline at end of file diff --git a/core/common/src/main/res/values-pt-rBR/strings.xml b/core/common/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 0000000..7a2f639 --- /dev/null +++ b/core/common/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,240 @@ + + + A ação falhou + Adicionar repositório + Endereço + Todos os aplicativos + Todos os seus aplicativos estão atualizados + Já existe + Sempre + Preto + Características indesejadas + Aplicativo + Não foi possível encontrar esse aplicativo + E-mail do autor + Página do autor + Explorar + Rastreador de erros + Cancelar + Não é possível editar o repositório pois ele está sincronizando no momento. + Lista de mudanças + Mudanças + Checando o repositório… + Compilado para depuração + Confirmação + Conectando… + Contém mídia não livre + Não foi possível baixar %s + Não foi possível sincronizar %s + Não foi possível validar %s + Créditos + Escuro + Excluir + Eliminar o repositório\? + Descrição + Detalhes + Doar + Baixado %s + Baixando + Baixando %s… + Editar repositório + Formato de arquivo inválido. + Fingerprint + Contém anúncio + Possui dependências não livres + Possui vulnerabilidades de segurança + Resposta de servidor inválida. + Proxy HTTP + Ignorar todas as novas versões + Ignorar esta versão + Sua %1$s (Versão da API %2$d) não é suportado. %3$s + A versão máxima da API é %d. + A versão mínima da API é %d. + Funcionalidades que estão faltando. + Esta versão é mais antiga que a instalada no seu dispositivo. + Desinstale a primeiro. + Sua %1$s plataforma não é suportada. + Plataformas suportadas: %2$s. + Esta versão é assinada com um certificado diferente do que está + instalado no seu dispositivo. Desinstale-a primeiro. + Versão incompatível + Versões incompatíveis + Mostrar versões do aplicativo incompatíveis com o dispositivo + Incompatível com %s + Instalar + Tipos de instalação + Instalado + Não foi possível verificar a integridade. + Endereço inválido + Formato de fingerprint inválido + Metadado inválido. + Permissão inválida. + Assinatura inválida. + Formato de nome do usuário inválido + Abrir + Licença + %s licença + Claro + Link copiado + Links + Animação da lista + Mostrar a animação da lista na página principal + Incorporando %s + Nome + Erro da rede + Nunca + Novas versões de aplicativos disponíveis + + %d aplicativo tem uma nova versão. + %d aplicativos possuem novas versões. + %d aplicativos possuem novas versões. + + Nenhum aplicativo disponível + Nenhum aplicativo instalado + Nenhuma descrição disponível + Não foi possível encontrar nenhum desses aplicativos + Sem proxy + Notificar atualizações + Mostrar uma notificação quando novas versões estiverem disponíveis + Número de aplicativos + OK + Somente compatível com %s + Somente no Wi-Fi + Abrir %s? + Outro + Não foi possível analisar o arquivo de índice. + Senha + Falta a senha + Permissões + +%d mais + Configurações + Processando %1$s… + Página do projeto + Promove serviços de rede não livres + Promove software não livre + Disponibilizado por %s + Proxy + Servidor de Proxy + Porta de Proxy + Tipo de Proxy + Atualização recente + Repositórios + Repositório + Este repositório ainda não foi usado. Ative-o para visualizar os aplicativos nele. + Sem assinatura. Não foi possível verificar a lista de aplicativos. Tenha cuidado ao baixar aplicativos de repositórios não assinados. + Requer %s + Instalação Silenciosa + Permitir acesso root para instalações silenciosas + Salvar + Salvando detalhes… + Capturas de tela + Pesquisar + Selecione um mirror + Compartilhar + Mostrar mais + Mostrar versões antigas + Assinatura %s + Assinado usando um algoritmo inseguro + Tamanho + Pular + proxy SOCKS + Ordenar por + Código fonte + Código fonte não está mais disponível + Sugerido + Sincronizar repositórios + Sincronizar repositórios automaticamente + Sincronizando + Sincronizando %s… + Seguir o do sistema + Toque para instalar. + Meta + Tema + Temas + Rastreia ou relata sua atividade + Desinstalar + Desconhecido + Error desconhecido. + Desconhecido: %s + Sem assinatura + Atualizações instáveis + Sugerir instalar versões instáveis + Não verificado + Atualizar + Atualizações + O código-fonte upstream não é gratuito + Nome de usuário + Falta o nome de usuário + O índice não pôde ser validado. + Versão + Versão %s + Versões + Esperando para começar a baixar… + O que há de novo + Página web + Idioma + Personalização + Mostrar Menos + Aplicativos instalados + Mais recentes + Explorar + Atualizar tudo + Ordenar e Filtrar + Novos aplicativos + Instalador + Instalador antigo + Instalador Shizuku + Instalador root + Instalador de sessão + Intervalo de limpeza do APK + Período para verificar e remover arquivos baixados + + Dia + Dias + Dias + + + Hora + Horas + Horas + + Apenas no Wi-Fi e Carregando + Incapaz de executar certas ações. + Você não tem conexão com a internet + Permite que a barra de aplicativos superior seja expandida e recolhida + Permitir que a Barra Superior de Aplicativos se Expanda + Use o tema de material you cores + Material You + Favoritos + Forçar limpeza + Limpa arquivos redundantes + Repositório inacessível + Ativar o repositório + Instalando + Reinicie o LeOS-Droid para ver as alterações + Aguardando para iniciar a instalação… + Atualizar aplicativos automaticamente + Tente instalar atualizações automaticamente + Possui componentes não livres + O servidor não conseguiu fornecer o novo pacote. + Não foi possível conectar ao servidor + Importar/Exportar + Deslizamento na tela principal + Importar configurações + Contém conteúdo não seguro para o trabalho + Shizuku não está em execução + Importar configurações e favoritos de um arquivo + Copiar + Exportar configurações + A Porta Proxy deve ser um inteiro + Exportar todos os repositórios para um arquivo + Importar repositórios + Exportar configurações e favoritos para um arquivo + Permitir que usuário deslize entre páginas na tela inicial + Exportar repositórios + Importar todos os repositórios de um arquivo + O seguinte repositório não foi encontrado + Créditos especiais + Shizuku não está instalado + Não foi possível abrir o link + \ No newline at end of file diff --git a/core/common/src/main/res/values-pt/strings.xml b/core/common/src/main/res/values-pt/strings.xml new file mode 100644 index 0000000..6b84e01 --- /dev/null +++ b/core/common/src/main/res/values-pt/strings.xml @@ -0,0 +1,236 @@ + + + Endereço + Créditos + Falha na ação + Sempre + Explorar + Confirmação + Tem dependências não livres + Já existe + Descrição + Detalhes + Doar + A descarregar + O seu %1$s (versão de API %2$d) não é suportada. %3$s + A versão máxima da API é %d. + A versão mínima da API é %d. + Não foi possível encontrar quaisquer aplicações + Não usar proxy + Notificar para atualizações + Mostrar uma notificação se existirem novas versões + Promove serviços de rede não livres + Promove software não livre + Nome de utilizador + Nome de utilizador em falta + Versão + Site do projeto + Atualizações + Novidades + Personalização + Aplicação não encontrada + Cancelar + Os repositórios estão a ser sincronizados e não podem ser editados. + Versão incompatível + Palavra-passe + Mostrar versões antigas + A sincronizar %s… + Tema + Recentes + Preto + Todas as aplicações + Todas as aplicações estão atualizadas + E-mail do autor + Site do autor + Registo de alterações + A verificar repositório… + Não foi possível descarregar %s + Escuro + A descarregar %s… + Impressão digital + Resposta do servidor inválida. + Anti-funcionalidades + Aplicação + Alterações + Contém multimédia não livre + Não foi possível sincronizar %s + Formato de ficheiro inválido. + Tem publicidade + Procurar + Compilada para depuração + Ignorar todas as novas versões + Funcionalidades em falta. + A sua plataforma %1$s não é suportada. Plataformas suportadas: %2$s. + Incompatível com %s + Instalar + Tipos de instalação + Instaladas + Esta versão está assinada com um certificado diferente do que está instalado no seu dispositivo. Desinstale essa primeiro. + Mostrar versões incompatíveis com o dispositivo + Licença + Licença %s + Claro + Ligação copiada + Ligações + Animação de listas + Animar lista da página principal + Nunca + Novas versões disponíveis + + %d aplicação tem uma nova versão. + %d aplicações têm novas versões. + %d aplicações têm novas versões. + + Abrir + +%d + Definições + A processar %1$s… + Disponibilizado por %s + Proxy + Servidor + Porta + Tipo + Atualizadas recentemente + Repositórios + Este repositório ainda não foi usado. Ative-o para ver as aplicações existentes. + Guardar + Não assinada + O código fonte principal não é livre + Versão %s + Versões + À espera para descarregar… + Site + Aplicações instaladas + Ordenar e filtrar + Novas aplicações + Adicionar repositório + Remover este repositório\? + Editar repositório + Assinatura inválida. + Assinatura %s + Tamanho + Ignorar + Atualizar + Rastreio de erros + A estabelecer ligação… + Não foi possível validar %s + Remover + %s descarregada + Tem vulnerabilidades de segurança + Proxy HTTP + Ignorar esta versão + Versões incompatíveis + A combinar %s + Sem aplicações disponíveis + Não foi possível verificar a lista de aplicações. Tenha cuidado ao descarregar aplicações de repositórios não assinados. + Esta versão é anterior à que tem instalada no seu dispositivo. Desinstale essa primeiro. + Não foi possível verificar a integridade. + Endereço inválido + Meta-dados inválidos. + Nome + Sem descrição + Abrir %s\? + Permissões + Formato de impressão digital inválido + Permissões inválidas. + Formato de nome de utilizador inválido + Erro de rede + Sem aplicações instaladas + Número de aplicações + OK + Apenas compatível com %s + Apenas por Wi-Fi + Outro + Não foi possível processar o ficheiro de índice. + Palavra-passe em falta + Repositório + Requer %s + Instalação silenciosa + A guardar detalhes… + Usar root para instalações sem interação + Selecione um espelho + Partilhar + Proxy SOCKS + Sugeridas + Imagens + Mostrar mais + Ordenação + Código fonte + Código fonte indisponível + Sincronizar repositórios + A sincronizar + Sistema + Toque para instalar. + Rastreia ou reporta a sua atividade + Idioma + Mostrar menos + Assinada com um algoritmo inseguro + Sincronizar repositórios automaticamente + Destino + Desinstalar + Temas + Não foi possível validar o índice. + Desconhecido + Erro desconhecido. + Desconhecido: %s + Atualizações instáveis + Não verificada + Sugerir instalação de versões instáveis + Explorar + Atualizar tudo + Instalador antigo + Instalador + Instalador root + Instalador Shizuku + Instalador da sessão + Intervalo de limpeza de APK + Período para verificar e remover ficheiros descarregados + + Dia + Dias + Dias + + Apenas em Wi-Fi e a carregar + + Hora + Horas + Horas + + Permitir expansão da barra de aplicações + Não foi possível executar algumas ações. + Permitir recolha/expansão da barra superior de aplicações + Não existe qualquer ligação à Internet + Usar tema de cores Material You + Material You + Favoritos + Não foi possível contactar com o repositório + Impor limpeza + Limpar ficheiros redundantes + Ativar repositório + À espera para começar a instalação… + A instalar + Reiniciar LeOS-Droid para ver as alterações + Atualizações automáticas + Tentar atualizar aplicações automaticamente + Tem componentes não livres + O servidor não conseguiu fornecer um novo pacote. + Não foi possível ligar ao servidor + Shizuku não está instalado + Shizuku não está em execução + Contém conteúdo não seguro para o trabalho + Créditos especiais + Deslize no ecrã principal + Permitir deslize para trocar de páginas no ecrã principal + Copiar + Apenas pode usar um número inteiro para a porta do proxy + Não foi possível encontrar o repositório seguinte + Importar definições + Importar/Exportar + Importar definições e favoritos de um ficheiro + Exportar definições + Exportar todos os repositórios para um ficheiro + Importar repositórios + Exportar definições e favoritos para um ficheiro + Exportar repositórios + Importar todos os repositórios de um ficheiro + \ No newline at end of file diff --git a/core/common/src/main/res/values-ro/strings.xml b/core/common/src/main/res/values-ro/strings.xml new file mode 100644 index 0000000..622b23d --- /dev/null +++ b/core/common/src/main/res/values-ro/strings.xml @@ -0,0 +1,215 @@ + + + Șterge + Donează + Ștergi depozitul\? + Descriere + Detalii + Se descarcă + Descărcat %s + Versiunea maximă API este %d. + Această versiune este mai veche decât cea instalată pe dispozitivul tău. Dezinstalează mai întâi pe aceea. + Platforma dvs. %1$s nu este acceptată. Platforme acceptate: %2$s. + Arată versiuni incompatibile ale aplicațiilor cu acest dispozitiv + Această versiune este semnat cu un certificat diferit ca cea instalată pe dispozitivul tău. Dezinstalează mai întâi pe aceea. + Versiuni incompatibile + Luminos + Se fuzionează %s + Instalator cu sesiune + Nu s-a putut verifica integritatea. + Licență %s + Format invalid al numelui de utilizator + Nicio aplicație instalată + Nu s-a putut găsi astfel de aplicații + Nu s-a putut analiza fișierul index. + Host proxy + Nesemnat. Nu se poate verifica lista de aplicații. Ai grijă când descarci aplicații din depozite nesemnate. + Salvează + Se salvează detalii… + Capturi de ecran + Semnătură %s + Nesemnat + Nume de utilizator + Aplicații noi + + Aplicația %d are o nouă versiune. + %d aplicații cu versiuni noi. + %d aplicații cu versiuni noi. + + Proxy SOCKS + Urmărește sau raportează activitatea ta + Arată o notificare când versiunile noi sunt disponibile + Acțiune eșuată + Adaugă depozit + Adresă + Toate aplicațiile + Toate aplicațiile tale sunt la zi + Permiteți extinderea barei superioare a aplicației + Permiteți ca bara superioară a aplicației să se extindă și să se prăbușească + Există deja + Negru + Funcții-Anti + Aplicație + Nu s-a putut găsi această aplicație + E-mailul autorului + Site-ul autorului + Urmăritor de bug-uri + Anulează + Jurnal de schimbări + Schimbări + Se verifică depozitul… + APK cleanup interval + Compilat pentru depanare + Conține media care nu sunt libere + Nu se poate sincroniza %s + Nu se poate valida %s + Credite + Întunecat + + Zi + Zile + Zile + + Editează depozitul + Activează depozitul + Favorite + Format de fișier invalid. + Amprentă + Forțează curățarea + Curăță fișiere redundante + Are reclame + Are dependențe non-libere + Are vulnerabilități de securitate + + Oră + Ore + Ore + + Se descarcă %s… + Răspuns server invalid. + Ignoră toate versiunile noi + Ignoră această versiune + Versiunea %1$s (versiunea API %2$d) nu este acceptată. %3$s + Proxy HTTP + Versiunea minimă API este %d. + Funcții lipsă. + Versiune incompatibil + Incompatibil cu %s + Instalează + Tipuri de Instalare + Instalator + Instalator moștenit + Instalator prin Root + Instalator Shizuku + Instalat + Adresă invalidă + Format de amprentă invalid + Metadata invalid. + Semnătură invalidă. + Imposibil de a efectua anumite acțiuni. + Lansează + Licență + Permisiuni invalide. + Link copiat în clipboard + Link-uri + Listă de Animații + Afișează lista de animații pe pagina principală + Material tu + Folosește tema de culoare Material You + Nume + Eroare de Rețea + Niciodată + Noi versiuni de aplicații disponibile + Nicio aplicație disponibilă + Nicio descriere disponibilă + Nu ai conexiune la internet + Fără proxy + Notifică despre noi versiuni ale aplicațiilor + Număr de aplicații + Compatibil numai cu %s + Numai pe Wi-Fi + Numai pe Wi-Fi și Încărcare + Deschizi %s\? + Altele + Parolă + Lipsește o parolă + Permisiuni + Încă +%d + Setări + OK + Se procesează %1$s… + Site-ul proiectului + Promovează servicii de rețea care nu sunt la liber + Promovează software care nu este la liber + Furnizat de %s + Port proxy + Tip de proxy + Actualizat recent + Depozite + Depozit + Acest depozit nu a fost utilizat încă. Activează-l pentru a vedea aplicațiile din el. + Depozit inaccesibil + Necesită %s + Instalare silențioasă + Acordă permisiunea root pentru instalații silențioase + Proxy + Caută + Selectează o oglindă + Distribuie + Arată mai multe + Arată versiuni vechi + Semnat folosind un algoritm nesigur + Mărime + Treci peste + Ordinea de sortare + Codul sursă + Codul sursă nu mai este disponibil + Sugerat + Sincronizează depozitele + Sincronizează depozitele automat + Se sincronizează + Se sincronizează %s… + Sistem + Apasă pentru a instala. + Țintă + Temă + Teme + Dezinstalează + Necunoscut + Eroare necunoscută. + Necunoscut: %s + Actualizări instabile + Sugerează instalarea de versiuni instabile + Neverificat + Actualizează + Actualizări + Codul sursă din amonte nu este gratuit + Lipsește numele de utilizator + Index-ul nu poate fi validat. + Versiune + Versiunea %s + Versiuni + În așteptare pentru a începe descărcarea… + Ce este nou + Limbă + Site + Personalizare + Arată mai puțin + Ultima + Explorează + Actualizează tot + Aplicații instalate + Sortează și filtrează + Întotdeauna + Explorează + Nu se poate edita depozitul când se sincronizează acum. + Period to check and remove downloaded files + Confirmație + Se conectează… + Nu se poate descărca %s + Actualizare automată a aplicațiilor + Se așteaptă începerea instalării… + Încercați să instalați actualizările automat + Reporniți LeOS-Droid pentru a vedea modificările + Instalarea + \ No newline at end of file diff --git a/core/common/src/main/res/values-ru/strings.xml b/core/common/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..929dd57 --- /dev/null +++ b/core/common/src/main/res/values-ru/strings.xml @@ -0,0 +1,241 @@ + + + Действие не выполнено + Добавить репозиторий + Адрес + Все приложения + Все приложения обновлены + Уже существует + Всегда + Чёрная + Анти-функционал + Приложение + Невозможно найти это приложение + Эл. почта автора + Сайт автора + Обзор + Трекер ошибок + Отмена + Невозможно редактировать репозиторий, так как он сейчас синхронизируется. + Список изменений + Изменения + Проверка репозитория… + Скомпилировано для отладки + Подтверждение + Подключение… + Содержит несвободные материалы + Невозможно скачать %s + Невозможно синхронизировать %s + Невозможно проверить %s + Авторы + Тёмная + Удалить + Удалить репозиторий\? + Описание + Подробности + Пожертвовать + Скачано %s + Скачивание + Скачивание %s… + Редактировать репозиторий + Неправильный формат файла. + Отпечаток + Содержит рекламу + Имеет несвободные зависимости + Имеет уязвимости безопасности + Некорректный ответ сервера. + HTTP-прокси + Игнорировать все новые версии + Игнорировать эту версию + Ваш %1$s (версия API %2$d) не поддерживается. %3$s + Максимальная версия API - %d. + Минимальная версия API - %d. + Отсутствующие функции. + Эта версия старше той, которая установлена на вашем устройстве. Сначала удалите её. + Ваша платформа %1$s не поддерживается. +Поддерживаемые платформы: %2$s. + Эта версия подписана сертификатом, отличным от установленной на вашем устройстве. Сначала удалите её. + Несовместимая версия + Несовместимые версии + Показать версии приложений, несовместимые с устройством + Несовместимо с %s + Установить + Типы установки + Установлено + Невозможно проверить целостность. + Неправильный адрес + Неправильный формат отпечатка + Неправильные метаданные. + Неправильные разрешения. + Неправильная подпись. + Неправильный формат имени пользователя + Открыть + Лицензия + Лицензия %s + Светлая + Ссылка скопирована + Ссылки + Анимация списков + Показывать анимацию списка на главной странице + Слияние %s + Имя + Ошибка сети + Никогда + Доступны новые версии приложений + + %d обновление. + %d обновления. + %d обновлений. + %d обновлений. + + Нет доступных приложений + Нет установленных приложений + Описание отсутствует + Не найдено таких приложений + Без прокси + Уведомлять об обновлениях + Отображать уведомление о доступности новых версий + Количество приложений + ОК + Совместимо только с %s + Только по Wi-Fi + Открыть %s? + Прочее + Невозможно разобрать индексный файл. + Пароль + Пароль отсутствует + Разрешения + +%d больше + Настройки + Обработка %1$s… + Сайт проекта + Продвигает несвободные веб-службы + Продвигает несвободное ПО + Предоставлено %s + Прокси + Узел прокси + Порт прокси + Тип прокси + Недавно обновлённые + Репозитории + Репозиторий + Этот репозиторий ещё не использовался. Вам необходимо включить его для просмотра находящиеся в нем приложений. + Подпись отсутствует. Невозможно проверить список приложений. Будьте осторожны, загружая приложения из неподписанных репозиториев. + Требуется %s + Тихая установка + Предоставьте root-права для включения тихой установки + Сохранить + Сохранение данных… + Снимки экрана + Поиск + Выберите зеркало + Поделиться + Показать больше + Показать старые версии + Подпись %s + Подписано с небезопасным алгоритмом + Размер + Пропустить + SOCKS-прокси + Сортировка + Исходный код + Исходный код более не доступен + Рекомендуется + Синхронизировать репозитории + Автоcинхронизация репозиториев + Синхронизация + Синхронизация %s… + Как в системе + Нажмите, чтобы установить. + Целевой + Тема + Темы + Отслеживает и передаёт информацию о вашей активности + Удалить + Неизвестно + Неизвестная ошибка. + Неизвестно: %s + Неподписанный + Нестабильные обновления + Предлагать установить нестабильные версии + Непроверенный + Обновить + Обновления + Вышестоящий исходный код несвободен + Имя пользователя + Имя пользователя отсутствует + Индекс не может быть проверен. + Версия + Версия %s + Версии + Ожидание скачивания… + Что нового + Сайт + Язык + Персонализация + Показать меньше + Установщик + Устаревший установщик + Установщик через Root + Установщик через Shizuku + Новое + Обзор + Обновить все + Установленные приложения + Сортировать и фильтровать + Новые приложения + Сессионный установщик + Период удаления скачанных файлов + Интервал удаления APK + + день + дня + дней + дней + + + час + часа + часов + часов + + Только по Wi-Fi и на зарядке + Невозможно выполнить определённые действия. + Отсутствует подключение к интернету + Расширение верхней панели + Верхняя панель приложения будет сворачиваться и разворачиваться + Material You + Использовать цветовую тему Material You + Избранное + Репозиторий недоступен + Произвести очистку + Удаление лишних файлов + Включить репозиторий + Перезапустите LeOS-Droid для применения изменений + Установка + Ожидание начала установки… + Автообновление приложений + Пытаться установить обновления автоматически + Содержит несвободные компоненты + Сервер не смог предоставить новый пакет. + Невозможно соединиться с сервером + Содержит NSFW-материал + Shizuku не запущен + Shizuku не установлен + Особая благодарность + Жесты на главном экране + Разрешить использование жестов на главном экране + Копировать + Эти репозитории не были найдены + Порт прокси должен быть целым числом + Импорт настроек + Импорт/Экспорт + Импортировать настройки и избранное из файла + Экспорт настроек + Экспортировать все репозитории в файл + Импорт репозиториев + Экспортировать настройки и избранное в файл + Экспорт репозиториев + Импортировать все репозитории из файла + Невозможно открыть ссылку + \ No newline at end of file diff --git a/core/common/src/main/res/values-ryu/strings.xml b/core/common/src/main/res/values-ryu/strings.xml new file mode 100644 index 0000000..2859cfb --- /dev/null +++ b/core/common/src/main/res/values-ryu/strings.xml @@ -0,0 +1,224 @@ + + + かんしんりりき + リポジトリチェックそーいびーん… + %s かくにんなやびらんたん + しちめい + %s ダウンロードさびたん + くぬリポジトリさくじょさびーが? + しょうさい + ふじゆーいんねーんどーんかんけいくくまびーん + ダウンロードちゅう + %s ダウンロードちゅう… + リポジトリへんしゅう + んーかやーファイルけいしきやいびーん。 + フィンガープリント + かんくちゅんくくまびーん + やーまい + ネットワークエラー + %s マージちゅう + さん + アプリぬみーさるバージョンがいようがのうやいびーん + リポジトリ + ふめいうぅい + あしっさんかいしっぺーさびたん + リポジトリちちが + アドレス + まじりぬアプリ + まじりぬアプリーさいしさぁ + しでぃにすんじぇーさびーん + ちゃー + ブラック + くぬましこーねーんがのうゆいぬあるちぬー + アプリ + アプリぬみちかやびらんたん + さくしゃぬメールアドレス + さくしゃぬウェブサイト + バグトラッカー + キャンセル + へんかんねーんよう + デバッグぐとぅコンパイルさりとーいびーん + かくにん + しちずくちーゅう… + ふじゆーるなメディアくくまびーん + %s ダウンロードなやびらんたん + %s ちゃーちーなやびらんたん + ダーク + さくじょ + ちーふ + セキュリティじょうぬしちじゃいちゅるゆいがあいびーん + + じがん + じがん + + サーバーぬういんとうがんーかやいびーん。 + HTTPプロキシ + まじりぬみーさるバージョンむしすん + くぬバージョンむしすん + うちかいぬ %1$s (APIバージョン %2$d) ーサポートさりやびらん。%3$s + さいだいぬAPIバージョンー %d やいびーん。 + さいしょうぬAPIバージョンー %d やいびーん。 + くぬバージョンーうちかいぬデバイスんかいインストールさりとーるバージョンやかふるさるバージョンやいびーん。さきんかいすちらアンインストールしみそーれー。 + うんじゅが %1$s プラットフォームーサポートさりやびらん。サポートさりとーるプラットフォーム: %2$s 。 + くぬバージョンー,デバイスんかいインストールさりとーるアプリぬしーょうめいしょとぅはくとぅなるしょうめいしょでぃしょめいさりとーいびーん。さきんかいすちらアンインストールしみそーれー。 + + ふぃい + ふぃい + + ぐかんゆいぬねーんバージョン + ぐかんゆいぬねーんバージョン + デバイスとぅぐかんせいぬねーんアプリぬバージョンひょうじさびーん + %s んでーぐかんせいがあいびらん + インストール + インストールほうしき + インストーラー + インストールじみ + せいごうゆいかくにんなやびらんたん。 + んーかやーアドレス + んーかやーフィンガープリントけいしき + んーかやーメタデータやいびーん。 + んーかなかりーるぎんやいびーん。 + んーかやーしょめいやいびーん。 + んーかやーユーザーめいけいしき + きどう + ライセンス + %s ライセンス + ライト + リンクぬコピーさりやびたん + リンク + リストアニメーション + メインページっしリストアニメーションすん + + %d くぬアプリっしみーさるバージョンがいようかのうやいびーん。 + %d くぬアプリっしみーさるバージョンがいようかのうやいびーん。 + + りようかのうやーアプリーあいびらん + インストールじみぬアプリーあいびらん + しちめいがあいびらん + うぬぐとーるアプリーみちかやびらんたん + プロキシなし + アップデートちうちすん + みーさるバージョンがりようかのうなたるとぅちにちうちひょうじさびーん + アプリぬかじ + OK + %s んでぃぬみぐかんせいがあいびーん + Wi-Fi ぬみ + Wi-Fi しちずくじとぅじゅうでぃんじぬみ + %s ふぃらちゃびーが\? + うぬふか + インデックスファイルこーいしちなやびらんたん。 + パスワード + パスワードぬあいびらん + きんぎん + しってい + %1$s しーょりちゅう… + プロジェクトぬウェブサイト + ふじゆーるなネットワークサービスすいしょうさびーん + ふじゆーるなソフトウェアすいしょうさびーん + %s ていきょう + プロキシ + さいきんぬこうしん + リポジトリ + %s やしがふぃちよう + サイレントインストール + ふずん + しーょうさいふずんそーいびーん… + スクリーンショット + きんさく + ミラーしんたく + ちゅーゆーいん + さらにひょうじ + みさみちやいびーん。アプリぬリストきんしょうなやびらんたん。しょめいさりてぃうぅらんリポジトリからアプリダウンロードしーんさいんかえーちゅういしみそーれー + ふるさるバージョンひょうじ + しょめい %s + あんさんやあらんアルゴリズムやしめいさりとーいびーん + サイズ + スキップ + SOCKSプロキシ + ならべいがい + ソースコード + ソースコードはりようならんなやびたん + すいしょう + リポジトリちゃーき + じちゃーてぃきんかいリポジトリちゃーき + ちゃーきちゅう + %sちゃーきちゅう… + システム + タップしインストールさびーん。 + ターゲット + テーマ + テーマ + うんじゅがこうどうちいしきまたーほうくーくさびーん + アンインストール + ふめいうぅいなエラーやいびーん。 + ふめいうぅい: %s + しょめいなし + ふあんていなアップデート + ふあんていなバージョンぬインストールていあんさびーん + みきんしょう + アップデート + アップデート + アップストリームぬソースコードーふじゆーいんやいびーん + ユーザーめい + ユーザーめいぬあいびらん + インデックスきんしょうなやびらんたん。 + バージョン + バージョン %s + バージョン + ダウンロードかいしまっちょーいびーん… + しじちゃいちゅんぬアプリ + ウェブサイト + ぎんぐ + くじーるしってい + ひょうじふぃならすん + さいしん + たんさく + まじりアップデート + インストールさりとーるアプリ + みーさるアプリ + たんさく + APK ぬじちゃークリーンアップかんかく + ダウンロードじみファイルぬかくにんとぅさくじょうくなうかんかく + ちぬーはんたくちくそーいびーん。 + ちゃーきちゅうなぬでぃリポジトリへんしゅうなやびらん。 + クレジット + レガシーインストーラー + セッションインストーラー + Rootインストーラー + Shizukuインストーラー + +%d しょうさい + プロキシホスト + プロキシポート + プロキシタイプ + くぬリポジトリーなーらささりやびらん。オンなしねー、リポジトリねーんぬアプリかくにんなやびーん。 + サイレントインストールじぬrootきんぎんちーょかする + ならべいがいとぅフィルター + トップアプリバーぬかくはべるきょかしーん + トップアプリバーぬかくだい・しゅくさかのうなさびーん + とぅくていぬアクションじっこうなやびらん。 + インターネットしちずくがあいびらん + アプリじちゃーっしアップデートすん + アップデートじちゃーてぃきんかいインストールするぐとぅさびーん + インストール + インストールかいしすしまっちょーいびーん… + うきーがいー + Material You + リポジトリんかいとうたちゅっしちゃびらん + リポジトリゆうこうなさびーん + ちゅーしちちーがクリーンアップさびーん + じょうはべるファイルクリーンアップさびーん + Material Youぬカラーテーマしーようさびーん + LeOS-Droidさいきぬーんちへんかんかくにんすん + ふじゆーるなコンポーネントくくまびーん + ホームやしがみんぬスワイプ + コンテンツんかえーあんさんやあらんむんがくくまっとーいびーん + Shizukuがんじゅちゃびらん + サーバーなさちどーくなやびらんたん + サーバーやみーさるパケットていきょうなやびらんたん。 + コピー + プロキシポートーせいするうぬみやいびーん + ホームやしがみんっしページかんスワイプないるぐとぅすん + ちぎぬリポジトリぬみちかやびらんたん + スペシャルクレジット + Shizukuやしがインストールさりやびらん + \ No newline at end of file diff --git a/core/common/src/main/res/values-si/strings.xml b/core/common/src/main/res/values-si/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/core/common/src/main/res/values-si/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/common/src/main/res/values-sl/strings.xml b/core/common/src/main/res/values-sl/strings.xml new file mode 100644 index 0000000..f61d25e --- /dev/null +++ b/core/common/src/main/res/values-sl/strings.xml @@ -0,0 +1,218 @@ + + + Dodaj skladišče + Vse aplikacije + Vse posodobljene aplikacije + Že obstaja + Vedno + Naslov + Dejanje ni uspelo + Amoled + Aplikacija + Neželjene funkcije + Na voljo + Prekliči + Aplikacije ni bilo mogoče najti + Sledilnik hroščev + Dnevnik sprememb + Skladišča ni mogoče urejati, ker se trenutno sinhronizira. + Preverjanje skladišča … + Prevejeno za odpravljanje napak + Povezovanje … + Potrditev + Ni bilo mogoče potrditi %s + Temno + Vsebuje neproste medije + Krediti + Opis + Doniraj + Prenešeno %s + Podrobnosti + Prstni odtis + Vsebuje oglaševanje + Vsebuje varnostne ranljivosti + Neveljaven odgovor strežnika. + HTTP proksi + Največja različica API-ja je %d. + Manjkajoče funkcije. + Vaša platforma %1$s ni podprta. Podprte platforme: %2$s. + Nameščeno + Oglejte si različice aplikacij, ki niso združljive z napravo + Vrste namestitve + Namesti + Celovitosti ni bilo mogoče preveriti. + Neveljavni metapodatki. + Neveljaven podpis. + Zaženi + Svetlo + Prikaži animacijo seznama na glavni strani + Na voljo so nove posodobitve + Nikoli + Napaka omrežja + Obvesti o posodobitvah + Opis ni na voljo + Takih aplikacij ni bilo mogoče najti + Ni nameščenih aplikacij + Samo Wi-Fi + Odpri %s\? + Ostalo + Število aplikacij + Vredu + Dovoljenja + + še %d + Nastavitve + Obravnavanje %1$s … + Oglašuje neprosto programsko opremo + Proksi + Skladišča + Potrebuje %s + Tiha namestitev + Shrani + Shranjevanje podrobnosti … + Posnetki zaslona + Preskoči + Predlagano + Sinhronizacija + Teme + Tarča + Neznano + Odstrani + Nepodpisan + Posodobitev + Različica + Različice + Kaj je novega + Čakanje na začetek prenosa … + Indeksa ni bilo mogoče potrditi. + Spletna stran + Spremembe + Email avtorja + %s ni bilo mogoče prenesti + Spletna stran avtorja + Izbriši + Nedavno posodobljeno + Licenca + Ni bilo mogoče sinhronizirati %s + Združljivo samo z %s + %s licenca + Povezava je bila kopirana v odložišče + Spletna stran projekta + Vrsta proksija + Skladišče + Indeksne datoteke ni bilo mogoče razčleniti. + Geslo + Pokaži obvestilo, ko so na voljo nove različice + Poganja ga %s + Izvorna koda ni več na voljo + Neveljavna oblika uporabniškega imena + Brez proksija + Manjka geslo + Izvorna koda + Sinhroniziraj skladišča + Avtomatično sinhroniziraj skladišča + To skladišče še ni bilo uporabljeno. Aktivirajte ga, da vidite aplikacije, ki jih vsebuje. + Podpis %s + Vsebuje neproste odvisnosti + Nezdružljiva različica + Sistem + Tema + Ignoriraj vse posodobitve + Ignoriraj to posodobitev + Velikost + Posodobitve + Uporabniško ime + Različica %s + Prenešanje + Deli + Pokaži več + Pokaži starelše različice + Nepreverjeno + Manjka uporabniško ime + Uredi skladišče + Neveljavna oblika datoteke. + Prenašanje %s … + Nezdružljivo z %s + Nezdružljive verzije + Neveljaven naslov + Nevelavna oblika prstnega odtisa + Neveljavna dovoljenja. + Oglašuje neproste omrežne storitve + Neznano: %s + Neznana napaka. + Sinhroniziranje %s … + Ta različica je podpisana z drugačnim podpisom kot tista, ki je nameščeno v vaši napravi. To najprej odstranite. + Izbriši skladišče? + Ta različica je starejša od tiste, ki je nameščena v vaši napravi. To najprej odstranite. + Vaš %1$s (API različica %2$d) ni podprt. %3$s + Najmanjša različica API-ja je %d. + Ni podpisano. Seznama prijav ni bilo mogoče preveriti. Bodite previdni pri prenašanju aplikacij iz nepodpisanih skladišč. + Predlagajte namestitev nestabilnih posodobitev + Nestabilne posodobitve + Dovolite korenske pravice za tihe namestitve + Proksi gostitelj + Tapnite za namestitev. + Sledi ali beleži vašo dejavnost + Proksi vrata + Išči + Vrstni red + SOCKS Proksi + Na voljo ni nobenih aplikacij + + Aplikacija %d ima nove posodobitev. + Aplikaciji %d imata nove posodobitev. + Aplikacije %d imajo nove posodobitve. + Aplikacije %d imajo nove posodobitve. + + Podpisano z nevarnim algoritmom + Izberite ogledalo + Prikaži animacije + Povezave + Spajanje %s + Ime + Izvorna koda navzgor ni brezplačna + Jezik + Personalizacija + Pokaži manj + Najnovejše + Razišči + Posodobi vse + Nameščene aplikacije + Razvrsti in filtriraj + Nove aplikacije + + Dan + Dneva + Dni + Dni + + + Ura + Uri + Ure + Ur + + Samo med polnjenjem in aktiviranim Wi-Fi-jem + Namestitveni program + Časovno obdobje za preverjanje in odstranitev prenesenih datotek + Interval čiščenja APK datotek + Korenski namestitveni program + Stari namestitveni program + Sejni namestitveni program + Shizuku namestitveni program + Določenih dejanj ni mogoče izvesti. + Nimate internetne povezave + Dovoli razširitev zgornje vrstice aplikacij + Dovoli razširitev in strnitev zgornje vrstice aplikacij + Priljubljene + Material You + Uporabite barvno shemo Material You + Skladišče nedosegljivo + Prisilno čiščenje + Aktiviraj skladišče + Odstrani podvojene datoteke + Nameščanje + Znova zaženite LeOS-Droid, da vidite spremembe + Čakanje na začetek namestitve … + Samodejno posodobite aplikacije + Poskusite samodejno namestiti posodobitve + \ No newline at end of file diff --git a/core/common/src/main/res/values-sr/strings.xml b/core/common/src/main/res/values-sr/strings.xml new file mode 100644 index 0000000..3c2ec55 --- /dev/null +++ b/core/common/src/main/res/values-sr/strings.xml @@ -0,0 +1,237 @@ + + + Ажурирања + Величина + %s лиценца + Програм за инсталацију „Сесија“ + Отисак прста + Некомпатибилна верзија + + сат + сата + сати + + Није могуће преузети %s + Изворни кôд више није доступан + Ова верзија је потписана другачијим сертификатом од верзије која је инсталирана на вашем уређају. Прво то деинсталирајте. + Сачувај + Аутоматска синхронизација репозиторијума + Ажурирај све + Превлачење почетног екрана + Ова верзија је старија од верзије инсталиране на вашем уређају. Прво то деинсталирајте. + Користите Material You тему боја + Само на Wi-Fi мрежи и током пуњења + Синхронизуј репозиторијуме + Програм за инсталацију „Root“ + Радња није успела + +%d више + Програм за инсталацију + Неважећи метаподаци. + Ваш %1$s (верзија API-ја %2$d) није подржан. %3$s + Само на Wi-Fi мрежи + Лозинка + Непотписано + Интервал чишћења APK-а + Непотписано. Није могуће проверити листу апликација. Будите пажљиви при преузимању апликација из непотписаних репозиторијума. + Садржи садржај који није безбедан за рад + Изаберите резервни извор + Има неслободне компоненте + Промовише неслободне мрежне услуге + Приказује анимацију листе на главној страници + Синхронизација %s… + Омиљено + Прати или пријављује вашу активност + Неважећи потпис. + Веб-сајт + Нема инсталираних апликација + Непоуздане функције + Неважећи одговор сервера. + Спајање %s + Чека се почетак преузимања… + Покушајте да аутоматски инсталирате ажурирања + Преузимање + Истражи + Приказује обавештење када су нове верзије доступне + Предложено + Дозволе + Сортирај и филтрирај + Отворити %s\? + Није могуће потврдити индекс. + Доступне су нове верзије апликација + Све ваше апликације су ажуриране + Има неслободне зависности + Евиденција промена + Shizuku није покренут + Непознато + + дан + дана + дана + + Није могуће синхронизовати %s + Инсталирање + Ваша платформа %1$s није подржана. Подржане платформе: %2$s. + Репозиторијум је недоступан + Рестартујте LeOS-Droid да бисте видели промене + Недостаје корисничко име + Повезивање… + Ажурирање + Прикажи више + Недостају функције. + Нема доступних апликација + Присилно чишћење + Тема + SOCKS прокси + Недостаје лозинка + Опис + Material You + Циљ + Чека се почетак инсталације… + Инсталирано + Назив + Потписано коришћењем небезбедног алгоритма + Није могуће повезати се на сервер + Изворни кôд + Корисничко име + Верзија %s + Светла + Све апликације + Инсталиране апликације + Порт проксија + Захтева %s + Избриши + Репозиторијум + Компатибилно само са %s + Сервер није успео да обезбеди нови пакет. + Предлаже инсталирање нестабилних верзија + Верзија + У реду + Није могуће извршити одређене радње. + Персонализација + Има безбедносне пропусте + Остало + Потпис %s + Линк је копиран + Обрада %1$s… + Програм за инсталацију „Shizuku“ + Нове апликације + Копирај + Деинсталирај + Немате интернет везу + Прескочи + Преузета је апликација %s + Није могуће изменити репозиторијум, јер се тренутно синхронизује. + Порт проксија може бити само цео број + Број апликација + Некомпатибилно са %s + Мрежна грешка + Лиценца + Аутоматско ажурирање апликација + Снимци екрана + Недавно ажурирано + Већ постоји + Веб-сајт аутора + Детаљи + Измени репозиторијум + Редослед сортирања + Игнориши ову верзију + Неверификовано + HTTP прокси + Апликација + Увек + Адреса + Тиха инсталација + Опис није доступан + Неважећи формат отиска прста + Чување детаља… + Линкови + Има огласе + Тамна + Шта је ново + Додирните да бисте инсталирали. + Откажи + Програм за праћење грешака + Неважеће дозволе. + Садржи неслободне медије + Чисти сувишне фајлове + Промовише неслободни софтвер + Покрени + Без проксија + Црна + Синхронизација + Језик + Врсте инсталације + Програм за инсталацију „Застарело“ + Претрага + Непозната грешка. + Теме + Анимације листе + Неважећи формат фајла. + + %d апликација има нову верзију. + %d апликације имају нове верзије. + %d апликација има нове верзије. + + Дозвољава кориснику да прелази између страница на почетном екрану + Врста проксија + Избрисати репозиторијум\? + Омогући репозиторијум + Веб-сајт пројекта + Донација + Додај репозиторијум + Дозволи да се горња трака апликације прошири + Није могуће потврдити %s + Истражи + Прикажи мање + Никада + Није могуће пронаћи ниједну такву апликацију + Минимална верзија API-ја је %d. + Дозволите да се горња трака апликације прошири и скупи + Период за проверу и уклањање преузетих фајлова + Подешавања + Неважећа адреса + Оригинални изворни кôд није слободан + Некомпатибилне верзије + Имејл аутора + Провера репозиторијума… + Верзије + Максимална верзија API-ја је %d. + Овај репозиторијум још није коришћен. Укључите га да бисте видели апликације у њему. + Није могуће рашчланити фајл индекса. + Инсталирај + Системски + Потврда + Следећи репозиторијум није пронађен + Посебни кредити + Дели + Промене + Неважећи формат корисничког имена + Обавештење за ажурирања + Обезбеђује %s + Приказује верзије апликација које нису компатибилне са уређајем + Прикажи старије верзије + Shizuku није инсталиран + Нестабилна ажурирања + Хост проксија + Игнориши све нове верзије + Преузимање %s… + Није могуће проверити интегритет. + Није могуће пронаћи ту апликацију + Прокси + Најновије + Кредити + Репозиторијуми + Дозволите root дозволу за тихе инсталације + Састављено за отклањање грешака + Непознато: %s + Увоз подешавања + Увоз/Извоз + Увезите подешавања и омиљене из фајла + Извоз подешавања + Извезите све репозиторијуме у фајл + Увоз репозиторијума + Извезите подешавања и омиљене у фајл + Извоз репозиторијума + Увезите све репозиторијуме из фајла + Није могуће отворити линк + \ No newline at end of file diff --git a/core/common/src/main/res/values-sv/strings.xml b/core/common/src/main/res/values-sv/strings.xml new file mode 100644 index 0000000..3c64af5 --- /dev/null +++ b/core/common/src/main/res/values-sv/strings.xml @@ -0,0 +1,234 @@ + + + Osignerad. Det gick inte att verifiera applikationslistan. Var försiktig när du laddar ner applikationer från osignerade arkiv. + Kräver %s + Tyst installation + Spara + Skärmdumpar + Välj en mirror + Signatur %s + Källkod + Synkroniseras + Synkronisera arkiv + Synkroniserar %s… + Sortera & Filtrera + Synkronisera arkiv automatiskt + Tryck för att installera. + Teman + Avinstallera + Uppströmskällkoden är inte gratis + Användarnamn + Version + Personalisering + Visa mindre + Senast + Instabila uppdateringar + Uppdatera + Versioner + Användarnamn saknas + Index kunde inte valideras. + Vad är nytt + Språk + Root Installerare + Det gick inte att kontrollera integriteten. + Ogiltig metadata. + Licens + Ljus + Länken har kopierats + Länkar + Listanimationer + Ogiltigt fingeravtrycksformat + Ogiltiga behörigheter. + Ogiltig signatur. + Material You + Sammanfogar %s + Namn + Aldrig + Visa ett meddelande när nya versioner är tillgängliga + Nya versioner av applikationer tillgängliga + Det gick inte att hitta några sådana applikationer + Avisera om uppdateringar + + %d applikation har en ny version. + %d applikationer med nya versioner. + + Du har ingen internetanslutning + Ingen proxy + Endast kompatibel med %s + Antal applikationer + Endast på Wi-Fi + Övrig + Det gick inte att analysera indexfilen. + Lösenord + Inställningar + Bearbetar %1$s… + Projektets hemsida + Främjar icke-fria nätverkstjänster + Tillhandahålls av %s + Proxyport + SOCKS proxy + Föreslagen + Okänt fel. + Okänd: %s + Marknadsför icke-fri programvara + Sök + Visa mer + Skippa + Källkoden är inte längre tillgänglig + Proxy + Typ av proxy + Nyligen uppdaterad + Storlek + Proxyvärd + Dela + Visa äldre versioner + Sorteringsordning + Arkiv + Arkiv + Det här arkivet har inte använts ännu. Slå på den för att se applikationerna i den. + Åtgärd misslyckades + Lägg till arkiv + Adress + Alla applikationer + Alltid + Svart + Anti-funktion + Applikation + Det gick inte att hitta den applikationen + Författarens e-post + Författarens webbplats + Utforska + Felsökare + Avbryt + Ändringslogg + Kontrollerar arkivet … + APK cleanup interval + Period to check and remove downloaded files + Bekräftelse + Innehåller icke-fria media + Kunde inte ladda ner %s + Kunde inte synkronisera %s + Kunde inte validera %s + Erkännande + Mörk + Radera + Ta bort arkivet\? + Beskrivning + Detaljer + Laddar ner %s… + Redigera arkiv + Ogiltigt filformat. + Ogiltigt serversvar. + HTTP proxy + Ignorera alla nya versioner + Ignorera den här versionen + Den maximala API-versionen är %d. + Inga tillgängliga applikationer + Inga installerade applikationer + Endast på Wi-Fi och laddning + Lösenord saknas + Tillåt rotbehörighet för tysta installationer + Signerad med en osäker algoritm + Tema + Spårar eller rapporterar din aktivitet + Osignerad + Föreslå att du installerar instabila versioner + Ej verifierad + Version %s + Väntar på att börja ladda ner… + Hemsida + Utforska + Uppdatera alla + Installerade applikationer + Nya applikationer + Behörigheter + +%d till + Uppdateringar + Alla dina applikationer är uppdaterade + Låt toppverktygsfältet expandera + Låt toppverktygsfältet expandera och dras ihop + Donera + Installerare + Existerar redan + Det går inte att utföra vissa åtgärder. + %s licens + Visa listanimering på huvudsidan + Okej + Öppna %s\? + Sparar detaljer… + Sammanställd för felsökning + Har icke-fria beroenden + Laddade ned %s + + Dag + Dagar + + + Timme + Timmar + + Laddar ner + Ingen beskrivning finns tillgänglig + Saknade funktioner. + Har säkerhetsbrister + Ansluter… + Det går inte att redigera arkivet eftersom det synkroniseras just nu. + Ändringar + Din %1$s-plattform stöds inte. Plattformar som stöds: %2$s. + Installationstyper + Din %1$s (API-version %2$d) stöds inte. %3$s + Fingeravtryck + Har reklam + Min API-version är %d. + Den här versionen är signerad med ett annat certifikat än det som är installerat på din enhet. Avinstallera den först. + Inkompatibel version + Inkompatibla versioner + Visa programversioner som är inkompatibla med enheten + Inkompatibel med %s + Installera + Äldre installationsprogram + Den här versionen är äldre än den som är installerad på din enhet. Avinstallera den först. + Session Installerare + Shizuku Installerare + Ogiltig adress + Ogiltigt användarnamnsformat + Starta + Använd Material You-färgtema + Nätverksfel + Installerad + System + Mål + Okänd + Favoriter + Rensar upp överflödiga filer + Arkivet kan inte nås + Tvinga städning + Aktivera arkivet + Installerar + Starta om LeOS-Droid för att se ändringarna + Väntar på att starta installationen… + Uppdatera appar automatiskt + Försök att installera uppdateringar automatiskt + Har icke-fria komponenter + Kunde inte ansluta till server + Servern misslyckades att tillhandahålla nytt paket. + Innehåller innehåll som inte är säkert för arbete + Shizuku körs inte + Shizuku är inte installerat + Svep på hemskärmen + Tillåt användare att svepa mellan sidor på hemskärmen + Särskilda tack + Kopiera + Importera inställningar + Import/Export + Importera inställningar och favoriter från fil + Exportera inställningar + Proxyport kan bara vara ett heltal + Exportera alla arkiv till fil + Importera arkiv + Exportera inställningar och favoriter till fil + Exportera arkiv + Importera alla arkiv från fil + Följande arkiv hittades inte + Kan inte öppna länk + \ No newline at end of file diff --git a/core/common/src/main/res/values-tl/strings.xml b/core/common/src/main/res/values-tl/strings.xml new file mode 100644 index 0000000..a86fe4a --- /dev/null +++ b/core/common/src/main/res/values-tl/strings.xml @@ -0,0 +1,212 @@ + + + Ang lahat ng aplikasyon ay nasa pinakabagong bersyon + Mayroon na + Hindi kanais-nais na features + Aplikasyon + Hindi mahanap ang aplikasyon + Website ng awtor + E-mail ng awtor + Magdagdag ng repository + Lahat ng mga aplikasyon + Awtomatikong i-update ang mga app + Subukang awtomatikong mag-install ng mga update + Hindi ma-download ang %s + Hindi ma-validate ang %s + Period to check and remove downloaded files + Huwag pansinin ang bersyon na ito + Mga nawawalang feature. + Ang iyong %1$s (bersyon ng API %2$d) ay hindi suportado. %3$s + Ang max na bersyon ng API ay %d. + Ang min na bersyon ng API ay %d. + Hindi tugmang bersyon + Taga-install ng Session + Root Installer + Naka-install + Hindi masuri ang integridad. + Di-wastong address + Di-wastong format ng fingerprint + Di-wastong metadata. + Di-wastong lagda. + Ipakita ang animation ng listahan sa pangunahing pahina + Pinagsasama ang %s + Pangalan + Walang magagamit na mga application + Walang naka-install na application + Mga paborito + %s lisensya + Pilitin ang paglilinis + Nililinis ang mga redundant na file + Mas luma ang bersyong ito kaysa sa naka-install sa iyong device. I-uninstall muna yan. + + Araw + Mga araw + + I-install + Di-wastong format ng username + Mga Uri ng Pag-install + Ilunsad + Lisensya + Materyal Ikaw + Gumamit ng materyal ikaw kulay na tema + Hindi kailanman + Available ang mga bagong bersyon ng mga application + + May bagong bersyon ang %d application. + %d application na may mga bagong bersyon. + + Wala kang koneksyon sa internet + Walang available na paglalarawan + Hindi mahanap ang anumang ganoong mga application + Walang proxy + Abisuhan ang tungkol sa mga bagong bersyon ng mga application + Changelog + Mga pagbabago + Sinusuri ang repositoryo… + Naipon para sa pag-debug + Kumpirmasyon + Itim + Galugarin + Kumokonekta… + Naglalaman ng hindi libreng media + Address + Palagi + Tanggalin + Paglalarawan + Mga Detalye + Mag-donate + Na-download ang %s + Nagda-download + Dina-download ang %s… + I-edit ang repository + Di-wastong format ng file. + Fingerprint + May advertising + + Oras + Oras + + Paganahin ang repositoryo + Ang bersyon na ito ay nilagdaan gamit ang isang certificate na iba kaysa sa naka-install sa iyong device. I-uninstall muna yan. + Mga hindi tugmang bersyon + Ipakita ang mga bersyon ng application na hindi tugma sa device + APK cleanup interval + Mga kredito + Tanggalin ang repositoryo\? + Madilim + Hindi ma-sync ang %s + Nabigo ang pagkilos + Hindi ma-edit ang repository dahil nagsi-sync ito ngayon. + Bug tracker + Kanselahin + Mga di-wastong pahintulot. + Di-wastong tugon ng server. + Ang iyong %1$s platform ay hindi suportado. Mga sinusuportahang platform: %2$s. + Huwag pansinin ang lahat ng mga bagong bersyon + Hindi tugma sa %s + installer + Pag-install + Legacy na Installer + Shizuku Installer + Liwanag + Error sa network + Nakopya ang link sa clipboard + Mga link + Listahan ng mga animation + Hindi magawa ang ilang partikular na pagkilos. + Payagan ang Nangungunang App Bar na Palawakin + Payagan ang nangungunang app bar na lumawak at bumagsak + May mga hindi libreng dependencies + May mga kahinaan sa seguridad + HTTP proxy + Nagtataguyod ng hindi libreng software + Iba pa + Nawawala ang kontrasenyas + +%d higit pa + Proxy + mabuti + Magpakita ng isang abiso kapag magagamit ang mga bagong bersyon + Sa wifi at singilin lamang + Katugma lamang sa %s + Kontrasenyas + Bukas %s\? + Hindi ma -parse ang index file. + Pagproseso %1$s … + Website ng proyekto + Mga Pahintulot + Proxy port + Proxy host + Mga Repositori + Repositoryo + Sa Wi-Fi lamang + Ibinigay ng %s + Kamakailan lamang na -update + Bilang ng mga aplikasyon + Nagtataguyod ng mga serbisyo na hindi libre sa network + Uri ng proxy + I-restart ang LeOS-Droid para makita ang mga pagbabago + Magpakita ng higit pa + Nilagdaan gamit ang hindi ligtas na algorithm + Pag-uuri ng pagkakasunud-sunod + Hindi na available ang source code + Sini-sync ang %s… + I-tap para i-install. + Hindi alam: %s + Hindi nakapirma + Mga hindi matatag na update + Naghihintay upang simulan ang pag-install… + Website + Wika + Personalization + Nangangailangan ng %s + Nagsi-sync + Ibahagi + Tahimik na Pag-install + Source code + Sistema + Tema + Mga setting + Hindi pa nagagamit ang repositoryong ito. I-on ito para tingnan ang mga application sa loob nito. + Hindi nakapirma. Hindi ma-verify ang listahan ng aplikasyon. Mag-ingat sa pag-download ng mga application mula sa mga hindi naka-sign na repository. + Payagan ang pahintulot sa ugat para sa mga tahimik na pag-install + Mga screenshot + Hindi maabot ang repository + Maghanap + Pumili ng salamin + Ipakita ang mga mas lumang bersyon + Lagda %s + Sinusubaybayan o iniuulat ang iyong aktibidad + I-uninstall + Hindi kilala + Hindi kilalang error. + Magmungkahi ng pag-install ng mga hindi matatag na bersyon + Hindi na-verify + Update + Mga update + Ang upstream source code ay hindi libre + Username + Nawawala ang username + Hindi ma-validate ang index. + Bersyon + Bersyon %s + Mga bersyon + Magpakita ng Mas Kaunti + Pinakabago + I-save + Mga bagong application + Sine-save ang mga detalye… + I-sync ang mga repository + Awtomatikong i-sync ang mga repositoryo + Sukat + Laktawan + proxy na SOCKS + Mga tema + Naghihintay upang simulan ang pag-download… + Anong bago + Galugarin + Update lahat + Mga naka-install na application + Pagbukud-bukurin at Salain + Iminungkahi + Pantarget + \ No newline at end of file diff --git a/core/common/src/main/res/values-tr/strings.xml b/core/common/src/main/res/values-tr/strings.xml new file mode 100644 index 0000000..291d144 --- /dev/null +++ b/core/common/src/main/res/values-tr/strings.xml @@ -0,0 +1,235 @@ + + + Eylem başarısız oldu + Yeni depo eklenm + Depo adresi + Tüm uygulamalar + Tüm uygulamalarınız güncel + Zaten mevcut + Her zaman + Siyah + Karşıt özellikler + Uygulama + Bu uygulama bulunamadı + Yazarın e-posta adresi + Yazarın web sitesi + Gözat + Hata izleme + İptal et + Depo şuan senkronize edildiği için düzenlenemiyor. + Değişim Günlüğü + Değişiklikler + Depo denetleniyor… + Hata ayıklama için derlendi + Onay + Bağlanıyor… + Özgür olmayan içerik içerir + %s indirilemedi + %s senkronize edilemedi + %s doğrulanamadı + Katkı Sağlayanlar + Koyu + Sil + Depo silinsin mi\? + Açıklama + Ayrıntılar + Bağış + %s indirildi + İndiriliyor + %s indiriliyor… + Depoyu düzenle + Geçersiz dosya biçimi. + Parmak izi + Reklam içerir + Özgür olmayan bağımlılıklar içerir + Güvenlik açığına sahip + Geçersiz sunucu yanıtı. + HTTP proxy + Tüm yeni sürümleri yoksay + Bu sürümü yoksay + Sizin %1$s (API sürümü %2$d) desteklenmemektedir. %3$s + En yüksek API sürümü %d. + En düşük API sürümü %d. + Özellikleri eksik. + Bu sürüm aygıtınızda kurulu olandan daha eski. Önce onu kaldırın. + Sizin %1$s platformunuz desteklenmemektedir. + Desteklenen platformlar: %2$s. + Bu sürüm aygıtınızda kurulu olan sürümden farklı bir sertifika ile imzalanmış. Önce onu kaldırın. + Uyumsuz sürüm + Uyumsuz sürümler + Aygıtla uyumlu olmayan uygulama sürümlerini göster + %s ile uyumsuz + Kur + Kurulum Türleri + Kurulu + Bütünlük kontrol edilemedi. + Geçersiz adres + Geçersiz parmak izi biçimi + Geçersiz metaveri. + Geçersiz izinler. + Geçersiz imza. + Geçersiz kullanıcı adı biçimi + Başlat + Lisans + %s lisansı + Açık + Bağlantı kopyalandı + Bağlantılar + Liste Animasyonu + Liste canlandırmasını ana sayfada göster + %s birleştiriliyor + İsim + Ağ hatası + Asla + Uygulamaların yeni sürümleri var + + %d uygulamanın yeni bir sürümü var. + %d uygulamanın sürümleri var. + + Kullanılabilir uygulama yok + Kurulu uygulama yok + Açıklama yok + Böyle bir uygulama bulunamadı + Proxy yok + Güncellemeleri bildir + Yeni sürümler kullanılabilir olduğunda bir bildirim göster + Uygulama sayısı + Tamam + Yalnızca %s ile uyumlu + Yalnızca Wi-Fi + %s açılsın mı? + Diğer + Dizin dosyası çözümlenemedi. + Parola + Parola eksik + İzinler + +%d daha + Ayarlar + %1$s işleniyor… + Proje web sitesi + Özgür olmayan ağ hizmetlerini destekler + Özgür olmayan yazılımı destekler + %s tarafından sağlandı + Proxy + Proxy sağlayıcısı + Vekil bağlantı noktası + Proxy türü + Son güncelleme + Depolar + Depo + Bu depo henüz kullanılmadı. İçindeki uygulamaları görüntülemek için etkinleştirin. + İmzalanmamış. Uygulama listesi doğrulanamadı. İmzalanmamış depolardan uygulama indirirken dikkatli olun. + %s gerektirir + Sessiz Kurulum + Sessiz kurulumlar için root iznine izin ver + Kaydet + Ayrıntılar kaydediliyor… + Ekran görüntüleri + Arama + Bir yansı seç + Paylaş + Daha fazla göster + Eski sürümleri göster + İmza %s + Güvenli olmayan bir algoritma ile imzalanmış + Boyut + Atla + SOCKS vekili + Sıralama düzeni + Kaynak kodu + Kaynak kodu artık mevcut değil + Önerilen + Depoları senkronize et + Depoları otomatik senkronize et + Senkronize ediliyor + %s senkronize ediliyor… + Sistem + Kurmak için dokunun. + Hedef + Tema + Temalar + Etkinliğinizi takip eder ya da raporlar + Kaldır + Bilinmeyen + Bilinmeyen hata. + Bilinmeyen: %s + İmzalanmamış + Kararsız güncellemeler + Kararsız sürümleri kurmayı öner + Doğrulanmamış + Güncelleştir + Güncellemeler + Yukarı akış kaynak kodu özgür değil + Kullanıcı adı + Kullanıcı adı yok + Dizin doğrulanamadı. + Sürüm + Sürüm %s + Sürümler + İndirmenin başlaması bekleniyor… + Ne var ne yok + Web sitesi + Dil + Kişiselleştirme + Daha Az Göster + En son + Gözat + Tümünü güncelle + Sırala ve Filtrele + Yüklü uygulamalar + Yeni uygulamalar + Kurucu + Eski Kurucu + Oturum Kurucusu + Root Kurucusu + Shizuku Kurucusu + APK temizleme aralığı + + Gün + Günler + + Sadece Wi-Fi açık ve Şarj Olurken + İndirilen dosyaları kontrol etme ve kaldırma süresi + + Saat + Saatler + + Belirli eylemler gerçekleştirilemiyor. + İnternet bağlantınız yok + Üst Uygulama Çubuğunun Genişletilmesine İzin Ver + Üst uygulama çubuğunun genişletilmesine ve daraltılmasına izin verin + Material you renk temasını kullan + Material You + Favoriler + Depoya ulaşılamıyor + Temizlemeye zorla + Gereksiz dosyaları temizler + Depoyu etkinleştir + Kuruluyor + Değişiklikleri görmek için LeOS-Droid\'ı yeniden başlatın + Kurulumun başlatılması bekleniyor… + Uygulamaları otomatik güncelle + Güncellemeleri otomatik olarak kurmaya çalış + Özgür olmayan bileşenlere sahip + Ana Ekranda Kaydırma + Uygunsuz içerik barındırır + Shizuku çalışmıyor + Sunucuya bağlanılamadı + Sunucu yeni paketi sağlayamadı. + Kopyala + Proxy kapısı (port) sadece tam sayı olabilir + Kullanıcı ana ekrandayken sayfalar arası kaydırabilmesini sağlar + İlgili depo bulunamadı + Teşekkürler + Shizuku kurulu değil + İçe aktarma seçenekleri + Al/Aktar + Ayarları ve favorileri dosyadan içe aktar + Dışa Aktarma Seçenekleri + Tüm depoları dosyaya aktar + Depo ekle + Ayarları ve favorileri dosyaya aktar + Depoları Dışa Aktar + Tüm depoları dosyadan içe aktar + Bağlantı açılamıyor + \ No newline at end of file diff --git a/core/common/src/main/res/values-uk/strings.xml b/core/common/src/main/res/values-uk/strings.xml new file mode 100644 index 0000000..e1d557e --- /dev/null +++ b/core/common/src/main/res/values-uk/strings.xml @@ -0,0 +1,240 @@ + + + Деталі + Невірний підпис. + Невірні дозволи. + Не вдалося перевірити %s + Ця версія підписана сертифікатом, що відрізняється від того, що встановлений на вашому пристрої. Спершу видаліть встановлену. + Немає доступний застосунків + Не вдалося знайти ні одного подібного застосунку + Відсутні функції. + Всі застосунки + Скасувати + Зміни + Перевіряю розпозиторії… + Застосунок + Завжди + Дія не вдалася + Додати репозиторій + Адреса + Усі ваші застосунки оновлені + Уже існує + Чорна + Анти-функції + Пошта автора + Вебсайт автора + Огляд + Баг-трекер + Не можу редагувати репозиторії, оскільки він зараз синхронізується. + Список змін + Скомпільовано для відладки + Приєднуюся… + Мість невільні матеріали + Не вдалося завантажити %s + Видалити репозиторій\? + Опис + Пожертвувати + Завантажено %s + Завантаження + Завантажую %s… + Редагувати репозиторій + Невірний формат файла. + Містить рекламу + Містить невільні залежності + Некоректна відповідь сервера. + HTTP проксі + Ігнорувати всі нові версії + Ігнорувати цю версію + Ваш %1$s (версія API %2$d) не підтримується. %3$s + Максимальна версія API %d. + Мінімальна версія API %d. + Поточна версія старіша за встановлену на вашому пристрої. Спершу видаліть встановлену. + Ваша %1$s платформа не підтримується. Платформи, що підтримуються: %2$s. + Несумісна версія + Показати версії застосунку, що не сумісні з цим пристроєм + Несумісно з %s + Встановити + Типи встановлення + Встановлено + Невірна адреса + Запустити + Ліцензія + Ліцензія %s + Посилання скопійовано + Посилання + Анімація Списків + Показувати анімацію списку на головній сторінці + Злиття %s + Ім\'я + Помилка мережі + Доступні нові версії застосунків + Немає встановлених застосунків + Опис відсутній + Без проксі + Сповіщати про оновлення + Відображати повідомлення про доступність нових версій + Кількість застосунків + ОК + Лише по Wi-Fi + Відкрити %s\? + Інше + Пароль відсутній + Дозволи + +%d більше + Налаштування + Сайт проєкту + Просуває невільні мережеві сервіси + Просуває невільне ПЗ + Надано %s + Проксі + Хост проксі + Порт проксі + Тип проксі + Нещодавно оновлено + Цей репозиторій іще не використовувався. Вам потрібно увімкнути його для перегляду застосунків у ньому. + Потребує %s + Тихе встановлення + + %d застосунок має нову версію. + %d застосунки мають нову версію. + %d застосунків мають нову версію. + %d застосунків мають нову версію. + + Персоналізація + Надати root права для увімкнення тихого встановлення + Зберегти + Збереження даних… + Скріншоти + Пошук + Показати старіші версії + Для підпису використано не безпечний алгоритм + Розмір + Пропустити + SOCKS проксі + Порядок сортування + Вихідний код + Рекомендується + Синхронізація репозиторіїв + Автосинхронізація репозиторіїв + Синхронізація + Синхронізація %s… + Тема + Теми + Відслідковує або повідомляє про вашу активність + Видалити + Невідомо + Невідома помилка. + Невідомо: %s + Не підписаний + Нестабільне оновлення + Пропонувати встановити нестабільні версії + Не перевірений + Оновити + Оновлення + Вихідний код батьківського застосунку не відкритий + Ім\'я користувача + Ім\'я користувача відсутнє + Не можу перевірити індекс. + Версія %s + Версії + Що нового + Вебсайт + Мова + Не вдалося знайти цей застосунок + Підтвердження + Не вдалося синхронізувати %s + Автори + Темна + Видалити + Відбиток + Невірні метадані. + Ніколи + Сумісно тільки з %s + Оберіть дзеркало + Містить вразливості безпеки + Несумісні версії + Невірний формат відбитку + Невірний формат імені користувача + Не вдалося розібрати індексний файл. + Репозиторії + Показати більше + Підпис %s + Не вдалося перевірити цілісність. + Світла + Пароль + Обробка %1$s… + Репозиторій + Підпис відсутній. Не вдалося перевірити список застосунків. Будьте обережні, завантажуючи застосунок із не підписаних репозиторіїв. + Поділитися + Вихідний код більше не доступний + Як у системі + Натисніть щоб встановити. + Цільовий + Версія + Очікування завантаження… + Показати менше + Останні + Огляд + Оновити все + Встановлені застосунки + Сортувати та фільтрувати + Нові застосунки + Застарілий інсталятор + Інсталятор + Root інсталятор + Shizuku інсталятор + Сесійний інсталятор + + Година + Години + Годин + Годин + + + День + Дні + Днів + Днів + + Тільки при Wi-Fi та зарядці + Інтервал очищення APK + Період для перевірки та видалення завантажених файлів + Неможливо виконати певні дії. + Дозволити розширення верхньої панелі застосунку + Дозволити верхній панелі застосунку розгортатися та згортатися + У вас немає підключення до Інтернету + Material You + Використовуйте кольорову тему Material You + Обране + Провести очищення + Видалення зайвих файлів + Репозиторій недоступний + Автооновлення застосунков + Намагатися встановити оновлення автоматично + Встановлення + Перезапустіть LeOS-Droid, щоб побачити зміни + Увімкніть репозиторій + Очікування початку встановлення… + Має невільні компоненти + Жести на головному екрані + Містить NSFW вміст + Shizuku не працює + Не вдалося з’єднатися з сервером + Сервер не зміг надати новий пакунок. + Копіювати + Порт проксі може бути тільки цілим числом + Дозвольте користувачеві перегортати сторінки на головному екрані + Наступний репозиторій не знайдено + Особлива подяка + Shizuku не встановлено + Налаштування імпорту + Імпорт/Експорт + Імпорт налаштувань та обраного з файлу + Налаштування експорту + Експорт всіх репозиторіїв до файлу + Імпорт репозиторіїв + Експорт налаштувань та обраного до файлу + Експорт репозиторіїв + Імпорт всіх репозиторіїв з файлу + Неможливо відкрити посилання + \ No newline at end of file diff --git a/core/common/src/main/res/values-ur/strings.xml b/core/common/src/main/res/values-ur/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/core/common/src/main/res/values-ur/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/common/src/main/res/values-v31/dimen.xml b/core/common/src/main/res/values-v31/dimen.xml new file mode 100644 index 0000000..85a5e4b --- /dev/null +++ b/core/common/src/main/res/values-v31/dimen.xml @@ -0,0 +1,5 @@ + + + @android:dimen/system_app_widget_inner_radius + @android:dimen/system_app_widget_background_radius + \ No newline at end of file diff --git a/core/common/src/main/res/values-v31/styles.xml b/core/common/src/main/res/values-v31/styles.xml new file mode 100644 index 0000000..6209322 --- /dev/null +++ b/core/common/src/main/res/values-v31/styles.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + diff --git a/core/common/src/main/res/values-vi/strings.xml b/core/common/src/main/res/values-vi/strings.xml new file mode 100644 index 0000000..fce4f61 --- /dev/null +++ b/core/common/src/main/res/values-vi/strings.xml @@ -0,0 +1,231 @@ + + + Địa chỉ + Tất cả ứng dụng của bạn đã được cập nhật + Đã tồn tại + E-mail của tác giả + Website của tác giả + Có sẵn + Theo dõi lỗi + Nhật kí thay đổi + Kiểm tra kho… + Biên dịch để gỡ lỗi + Xác nhận + Đang kết nối… + Không thể tải về %s + Không thể đồng bộ %s + Không thể xác thực %s + Ghi nhận + Tối + Xóa kho\? + Mô tả + Đang tải về + Sửa kho + Fingerprint + Có quảng cáo + HTTP proxy + Bỏ qua tất cả các phiên bản mới + %1$s của bạn (Phiên bản API %2$d) không được hỗ trợ. %3$s + Phiên bản API nhỏ nhất là %d. + Phiên bản không tương thích + Các phiên bản không tương thích + Hiển thị các phiên bản ứng dụng không tương thích với thiết bị + Cài đặt + Đã cài đặt + Đang gộp %s + Tên + Không bao giờ + + %d ứng dụng có phiên bản mới. + + Không có ứng dụng đã cài + Hiển thị thông báo khi có phiên bản mới + Số lượng ứng dụng + OK + Chỉ trên Wi-Fi + Mật khẩu + Không thể phân tích cú pháp tệp chỉ mục. + Quyền + +%d nhiều hơn + Thiết đặt + Website của dự án + Thúc đẩy phần mềm không tự do + Cung cấp bởi %s + Cổng Proxy + Kho + Yêu cầu %s + Cài đặt nền + Cho phép quyền root để cài đặt nền + Lưu + Ảnh chụp màn hình + Chọn một mirror + Chữ kí %s + Đã ký bằng thuật toán không an toàn + Bỏ qua + Xếp theo + Mã nguồn không khả dụng nữa + Gợi ý + Đồng bộ kho + Đang đồng bộ + Đang đồng bộ %s… + Đích + Chủ đề + Các chủ đề + Theo dõi hoặc báo cáo hoạt động của bạn + Không rõ: %s + Chưa kí + Chưa xác minh + Cập nhật + Các cập nhật + Mã nguồn ngược dòng không tự do + Tên người dùng + Tên người dùng bị thiếu + Phiên bản %s + Phiên bản + Hành động thất bại + Hủy + Phụ thuộc cái không tự do + Trang web + Mới nhất + Khám phá + Cập nhật tất cả + Ứng dụng đã cài đặt + Sắp xếp & Lọc + Ứng dụng mới + Không thể chỉnh sửa kho khi đang đồng bộ. + Thêm kho lưu trữ + Không tìm thấy ứng dụng + Chi tiết + Luôn luôn + Đen + Tính năng không mong muốn + Ứng dụng + Thay đổi + Không chứa phương tiện tự do + Định dạng sai. + Xóa + Ủng hộ + Đang tải về %s… + Có lỗ hổng bảo mật + Đã tải về %s + Máy chủ không phản hồi. + Bỏ qua phiên bản này + Phiên vản API tối đa là %d. + Tính năng bị thiếu. + Không tương thích với %s + Phiên bản này cũ hơn phiên bản được cài đặt trên thiết bị của bạn. Hãy gỡ cài đặt đó trước. + Nền tảng %1$s của bạn không được hỗ trợ. Nền tảng hỗ trợ: %2$s. + Phiên bản này được ký bằng chứng chỉ khác với phiên bản được cài đặt trên thiết bị của bạn. Hãy gỡ cài đặt đó trước. + Loại cài đặt + Không thể kiểm tra tính nguyên vẹn. + Giấy phép %s + Sai định dạng fingerprint + Đã sao chép liên kết + Hiện hiệu ứng danh sách trên trang chính + Lỗi mạng + Sai địa chỉ + Dữ liệu không hợp lệ. + Quyền không hợp lệ. + Sai chữ ký. + Sại định dạng người dùng + Mở + Giấy phép + Liên kết + Hiệu ứng danh sách + Không có mô tả + Các kho + Tìm kiếm + Chia sẻ + Sáng + Đã có các phiên bản mới của ứng dụng + Không có ứng dụng nào + Mật khẩu bị thiếu + Không tìm thấy ứng dụng nào + Không proxy + Cập nhật gần đây + Thông báo các bản cập nhật + Chỉ tương thích với %s + Mở %s\? + Máy chủ Proxy + Lưu chi tiết… + Khác + Đang xử lý %1$s… + Loại Proxy + Quảng cáo các dịch vụ mạng không tự do + Kho này vẫn chưa được sử dụng. Bật nó lên để xem các ứng dụng trong đó. + Hiện phiên bản cũ + Chưa kí. Không thể xác minh danh sách ứng dụng. Cận thẩn với kho chưa kí. + Hiện thêm + Đồng bộ kho tự động + Dung lượng + Lỗi không rõ. + Có gì mới nè + Mã nguồn + Ngôn ngữ + Proxy + SOCKS Proxy + Nhấn để cài. + Phiên bản + Cá nhân hóa + Hệ thống + Gỡ cài đặt + Không rõ + Bản cập nhật không ổn định + Chỉ mục không thể được xác thực. + Gợi ý cài đặt các phiên bản không ổn định + Chờ để tải về… + Ẩn bớt + Tất cả ứng dụng + Khoảng thời gian dọn dẹp APK + Khoảng thời gian kiểm tra và xóa các tệp đã tải xuống + + Ngày + + + Giờ + + Trình cài đặt + Trình cài đặt cũ + Trình cài đặt phiên + Trình cài đặt Root + Cho phép mở rộng thanh công cụ + Cho phép mở rộng thanh công cụ và sụp đổ + Không thể thực hiện một số hành động nhất định. + Trình cài đặt Shizuku + Bạn không có kết nối internet + Chỉ trên wifi và sạc + Material You + Sử dụng chủ đề màu Material You + Tự động cập nhật ứng dụng + Cố gắng cài đặt các bản cập nhật tự động + Cài đặt + Đang đợi để bắt đầu cài đặt… + Yêu thích + Buộc dọn dẹp + Dọn dẹp các tệp dư thừa + Kho lưu trữ không thể truy cập + Kích hoạt kho lưu trữ + Khởi động lại LeOS-Droid để xem thay đổi + Vuốt màn hình chính + Chứa nội dung không an toàn cho công việc + Có các thành phần không tự do + Shizuku không chạy + Không thể kết nối tới máy chủ + Máy chủ không cung cấp được gói mới. + Sao chép + Cổng proxy chỉ có thể là Số nguyên + Cho phép người dùng vuốt giữa các trang trong màn hình chính + Không tìm thấy kho lưu trữ sau + Công trạng đặc biệt + Shizuku chưa được cài đặt + Nhập thiết đặt + Nhập/Xuất + Nhập thiết đặt và mục yêu thích từ tệp + Xuất thiết đặt + Xuất mọi kho lưu trữ sang tệp + Nhập kho lưu trữ + Xuất thiết đặt và mục yêu thích sang tệp + Xuất kho lưu trữ + Nhập mọi kho lưu trữ từ tệp + Không thể mở liên kết + \ No newline at end of file diff --git a/core/common/src/main/res/values-zh-rCN/strings.xml b/core/common/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000..1906560 --- /dev/null +++ b/core/common/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,230 @@ + + + 操作失败 + 添加存储库 + 地址 + 所有应用 + 所有应用已是最新 + 已经存在 + 总是 + 黑色(AMOLED) + 负面特征 + 应用 + 找不到应用 + 作者邮箱 + 作者网站 + 探索 + 错误追踪器 + 取消 + 存储库正在同步时无法编辑。 + 更新日志 + 更新内容 + 检查存储库… + 用于调试的版本 + 确认 + 连接中… + 包含非自由媒体 + 无法下载 %s + 无法同步 %s + 无法验证 %s + 鸣谢 + 深色 + 删除 + 确定要删除此存储库吗? + 描述 + 详情 + 捐助 + 已下载 %s + 下载中 + 正在下载 %s… + 编辑存储库 + 文件格式无效。 + 指纹 + 包含广告 + 包含非自由依赖 + 包含安全漏洞 + 服务器响应无效。 + HTTP 代理 + 忽略所有新版本 + 忽略此版本 + 您的 %1$s(API 版本 %2$d)不受支持。%3$s + 最高 API 版本为 %d。 + 最低 API 版本为 %d。 + 缺少的功能。 + 此版本比您设备上安装的版本旧。请先卸载现有的版本。 + 您的 %1$s 平台不受支持。支持的平台:%2$s。 + 此版本和您设备上安装的版本使用了不同的证书来签名。请先卸载现有的版本。 + 版本不兼容 + 不兼容的版本 + 显示与设备不兼容的应用版本 + 与 %s 不兼容 + 安装 + 安装类型 + 已安装 + 无法检查完整性。 + 地址无效 + 指纹格式无效 + 元数据无效。 + 权限无效。 + 签名无效。 + 用户名格式无效 + 启动 + 许可证 + 许可证 %s + 浅色 + 链接已复制 + 链接 + 列表动画 + 在主页上显示列表动画 + 合并 %s + 名称 + 网络错误 + 从不 + 有应用的新版本可用 + + %d 个应用有新版本。 + + 没有可用的应用 + 没有已安装的应用 + 没有描述可用 + 找不到任何这样的应用 + 无代理 + 通知更新 + 有新版本可用时显示通知 + 应用数量 + 好的 + 仅与 %s 兼容 + 仅在连接 Wi-Fi 时 + 打开 %s? + 其他 + 无法解析索引文件。 + 密码 + 缺少密码 + 权限 + +%d 个 + 设置 + 正在处理 %1$s… + 项目网站 + 推广非自由网络服务 + 推广非自由软件 + 由 %s 提供 + 代理 + 代理主机 + 代理端口 + 代理类型 + 最近更新 + 存储库 + 存储库 + 您还未启用此存储库。您需要启用它以查看其中的应用。 + 未签名,无法验证应用列表。从未签名的存储库下载应用时需谨慎。 + 需要 %s + 后台安装 + 授予 Root 权限以启用后台安装 + 保存 + 保存详情中… + 截图 + 搜索 + 选择一个镜像 + 分享 + 显示更多 + 显示旧版本 + 签名 %s + 使用不安全的算法签名 + 大小 + 跳过 + SOCKS 代理 + 排序依据 + 源代码 + 源代码不再可用 + 建议 + 同步存储库 + 自动同步存储库 + 同步中 + 正在同步 %s… + 跟随系统 + 点击安装。 + 目标 + 主题 + 主题 + 跟踪或报告您的活动 + 卸载 + 未知 + 未知错误。 + 未知:%s + 未签名 + 不稳定的更新 + 安装不稳定的版本 + 未验证 + 更新 + 更新 + 上游源代码不是自由的 + 用户名 + 缺少用户名 + 无法验证索引。 + 版本 + 版本 %s + 版本 + 等待开始下载… + 更新日志 + 网站 + 语言 + 个性化 + 收起 + 已安装应用 + 排序 & 过滤 + 最新 + 探索 + 全部更新 + 安装器 + 传统安装器 + 会话安装器 + Root 安装器 + Shizuku 安装器 + 新应用 + + + + 仅在连接 Wi-Fi 和充电时 + APK 清理间隔 + 检查并删除已下载文件的周期 + + 小时 + + 您没有连接互联网 + 无法执行某些操作。 + 允许顶部应用栏展开和折叠 + 允许顶部应用栏展开 + 自动更新应用 + 尝试自动安装更新 + 正在安装 + 收藏夹 + 使用 Material You 配色 + 存储库无法访问 + 强制清除缓存 + 清理冗余文件 + 启用此存储库 + Material You 设计 + 等待开始安装… + 重新启动 LeOS-Droid 使更改生效 + 包含非自由组件 + 服务器未能提供新数据包。 + 无法连接服务器 + 含有工作场所不宜(NSFW)的内容 + Shizuku 未运行 + Shizuku 未安装 + 特别鸣谢 + 主屏幕滑动 + 允许用户在页面之间进行滑动切换 + 复制 + 未找到以下仓库 + 代理的端口号只能是整数 + 导入设置 + 导入/导出 + 从文件导入设置和收藏夹 + 导出设置 + 导出所有存储库到文件 + 导入存储库 + 导出设置和收藏夹到文件 + 导出存储库 + 从文件导入所有存储库 + \ No newline at end of file diff --git a/core/common/src/main/res/values-zh-rTW/strings.xml b/core/common/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000..afad3dc --- /dev/null +++ b/core/common/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,231 @@ + + + 新增儲存庫 + 位址 + 操作失敗 + 已經存在 + 確認 + 連線中…… + 包含非自由媒體 + 無法下載 %s + 無法同步 %s + 安裝 + 舊版安裝程式 + Session 安裝程式 + Root 安裝程式 + 已安裝 + 無法檢查完整性。 + 位址無效 + 指紋格式無效 + 中繼資料無效。 + Shizuku 安裝程式 + 簽名無效。 + 無法解析索引檔案。 + 密碼 + 缺少密碼 + 權限 + +%d 個 + 設定 + 儲存庫 + 您還未啟用此儲存庫。您需要啟用它以檢視其中的應用程式。 + 需要 %s + 背景安裝 + 顯示舊版本 + 簽名 %s + 未知 + 最新 + 探索 + 更新全部 + 已安裝的應用程式 + 排序和過濾 + 新的應用程式 + 所有應用程式 + 所有應用程式已是最新 + 總是 + 黑色 + 負面特色 + 應用程式 + 找不到應用程式 + 作者信箱 + 作者網站 + 探索 + Bug 追蹤器 + 取消 + 無法編輯正在同步的儲存庫。 + 更新日誌 + 更新內容 + 檢查儲存庫…… + 為除錯而編譯構建 + 無法驗證 %s + 鳴謝 + 暗色 + 刪除 + 您確定要刪除此儲存庫嗎? + 描述 + 詳細資訊 + 贊助 + 已下載 %s + 下載中 + 正在下載 %s…… + 編輯儲存庫 + 檔案格式無效。 + 指紋 + 包含廣告 + 包含安全漏洞 + 伺服器回應無效。 + 包含非自由依賴 + HTTP 代理 + 忽略所有新版本 + 忽略此版本 + 您的 %1$s(API 版本 %2$d)不受支援。 %3$s + 最高 API 版本為 %d。 + 缺少的功能。 + 此版本比您裝置上安裝的版本舊。 請先解除安裝您已有的版本。 + 您的 %1$s 平台不受支援。支援的平台:%2$s。 + 此版本和您裝置上安裝的版本使用了不同的簽名檔。請先解除安裝現有的版本。 + 最低 API 版本為 %d。 + 版本不相容 + 不相容版本 + 顯示與裝置不相容的應用版本 + 與 %s 不相容 + 安裝類型 + 權限無效。 + 使用者名稱格式無效 + 啟動 + 授權 + %s 授權 + 亮色 + 已複製連結 + 連結 + 清單動畫 + 在主頁上顯示清單動畫 + 合併 %s + 名稱 + 網路錯誤 + 從不 + 有應用程式的新版本可用 + + %d 個應用程式有新版本。 + + 沒有可用的應用程式 + 沒有已安裝的應用程式 + 沒有可用的描述 + 有更新時顯示通知 + 找不到應用程式 + 無代理 + 通知更新 + 應用程式數量 + 好的 + 僅與 %s 相容 + 僅連到 Wi-Fi 時 + 開啟 %s? + 其他 + 正在處理 %1$s…… + 專案網站 + 推廣非自由網路服務 + 推廣非自由軟體 + 由 %s 提供 + 代理 + 代理主機 + 代理埠 + 儲存詳細資訊中…… + 截圖 + 搜尋 + 選擇一個鏡像 + 代理類型 + 最近更新 + 儲存庫 + 未簽名,無法驗證應用程式列表。從未簽名的儲存庫下載應用程式時要小心。 + 開啟 Root 權限以啟用背景安裝 + 儲存 + 分享 + 顯示更多 + 使用不安全的演算法簽名 + 大小 + 跳過 + SOCKS 代理 + 排序 + 原始碼 + 原始碼不再可用 + 建議 + 正在同步 %s…… + 同步儲存庫 + 系統 + 自動同步儲存庫 + 同步中 + 點選安裝。 + 目標 + 主題 + 主題 + 追蹤或報告您的活動 + 解除安裝 + 未知錯誤。 + 未簽名 + 未知: %s + 不穩定的更新 + 建議安裝不穩定的版本 + 未驗證 + 更新 + 更新 + 上游原始碼不自由 + 使用者名稱 + 缺少使用者名稱 + 無法驗證索引。 + 版本 + 版本 %s + 版本 + 等待開始下載…… + 最近上架 + 網站 + 語言 + 個人化 + 顯示較少 + 安裝程式 + APK 清理時間間隔 + 檢查和刪除下載檔案的期限 + + 小時 + + + 天數 + + 僅在連到 Wi-Fi 和充電時 + 無法執行某些特定操作。 + 允許頂部工具列展開 + 允許頂部工具列展開和折疊 + 您沒有網路連線 + Material You 設計 + 使用 material you 顏色主題 + 我的最愛 + 移除不必要檔案 + 存儲庫無法訪問 + 啟用存儲庫 + 強迫清理 + 自動更新程式 + 嘗試自動安裝更新 + 安裝 + 重新啟動 LeOS-Droid 查看更變 + 等待開始安裝… + 具有非自由元件 + 無法連線至伺服器 + 伺服器未能提供新封包。 + Shizuku 未在執行中 + Shizuku 未安裝 + 包含不適宜工作環境內容 + 特別感謝 + 首頁畫面滑動 + 允許使用者在首頁畫面之間進行滑動切換 + 複製 + 代理端口只能設定為整數 + 未找到下列儲存庫 + 匯入設定 + 匯入 / 匯出 + 從檔案中匯入設定和我的最愛 + 匯出設定 + 將所有軟體庫匯出至檔案 + 匯入軟體庫 + 將設定和我的最愛匯出至檔案 + 匯出軟體庫 + 從檔案中匯入所有軟體庫 + 無法開啟連結 + \ No newline at end of file diff --git a/core/common/src/main/res/values/colors.xml b/core/common/src/main/res/values/colors.xml new file mode 100644 index 0000000..d452123 --- /dev/null +++ b/core/common/src/main/res/values/colors.xml @@ -0,0 +1,68 @@ + + + + #000000 + #2C2C2C + + #006D3E + #006D3E + #FFFFFF + #5EFF97 + #00210F + #924C00 + #FFFFFF + #FFDCC4 + #2F1400 + #3B6470 + #FFFFFF + #BEEAF7 + #001F26 + #BA1A1A + #FFDAD6 + #FFFFFF + #410002 + #FBFDF8 + #191C1A + #E8F5E9 + #191C1A + #DCE5DB + #414942 + #717971 + #F0F1EC + #2E312E + #7ADA9D + #000000 + #006D3E + #006D3E + + #7ADA9D + #00391E + #00522D + #DAFFE7 + #FFB780 + #4E2600 + #6F3800 + #FFDCC4 + #A3CDDB + #023640 + #214C57 + #BEEAF7 + #FFB4AB + #93000A + #690005 + #FFDAD6 + #191C1A + #E1E3DE + #262B27 + #191C1A + #E1E3DE + #414942 + #C0C9C0 + #8A938B + #191C1A + #E1E3DE + #006D3E + #000000 + #7ADA9D + #7ADA9D + diff --git a/core/common/src/main/res/values/dimen.xml b/core/common/src/main/res/values/dimen.xml new file mode 100644 index 0000000..94de393 --- /dev/null +++ b/core/common/src/main/res/values/dimen.xml @@ -0,0 +1,10 @@ + + + 16dp + 4dp + 8dp + 16dp + 4dp + 8dp + 16dp + \ No newline at end of file diff --git a/core/common/src/main/res/values/strings.xml b/core/common/src/main/res/values/strings.xml new file mode 100644 index 0000000..62e7680 --- /dev/null +++ b/core/common/src/main/res/values/strings.xml @@ -0,0 +1,237 @@ + + + Action failed + Add repository + Address + All applications + All your applications are up to date + Allow Top App Bar to Expand + Allow top app bar to expand and collapse + Already exists + Always + Black + Anti-features + Application + Could not find that application + Author e-mail + Author website + Auto update apps + Try to install updates automatically + Explore + Bug tracker + Cancel + Cannot Open Link + Cannot edit repository since it is syncing right now. + Changelog + Changes + Checking repository… + APK cleanup interval + Period to check and remove downloaded files + Compiled for debugging + Confirmation + Connecting… + Contains non-free media + Could not download %s + Could not sync %s + Could not validate %s + Credits + Dark + + Day + Days + + Delete + Delete the repository? + Description + Details + Donate + Downloaded %s + Downloading + Downloading %s… + Edit repository + Import/Export + Import Settings + Import settings and favourites from file + Export Settings + Export settings and favourites to file + Import Repositories + Import all repositories from file + Export Repositories + Export all repositories to file + Enable the repository + Favourites + Invalid file format. + Fingerprint + Force clean up + Cleans up redundant files + Has advertising + Has non-free dependencies + Has non-free components + Has security vulnerabilities + Home Screen Swiping + Allow user to swipe between pages in home screen + + Hour + Hours + + Invalid server response. + Server failed to provide new packet. + Could not connect to server + HTTP proxy + Ignore all new versions + Ignore this version + Your %1$s (API version %2$d) is not supported. %3$s + The max API version is %d. + The min API version is %d. + Missing features. + This version is older than the one installed on your device. + Uninstall that first. + Your %1$s platform is not supported. + Supported platforms: %2$s. + This version is signed with a different certificate than the one + installed on your device. Uninstall that first. + Incompatible version + Incompatible versions + Show application versions incompatible with the device + Incompatible with %s + Install + Installation Types + Installer + Legacy Installer + Session Installer + Root Installer + Shizuku Installer + Shizuku is not running + Shizuku is not installed + Installed + Installing + Could not check integrity. + Invalid address + Invalid fingerprint format + Invalid metadata. + Invalid permissions. + Invalid signature. + Invalid username format + Unable to perform certain actions. + Copy + Launch + License + %s license + Light + Link copied + Links + List Animations + Show list animation on the main page + Material You + Use material you color theme + Merging %s + Name + Network error + Never + New versions of applications available + + %d application has a new version. + %d applications with new versions. + + No available applications + No installed applications + No description available + You have no internet connection + Could not find any such applications + No proxy + Notify for updates + Show a notification when new versions are available + Number of applications + OK + Only compatible with %s + Only on Wi-Fi + Only on Wi-Fi & Charging + Open %s? + Other + Could not parse the index file. + Password + Password missing + Permissions + +%d more + Processing %1$s… + Project website + Promotes non-free network services + Promotes non-free software + Provided by %s + Proxy + Proxy host + Proxy port + Proxy port can only be a Integer + Proxy type + Recently updated + Repositories + Repository + This repository has not been used yet. Turn it on to view the applications in it. + Following repository was not found + Unsigned. Could not verify the application list. Be careful downloading applications from unsigned repositories. + Repository unreachable + Requires %s + Restart LeOS-Droid to see changes + Silent Install + Allow root permission for silent installs + Save + Saving details… + Screenshots + Search + Select a mirror + Settings + Share + Show more + Show older versions + Signature %s + Signed using an unsafe algorithm + Size + Skip + SOCKS proxy + Sorting order + Source code + Source code no longer available + Special Credits + Suggested + Sync repositories + Sync repositories automatically + Syncing + Syncing %s… + System + Tap to install. + Target + Theme + Themes + Tracks or reports your activity + Uninstall + Unknown + Unknown error. + Unknown: %s + Unsigned + Unstable updates + Suggest installing unstable versions + Unverified + Update + Updates + The upstream source code is not free + Username + Username missing + Index could not be validated. + Version + Version %s + Versions + Waiting to start download… + Waiting to start installation… + What\'s New + Website + Language + Personalization + Show Less + Latest + Explore + Update all + Installed applications + Sort & Filter + New applications + Contains not safe for work content + diff --git a/core/common/src/main/res/values/styles.xml b/core/common/src/main/res/values/styles.xml new file mode 100644 index 0000000..0f3d39e --- /dev/null +++ b/core/common/src/main/res/values/styles.xml @@ -0,0 +1,197 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/common/src/test/java/com/looker/core/common/signature/HashCheckerTest.kt b/core/common/src/test/java/com/looker/core/common/signature/HashCheckerTest.kt new file mode 100644 index 0000000..71f6ed4 --- /dev/null +++ b/core/common/src/test/java/com/looker/core/common/signature/HashCheckerTest.kt @@ -0,0 +1,59 @@ +package com.leos.core.common.signature + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +class HashCheckerTest { + + companion object { + private val sampleFile = HashCheckerTest::class.java.classLoader?.getResource("sample.txt") + private const val md5Value = "ed076287532e86365e841e92bfc50d8c" + private const val sha256Value = + "7f83b1657ff1fc53b92dc18148a1d65dfc2d4b1fa3d677284addd200126d9069" + } + + @Test + fun checkHashClass() { + val sha256 = Hash.SHA256("") + val md5 = Hash.MD5("") + val emptyHash = Hash("", "") + assertFalse(emptyHash.isValid()) + assertFalse(sha256.isValid()) + assertFalse(md5.isValid()) + } + + @Test + fun verifySha256Hash() = runBlocking { + assertNotNull(sampleFile) + val file = File(sampleFile.toURI()) + assertTrue(file.verifyHash(Hash.SHA256(sha256Value))) + } + + @Test + fun calculateSha256Hash() = runBlocking { + assertNotNull(sampleFile) + val file = File(sampleFile.toURI()) + val calculatedSha256 = file.calculateHash("sha256") + assertEquals(calculatedSha256, sha256Value) + } + + @Test + fun verifyMd5Hash() = runBlocking { + assertNotNull(sampleFile) + val file = File(sampleFile.toURI()) + assertTrue(file.verifyHash(Hash.MD5(md5Value))) + } + + @Test + fun calculateMd5Hash() = runBlocking { + assertNotNull(sampleFile) + val file = File(sampleFile.toURI()) + val calculatedMd5 = file.calculateHash("md5") + assertEquals(calculatedMd5, md5Value) + } +} diff --git a/core/common/src/test/resources/sample.txt b/core/common/src/test/resources/sample.txt new file mode 100644 index 0000000..c57eff5 --- /dev/null +++ b/core/common/src/test/resources/sample.txt @@ -0,0 +1 @@ +Hello World! \ No newline at end of file diff --git a/core/data/.gitignore b/core/data/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/core/data/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts new file mode 100644 index 0000000..34574a0 --- /dev/null +++ b/core/data/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + alias(libs.plugins.looker.android.library) + alias(libs.plugins.looker.hilt.work) + alias(libs.plugins.looker.lint) +} + +android { + namespace = "com.leos.core.data" + + buildTypes { + release { + // TODO: Enable once using + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) + } + create("alpha") { + initWith(getByName("debug")) + isMinifyEnabled = true + } + } +} + +dependencies { + modules( + Modules.coreCommon, + Modules.coreDatabase, + Modules.coreDatastore, + Modules.coreDI, + Modules.coreDomain, + Modules.coreNetwork + ) + + implementation(libs.kotlinx.coroutines.android) + implementation(libs.fdroid.index) + implementation(libs.fdroid.download) +} diff --git a/core/data/src/main/AndroidManifest.xml b/core/data/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8c4c982 --- /dev/null +++ b/core/data/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/core/data/src/main/java/com/looker/core/data/di/DataModule.kt b/core/data/src/main/java/com/looker/core/data/di/DataModule.kt new file mode 100644 index 0000000..2c8bdd4 --- /dev/null +++ b/core/data/src/main/java/com/looker/core/data/di/DataModule.kt @@ -0,0 +1,32 @@ +package com.leos.core.data.di + +import com.leos.core.data.fdroid.repository.AppRepository +import com.leos.core.data.fdroid.repository.RepoRepository +import com.leos.core.data.fdroid.repository.offline.OfflineFirstAppRepository +import com.leos.core.data.fdroid.repository.offline.OfflineFirstRepoRepository +import com.leos.core.data.fdroid.sync.IndexDownloader +import com.leos.core.data.fdroid.sync.IndexDownloaderImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface DataModule { + + @Binds + fun bindsAppRepository( + appRepository: OfflineFirstAppRepository + ): AppRepository + + @Binds + fun bindsRepoRepository( + repoRepository: OfflineFirstRepoRepository + ): RepoRepository + + @Binds + fun bindIndexDownloader( + indexDownloader: IndexDownloaderImpl + ): IndexDownloader +} diff --git a/core/data/src/main/java/com/looker/core/data/di/DataModuleSingleton.kt b/core/data/src/main/java/com/looker/core/data/di/DataModuleSingleton.kt new file mode 100644 index 0000000..2ac6196 --- /dev/null +++ b/core/data/src/main/java/com/looker/core/data/di/DataModuleSingleton.kt @@ -0,0 +1,22 @@ +package com.leos.core.data.di + +import com.leos.core.data.fdroid.sync.IndexDownloader +import com.leos.core.data.fdroid.sync.IndexManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.fdroid.index.IndexConverter + +@Module +@InstallIn(SingletonComponent::class) +object DataModuleSingleton { + + @Provides + fun provideIndexManager( + downloader: IndexDownloader + ): IndexManager = IndexManager( + indexDownloader = downloader, + converter = IndexConverter() + ) +} diff --git a/core/data/src/main/java/com/looker/core/data/fdroid/Mapper.kt b/core/data/src/main/java/com/looker/core/data/fdroid/Mapper.kt new file mode 100644 index 0000000..6cd4ca5 --- /dev/null +++ b/core/data/src/main/java/com/looker/core/data/fdroid/Mapper.kt @@ -0,0 +1,132 @@ +package com.leos.core.data.fdroid + +import com.leos.core.database.model.AntiFeatureEntity +import com.leos.core.database.model.AppEntity +import com.leos.core.database.model.CategoryEntity +import com.leos.core.database.model.PackageEntity +import com.leos.core.database.model.PermissionEntity +import com.leos.core.database.model.RepoEntity +import org.fdroid.index.v2.PackageV2 +import org.fdroid.index.v2.PackageVersionV2 +import org.fdroid.index.v2.RepoV2 + +fun PackageV2.toEntity( + packageName: String, + repoId: Long, + allowUnstable: Boolean = false +): AppEntity = + AppEntity( + repoId = repoId, + packageName = packageName, + categories = metadata.categories, + summary = metadata.summary ?: emptyMap(), + description = metadata.description ?: emptyMap(), + changelog = metadata.changelog ?: "", + translation = metadata.translation ?: "", + issueTracker = metadata.issueTracker ?: "", + sourceCode = metadata.sourceCode ?: "", + binaries = "", + name = metadata.name ?: emptyMap(), + authorName = metadata.authorName ?: "", + authorEmail = metadata.authorEmail ?: "", + authorWebSite = metadata.authorWebSite ?: "", + donate = metadata.donate.firstOrNull() ?: "", + liberapayID = metadata.liberapayID ?: "", + liberapay = metadata.liberapay ?: "", + openCollective = metadata.openCollective ?: "", + bitcoin = metadata.bitcoin ?: "", + litecoin = metadata.litecoin ?: "", + flattrID = metadata.flattrID ?: "", + suggestedVersionCode = versions.values.firstOrNull()?.manifest?.versionCode ?: -1, + suggestedVersionName = versions.values.firstOrNull()?.manifest?.versionName ?: "", + license = metadata.license ?: "", + webSite = metadata.webSite ?: "", + added = metadata.added, + icon = metadata.icon?.mapValues { it.value.name } ?: emptyMap(), + lastUpdated = metadata.lastUpdated, + phoneScreenshots = metadata.screenshots?.phone?.mapValues { it.value.map { it.name } } + ?: emptyMap(), + tenInchScreenshots = metadata.screenshots?.tenInch?.mapValues { it.value.map { it.name } } + ?: emptyMap(), + sevenInchScreenshots = metadata.screenshots?.sevenInch + ?.mapValues { it.value.map { it.name } } ?: emptyMap(), + tvScreenshots = metadata.screenshots?.tv?.mapValues { it.value.map { it.name } } + ?: emptyMap(), + wearScreenshots = metadata.screenshots?.wear?.mapValues { it.value.map { it.name } } + ?: emptyMap(), + featureGraphic = metadata.featureGraphic?.mapValues { it.value.name } ?: emptyMap(), + promoGraphic = metadata.promoGraphic?.mapValues { it.value.name } ?: emptyMap(), + tvBanner = metadata.tvBanner?.mapValues { it.value.name } ?: emptyMap(), + video = metadata.video ?: emptyMap(), + packages = versions.values.map(PackageVersionV2::toPackage).checkUnstable( + allowUnstable, + versions.values.firstOrNull()?.manifest?.versionCode ?: -1 + ) + ) + +private fun List.checkUnstable( + allowUnstable: Boolean, + suggestedVersionCode: Long +): List = filter { + allowUnstable || (suggestedVersionCode > 0L && it.versionCode >= suggestedVersionCode) +} + +fun PackageVersionV2.toPackage(): PackageEntity = PackageEntity( + added = added, + hash = file.sha256, + features = manifest.features.map { it.name }, + apkName = file.name, + hashType = "SHA-256", + minSdkVersion = manifest.minSdkVersion ?: -1, + maxSdkVersion = manifest.maxSdkVersion ?: -1, + signer = manifest.signer?.sha256?.firstOrNull() ?: "", + size = file.size ?: -1, + usesPermission = manifest.usesPermission.map { + PermissionEntity(name = it.name, maxSdk = it.maxSdkVersion) + } + manifest.usesPermissionSdk23.map { + PermissionEntity(name = it.name, maxSdk = it.maxSdkVersion, minSdk = 23) + }, + versionCode = manifest.versionCode, + versionName = manifest.versionName, + srcName = src?.name ?: "", + nativeCode = manifest.nativecode, + antiFeatures = antiFeatures.keys.toList(), + targetSdkVersion = manifest.usesSdk?.targetSdkVersion ?: -1, + sig = signer?.sha256?.firstOrNull() ?: "", + whatsNew = whatsNew +) + +fun RepoV2.toEntity( + id: Long, + fingerprint: String, + etag: String, + username: String, + password: String, + enabled: Boolean = true +) = RepoEntity( + id = id, + enabled = enabled, + fingerprint = fingerprint, + mirrors = mirrors.map { it.url }, + address = address, + name = name, + description = description, + timestamp = timestamp, + etag = etag, + username = username, + password = password, + antiFeatures = antiFeatures.mapValues { + AntiFeatureEntity( + name = it.value.name, + icon = it.value.icon.mapValues { it.value.name }, + description = it.value.description + ) + }, + categories = categories.mapValues { + CategoryEntity( + name = it.value.name, + icon = it.value.icon.mapValues { it.value.name }, + description = it.value.description + ) + } +) diff --git a/core/data/src/main/java/com/looker/core/data/fdroid/repository/AppRepository.kt b/core/data/src/main/java/com/looker/core/data/fdroid/repository/AppRepository.kt new file mode 100644 index 0000000..62af12a --- /dev/null +++ b/core/data/src/main/java/com/looker/core/data/fdroid/repository/AppRepository.kt @@ -0,0 +1,24 @@ +package com.leos.core.data.fdroid.repository + +import com.leos.core.common.PackageName +import com.leos.core.domain.newer.App +import com.leos.core.domain.newer.Author +import com.leos.core.domain.newer.Package +import kotlinx.coroutines.flow.Flow + +interface AppRepository { + + fun getApps(): Flow> + + fun getApp(packageName: PackageName): Flow> + + fun getAppFromAuthor(author: Author): Flow> + + fun getPackages(packageName: PackageName): Flow> + + /** + * returns true is the app is added successfully + * returns false if the app was already in the favourites and so it is removed + */ + suspend fun addToFavourite(packageName: PackageName): Boolean +} diff --git a/core/data/src/main/java/com/looker/core/data/fdroid/repository/RepoRepository.kt b/core/data/src/main/java/com/looker/core/data/fdroid/repository/RepoRepository.kt new file mode 100644 index 0000000..f875e6f --- /dev/null +++ b/core/data/src/main/java/com/looker/core/data/fdroid/repository/RepoRepository.kt @@ -0,0 +1,19 @@ +package com.leos.core.data.fdroid.repository + +import com.leos.core.domain.newer.Repo +import kotlinx.coroutines.flow.Flow + +interface RepoRepository { + + suspend fun getRepo(id: Long): Repo + + fun getRepos(): Flow> + + suspend fun updateRepo(repo: Repo) + + suspend fun enableRepository(repo: Repo, enable: Boolean) + + suspend fun sync(repo: Repo): Boolean + + suspend fun syncAll(): Boolean +} diff --git a/core/data/src/main/java/com/looker/core/data/fdroid/repository/offline/OfflineFirstAppRepository.kt b/core/data/src/main/java/com/looker/core/data/fdroid/repository/offline/OfflineFirstAppRepository.kt new file mode 100644 index 0000000..415a124 --- /dev/null +++ b/core/data/src/main/java/com/looker/core/data/fdroid/repository/offline/OfflineFirstAppRepository.kt @@ -0,0 +1,85 @@ +package com.leos.core.data.fdroid.repository.offline + +import com.leos.core.common.PackageName +import com.leos.core.data.fdroid.repository.AppRepository +import com.leos.core.database.dao.AppDao +import com.leos.core.database.dao.InstalledDao +import com.leos.core.database.model.AppEntity +import com.leos.core.database.model.InstalledEntity +import com.leos.core.database.model.PackageEntity +import com.leos.core.database.model.toExternal +import com.leos.core.datastore.SettingsRepository +import com.leos.core.datastore.get +import com.leos.core.domain.newer.App +import com.leos.core.domain.newer.Author +import com.leos.core.domain.newer.Package +import javax.inject.Inject +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +class OfflineFirstAppRepository @Inject constructor( + installedDao: InstalledDao, + private val appDao: AppDao, + private val settingsRepository: SettingsRepository +) : AppRepository { + + private val localePreference = settingsRepository.get { language } + + private val installedFlow = installedDao.getInstalledStream() + + override fun getApps(): Flow> = + appDao.getAppStream().localizedAppList(localePreference, installedFlow) + + override fun getApp(packageName: PackageName): Flow> = + appDao.getApp(packageName.name).localizedAppList(localePreference, installedFlow) + + override fun getAppFromAuthor(author: Author): Flow> = + appDao.getAppsFromAuthor(author.name).localizedAppList(localePreference, installedFlow) + + override fun getPackages(packageName: PackageName): Flow> = + appDao.getPackages(packageName.name) + .localizedPackages(packageName, localePreference, installedFlow) + + override suspend fun addToFavourite(packageName: PackageName): Boolean = coroutineScope { + val isFavourite = + async { + settingsRepository + .getInitial() + .favouriteApps + .any { it == packageName.name } + } + launch { + settingsRepository.toggleFavourites(packageName.name) + } + !isFavourite.await() + } +} + +private fun Flow>.localizedAppList( + preference: Flow, + installedFlow: Flow> +): Flow> = + combine(this, preference, installedFlow) { appsList, locale, installedList -> + appsList.toExternal(locale) { + it.findInstalled(installedList) + } + } + +private fun Flow>.localizedPackages( + packageName: PackageName, + preference: Flow, + installedFlow: Flow> +): Flow> = + combine(this, preference, installedFlow) { packagesList, locale, installedList -> + packagesList.toExternal(locale) { + InstalledEntity(packageName.name, it.versionCode, it.sig) in installedList + } + } + +private fun AppEntity.findInstalled(list: List): PackageEntity? = + packages.find { + InstalledEntity(packageName, it.versionCode, it.sig) in list + } diff --git a/core/data/src/main/java/com/looker/core/data/fdroid/repository/offline/OfflineFirstRepoRepository.kt b/core/data/src/main/java/com/looker/core/data/fdroid/repository/offline/OfflineFirstRepoRepository.kt new file mode 100644 index 0000000..3d2aa88 --- /dev/null +++ b/core/data/src/main/java/com/looker/core/data/fdroid/repository/offline/OfflineFirstRepoRepository.kt @@ -0,0 +1,115 @@ +package com.leos.core.data.fdroid.repository.offline + +import com.leos.core.common.extension.exceptCancellation +import com.leos.core.data.fdroid.repository.RepoRepository +import com.leos.core.data.fdroid.sync.IndexManager +import com.leos.core.data.fdroid.toEntity +import com.leos.core.database.dao.AppDao +import com.leos.core.database.dao.RepoDao +import com.leos.core.database.model.toExternal +import com.leos.core.database.model.update +import com.leos.core.datastore.SettingsRepository +import com.leos.core.di.ApplicationScope +import com.leos.core.di.DefaultDispatcher +import com.leos.core.domain.newer.Repo +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext + +class OfflineFirstRepoRepository @Inject constructor( + private val appDao: AppDao, + private val repoDao: RepoDao, + private val settingsRepository: SettingsRepository, + private val indexManager: IndexManager, + @DefaultDispatcher private val dispatcher: CoroutineDispatcher, + @ApplicationScope private val scope: CoroutineScope +) : RepoRepository { + + private val preference = runBlocking { + settingsRepository.getInitial() + } + + private val locale = preference.language + + override suspend fun getRepo(id: Long): Repo = withContext(dispatcher) { + repoDao.getRepoById(id).toExternal(locale) + } + + override fun getRepos(): Flow> = + repoDao.getRepoStream().map { it.toExternal(locale) } + + override suspend fun updateRepo(repo: Repo) { + scope.launch { + val entity = repoDao.getRepoById(repo.id) + repoDao.upsertRepo(entity.update(repo)) + } + } + + override suspend fun enableRepository(repo: Repo, enable: Boolean) { + scope.launch { + val entity = repoDao.getRepoById(repo.id) + repoDao.upsertRepo(entity.copy(enabled = enable)) + if (enable) sync(repo) + } + } + + override suspend fun sync(repo: Repo): Boolean = coroutineScope { + val index = try { + indexManager.getIndex(listOf(repo))[repo] ?: throw Exception("Empty index returned") + } catch (e: Exception) { + e.exceptCancellation() + return@coroutineScope false + } + val updatedRepo = index.repo.toEntity( + id = repo.id, + fingerprint = repo.fingerprint, + username = repo.authentication.username, + password = repo.authentication.password, + etag = repo.versionInfo.etag ?: "", + enabled = true + ) + repoDao.upsertRepo(updatedRepo) + val apps = index.packages.map { + it.value.toEntity(it.key, repo.id, preference.unstableUpdate) + } + appDao.upsertApps(apps) + true + } + + override suspend fun syncAll(): Boolean = supervisorScope { + val repos = repoDao.getRepoStream().first().filter { it.enabled } + val indices = try { + indexManager + .getIndex(repos.toExternal(locale)) + .filter { (_, index) -> index != null } + } catch (e: Exception) { + e.exceptCancellation() + return@supervisorScope false + } + if (indices.isEmpty()) return@supervisorScope true + indices.forEach { (repo, index) -> + val updatedRepo = index!!.repo.toEntity( + id = repo.id, + fingerprint = repo.fingerprint, + username = repo.authentication.username, + password = repo.authentication.password, + etag = repo.versionInfo.etag ?: "", + enabled = true + ) + repoDao.upsertRepo(updatedRepo) + val apps = index.packages.map { + it.value.toEntity(it.key, repo.id, preference.unstableUpdate) + } + appDao.upsertApps(apps) + } + true + } +} diff --git a/core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexDownloader.kt b/core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexDownloader.kt new file mode 100644 index 0000000..f3f4ec5 --- /dev/null +++ b/core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexDownloader.kt @@ -0,0 +1,33 @@ +package com.leos.core.data.fdroid.sync + +import com.leos.core.domain.newer.Repo +import org.fdroid.index.v1.IndexV1 +import org.fdroid.index.v2.Entry +import org.fdroid.index.v2.IndexV2 + +interface IndexDownloader { + + suspend fun downloadIndexV1(repo: Repo): IndexDownloadResponse + + suspend fun downloadIndexV2(repo: Repo): IndexV2 + + suspend fun downloadIndexDiff(repo: Repo, name: String): IndexV2 + + suspend fun downloadEntry(repo: Repo): IndexDownloadResponse + + suspend fun determineIndexType(repo: Repo): IndexType +} + +data class IndexDownloadResponse( + val index: T, + val fingerprint: String, + val lastModified: Long?, + val etag: String? +) + +fun Repo.indexUrl(parameter: String): String = + buildString { + append(address.removeSuffix("/")) + append("/") + append(parameter.removePrefix("/")) + } diff --git a/core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexDownloaderImpl.kt b/core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexDownloaderImpl.kt new file mode 100644 index 0000000..4fa3671 --- /dev/null +++ b/core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexDownloaderImpl.kt @@ -0,0 +1,134 @@ +package com.leos.core.data.fdroid.sync + +import com.leos.core.common.signature.FileValidator +import com.leos.core.data.fdroid.sync.signature.EntryValidator +import com.leos.core.data.fdroid.sync.signature.IndexValidator +import com.leos.core.domain.newer.Repo +import com.leos.network.Downloader +import com.leos.network.NetworkResponse +import java.io.File +import java.io.InputStream +import java.util.Date +import java.util.UUID +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.fdroid.index.IndexParser +import org.fdroid.index.parseV2 +import org.fdroid.index.v1.IndexV1 +import org.fdroid.index.v2.Entry +import org.fdroid.index.v2.IndexV2 + +class IndexDownloaderImpl @Inject constructor( + private val downloader: Downloader +) : IndexDownloader { + + companion object { + private val parser = IndexParser + + private fun InputStream.parseIndexV2(): IndexV2 = parser.parseV2(this) + + private const val INDEX_V1_FILE_NAME = "index-v1.jar" + private const val INDEX_V2_FILE_NAME = "index-v2.json" + private const val ENTRY_FILE_NAME = "entry.jar" + } + + override suspend fun downloadIndexV1( + repo: Repo + ): IndexDownloadResponse = withContext(Dispatchers.IO) { + var repoFingerprint: String? = null + var fileIndex: IndexV1? = null + val validator = IndexValidator(repo) { index, fingerprint -> + repoFingerprint = fingerprint + fileIndex = index + } + val (_, response) = downloadIndexFile(repo, INDEX_V1_FILE_NAME, validator) + val isFingerprintAndIndexValid = repoFingerprint == null || + fileIndex == null || + repoFingerprint?.isBlank() == true || + response is NetworkResponse.Error + if (isFingerprintAndIndexValid) { + throw IllegalStateException("Fingerprint: $repoFingerprint, Index: $fileIndex") + } + IndexDownloadResponse( + index = fileIndex!!, + fingerprint = repoFingerprint!!, + lastModified = fileIndex?.repo?.timestamp, + etag = (response as NetworkResponse.Success).etag + ) + } + + override suspend fun downloadIndexV2( + repo: Repo + ): IndexV2 = withContext(Dispatchers.Default) { + val (file, _) = downloadIndexFile(repo, INDEX_V2_FILE_NAME) + file.inputStream().parseIndexV2() + } + + override suspend fun downloadIndexDiff( + repo: Repo, + name: String + ): IndexV2 = withContext(Dispatchers.Default) { + val (file, _) = downloadIndexFile(repo, name) + file.inputStream().parseIndexV2() + } + + override suspend fun downloadEntry( + repo: Repo + ): IndexDownloadResponse = withContext(Dispatchers.IO) { + var repoFingerprint: String? = null + var fileEntry: Entry? = null + val validator = EntryValidator(repo) { entry, fingerprint -> + repoFingerprint = fingerprint + fileEntry = entry + } + val (_, response) = downloadIndexFile(repo, ENTRY_FILE_NAME, validator) + val isFingerprintAndIndexValid = repoFingerprint == null || + fileEntry == null || + repoFingerprint?.isBlank() == true || + response is NetworkResponse.Error.Validation + require(isFingerprintAndIndexValid) { "Empty Fingerprint" } + IndexDownloadResponse( + index = fileEntry!!, + fingerprint = repoFingerprint!!, + lastModified = fileEntry?.timestamp, + etag = (response as NetworkResponse.Success).etag + ) + } + + override suspend fun determineIndexType(repo: Repo): IndexType { + val isIndexV2 = downloader.headCall(repo.indexUrl(ENTRY_FILE_NAME)) + return if (isIndexV2 is NetworkResponse.Success) { + IndexType.ENTRY + } else { + IndexType.INDEX_V1 + } + } + + private suspend fun downloadIndexFile( + repo: Repo, + indexParameter: String, + validator: FileValidator? = null + ): Pair = withContext(Dispatchers.IO) { + val tempFile = File.createTempFile(repo.name, UUID.randomUUID().toString()) + val response = downloader.downloadToFile( + url = repo.indexUrl(indexParameter), + target = tempFile, + validator = validator, + headers = { + if (repo.shouldAuthenticate) { + authentication( + repo.authentication.username, + repo.authentication.password + ) + } + if (repo.versionInfo.timestamp > 0L) { + ifModifiedSince( + Date(repo.versionInfo.timestamp) + ) + } + } + ) + tempFile to response + } +} diff --git a/core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexManager.kt b/core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexManager.kt new file mode 100644 index 0000000..2095092 --- /dev/null +++ b/core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexManager.kt @@ -0,0 +1,52 @@ +package com.leos.core.data.fdroid.sync + +import com.leos.core.domain.newer.Repo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.fdroid.index.IndexConverter +import org.fdroid.index.v2.EntryFileV2 +import org.fdroid.index.v2.IndexV2 + +class IndexManager( + private val indexDownloader: IndexDownloader, + private val converter: IndexConverter +) { + + suspend fun getIndex( + repos: List + ): Map = withContext(Dispatchers.Default) { + repos.associate { repo -> + when (indexDownloader.determineIndexType(repo)) { + IndexType.INDEX_V1 -> { + val response = indexDownloader.downloadIndexV1(repo) + repo.update( + fingerprint = response.fingerprint, + timestamp = response.lastModified, + etag = response.etag + ) to converter.toIndexV2(response.index) + } + + IndexType.ENTRY -> { + val response = indexDownloader.downloadEntry(repo) + val updatedRepo = repo.update( + fingerprint = response.fingerprint, + timestamp = response.lastModified, + etag = response.etag + ) + if (response.lastModified == repo.versionInfo.timestamp) { + return@associate updatedRepo to null + } + val diff = response.index.getDiff(repo.versionInfo.timestamp) + updatedRepo to downloadIndexBasedOnDiff(repo, diff) + } + } + } + } + + private suspend fun downloadIndexBasedOnDiff(repo: Repo, diff: EntryFileV2?): IndexV2 = + if (diff == null) { + indexDownloader.downloadIndexV2(repo) + } else { + indexDownloader.downloadIndexDiff(repo, diff.name) + } +} diff --git a/core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexType.kt b/core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexType.kt new file mode 100644 index 0000000..a1b225f --- /dev/null +++ b/core/data/src/main/java/com/looker/core/data/fdroid/sync/IndexType.kt @@ -0,0 +1,6 @@ +package com.leos.core.data.fdroid.sync + +enum class IndexType { + INDEX_V1, + ENTRY +} diff --git a/core/data/src/main/java/com/looker/core/data/fdroid/sync/signature/EntryValidator.kt b/core/data/src/main/java/com/looker/core/data/fdroid/sync/signature/EntryValidator.kt new file mode 100644 index 0000000..e44d9cd --- /dev/null +++ b/core/data/src/main/java/com/looker/core/data/fdroid/sync/signature/EntryValidator.kt @@ -0,0 +1,55 @@ +package com.leos.core.data.fdroid.sync.signature + +import com.leos.core.common.extension.certificate +import com.leos.core.common.extension.codeSigner +import com.leos.core.common.extension.fingerprint +import com.leos.core.common.extension.toJarFile +import com.leos.core.common.signature.FileValidator +import com.leos.core.common.signature.ValidationException +import com.leos.core.domain.newer.Repo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.fdroid.index.IndexParser +import org.fdroid.index.parseEntry +import org.fdroid.index.v2.Entry +import java.io.File + +class EntryValidator( + private val repo: Repo, + private val fingerprintBlock: (Entry, String) -> Unit +) : FileValidator { + override suspend fun validate(file: File) = withContext(Dispatchers.IO) { + val (entry, fingerprint) = getEntryAndFingerprint(file) + if (repo.fingerprint.isNotBlank() && + !repo.fingerprint.equals(fingerprint, ignoreCase = true) + ) { + throw ValidationException( + "Expected Fingerprint: ${repo.fingerprint}, Acquired Fingerprint: $fingerprint" + ) + } + fingerprintBlock(entry, fingerprint) + } + + companion object { + const val JSON_NAME = "entry.json" + } + + private suspend fun getEntryAndFingerprint( + file: File + ): Pair = withContext(Dispatchers.IO) { + val jar = file.toJarFile() + val jarEntry = requireNotNull(jar.getJarEntry(JSON_NAME)) { + "No entry for: $JSON_NAME" + } + + val entry = jar + .getInputStream(jarEntry) + .use(IndexParser::parseEntry) + + val fingerprint = jarEntry + .codeSigner + .certificate + .fingerprint() + entry to fingerprint + } +} diff --git a/core/data/src/main/java/com/looker/core/data/fdroid/sync/signature/IndexValidator.kt b/core/data/src/main/java/com/looker/core/data/fdroid/sync/signature/IndexValidator.kt new file mode 100644 index 0000000..1a4ffcd --- /dev/null +++ b/core/data/src/main/java/com/looker/core/data/fdroid/sync/signature/IndexValidator.kt @@ -0,0 +1,55 @@ +package com.leos.core.data.fdroid.sync.signature + +import com.leos.core.common.extension.certificate +import com.leos.core.common.extension.codeSigner +import com.leos.core.common.extension.fingerprint +import com.leos.core.common.extension.toJarFile +import com.leos.core.common.signature.FileValidator +import com.leos.core.common.signature.ValidationException +import com.leos.core.domain.newer.Repo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.fdroid.index.IndexParser +import org.fdroid.index.parseV1 +import org.fdroid.index.v1.IndexV1 +import java.io.File + +class IndexValidator( + private val repo: Repo, + private val fingerprintBlock: (IndexV1, String) -> Unit +) : FileValidator { + override suspend fun validate(file: File) = withContext(Dispatchers.IO) { + val (index, fingerprint) = getIndexAndFingerprint(file) + if (repo.fingerprint.isNotBlank() && + !repo.fingerprint.equals(fingerprint, ignoreCase = true) + ) { + throw ValidationException( + "Expected Fingerprint: ${repo.fingerprint}, Acquired Fingerprint: $fingerprint" + ) + } + fingerprintBlock(index, fingerprint) + } + + companion object { + const val JSON_NAME = "index-v1.json" + } + + private suspend fun getIndexAndFingerprint( + file: File + ): Pair = withContext(Dispatchers.IO) { + val jar = file.toJarFile() + val jarEntry = requireNotNull(jar.getJarEntry(JSON_NAME)) { + "No entry for: $JSON_NAME" + } + + val entry = jar + .getInputStream(jarEntry) + .use(IndexParser::parseV1) + + val fingerprint = jarEntry + .codeSigner + .certificate + .fingerprint() + entry to fingerprint + } +} diff --git a/core/data/src/main/java/com/looker/core/data/fdroid/sync/workers/DelegatingWorker.kt b/core/data/src/main/java/com/looker/core/data/fdroid/sync/workers/DelegatingWorker.kt new file mode 100644 index 0000000..0852843 --- /dev/null +++ b/core/data/src/main/java/com/looker/core/data/fdroid/sync/workers/DelegatingWorker.kt @@ -0,0 +1,48 @@ +package com.leos.core.data.fdroid.sync.workers + +import android.content.Context +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import kotlin.reflect.KClass + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface HiltWorkerFactoryEntryPoint { + fun hiltWorkerFactory(): HiltWorkerFactory +} + +private const val WORKER_CLASS_NAME = "RouterWorkerDelegateClassName" + +internal fun KClass.delegatedData() = + Data.Builder() + .putString(WORKER_CLASS_NAME, qualifiedName) + .build() + +internal class DelegatingWorker( + appContext: Context, + workerParams: WorkerParameters +) : CoroutineWorker(appContext, workerParams) { + + private val workerClassName = + workerParams.inputData.getString(WORKER_CLASS_NAME) ?: "" + + private val delegateWorker = + EntryPointAccessors.fromApplication(appContext) + .hiltWorkerFactory() + .createWorker(appContext, workerClassName, workerParams) + as? CoroutineWorker + ?: throw IllegalArgumentException("Unable to find appropriate worker") + + override suspend fun getForegroundInfo(): ForegroundInfo = + delegateWorker.getForegroundInfo() + + override suspend fun doWork(): Result = + delegateWorker.doWork() +} diff --git a/core/data/src/main/java/com/looker/core/data/fdroid/sync/workers/SyncWorkHelper.kt b/core/data/src/main/java/com/looker/core/data/fdroid/sync/workers/SyncWorkHelper.kt new file mode 100644 index 0000000..c2b43e7 --- /dev/null +++ b/core/data/src/main/java/com/looker/core/data/fdroid/sync/workers/SyncWorkHelper.kt @@ -0,0 +1,42 @@ +package com.leos.core.data.fdroid.sync.workers + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.work.ForegroundInfo +import com.leos.core.common.R as CommonR +import com.leos.core.common.SdkCheck +import com.leos.core.common.extension.notificationManager + +private const val SyncNotificationID = 12 +private const val SyncNotificationChannelID = "SyncNotificationChannelID" + +fun Context.syncForegroundInfo() = ForegroundInfo( + SyncNotificationID, + syncWorkNotification() +) + +private fun Context.syncWorkNotification(): Notification { + if (SdkCheck.isOreo) { + val channel = NotificationChannel( + SyncNotificationChannelID, + getString(CommonR.string.sync_repositories), + NotificationManager.IMPORTANCE_LOW + ).apply { + description = getString(CommonR.string.sync_repositories) + } + // Register the channel with the system + notificationManager?.createNotificationChannel(channel) + } + + return NotificationCompat.Builder( + this, + SyncNotificationChannelID + ) + .setSmallIcon(CommonR.drawable.ic_sync) + .setContentTitle(getString(CommonR.string.syncing)) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() +} diff --git a/core/data/src/main/java/com/looker/core/data/fdroid/sync/workers/SyncWorker.kt b/core/data/src/main/java/com/looker/core/data/fdroid/sync/workers/SyncWorker.kt new file mode 100644 index 0000000..47c38c0 --- /dev/null +++ b/core/data/src/main/java/com/looker/core/data/fdroid/sync/workers/SyncWorker.kt @@ -0,0 +1,81 @@ +package com.leos.core.data.fdroid.sync.workers + +import android.content.Context +import android.util.Log +import androidx.hilt.work.HiltWorker +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.leos.core.data.fdroid.repository.RepoRepository +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@HiltWorker +class SyncWorker @AssistedInject constructor( + @Assisted private val appContext: Context, + @Assisted workParams: WorkerParameters, + private val repoRepository: RepoRepository +) : CoroutineWorker(appContext, workParams) { + + override suspend fun getForegroundInfo(): ForegroundInfo = + appContext.syncForegroundInfo() + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + Log.i(SYNC_WORK, "Start Sync") + setForegroundAsync(appContext.syncForegroundInfo()) + val isSuccess = try { + repoRepository.syncAll() + } catch (e: Exception) { + e.printStackTrace() + return@withContext Result.failure() + } + if (isSuccess) Result.success() else Result.failure() + } + + companion object { + private const val SYNC_WORK = "sync_work" + + fun cancelSyncWork(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(SYNC_WORK) + } + + fun scheduleSyncWork(context: Context, constraints: Constraints) { + WorkManager.getInstance(context).apply { + val work = PeriodicWorkRequestBuilder(12L, TimeUnit.HOURS) + .setConstraints(constraints) + .setInputData(SyncWorker::class.delegatedData()) + .build() + enqueueUniquePeriodicWork(SYNC_WORK, ExistingPeriodicWorkPolicy.REPLACE, work) + } + } + + fun startSyncWork(context: Context) { + WorkManager.getInstance(context).apply { + val netRequired = Constraints( + requiredNetworkType = NetworkType.CONNECTED + ) + val work = OneTimeWorkRequestBuilder() + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .setConstraints(netRequired) + .setInputData(SyncWorker::class.delegatedData()) + .build() + beginUniqueWork( + SYNC_WORK, + ExistingWorkPolicy.REPLACE, + work + ).enqueue() + } + } + } +} diff --git a/core/data/src/main/java/com/looker/core/data/utils/ConnectivityManagerNetworkMonitor.kt b/core/data/src/main/java/com/looker/core/data/utils/ConnectivityManagerNetworkMonitor.kt new file mode 100644 index 0000000..294f995 --- /dev/null +++ b/core/data/src/main/java/com/looker/core/data/utils/ConnectivityManagerNetworkMonitor.kt @@ -0,0 +1,55 @@ +package com.leos.core.data.utils + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import com.leos.core.common.extension.connectivityManager +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.conflate + +class ConnectivityManagerNetworkMonitor +@Inject constructor( + @ApplicationContext context: Context +) : NetworkMonitor { + override val isOnline: Flow = callbackFlow { + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + channel.trySend(true) + } + + override fun onLost(network: Network) { + channel.trySend(false) + } + } + + val connectivityManager = context.connectivityManager + + connectivityManager?.registerNetworkCallback( + NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build(), + callback + ) + + channel.trySend(connectivityManager.isCurrentlyConnected()) + + awaitClose { + connectivityManager?.unregisterNetworkCallback(callback) + } + }.conflate() + + private fun ConnectivityManager?.isCurrentlyConnected() = when (this) { + null -> false + else -> + activeNetwork + ?.let(::getNetworkCapabilities) + ?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + ?: false + } +} diff --git a/core/data/src/main/java/com/looker/core/data/utils/NetworkMonitor.kt b/core/data/src/main/java/com/looker/core/data/utils/NetworkMonitor.kt new file mode 100644 index 0000000..e6f4363 --- /dev/null +++ b/core/data/src/main/java/com/looker/core/data/utils/NetworkMonitor.kt @@ -0,0 +1,7 @@ +package com.leos.core.data.utils + +import kotlinx.coroutines.flow.Flow + +interface NetworkMonitor { + val isOnline: Flow +} diff --git a/core/data/src/main/java/com/looker/core/data/utils/SyncStatusMonitor.kt b/core/data/src/main/java/com/looker/core/data/utils/SyncStatusMonitor.kt new file mode 100644 index 0000000..1dc44ed --- /dev/null +++ b/core/data/src/main/java/com/looker/core/data/utils/SyncStatusMonitor.kt @@ -0,0 +1,7 @@ +package com.leos.core.data.utils + +import kotlinx.coroutines.flow.Flow + +interface SyncStatusMonitor { + val isSyncing: Flow +} diff --git a/core/database/.gitignore b/core/database/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/core/database/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts new file mode 100644 index 0000000..86b204e --- /dev/null +++ b/core/database/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + alias(libs.plugins.looker.android.library) + alias(libs.plugins.looker.room) + alias(libs.plugins.looker.hilt) + alias(libs.plugins.looker.serialization) +} + +android { + namespace = "com.leos.core.database" + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) + } + create("alpha") { + initWith(getByName("debug")) + isMinifyEnabled = true + } + } +} + +dependencies { + modules(Modules.coreCommon, Modules.coreDomain) + + implementation(libs.kotlinx.coroutines.android) + implementation(libs.androidx.core.ktx) +} diff --git a/core/database/schemas/com.looker.core.database.DroidifyDatabase/1.json b/core/database/schemas/com.looker.core.database.DroidifyDatabase/1.json new file mode 100644 index 0000000..c5e1c26 --- /dev/null +++ b/core/database/schemas/com.looker.core.database.DroidifyDatabase/1.json @@ -0,0 +1,381 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "b01a8fae755b8b96d36459e885dea04b", + "entities": [ + { + "tableName": "apps", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `repoId` INTEGER NOT NULL, `categories` BLOB NOT NULL, `summary` TEXT NOT NULL, `description` TEXT NOT NULL, `changelog` TEXT NOT NULL, `translation` TEXT NOT NULL, `issueTracker` TEXT NOT NULL, `sourceCode` TEXT NOT NULL, `binaries` TEXT NOT NULL, `name` TEXT NOT NULL, `authorName` TEXT NOT NULL, `authorEmail` TEXT NOT NULL, `authorWebSite` TEXT NOT NULL, `donate` TEXT NOT NULL, `liberapayID` TEXT NOT NULL, `liberapay` TEXT NOT NULL, `openCollective` TEXT NOT NULL, `bitcoin` TEXT NOT NULL, `litecoin` TEXT NOT NULL, `flattrID` TEXT NOT NULL, `suggestedVersionName` TEXT NOT NULL, `suggestedVersionCode` INTEGER NOT NULL, `license` TEXT NOT NULL, `webSite` TEXT NOT NULL, `added` INTEGER NOT NULL, `icon` TEXT NOT NULL, `phoneScreenshots` TEXT NOT NULL, `sevenInchScreenshots` TEXT NOT NULL, `tenInchScreenshots` TEXT NOT NULL, `wearScreenshots` TEXT NOT NULL, `tvScreenshots` TEXT NOT NULL, `featureGraphic` TEXT NOT NULL, `promoGraphic` TEXT NOT NULL, `tvBanner` TEXT NOT NULL, `video` TEXT NOT NULL, `lastUpdated` INTEGER NOT NULL, `packages` TEXT NOT NULL, PRIMARY KEY(`repoId`, `packageName`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "categories", + "columnName": "categories", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "summary", + "columnName": "summary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changelog", + "columnName": "changelog", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translation", + "columnName": "translation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "issueTracker", + "columnName": "issueTracker", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceCode", + "columnName": "sourceCode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "binaries", + "columnName": "binaries", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authorWebSite", + "columnName": "authorWebSite", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "donate", + "columnName": "donate", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "liberapayID", + "columnName": "liberapayID", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "liberapay", + "columnName": "liberapay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "openCollective", + "columnName": "openCollective", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bitcoin", + "columnName": "bitcoin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "litecoin", + "columnName": "litecoin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flattrID", + "columnName": "flattrID", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "suggestedVersionName", + "columnName": "suggestedVersionName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "suggestedVersionCode", + "columnName": "suggestedVersionCode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "webSite", + "columnName": "webSite", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "added", + "columnName": "added", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneScreenshots", + "columnName": "phoneScreenshots", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sevenInchScreenshots", + "columnName": "sevenInchScreenshots", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tenInchScreenshots", + "columnName": "tenInchScreenshots", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wearScreenshots", + "columnName": "wearScreenshots", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tvScreenshots", + "columnName": "tvScreenshots", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureGraphic", + "columnName": "featureGraphic", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "promoGraphic", + "columnName": "promoGraphic", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tvBanner", + "columnName": "tvBanner", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "video", + "columnName": "video", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packages", + "columnName": "packages", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId", + "packageName" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "repos", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `enabled` INTEGER NOT NULL, `fingerprint` TEXT NOT NULL, `etag` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `address` TEXT NOT NULL, `mirrors` BLOB NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `antiFeatures` TEXT NOT NULL, `categories` TEXT NOT NULL, `timestamp` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mirrors", + "columnName": "mirrors", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "antiFeatures", + "columnName": "antiFeatures", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categories", + "columnName": "categories", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "InstalledEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `versionCode` INTEGER NOT NULL, `signature` TEXT NOT NULL, PRIMARY KEY(`packageName`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionCode", + "columnName": "versionCode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "packageName" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b01a8fae755b8b96d36459e885dea04b')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/main/assets/repo.db b/core/database/src/main/assets/repo.db new file mode 100644 index 0000000..584449a Binary files /dev/null and b/core/database/src/main/assets/repo.db differ diff --git a/core/database/src/main/java/com/looker/core/database/Converters.kt b/core/database/src/main/java/com/looker/core/database/Converters.kt new file mode 100644 index 0000000..7140b99 --- /dev/null +++ b/core/database/src/main/java/com/looker/core/database/Converters.kt @@ -0,0 +1,94 @@ +package com.leos.core.database + +import androidx.room.TypeConverter +import com.leos.core.database.model.AntiFeatureEntity +import com.leos.core.database.model.CategoryEntity +import com.leos.core.database.model.LocalizedList +import com.leos.core.database.model.LocalizedString +import com.leos.core.database.model.PackageEntity +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true +} + +internal const val STRING_DELIMITER = "!@#$%^&*" +private val stringListSerializer = ListSerializer(String.serializer()) +private val localizedStringSerializer = MapSerializer(String.serializer(), String.serializer()) +private val localizedListSerializer = MapSerializer(String.serializer(), stringListSerializer) +private val antiFeatureSerializer = + MapSerializer(String.serializer(), AntiFeatureEntity.serializer()) +private val categorySerializer = MapSerializer(String.serializer(), CategoryEntity.serializer()) +private val packageListSerializer = ListSerializer(PackageEntity.serializer()) + +class CollectionConverter { + + @TypeConverter + fun listToString(list: List): ByteArray = + list.joinToString(STRING_DELIMITER).toByteArray() + + @TypeConverter + fun stringToList(byteArray: ByteArray): List = String(byteArray).split(STRING_DELIMITER) +} + +class LocalizedConverter { + + @TypeConverter + fun localizedStringToJson(localizedEntity: LocalizedString): String = + json.encodeToString(localizedStringSerializer, localizedEntity) + + @TypeConverter + fun jsonToLocalizedString(jsonObject: String): LocalizedString = + json.decodeFromString(localizedStringSerializer, jsonObject) + + @TypeConverter + fun localizedListToJson(localizedEntity: LocalizedList): String = + json.encodeToString(localizedListSerializer, localizedEntity) + + @TypeConverter + fun jsonToLocalizedList(jsonObject: String): LocalizedList = + json.decodeFromString(localizedListSerializer, jsonObject) +} + +class PackageEntityConverter { + + @TypeConverter + fun entityToString(packageEntity: PackageEntity): String = + json.encodeToString(packageEntity) + + @TypeConverter + fun stringToPackage(jsonString: String): PackageEntity = + json.decodeFromString(jsonString) + + @TypeConverter + fun entityListToString(packageEntity: List): String = + json.encodeToString(packageListSerializer, packageEntity) + + @TypeConverter + fun stringToPackageList(jsonString: String): List = + json.decodeFromString(packageListSerializer, jsonString) +} + +class RepoConverter { + + @TypeConverter + fun antiFeaturesToString(map: Map): String = + json.encodeToString(antiFeatureSerializer, map) + + @TypeConverter + fun stringToAntiFeatures(string: String): Map = + json.decodeFromString(antiFeatureSerializer, string) + + @TypeConverter + fun categoryToString(map: Map): String = + json.encodeToString(categorySerializer, map) + + @TypeConverter + fun stringToCategory(string: String): Map = + json.decodeFromString(categorySerializer, string) +} diff --git a/core/database/src/main/java/com/looker/core/database/DroidifyDatabase.kt b/core/database/src/main/java/com/looker/core/database/DroidifyDatabase.kt new file mode 100644 index 0000000..4135a0c --- /dev/null +++ b/core/database/src/main/java/com/looker/core/database/DroidifyDatabase.kt @@ -0,0 +1,34 @@ +package com.leos.core.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.leos.core.database.dao.AppDao +import com.leos.core.database.dao.InstalledDao +import com.leos.core.database.dao.RepoDao +import com.leos.core.database.model.AppEntity +import com.leos.core.database.model.InstalledEntity +import com.leos.core.database.model.RepoEntity + +@Database( + version = 1, + entities = [ + AppEntity::class, + RepoEntity::class, + InstalledEntity::class + ] +) +@TypeConverters( + CollectionConverter::class, + LocalizedConverter::class, + PackageEntityConverter::class, + RepoConverter::class +) +abstract class DroidifyDatabase : RoomDatabase() { + + abstract fun appDao(): AppDao + + abstract fun repoDao(): RepoDao + + abstract fun installedDao(): InstalledDao +} diff --git a/core/database/src/main/java/com/looker/core/database/dao/AppDao.kt b/core/database/src/main/java/com/looker/core/database/dao/AppDao.kt new file mode 100644 index 0000000..170d353 --- /dev/null +++ b/core/database/src/main/java/com/looker/core/database/dao/AppDao.kt @@ -0,0 +1,50 @@ +package com.leos.core.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Upsert +import com.leos.core.database.model.AppEntity +import com.leos.core.database.model.PackageEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface AppDao { + + @Query(value = "SELECT * FROM apps") + fun getAppStream(): Flow> + + @Query( + value = """ + SELECT * FROM apps + WHERE authorName = :authorName + """ + ) + fun getAppsFromAuthor(authorName: String): Flow> + + @Query(value = "SELECT * FROM apps WHERE packageName = :packageName") + fun getApp(packageName: String): Flow> + + @Query( + value = """ + SELECT packages FROM apps + WHERE packageName = :packageName + """ + ) + fun getPackages(packageName: String): Flow> + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertOrIgnore(apps: List) + + @Upsert + suspend fun upsertApps(apps: List) + + @Query( + value = """ + DELETE FROM apps + WHERE repoId = :repoId + """ + ) + suspend fun deleteApps(repoId: Long) +} diff --git a/core/database/src/main/java/com/looker/core/database/dao/InstalledDao.kt b/core/database/src/main/java/com/looker/core/database/dao/InstalledDao.kt new file mode 100644 index 0000000..244a7c3 --- /dev/null +++ b/core/database/src/main/java/com/looker/core/database/dao/InstalledDao.kt @@ -0,0 +1,18 @@ +package com.leos.core.database.dao + +import androidx.room.* +import com.leos.core.database.model.InstalledEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface InstalledDao { + + @Query("SELECT * FROM installedentity") + fun getInstalledStream(): Flow> + + @Upsert + suspend fun upsertInstalled(installedEntity: InstalledEntity) + + @Delete + suspend fun deleteInstalled(installedEntity: InstalledEntity) +} diff --git a/core/database/src/main/java/com/looker/core/database/dao/RepoDao.kt b/core/database/src/main/java/com/looker/core/database/dao/RepoDao.kt new file mode 100644 index 0000000..d210801 --- /dev/null +++ b/core/database/src/main/java/com/looker/core/database/dao/RepoDao.kt @@ -0,0 +1,28 @@ +package com.leos.core.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import com.leos.core.database.model.RepoEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface RepoDao { + + @Query(value = "SELECT * FROM repos") + fun getRepoStream(): Flow> + + @Query(value = "SELECT * FROM repos WHERE id = :id") + suspend fun getRepoById(id: Long): RepoEntity + + @Upsert + suspend fun upsertRepo(repoEntity: RepoEntity) + + @Query( + value = """ + DELETE FROM repos + WHERE id = :id + """ + ) + suspend fun deleteRepo(id: Long) +} diff --git a/core/database/src/main/java/com/looker/core/database/di/DaoModule.kt b/core/database/src/main/java/com/looker/core/database/di/DaoModule.kt new file mode 100644 index 0000000..aedb81a --- /dev/null +++ b/core/database/src/main/java/com/looker/core/database/di/DaoModule.kt @@ -0,0 +1,34 @@ +package com.leos.core.database.di + +import com.leos.core.database.DroidifyDatabase +import com.leos.core.database.dao.AppDao +import com.leos.core.database.dao.InstalledDao +import com.leos.core.database.dao.RepoDao +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DaoModule { + + @Provides + @Singleton + fun provideRepoDao( + database: DroidifyDatabase + ): RepoDao = database.repoDao() + + @Provides + @Singleton + fun provideAppDao( + database: DroidifyDatabase + ): AppDao = database.appDao() + + @Provides + @Singleton + fun provideInstalledDao( + database: DroidifyDatabase + ): InstalledDao = database.installedDao() +} diff --git a/core/database/src/main/java/com/looker/core/database/di/DatabaseModule.kt b/core/database/src/main/java/com/looker/core/database/di/DatabaseModule.kt new file mode 100644 index 0000000..2a148b2 --- /dev/null +++ b/core/database/src/main/java/com/looker/core/database/di/DatabaseModule.kt @@ -0,0 +1,26 @@ +package com.leos.core.database.di + +import android.content.Context +import androidx.room.Room +import com.leos.core.database.DroidifyDatabase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DatabaseModule { + + @Provides + @Singleton + fun provideDroidifyDatabase( + @ApplicationContext context: Context + ): DroidifyDatabase = Room.databaseBuilder( + context, + DroidifyDatabase::class.java, + "droidify-database" + ).createFromAsset("repo.db").build() +} diff --git a/core/database/src/main/java/com/looker/core/database/model/AppEntity.kt b/core/database/src/main/java/com/looker/core/database/model/AppEntity.kt new file mode 100644 index 0000000..df7d65e --- /dev/null +++ b/core/database/src/main/java/com/looker/core/database/model/AppEntity.kt @@ -0,0 +1,132 @@ +package com.leos.core.database.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import com.leos.core.common.nullIfEmpty +import com.leos.core.common.toPackageName +import com.leos.core.database.utils.localizedValue +import com.leos.core.domain.newer.App +import com.leos.core.domain.newer.Author +import com.leos.core.domain.newer.Donation +import com.leos.core.domain.newer.Graphics +import com.leos.core.domain.newer.Links +import com.leos.core.domain.newer.Metadata +import com.leos.core.domain.newer.Screenshots + +internal typealias LocalizedString = Map +internal typealias LocalizedList = Map> + +@Entity(tableName = "apps", primaryKeys = ["repoId", "packageName"]) +data class AppEntity( + @ColumnInfo(name = "packageName") + val packageName: String, + @ColumnInfo(name = "repoId") + val repoId: Long, + val categories: List, + val summary: LocalizedString, + val description: LocalizedString, + val changelog: String, + val translation: String, + val issueTracker: String, + val sourceCode: String, + val binaries: String, + val name: LocalizedString, + val authorName: String, + val authorEmail: String, + val authorWebSite: String, + val donate: String, + val liberapayID: String, + val liberapay: String, + val openCollective: String, + val bitcoin: String, + val litecoin: String, + val flattrID: String, + val suggestedVersionName: String, + val suggestedVersionCode: Long, + val license: String, + val webSite: String, + val added: Long, + val icon: LocalizedString, + val phoneScreenshots: LocalizedList, + val sevenInchScreenshots: LocalizedList, + val tenInchScreenshots: LocalizedList, + val wearScreenshots: LocalizedList, + val tvScreenshots: LocalizedList, + val featureGraphic: LocalizedString, + val promoGraphic: LocalizedString, + val tvBanner: LocalizedString, + val video: LocalizedString, + val lastUpdated: Long, + val packages: List +) + +fun AppEntity.toExternal(locale: String, installed: PackageEntity? = null): App = App( + repoId = repoId, + categories = categories, + links = links(), + metadata = metadata(locale), + screenshots = screenshots(locale), + graphics = graphics(locale), + author = author(), + donation = donations(), + packages = packages.toExternal(locale) { it == installed } +) + +fun List.toExternal( + locale: String, + isInstalled: (AppEntity) -> PackageEntity? +): List = map { + it.toExternal(locale, isInstalled(it)) +} + +private fun AppEntity.author(): Author = Author( + name = authorName, + email = authorEmail, + web = authorWebSite +) + +private fun AppEntity.donations(): Donation = Donation( + regularUrl = donate.nullIfEmpty(), + bitcoinAddress = bitcoin.nullIfEmpty(), + flattrId = flattrID.nullIfEmpty(), + liteCoinAddress = litecoin.nullIfEmpty(), + openCollectiveId = openCollective.nullIfEmpty(), + librePayId = liberapayID.nullIfEmpty(), + librePayAddress = liberapay.nullIfEmpty() +) + +private fun AppEntity.graphics(locale: String): Graphics = Graphics( + featureGraphic = featureGraphic.localizedValue(locale) ?: "", + promoGraphic = promoGraphic.localizedValue(locale) ?: "", + tvBanner = tvBanner.localizedValue(locale) ?: "", + video = video.localizedValue(locale) ?: "" +) + +private fun AppEntity.links(): Links = Links( + changelog = changelog, + issueTracker = issueTracker, + sourceCode = sourceCode, + translation = translation, + webSite = webSite +) + +private fun AppEntity.metadata(locale: String): Metadata = Metadata( + name = name.localizedValue(locale) ?: "", + packageName = packageName.toPackageName(), + added = added, + description = description.localizedValue(locale) ?: "", + icon = icon.localizedValue(locale) ?: "", + lastUpdated = lastUpdated, + license = license, + suggestedVersionCode = suggestedVersionCode, + suggestedVersionName = suggestedVersionName, + summary = summary.localizedValue(locale) ?: "" +) + +private fun AppEntity.screenshots(locale: String): Screenshots = Screenshots( + phone = phoneScreenshots.localizedValue(locale) ?: emptyList(), + sevenInch = sevenInchScreenshots.localizedValue(locale) ?: emptyList(), + tenInch = tenInchScreenshots.localizedValue(locale) ?: emptyList(), + tv = tvScreenshots.localizedValue(locale) ?: emptyList(), + wear = wearScreenshots.localizedValue(locale) ?: emptyList() +) diff --git a/core/database/src/main/java/com/looker/core/database/model/InstalledEntity.kt b/core/database/src/main/java/com/looker/core/database/model/InstalledEntity.kt new file mode 100644 index 0000000..7188432 --- /dev/null +++ b/core/database/src/main/java/com/looker/core/database/model/InstalledEntity.kt @@ -0,0 +1,12 @@ +package com.leos.core.database.model + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class InstalledEntity( + @PrimaryKey + val packageName: String, + val versionCode: Long, + val signature: String +) diff --git a/core/database/src/main/java/com/looker/core/database/model/PackageEntity.kt b/core/database/src/main/java/com/looker/core/database/model/PackageEntity.kt new file mode 100644 index 0000000..31a3200 --- /dev/null +++ b/core/database/src/main/java/com/looker/core/database/model/PackageEntity.kt @@ -0,0 +1,70 @@ +package com.leos.core.database.model + +import com.leos.core.database.utils.localizedValue +import com.leos.core.domain.newer.ApkFile +import com.leos.core.domain.newer.Manifest +import com.leos.core.domain.newer.Package +import com.leos.core.domain.newer.Permission +import com.leos.core.domain.newer.Platforms +import com.leos.core.domain.newer.SDKs +import kotlinx.serialization.Serializable + +@Serializable +data class PackageEntity( + val added: Long, + val apkName: String, + val hash: String, + val hashType: String, + val minSdkVersion: Int, + val maxSdkVersion: Int, + val targetSdkVersion: Int, + val sig: String, + val signer: String, + val size: Long, + val srcName: String, + val usesPermission: List, + val versionCode: Long, + val versionName: String, + val nativeCode: List, + val features: List, + val antiFeatures: List, + val whatsNew: LocalizedString +) + +@Serializable +data class PermissionEntity( + val name: String, + val minSdk: Int? = null, + val maxSdk: Int? = null +) + +fun PackageEntity.toExternal(locale: String, installed: Boolean): Package = Package( + installed = installed, + added = added, + apk = ApkFile( + name = apkName, + hash = hash, + size = size + ), + manifest = Manifest( + versionCode = versionCode, + versionName = versionName, + usesSDKs = SDKs(minSdkVersion, targetSdkVersion), + signer = setOf(signer), + permissions = usesPermission.map(PermissionEntity::toExternalModel) + ), + platforms = Platforms(nativeCode), + features = features, + antiFeatures = antiFeatures, + whatsNew = whatsNew.localizedValue(locale) ?: "" +) + +fun List.toExternal( + locale: String, + installed: (PackageEntity) -> Boolean +): List = map { it.toExternal(locale, installed(it)) } + +fun PermissionEntity.toExternalModel(): Permission = Permission( + name = name, + sdKs = SDKs(min = minSdk ?: -1, max = maxSdk ?: -1) +) diff --git a/core/database/src/main/java/com/looker/core/database/model/RepoEntity.kt b/core/database/src/main/java/com/looker/core/database/model/RepoEntity.kt new file mode 100644 index 0000000..502679c --- /dev/null +++ b/core/database/src/main/java/com/looker/core/database/model/RepoEntity.kt @@ -0,0 +1,89 @@ +package com.leos.core.database.model + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.leos.core.database.utils.localizedValue +import com.leos.core.domain.newer.AntiFeature +import com.leos.core.domain.newer.Authentication +import com.leos.core.domain.newer.Category +import com.leos.core.domain.newer.Repo +import com.leos.core.domain.newer.VersionInfo +import kotlinx.serialization.Serializable + +@Entity(tableName = "repos") +data class RepoEntity( + @PrimaryKey(autoGenerate = true) + val id: Long? = null, + val enabled: Boolean, + val fingerprint: String, + val etag: String, + val username: String, + val password: String, + val address: String, + val mirrors: List, + val name: LocalizedString, + val description: LocalizedString, + val antiFeatures: Map, + val categories: Map, + val timestamp: Long +) + +fun RepoEntity.update(repo: Repo) = copy( + username = repo.authentication.username, + password = repo.authentication.password, + timestamp = repo.versionInfo.timestamp, + enabled = repo.enabled, + mirrors = repo.mirrors, + fingerprint = repo.fingerprint +) + +fun RepoEntity.toExternal(locale: String): Repo = Repo( + id = id!!, + enabled = enabled, + address = address, + name = name.localizedValue(locale) ?: "", + description = description.localizedValue(locale) ?: "", + fingerprint = fingerprint, + authentication = Authentication(username, password), + versionInfo = VersionInfo(timestamp = timestamp, etag = etag), + mirrors = mirrors, + categories = categories.values.toCategoryList(locale), + antiFeatures = antiFeatures.values.toAntiFeatureList(locale) +) + +fun List.toExternal(locale: String): List = + map { it.toExternal(locale) } + +@Serializable +data class CategoryEntity( + val icon: LocalizedString, + val name: LocalizedString, + val description: LocalizedString +) + +private fun CategoryEntity.toCategory(locale: String) = + Category( + name = name.localizedValue(locale) ?: "", + icon = icon.localizedValue(locale) ?: "", + description = description.localizedValue(locale) ?: "" + ) + +fun Collection.toCategoryList(locale: String): List = + map { it.toCategory(locale) } + +@Serializable +data class AntiFeatureEntity( + val icon: LocalizedString, + val name: LocalizedString, + val description: LocalizedString +) + +private fun AntiFeatureEntity.toAntiFeature(locale: String) = + AntiFeature( + name = name.localizedValue(locale) ?: "", + icon = icon.localizedValue(locale) ?: "", + description = description.localizedValue(locale) ?: "" + ) + +fun Collection.toAntiFeatureList(locale: String): List = + map { it.toAntiFeature(locale) } diff --git a/core/database/src/main/java/com/looker/core/database/utils/Localization.kt b/core/database/src/main/java/com/looker/core/database/utils/Localization.kt new file mode 100644 index 0000000..7b84236 --- /dev/null +++ b/core/database/src/main/java/com/looker/core/database/utils/Localization.kt @@ -0,0 +1,53 @@ +package com.leos.core.database.utils + +import androidx.core.os.LocaleListCompat +import com.leos.core.common.stripBetween +import java.util.Locale + +internal fun localeListCompat(tag: String): LocaleListCompat = + LocaleListCompat.forLanguageTags(tag) + +/** + * Find the Localized value from [Map] using [locale] + * + * Returns null if none matches or map or [locale] is empty + */ +fun Map?.localizedValue(locale: String): T? { + val localeList = localeListCompat(locale) + if (isNullOrEmpty() || localeList.isEmpty) return null + val suitableLocale = localeList.suitableLocale(keys) + return get(suitableLocale) + ?: get("en_US") + ?: get("en-US") + ?: get("en") + ?: values.firstOrNull() +} + +/** + * Retrieve the most suitable Locale from the [keys] using [LocaleListCompat] + * + * Returns null if none found + */ +internal fun LocaleListCompat.suitableLocale(keys: Set): String? = (0..): String? { + if (keys.isEmpty()) return null + val currentLocale = this ?: return null + val tag = currentLocale.toLanguageTag() + val soloTag = currentLocale.language + val strippedTag = tag.stripBetween("-") + + return if (tag in keys) tag + else if (strippedTag in keys) strippedTag + else if (soloTag in keys) soloTag + // try children of the language + else keys.find { it.startsWith(soloTag) } +} diff --git a/core/database/src/test/java/com/looker/core/database/LocalizationTest.kt b/core/database/src/test/java/com/looker/core/database/LocalizationTest.kt new file mode 100644 index 0000000..77b70d7 --- /dev/null +++ b/core/database/src/test/java/com/looker/core/database/LocalizationTest.kt @@ -0,0 +1,195 @@ +package com.leos.core.database + +import androidx.core.os.LocaleListCompat +import androidx.core.os.LocaleListCompat.getEmptyLocaleList +import com.leos.core.database.utils.localeListCompat +import com.leos.core.database.utils.localizedValue +import com.leos.core.database.utils.suitableLocale +import com.leos.core.database.utils.suitableTag +import org.junit.Test +import java.util.Locale +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * + * This code is copyrighted to (F-Droid.org), I merely rewrote it. + * Tests based on F-Droid's BestLocaleTest [https://gitlab.com/fdroid/fdroidclient/-/blob/680a1154cf3806390c2e4a9e95a7c6d6107b470f/libs/index/src/androidAndroidTest/kotlin/org/fdroid/BestLocaleTest.kt] + * + * https://developer.android.com/guide/topics/resources/multilingual-support#resource-resolution-examples + */ +class LocalizationTest { + + @Test + fun `Get correct localeList`() { + assertEquals( + LocaleListCompat.create(Locale.ENGLISH, Locale.US), + localeListCompat("en,en-US") + ) + } + + @Test + fun `Return empty locale on none match`() { + assertNull(emptyMap().localizedValue("en-US,de-DE")) + assertNull(getMap("en-US", "de-DE").localizedValue("")) + } + + @Test + fun `Fallback to english`() { + assertEquals( + "en", + getMap("de-AT", "de-DE", "en").localizedValue("fr-FR") + ) + assertEquals( + "en-US", + getMap("en", "en-US").localizedValue("zh-Hant-TW,zh-Hans-CN") + ) + } + + @Test + fun `Use the first selected locale, en_US`() { + assertEquals( + "en-US", + getMap("de-AT", "de-DE", "en-US").localizedValue("en-US,de-DE") + ) + } + + @Test + fun `Use the first en translation`() { + assertEquals( + "en-US", + getMap("de-AT", "de-DE", "en-US").localizedValue("en-SE,de-DE") + ) + } + + @Test + fun `Use the first full match against a non-default locale`() { + assertEquals( + "de-AT", + getMap( + "de-AT", + "de-DE", + "en-GB", + "en-US" + ).localizedValue("de-AT,de-DE") + ) + assertEquals( + "de", + getMap("de-AT", "de", "en-GB", "en-US").localizedValue("de-CH,en-US") + ) + } + + @Test + fun `Stripped locale tag`() { + assertEquals( + "zh-TW", + getMap( + "en-US", + "zh-CN", + "zh-HK", + "zh-TW" + ).localizedValue("zh-Hant-TW,zh-Hans-CN") + ) + } + + @Test + fun `Google specified test`() { + assertEquals( + "fr-FR", + getMap("en-US", "de-DE", "es-ES", "fr-FR", "it-IT") + .localizedValue("fr-CH") + ) + + assertEquals( + "it-IT", + getMap("en-US", "de-DE", "es-ES", "it-IT") + .localizedValue("fr-CH,it-CH") + ) + } + + @Test + fun `Check null for suitable locale from list`() { + assertNull(localeListCompat("en-US").suitableLocale(keys("de-DE", "es-ES"))) + assertNull(localeListCompat("en-US").suitableLocale(keys())) + assertNull(getEmptyLocaleList().suitableLocale(keys("de-DE", "es-ES"))) + } + + @Test + fun `Find suitable locale from wrong list`() { + assertNull(localeListCompat("en-US").suitableLocale(keys("de-DE", "es-ES"))) + } + + @Test + fun `Find suitable locale from list without modification`() { + assertEquals( + "en-US", + localeListCompat("en-US").suitableLocale(keys("en", "en-US", "en-UK")) + ) + } + + @Test + fun `Find suitable locale from list only with language`() { + assertEquals( + "en", + localeListCompat("en-US").suitableLocale(keys("de-DE", "fr-FR", "en-UK", "en")) + ) + } + + @Test + fun `Find stripped locale from the list`() { + assertEquals( + "zh-TW", + localeListCompat("zh-Hant-TW").suitableLocale( + keys( + "en", + "de-DE", + "fr-FR", + "zh-TW", + "zh" + ) + ) + ) + } + + @Test + fun `Check null for suitable locale`() { + val locale: Locale? = null + assertNull(locale.suitableTag(keys("en-US", "de-DE", "es-ES", "it-IT"))) + assertNull(Locale.ENGLISH.suitableTag(keys())) + } + + @Test + fun `Find suitable locale from wrong keys`() { + assertNull(Locale.ENGLISH.suitableTag(keys("de-DE", "es-ES"))) + } + + @Test + fun `Get suitable locale without modification`() { + assertEquals("en-US", Locale("en", "US").suitableTag(keys("en", "en-US", "en-UK"))) + } + + @Test + fun `Get suitable locale with only language`() { + assertEquals("en", Locale("en", "US").suitableTag(keys("en", "de-DE", "fr-FR"))) + } + + @Test + fun `Get suitable locale with stripped parts`() { + assertEquals( + "zh-TW", + localeListCompat("zh-Hant-TW")[0].suitableTag( + keys( + "en", + "de-DE", + "fr-FR", + "zh-TW", + "zh" + ) + ) + ) + } + + private fun keys(vararg tag: String): Set = tag.toSet() + + private fun getMap(vararg locales: String): Map = locales.associateWith { it } +} diff --git a/core/datastore/.gitignore b/core/datastore/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/core/datastore/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts new file mode 100644 index 0000000..fb2675d --- /dev/null +++ b/core/datastore/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + alias(libs.plugins.looker.android.library) + alias(libs.plugins.looker.hilt) + alias(libs.plugins.looker.lint) + alias(libs.plugins.looker.serialization) +} + +android { + namespace = "com.leos.core.datastore" + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) + } + create("alpha") { + initWith(getByName("debug")) + isMinifyEnabled = true + } + } +} + +dependencies { + modules(Modules.coreCommon, Modules.coreDI) + implementation(libs.androidx.dataStore.core) + implementation(libs.androidx.dataStore.proto) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.datetime) +} diff --git a/core/datastore/src/main/java/com/looker/core/datastore/DataStoreSettingsRepository.kt b/core/datastore/src/main/java/com/looker/core/datastore/DataStoreSettingsRepository.kt new file mode 100644 index 0000000..03726d6 --- /dev/null +++ b/core/datastore/src/main/java/com/looker/core/datastore/DataStoreSettingsRepository.kt @@ -0,0 +1,158 @@ +package com.leos.core.datastore + +import android.net.Uri +import android.util.Log +import androidx.datastore.core.DataStore +import com.leos.core.common.Exporter +import com.leos.core.common.extension.updateAsMutable +import com.leos.core.datastore.model.AutoSync +import com.leos.core.datastore.model.InstallerType +import com.leos.core.datastore.model.ProxyType +import com.leos.core.datastore.model.SortOrder +import com.leos.core.datastore.model.Theme +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.datetime.Clock +import java.io.IOException +import kotlin.time.Duration + +class DataStoreSettingsRepository( + private val dataStore: DataStore, + private val exporter: Exporter +) : SettingsRepository { + private companion object { + const val TAG: String = "SettingsRepository" + } + + override val data: Flow = dataStore.data + .catch { exception -> + if (exception is IOException) { + Log.e(TAG, "Error reading preferences.", exception) + } else { + throw exception + } + } + + override suspend fun getInitial(): Settings { + return dataStore.data.first() + } + + override suspend fun setLanguage(language: String) { + dataStore.updateData { settings -> + settings.copy(language = language) + } + } + + override suspend fun enableIncompatibleVersion(enable: Boolean) { + dataStore.updateData { settings -> + settings.copy(incompatibleVersions = enable) + } + } + + override suspend fun enableNotifyUpdates(enable: Boolean) { + dataStore.updateData { settings -> + settings.copy(notifyUpdate = enable) + } + } + + override suspend fun enableUnstableUpdates(enable: Boolean) { + dataStore.updateData { settings -> + settings.copy(unstableUpdate = enable) + } + } + + override suspend fun setTheme(theme: Theme) { + dataStore.updateData { settings -> + settings.copy(theme = theme) + } + } + + override suspend fun setDynamicTheme(enable: Boolean) { + dataStore.updateData { settings -> + settings.copy(dynamicTheme = enable) + } + } + + override suspend fun setInstallerType(installerType: InstallerType) { + dataStore.updateData { settings -> + settings.copy(installerType = installerType) + } + } + + override suspend fun setAutoUpdate(allow: Boolean) { + dataStore.updateData { settings -> + settings.copy(autoUpdate = allow) + } + } + + override suspend fun setAutoSync(autoSync: AutoSync) { + dataStore.updateData { settings -> + settings.copy(autoSync = autoSync) + } + } + + override suspend fun setSortOrder(sortOrder: SortOrder) { + dataStore.updateData { settings -> + settings.copy(sortOrder = sortOrder) + } + } + + override suspend fun setProxyType(proxyType: ProxyType) { + dataStore.updateData { settings -> + settings.copy(proxy = settings.proxy.update(newType = proxyType)) + } + } + + override suspend fun setProxyHost(proxyHost: String) { + dataStore.updateData { settings -> + settings.copy(proxy = settings.proxy.update(newHost = proxyHost)) + } + } + + override suspend fun setProxyPort(proxyPort: Int) { + dataStore.updateData { settings -> + settings.copy(proxy = settings.proxy.update(newPort = proxyPort)) + } + } + + override suspend fun setCleanUpInterval(interval: Duration) { + dataStore.updateData { settings -> + settings.copy(cleanUpInterval = interval) + } + } + + override suspend fun setCleanupInstant() { + dataStore.updateData { settings -> + settings.copy(lastCleanup = Clock.System.now()) + } + } + + override suspend fun setHomeScreenSwiping(value: Boolean) { + dataStore.updateData { settings -> + settings.copy(homeScreenSwiping = value) + } + } + + override suspend fun export(target: Uri) { + val currentSettings = getInitial() + exporter.export(currentSettings, target) + } + + override suspend fun import(target: Uri) { + val importedSettings = exporter.import(target) + val updatedFavorites = importedSettings.favouriteApps + + getInitial().favouriteApps + val updatedSettings = importedSettings.copy(favouriteApps = updatedFavorites) + dataStore.updateData { updatedSettings } + } + + override suspend fun toggleFavourites(packageName: String) { + dataStore.updateData { settings -> + val newSet = settings.favouriteApps.updateAsMutable { + if (!add(packageName)) remove(packageName) + } + settings.copy(favouriteApps = newSet) + } + } +} diff --git a/core/datastore/src/main/java/com/looker/core/datastore/Settings.kt b/core/datastore/src/main/java/com/looker/core/datastore/Settings.kt new file mode 100644 index 0000000..ed26c97 --- /dev/null +++ b/core/datastore/src/main/java/com/looker/core/datastore/Settings.kt @@ -0,0 +1,66 @@ +package com.leos.core.datastore + +import androidx.datastore.core.Serializer +import com.leos.core.datastore.model.AutoSync +import com.leos.core.datastore.model.InstallerType +import com.leos.core.datastore.model.ProxyPreference +import com.leos.core.datastore.model.SortOrder +import com.leos.core.datastore.model.Theme +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlinx.datetime.Instant +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import kotlinx.serialization.json.encodeToStream + +@Serializable +data class Settings( + val language: String = "system", + val incompatibleVersions: Boolean = false, + val notifyUpdate: Boolean = true, + val unstableUpdate: Boolean = false, + val theme: Theme = Theme.SYSTEM, + val dynamicTheme: Boolean = false, + val installerType: InstallerType = InstallerType.Default, + val autoUpdate: Boolean = false, + val autoSync: AutoSync = AutoSync.WIFI_ONLY, + val sortOrder: SortOrder = SortOrder.UPDATED, + val proxy: ProxyPreference = ProxyPreference(), + val cleanUpInterval: Duration = 12.hours, + val lastCleanup: Instant? = null, + val favouriteApps: Set = emptySet(), + val homeScreenSwiping: Boolean = true +) + +@OptIn(ExperimentalSerializationApi::class) +object SettingsSerializer : Serializer { + + private val json = Json { encodeDefaults = true } + + override val defaultValue: Settings = Settings() + + override suspend fun readFrom(input: InputStream): Settings { + return try { + json.decodeFromStream(input) + } catch (e: SerializationException) { + e.printStackTrace() + defaultValue + } + } + + override suspend fun writeTo(t: Settings, output: OutputStream) { + try { + json.encodeToStream(t, output) + } catch (e: SerializationException) { + e.printStackTrace() + } catch (e: IOException) { + e.printStackTrace() + } + } +} diff --git a/core/datastore/src/main/java/com/looker/core/datastore/SettingsRepository.kt b/core/datastore/src/main/java/com/looker/core/datastore/SettingsRepository.kt new file mode 100644 index 0000000..b77a6a6 --- /dev/null +++ b/core/datastore/src/main/java/com/looker/core/datastore/SettingsRepository.kt @@ -0,0 +1,61 @@ +package com.leos.core.datastore + +import android.net.Uri +import com.leos.core.datastore.model.AutoSync +import com.leos.core.datastore.model.InstallerType +import com.leos.core.datastore.model.ProxyType +import com.leos.core.datastore.model.SortOrder +import com.leos.core.datastore.model.Theme +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlin.time.Duration + +interface SettingsRepository { + + val data: Flow + + suspend fun getInitial(): Settings + + suspend fun export(target: Uri) + + suspend fun import(target: Uri) + + suspend fun setLanguage(language: String) + + suspend fun enableIncompatibleVersion(enable: Boolean) + + suspend fun enableNotifyUpdates(enable: Boolean) + + suspend fun enableUnstableUpdates(enable: Boolean) + + suspend fun setTheme(theme: Theme) + + suspend fun setDynamicTheme(enable: Boolean) + + suspend fun setInstallerType(installerType: InstallerType) + + suspend fun setAutoUpdate(allow: Boolean) + + suspend fun setAutoSync(autoSync: AutoSync) + + suspend fun setSortOrder(sortOrder: SortOrder) + + suspend fun setProxyType(proxyType: ProxyType) + + suspend fun setProxyHost(proxyHost: String) + + suspend fun setProxyPort(proxyPort: Int) + + suspend fun setCleanUpInterval(interval: Duration) + + suspend fun setCleanupInstant() + + suspend fun setHomeScreenSwiping(value: Boolean) + + suspend fun toggleFavourites(packageName: String) +} + +inline fun SettingsRepository.get(crossinline block: suspend Settings.() -> T): Flow { + return data.map(block).distinctUntilChanged() +} diff --git a/core/datastore/src/main/java/com/looker/core/datastore/di/DatastoreModule.kt b/core/datastore/src/main/java/com/looker/core/datastore/di/DatastoreModule.kt new file mode 100644 index 0000000..a35e753 --- /dev/null +++ b/core/datastore/src/main/java/com/looker/core/datastore/di/DatastoreModule.kt @@ -0,0 +1,80 @@ +package com.leos.core.datastore.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.dataStoreFile +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile +import com.leos.core.common.Exporter +import com.leos.core.datastore.DataStoreSettingsRepository +import com.leos.core.datastore.Settings +import com.leos.core.datastore.SettingsRepository +import com.leos.core.datastore.SettingsSerializer +import com.leos.core.datastore.exporter.SettingsExporter +import com.leos.core.datastore.migration.ProtoDataStoreMigration +import com.leos.core.di.ApplicationScope +import com.leos.core.di.IoDispatcher +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.serialization.json.Json + +private const val OLD_PREFERENCES = "preferences_file" +private const val PREFERENCES = "settings_file" + +@Module +@InstallIn(SingletonComponent::class) +object DatastoreModule { + + @Singleton + @Provides + fun provideDatastore( + @ApplicationContext context: Context + ): DataStore = PreferenceDataStoreFactory.create { + context.preferencesDataStoreFile(OLD_PREFERENCES) + } + + @Singleton + @Provides + fun provideProtoDatastore( + @ApplicationContext context: Context, + oldDataStore: DataStore + ): DataStore = DataStoreFactory.create( + serializer = SettingsSerializer, + migrations = listOf( + ProtoDataStoreMigration(oldDataStore) + ) + ) { + context.dataStoreFile(PREFERENCES) + } + + @Singleton + @Provides + fun provideSettingsExporter( + @ApplicationContext context: Context, + @ApplicationScope scope: CoroutineScope, + @IoDispatcher dispatcher: CoroutineDispatcher + ): Exporter = SettingsExporter( + context = context, + scope = scope, + ioDispatcher = dispatcher, + json = Json { + encodeDefaults = true + prettyPrint = true + } + ) + + @Singleton + @Provides + fun provideSettingsRepository( + dataStore: DataStore, + exporter: Exporter + ): SettingsRepository = DataStoreSettingsRepository(dataStore, exporter) +} diff --git a/core/datastore/src/main/java/com/looker/core/datastore/exporter/SettingsExporter.kt b/core/datastore/src/main/java/com/looker/core/datastore/exporter/SettingsExporter.kt new file mode 100644 index 0000000..d6c6f98 --- /dev/null +++ b/core/datastore/src/main/java/com/looker/core/datastore/exporter/SettingsExporter.kt @@ -0,0 +1,57 @@ +package com.leos.core.datastore.exporter + +import android.content.Context +import android.net.Uri +import com.leos.core.common.Exporter +import com.leos.core.datastore.Settings +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import kotlinx.serialization.json.encodeToStream +import java.io.IOException + +@OptIn(ExperimentalSerializationApi::class) +class SettingsExporter( + private val context: Context, + private val scope: CoroutineScope, + private val ioDispatcher: CoroutineDispatcher, + private val json: Json +) : Exporter { + + override suspend fun export(item: Settings, target: Uri) { + scope.launch(ioDispatcher) { + try { + context.contentResolver.openOutputStream(target).use { + if (it != null) json.encodeToStream(item, it) + } + } catch (e: SerializationException) { + e.printStackTrace() + cancel() + } catch (e: IOException) { + e.printStackTrace() + cancel() + } + } + } + + override suspend fun import(target: Uri): Settings = withContext(ioDispatcher) { + try { + context.contentResolver.openInputStream(target).use { + checkNotNull(it) { "Null input stream for import file" } + json.decodeFromStream(it) + } + } catch (e: SerializationException) { + e.printStackTrace() + throw IllegalStateException(e.message) + } catch (e: IOException) { + e.printStackTrace() + throw IllegalStateException(e.message) + } + } +} diff --git a/core/datastore/src/main/java/com/looker/core/datastore/extension/Preferences.kt b/core/datastore/src/main/java/com/looker/core/datastore/extension/Preferences.kt new file mode 100644 index 0000000..4dd96e5 --- /dev/null +++ b/core/datastore/src/main/java/com/looker/core/datastore/extension/Preferences.kt @@ -0,0 +1,123 @@ +package com.leos.core.datastore.extension + +import android.content.Context +import android.content.res.Configuration +import com.leos.core.common.R +import com.leos.core.common.R.string as stringRes +import com.leos.core.common.R.style as styleRes +import com.leos.core.common.SdkCheck +import com.leos.core.datastore.model.AutoSync +import com.leos.core.datastore.model.InstallerType +import com.leos.core.datastore.model.ProxyType +import com.leos.core.datastore.model.SortOrder +import com.leos.core.datastore.model.Theme +import kotlin.time.Duration + +fun Configuration.getThemeRes(theme: Theme, dynamicTheme: Boolean) = when (theme) { + Theme.SYSTEM -> { + if ((uiMode and Configuration.UI_MODE_NIGHT_YES) != 0) { + if (SdkCheck.isSnowCake && dynamicTheme) { + styleRes.Theme_Main_DynamicDark + } else { + styleRes.Theme_Main_Dark + } + } else { + if (SdkCheck.isSnowCake && dynamicTheme) { + styleRes.Theme_Main_DynamicLight + } else { + styleRes.Theme_Main_Light + } + } + } + + Theme.SYSTEM_BLACK -> { + if ((uiMode and Configuration.UI_MODE_NIGHT_YES) != 0) { + if (SdkCheck.isSnowCake && dynamicTheme) { + styleRes.Theme_Main_DynamicAmoled + } else { + styleRes.Theme_Main_Amoled + } + } else { + if (SdkCheck.isSnowCake && dynamicTheme) { + styleRes.Theme_Main_DynamicLight + } else { + styleRes.Theme_Main_Light + } + } + } + + Theme.LIGHT -> if (SdkCheck.isSnowCake && dynamicTheme) { + styleRes.Theme_Main_DynamicLight + } else { + styleRes.Theme_Main_Light + } + Theme.DARK -> if (SdkCheck.isSnowCake && dynamicTheme) { + styleRes.Theme_Main_DynamicDark + } else { + styleRes.Theme_Main_Dark + } + Theme.AMOLED -> if (SdkCheck.isSnowCake && dynamicTheme) { + styleRes.Theme_Main_DynamicAmoled + } else { + styleRes.Theme_Main_Amoled + } +} + +fun Context?.toTime(duration: Duration): String { + val time = duration.inWholeHours.toInt() + val days = duration.inWholeDays.toInt() + if (duration == Duration.INFINITE) return this?.getString(R.string.never) ?: "" + return if (time >= 24) { + "$days " + this?.resources?.getQuantityString( + R.plurals.days, + days + ) + } else { + "$time " + this?.resources?.getQuantityString(R.plurals.hours, time) + } +} + +fun Context?.themeName(theme: Theme) = this?.let { + when (theme) { + Theme.SYSTEM -> getString(stringRes.system) + Theme.SYSTEM_BLACK -> getString(stringRes.system) + " " + getString(stringRes.amoled) + Theme.LIGHT -> getString(stringRes.light) + Theme.DARK -> getString(stringRes.dark) + Theme.AMOLED -> getString(stringRes.amoled) + } +} ?: "" + +fun Context?.sortOrderName(sortOrder: SortOrder) = this?.let { + when (sortOrder) { + SortOrder.UPDATED -> getString(stringRes.recently_updated) + SortOrder.ADDED -> getString(stringRes.whats_new) + SortOrder.NAME -> getString(stringRes.name) +// SortOrder.SIZE -> getString(stringRes.size) + } +} ?: "" + +fun Context?.autoSyncName(autoSync: AutoSync) = this?.let { + when (autoSync) { + AutoSync.NEVER -> getString(stringRes.never) + AutoSync.WIFI_ONLY -> getString(stringRes.only_on_wifi) + AutoSync.WIFI_PLUGGED_IN -> getString(stringRes.only_on_wifi_with_charging) + AutoSync.ALWAYS -> getString(stringRes.always) + } +} ?: "" + +fun Context?.proxyName(proxyType: ProxyType) = this?.let { + when (proxyType) { + ProxyType.DIRECT -> getString(stringRes.no_proxy) + ProxyType.HTTP -> getString(stringRes.http_proxy) + ProxyType.SOCKS -> getString(stringRes.socks_proxy) + } +} ?: "" + +fun Context?.installerName(installerType: InstallerType) = this?.let { + when (installerType) { + InstallerType.LEGACY -> getString(stringRes.legacy_installer) + InstallerType.SESSION -> getString(stringRes.session_installer) + InstallerType.SHIZUKU -> getString(stringRes.shizuku_installer) + InstallerType.ROOT -> getString(stringRes.root_installer) + } +} ?: "" diff --git a/core/datastore/src/main/java/com/looker/core/datastore/migration/ProtoDataStoreMigration.kt b/core/datastore/src/main/java/com/looker/core/datastore/migration/ProtoDataStoreMigration.kt new file mode 100644 index 0000000..443bc97 --- /dev/null +++ b/core/datastore/src/main/java/com/looker/core/datastore/migration/ProtoDataStoreMigration.kt @@ -0,0 +1,100 @@ +package com.leos.core.datastore.migration + +import androidx.datastore.core.DataMigration +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import com.leos.core.datastore.Settings +import com.leos.core.datastore.model.AutoSync +import com.leos.core.datastore.model.InstallerType +import com.leos.core.datastore.model.ProxyPreference +import com.leos.core.datastore.model.ProxyType +import com.leos.core.datastore.model.SortOrder +import com.leos.core.datastore.model.Theme +import kotlin.time.Duration.Companion.hours +import kotlinx.coroutines.flow.first +import kotlinx.datetime.Instant + +class ProtoDataStoreMigration( + private val oldDataStore: DataStore +) : DataMigration { + override suspend fun cleanUp() { + oldDataStore.edit { it.clear() } + } + + override suspend fun shouldMigrate(currentData: Settings): Boolean = + oldDataStore.data.first().asMap().isNotEmpty() + + override suspend fun migrate(currentData: Settings): Settings { + return oldDataStore.data.first().mapSettings() + } + + // TODO: Remove after next update + private companion object { + val LANGUAGE = stringPreferencesKey("key_language") + val INCOMPATIBLE_VERSIONS = booleanPreferencesKey("key_incompatible_versions") + val NOTIFY_UPDATES = booleanPreferencesKey("key_notify_updates") + val UNSTABLE_UPDATES = booleanPreferencesKey("key_unstable_updates") + val THEME = stringPreferencesKey("key_theme") + val DYNAMIC_THEME = booleanPreferencesKey("key_dynamic_theme") + val INSTALLER_TYPE = stringPreferencesKey("key_installer_type") + val AUTO_UPDATE = booleanPreferencesKey("key_auto_updates") + val AUTO_SYNC = stringPreferencesKey("key_auto_sync") + val SORT_ORDER = stringPreferencesKey("key_sort_order") + val PROXY_TYPE = stringPreferencesKey("key_proxy_type") + val PROXY_HOST = stringPreferencesKey("key_proxy_host") + val PROXY_PORT = intPreferencesKey("key_proxy_port") + val CLEAN_UP_INTERVAL = longPreferencesKey("clean_up_interval") + val LAST_CLEAN_UP = longPreferencesKey("last_clean_up_time") + val FAVOURITE_APPS = stringSetPreferencesKey("favourite_apps") + val HOME_SCREEN_SWIPING = booleanPreferencesKey("home_swiping") + + private fun Preferences.mapSettings(): Settings { + val defaultSetting = Settings() + + val language = this[LANGUAGE] ?: defaultSetting.language + val incompatibleVersions = this[INCOMPATIBLE_VERSIONS] + ?: defaultSetting.incompatibleVersions + val notifyUpdate = this[NOTIFY_UPDATES] ?: defaultSetting.notifyUpdate + val unstableUpdate = this[UNSTABLE_UPDATES] ?: defaultSetting.unstableUpdate + val theme = Theme.valueOf(this[THEME] ?: Theme.SYSTEM.name) + val dynamicTheme = this[DYNAMIC_THEME] ?: defaultSetting.dynamicTheme + val installerType = + InstallerType.valueOf(this[INSTALLER_TYPE] ?: defaultSetting.installerType.name) + val autoUpdate = this[AUTO_UPDATE] ?: false + val autoSync = AutoSync.valueOf(this[AUTO_SYNC] ?: defaultSetting.autoSync.name) + val sortOrder = SortOrder.valueOf(this[SORT_ORDER] ?: defaultSetting.sortOrder.name) + val type = ProxyType.valueOf(this[PROXY_TYPE] ?: defaultSetting.proxy.type.name) + val host = this[PROXY_HOST] ?: defaultSetting.proxy.host + val port = this[PROXY_PORT] ?: defaultSetting.proxy.port + val proxy = ProxyPreference(type = type, host = host, port = port) + val cleanUpInterval = this[CLEAN_UP_INTERVAL]?.hours ?: defaultSetting.cleanUpInterval + val lastCleanup = this[LAST_CLEAN_UP]?.let { Instant.fromEpochMilliseconds(it) } + val favouriteApps = this[FAVOURITE_APPS] ?: defaultSetting.favouriteApps + val homeScreenSwiping = this[HOME_SCREEN_SWIPING] ?: defaultSetting.homeScreenSwiping + + return Settings( + language = language, + incompatibleVersions = incompatibleVersions, + notifyUpdate = notifyUpdate, + unstableUpdate = unstableUpdate, + theme = theme, + dynamicTheme = dynamicTheme, + installerType = installerType, + autoUpdate = autoUpdate, + autoSync = autoSync, + sortOrder = sortOrder, + proxy = proxy, + cleanUpInterval = cleanUpInterval, + lastCleanup = lastCleanup, + favouriteApps = favouriteApps, + homeScreenSwiping = homeScreenSwiping + ) + } + } +} diff --git a/core/datastore/src/main/java/com/looker/core/datastore/model/AutoSync.kt b/core/datastore/src/main/java/com/looker/core/datastore/model/AutoSync.kt new file mode 100644 index 0000000..28b9e86 --- /dev/null +++ b/core/datastore/src/main/java/com/looker/core/datastore/model/AutoSync.kt @@ -0,0 +1,8 @@ +package com.leos.core.datastore.model + +enum class AutoSync { + ALWAYS, + WIFI_ONLY, + WIFI_PLUGGED_IN, + NEVER +} diff --git a/core/datastore/src/main/java/com/looker/core/datastore/model/InstallerType.kt b/core/datastore/src/main/java/com/looker/core/datastore/model/InstallerType.kt new file mode 100644 index 0000000..b11e6a4 --- /dev/null +++ b/core/datastore/src/main/java/com/looker/core/datastore/model/InstallerType.kt @@ -0,0 +1,19 @@ +package com.leos.core.datastore.model + +import com.leos.core.common.device.Miui + +enum class InstallerType { + LEGACY, + SESSION, + SHIZUKU, + ROOT; + + companion object { + val Default: InstallerType + get() = if (Miui.isMiui) { + if (Miui.isMiuiOptimizationDisabled()) SESSION else LEGACY + } else { + SESSION + } + } +} diff --git a/core/datastore/src/main/java/com/looker/core/datastore/model/ProxyPreference.kt b/core/datastore/src/main/java/com/looker/core/datastore/model/ProxyPreference.kt new file mode 100644 index 0000000..4e8cee7 --- /dev/null +++ b/core/datastore/src/main/java/com/looker/core/datastore/model/ProxyPreference.kt @@ -0,0 +1,20 @@ +package com.leos.core.datastore.model + +import kotlinx.serialization.Serializable + +@Serializable +data class ProxyPreference( + val type: ProxyType = ProxyType.DIRECT, + val host: String = "localhost", + val port: Int = 9050 +) { + fun update( + newType: ProxyType? = null, + newHost: String? = null, + newPort: Int? = null + ): ProxyPreference = copy( + type = newType ?: type, + host = newHost ?: host, + port = newPort ?: port + ) +} diff --git a/core/datastore/src/main/java/com/looker/core/datastore/model/ProxyType.kt b/core/datastore/src/main/java/com/looker/core/datastore/model/ProxyType.kt new file mode 100644 index 0000000..e4586d7 --- /dev/null +++ b/core/datastore/src/main/java/com/looker/core/datastore/model/ProxyType.kt @@ -0,0 +1,7 @@ +package com.leos.core.datastore.model + +enum class ProxyType { + DIRECT, + HTTP, + SOCKS +} diff --git a/core/datastore/src/main/java/com/looker/core/datastore/model/SortOrder.kt b/core/datastore/src/main/java/com/looker/core/datastore/model/SortOrder.kt new file mode 100644 index 0000000..a527724 --- /dev/null +++ b/core/datastore/src/main/java/com/looker/core/datastore/model/SortOrder.kt @@ -0,0 +1,8 @@ +package com.leos.core.datastore.model + +// todo: Add Support for sorting by size +enum class SortOrder { + UPDATED, + ADDED, + NAME +} diff --git a/core/datastore/src/main/java/com/looker/core/datastore/model/Theme.kt b/core/datastore/src/main/java/com/looker/core/datastore/model/Theme.kt new file mode 100644 index 0000000..04914f1 --- /dev/null +++ b/core/datastore/src/main/java/com/looker/core/datastore/model/Theme.kt @@ -0,0 +1,9 @@ +package com.leos.core.datastore.model + +enum class Theme { + SYSTEM, + SYSTEM_BLACK, + LIGHT, + DARK, + AMOLED +} diff --git a/core/di/.gitignore b/core/di/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/core/di/.gitignore @@ -0,0 +1 @@ +/build diff --git a/core/di/build.gradle.kts b/core/di/build.gradle.kts new file mode 100644 index 0000000..902d8ca --- /dev/null +++ b/core/di/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + alias(libs.plugins.looker.android.library) + alias(libs.plugins.looker.hilt) + alias(libs.plugins.looker.lint) +} + +android { + namespace = "com.leos.core.di" + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) + } + create("alpha") { + initWith(getByName("debug")) + isMinifyEnabled = true + } + } +} diff --git a/core/di/src/main/kotlin/com/looker/core/di/CoroutinesModule.kt b/core/di/src/main/kotlin/com/looker/core/di/CoroutinesModule.kt new file mode 100644 index 0000000..a994bb6 --- /dev/null +++ b/core/di/src/main/kotlin/com/looker/core/di/CoroutinesModule.kt @@ -0,0 +1,41 @@ +package com.leos.core.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.* +import javax.inject.Qualifier +import javax.inject.Singleton + +@Retention(AnnotationRetention.RUNTIME) +@Qualifier +annotation class IoDispatcher + +@Retention(AnnotationRetention.RUNTIME) +@Qualifier +annotation class DefaultDispatcher + +@Retention(AnnotationRetention.RUNTIME) +@Qualifier +annotation class ApplicationScope + +@Module +@InstallIn(SingletonComponent::class) +object CoroutinesModule { + + @Provides + @IoDispatcher + fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO + + @Provides + @DefaultDispatcher + fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default + + @Provides + @Singleton + @ApplicationScope + fun providesCoroutineScope( + @DefaultDispatcher dispatcher: CoroutineDispatcher + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) +} diff --git a/core/domain/.gitignore b/core/domain/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/core/domain/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts new file mode 100644 index 0000000..0d186ee --- /dev/null +++ b/core/domain/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + alias(libs.plugins.looker.android.library) + alias(libs.plugins.looker.lint) + id("kotlin-parcelize") +} + +android { + namespace = "com.leos.core.domain" + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) + } + create("alpha") { + initWith(getByName("debug")) + isMinifyEnabled = true + } + } +} + +dependencies { + modules(Modules.coreCommon) +} diff --git a/core/domain/src/main/kotlin/com/looker/core/domain/InstalledItem.kt b/core/domain/src/main/kotlin/com/looker/core/domain/InstalledItem.kt new file mode 100644 index 0000000..e9a6643 --- /dev/null +++ b/core/domain/src/main/kotlin/com/looker/core/domain/InstalledItem.kt @@ -0,0 +1,8 @@ +package com.leos.core.domain + +class InstalledItem( + val packageName: String, + val version: String, + val versionCode: Long, + val signature: String +) diff --git a/core/domain/src/main/kotlin/com/looker/core/domain/Product.kt b/core/domain/src/main/kotlin/com/looker/core/domain/Product.kt new file mode 100644 index 0000000..d64bb16 --- /dev/null +++ b/core/domain/src/main/kotlin/com/looker/core/domain/Product.kt @@ -0,0 +1,102 @@ +package com.leos.core.domain + +data class Product( + var repositoryId: Long, + val packageName: String, + val name: String, + val summary: String, + var description: String, + val whatsNew: String, + val icon: String, + val metadataIcon: String, + val author: Author, + val source: String, + val changelog: String, + val web: String, + val tracker: String, + val added: Long, + val updated: Long, + val suggestedVersionCode: Long, + val categories: List, + val antiFeatures: List, + val licenses: List, + val donates: List, + val screenshots: List, + val releases: List +) { + data class Author(val name: String, val email: String, val web: String) + + sealed class Donate { + data class Regular(val url: String) : Donate() + data class Bitcoin(val address: String) : Donate() + data class Litecoin(val address: String) : Donate() + data class Flattr(val id: String) : Donate() + data class Liberapay(val id: String) : Donate() + data class OpenCollective(val id: String) : Donate() + } + + class Screenshot(val locale: String, val type: Type, val path: String) { + enum class Type(val jsonName: String) { + PHONE("phone"), + SMALL_TABLET("smallTablet"), + LARGE_TABLET("largeTablet") + } + + val identifier: String + get() = "$locale.${type.name}.$path" + } + + // Same releases with different signatures + val selectedReleases: List + get() = releases.filter { it.selected } + + val displayRelease: Release? + get() = selectedReleases.firstOrNull() ?: releases.firstOrNull() + + val version: String + get() = displayRelease?.version.orEmpty() + + val versionCode: Long + get() = selectedReleases.firstOrNull()?.versionCode ?: 0L + + val compatible: Boolean + get() = selectedReleases.firstOrNull()?.incompatibilities?.isEmpty() == true + + val signatures: List + get() = selectedReleases.mapNotNull { it.signature.ifBlank { null } }.distinct().toList() + + fun item(): ProductItem { + return ProductItem( + repositoryId, + packageName, + name, + summary, + icon, + metadataIcon, + version, + "", + compatible, + false, + 0 + ) + } + + fun canUpdate(installedItem: InstalledItem?): Boolean { + return installedItem != null && compatible && versionCode > installedItem.versionCode && + installedItem.signature in signatures + } +} + +fun List>.findSuggested( + installedItem: InstalledItem? +): Pair? = maxWithOrNull( + compareBy( + { (product, _) -> + product.compatible && + (installedItem == null || installedItem.signature in product.signatures) + }, + { (product, _) -> + product.versionCode + } + ) +) diff --git a/core/domain/src/main/kotlin/com/looker/core/domain/ProductItem.kt b/core/domain/src/main/kotlin/com/looker/core/domain/ProductItem.kt new file mode 100644 index 0000000..4418e4f --- /dev/null +++ b/core/domain/src/main/kotlin/com/looker/core/domain/ProductItem.kt @@ -0,0 +1,30 @@ +package com.leos.core.domain + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +data class ProductItem( + var repositoryId: Long, + var packageName: String, + var name: String, + var summary: String, + val icon: String, + val metadataIcon: String, + val version: String, + var installedVersion: String, + var compatible: Boolean, + var canUpdate: Boolean, + var matchRank: Int +) { + sealed class Section : Parcelable { + + @Parcelize + data object All : Section() + + @Parcelize + data class Category(val name: String) : Section() + + @Parcelize + data class Repository(val id: Long, val name: String) : Section() + } +} diff --git a/core/domain/src/main/kotlin/com/looker/core/domain/ProductPreference.kt b/core/domain/src/main/kotlin/com/looker/core/domain/ProductPreference.kt new file mode 100644 index 0000000..dc8b549 --- /dev/null +++ b/core/domain/src/main/kotlin/com/looker/core/domain/ProductPreference.kt @@ -0,0 +1,7 @@ +package com.leos.core.domain + +data class ProductPreference(val ignoreUpdates: Boolean, val ignoreVersionCode: Long) { + fun shouldIgnoreUpdate(versionCode: Long): Boolean { + return ignoreUpdates || ignoreVersionCode == versionCode + } +} diff --git a/core/domain/src/main/kotlin/com/looker/core/domain/Release.kt b/core/domain/src/main/kotlin/com/looker/core/domain/Release.kt new file mode 100644 index 0000000..845cb7c --- /dev/null +++ b/core/domain/src/main/kotlin/com/looker/core/domain/Release.kt @@ -0,0 +1,46 @@ +package com.leos.core.domain + +import android.net.Uri + +data class Release( + val selected: Boolean, + val version: String, + val versionCode: Long, + val added: Long, + val size: Long, + val minSdkVersion: Int, + val targetSdkVersion: Int, + val maxSdkVersion: Int, + val source: String, + val release: String, + val hash: String, + val hashType: String, + val signature: String, + val obbMain: String, + val obbMainHash: String, + val obbMainHashType: String, + val obbPatch: String, + val obbPatchHash: String, + val obbPatchHashType: String, + val permissions: List, + val features: List, + val platforms: List, + val incompatibilities: List +) { + sealed class Incompatibility { + object MinSdk : Incompatibility() + object MaxSdk : Incompatibility() + object Platform : Incompatibility() + data class Feature(val feature: String) : Incompatibility() + } + + val identifier: String + get() = "$versionCode.$hash" + + fun getDownloadUrl(repository: Repository): String { + return Uri.parse(repository.address).buildUpon().appendPath(release).build().toString() + } + + val cacheFileName: String + get() = "${hash.replace('/', '-')}.apk" +} diff --git a/core/domain/src/main/kotlin/com/looker/core/domain/Repository.kt b/core/domain/src/main/kotlin/com/looker/core/domain/Repository.kt new file mode 100644 index 0000000..18434c1 --- /dev/null +++ b/core/domain/src/main/kotlin/com/looker/core/domain/Repository.kt @@ -0,0 +1,407 @@ +package com.leos.core.domain + +import com.leos.core.domain.newer.isOnion +import java.net.URL + +data class Repository( + var id: Long, + val address: String, + val mirrors: List, + val name: String, + val description: String, + val version: Int, + val enabled: Boolean, + val fingerprint: String, + val lastModified: String, + val entityTag: String, + val updated: Long, + val timestamp: Long, + val authentication: String +) { + + /** + * Remove all onion addresses and supply it as random address + * + * If the list only contains onion urls we will provide the default address + */ + val randomAddress: String + get() = (mirrors + address) + .filter { !it.isOnion } + .randomOrNull() ?: address + + fun edit(address: String, fingerprint: String, authentication: String): Repository { + val isAddressChanged = this.address != address + val isFingerprintChanged = this.fingerprint != fingerprint + val shouldForceUpdate = isAddressChanged || isFingerprintChanged + return copy( + address = address, + fingerprint = fingerprint, + lastModified = if (shouldForceUpdate) "" else lastModified, + entityTag = if (shouldForceUpdate) "" else entityTag, + authentication = authentication + ) + } + + fun update( + mirrors: List, + name: String, + description: String, + version: Int, + lastModified: String, + entityTag: String, + timestamp: Long + ): Repository { + return copy( + mirrors = mirrors, + name = name, + description = description, + version = if (version >= 0) version else this.version, + lastModified = lastModified, + entityTag = entityTag, + updated = System.currentTimeMillis(), + timestamp = timestamp + ) + } + + fun enable(enabled: Boolean): Repository { + return copy(enabled = enabled, lastModified = "", entityTag = "") + } + + @Suppress("SpellCheckingInspection") + companion object { + + fun newRepository( + address: String, + fingerprint: String, + authentication: String + ): Repository { + val name = try { + URL(address).let { "${it.host}${it.path}" } + } catch (e: Exception) { + address + } + return defaultRepository(address, name, "", 0, true, fingerprint, authentication) + } + + private fun defaultRepository( + address: String, + name: String, + description: String, + version: Int = 21, + enabled: Boolean = false, + fingerprint: String, + authentication: String = "" + ): Repository { + return Repository( + -1, address, emptyList(), name, description, version, enabled, + fingerprint, "", "", 0L, 0L, authentication + ) + } + + val defaultRepositories = listOf( + + defaultRepository( + address = "http://62.178.96.192:3000/JoJo/apps/raw/branch/master/fdroid/repo", + name = "LeOS ungooled Apps", + description = "The respository includes goolge and trackerfree FOSS apps ", + enabled = true, + fingerprint = "020480A56FD52E6358FAFEFACE2261FFCFD5C6962B245B0244C9257EEFBDFF5C" + ), + defaultRepository( + address = "https://gitlab.com/harvey186/apps/-/raw/master/fdroid/repo", + name = "Alternativ Server for LeOS ungooled Apps", + description = "Alternativ Server for LeOS ungooled Apps", + enabled = false, + fingerprint = "020480A56FD52E6358FAFEFACE2261FFCFD5C6962B245B0244C9257EEFBDFF5C" + ), + + defaultRepository( + address = "https://f-droid.org/repo", + name = "F-Droid", + description = "The official F-Droid Free Software repos" + + "itory. Everything in this repository is always buil" + + "t from the source code.", + enabled = true, + fingerprint = "43238D512C1E5EB2D6569F4A3AFBF5523418B82E0A3ED1552770ABB9A9C9CCAB" + ), + defaultRepository( + address = "https://f-droid.org/archive", + name = "F-Droid Archive", + description = "The archive of the official F-Droid Free" + + " Software repository. Apps here are old and can co" + + "ntain known vulnerabilities and security issues!", + fingerprint = "43238D512C1E5EB2D6569F4A3AFBF5523418B82E0A3ED1552770ABB9A9C9CCAB" + ), + defaultRepository( + address = "https://guardianproject.info/fdroid/repo", + name = "Guardian Project Official Releases", + description = "The official repository of The Guardian " + + "Project apps for use with the F-Droid client. Appl" + + "ications in this repository are official binaries " + + "built by the original application developers and " + + "signed by the same key as the APKs that are relea" + + "sed in the Google Play Store.", + fingerprint = "B7C2EEFD8DAC7806AF67DFCD92EB18126BC08312A7F2D6F3862E46013C7A6135" + ), + defaultRepository( + address = "https://guardianproject.info/fdroid/archive", + name = "Guardian Project Archive", + description = "The official repository of The Guardian Pr" + + "oject apps for use with the F-Droid client. This con" + + "tains older versions of applications from the main repository.", + fingerprint = "B7C2EEFD8DAC7806AF67DFCD92EB18126BC08312A7F2D6F3862E46013C7A6135" + ), + defaultRepository( + address = "https://apt.izzysoft.de/fdroid/repo", + name = "IzzyOnDroid F-Droid Repo", + description = "This is a repository of apps to be used with" + + " F-Droid the original application developers, taken" + + " from the resp. repositories (mostly GitHub). At thi" + + "s moment I cannot give guarantees on regular updates" + + " for all of them, though most are checked multiple times a week ", + enabled = true, + fingerprint = "3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A" + ), + defaultRepository( + address = "https://microg.org/fdroid/repo", + name = "MicroG Project", + description = "The official repository for MicroG." + + " MicroG is a lightweight open-source implementation" + + " of Google Play Services.", + fingerprint = "9BD06727E62796C0130EB6DAB39B73157451582CBD138E86C468ACC395D14165" + ), + defaultRepository( + address = "https://repo.netsyms.com/fdroid/repo", + name = "Netsyms Technologies", + description = "Official collection of open-source apps created" + + " by Netsyms Technologies.", + fingerprint = "2581BA7B32D3AB443180C4087CAB6A7E8FB258D3A6E98870ECB3C675E4D64489" + ), + defaultRepository( + address = "https://fdroid.bromite.org/fdroid/repo", + name = "Bromite", + description = "The official repository for Bromite. " + + "Bromite is a Chromium with ad blocking and enhanced p" + + "rivacy.", + fingerprint = "E1EE5CD076D7B0DC84CB2B45FB78B86DF2EB39A3B6C56BA3DC292A5E0C3B9504" + ), + defaultRepository( + address = "https://molly.im/fdroid/foss/fdroid/repo", + name = "Molly", + description = "The official repository for Molly. " + + "Molly is a fork of Signal focused on security.", + fingerprint = "5198DAEF37FC23C14D5EE32305B2AF45787BD7DF2034DE33AD302BDB3446DF74" + ), + defaultRepository( + address = "https://archive.newpipe.net/fdroid/repo", + name = "NewPipe", + description = "The official repository for NewPipe." + + " NewPipe is a lightweight client for Youtube, PeerTube" + + ", Soundcloud, etc.", + fingerprint = "E2402C78F9B97C6C89E97DB914A2751FDA1D02FE2039CC0897A462BDB57E7501" + ), + defaultRepository( + address = "https://www.collaboraoffice.com/downloads/fdroid/repo", + name = "Collabora Office", + description = "Collabora Office is an office suite based on LibreOffice.", + fingerprint = "573258C84E149B5F4D9299E7434B2B69A8410372921D4AE586BA91EC767892CC" + ), + defaultRepository( + address = "https://fdroid.libretro.com/repo", + name = "LibRetro", + description = "The official canary repository for this great" + + " retro emulators hub.", + fingerprint = "3F05B24D497515F31FEAB421297C79B19552C5C81186B3750B7C131EF41D733D" + ), + defaultRepository( + address = "https://cdn.kde.org/android/fdroid/repo", + name = "KDE Android", + description = "The official nightly repository for KDE Android apps.", + fingerprint = "B3EBE10AFA6C5C400379B34473E843D686C61AE6AD33F423C98AF903F056523F" + ), + defaultRepository( + address = "https://calyxos.gitlab.io/calyx-fdroid-repo/fdroid/repo", + name = "Calyx OS Repo", + description = "The official Calyx Labs F-Droid repository.", + fingerprint = "C44D58B4547DE5096138CB0B34A1CC99DAB3B4274412ED753FCCBFC11DC1B7B6" + ), + defaultRepository( + address = "https://divestos.org/fdroid/official", + name = "Divest OS Repo", + description = "The official Divest OS F-Droid repository.", + fingerprint = "E4BE8D6ABFA4D9D4FEEF03CDDA7FF62A73FD64B75566F6DD4E5E577550BE8467" + ), + defaultRepository( + address = "https://fdroid.fedilab.app/repo", + name = "Fedilab", + description = "The official repository for Fedilab. Fedilab is a " + + "multi-accounts client for Mastodon, Peertube, and other free" + + " software social networks.", + fingerprint = "11F0A69910A4280E2CD3CCC3146337D006BE539B18E1A9FEACE15FF757A94FEB" + ), + defaultRepository( + address = "https://store.nethunter.com/repo", + name = "Kali Nethunter", + description = "Kali Nethunter's official selection of original b" + + "inaries.", + fingerprint = "7E418D34C3AD4F3C37D7E6B0FACE13332364459C862134EB099A3BDA2CCF4494" + ), + defaultRepository( + address = "https://secfirst.org/fdroid/repo", + name = "Umbrella", + description = "The official repository for Umbrella. Umbrella is" + + " a collection of security advices, tutorials, tools etc.", + fingerprint = "39EB57052F8D684514176819D1645F6A0A7BD943DBC31AB101949006AC0BC228" + ), + defaultRepository( + address = "https://thecapslock.gitlab.io/fdroid-patched-apps/fdroid/repo", + name = "Patched Apps", + description = "A collection of patched applications to provid" + + "e better compatibility, privacy etc..", + fingerprint = "313D9E6E789FF4E8E2D687AAE31EEF576050003ED67963301821AC6D3763E3AC" + ), + defaultRepository( + address = "https://mobileapp.bitwarden.com/fdroid/repo", + name = "Bitwarden", + description = "The official repository for Bitwarden. Bitward" + + "en is a password manager.", + fingerprint = "BC54EA6FD1CD5175BCCCC47C561C5726E1C3ED7E686B6DB4B18BAC843A3EFE6C" + ), + defaultRepository( + address = "https://briarproject.org/fdroid/repo", + name = "Briar", + description = "The official repository for Briar. Briar is a" + + " serverless/offline messenger that focused on privacy, s" + + "ecurity, and decentralization.", + fingerprint = "1FB874BEE7276D28ECB2C9B06E8A122EC4BCB4008161436CE474C257CBF49BD6" + ), + defaultRepository( + address = "https://guardianproject-wind.s3.amazonaws.com/fdroid/repo", + name = "Wind Project", + description = "A collection of interesting offline/serverless apps.", + fingerprint = "182CF464D219D340DA443C62155198E399FEC1BC4379309B775DD9FC97ED97E1" + ), + defaultRepository( + address = "https://nanolx.org/fdroid/repo", + name = "NanoDroid", + description = "A companion repository to microG's installer.", + fingerprint = "862ED9F13A3981432BF86FE93D14596B381D75BE83A1D616E2D44A12654AD015" + ), + defaultRepository( + address = "https://releases.threema.ch/fdroid/repo", + name = "Threema Libre", + description = "The official repository for Threema Libre. R" + + "equires Threema Shop license. Threema Libre is an open" + + "-source messanger focused on security and privacy.", + fingerprint = "5734E753899B25775D90FE85362A49866E05AC4F83C05BEF5A92880D2910639E" + ), + defaultRepository( + address = "https://fdroid.getsession.org/fdroid/repo", + name = "Session", + description = "The official repository for Session. Session" + + " is an open-source messanger focused on security and privacy.", + fingerprint = "DB0E5297EB65CC22D6BD93C869943BDCFCB6A07DC69A48A0DD8C7BA698EC04E6" + ), + defaultRepository( + address = "https://www.cromite.org/fdroid/repo", + name = "Cromite", + description = "The official repository for Cromite. Cromite" + + " is a Chromium with ad blocking and enhanced privacy.", + fingerprint = "49F37E74DEE483DCA2B991334FB5A0200787430D0B5F9A783DD5F13695E9517B" + ), + defaultRepository( + address = "https://fdroid.twinhelix.com/fdroid/repo", + name = "TwinHelix", + description = "TwinHelix F-Droid Repository, used for Signa" + + "l-FOSS, an open-source fork of Signal Private Messenger.", + fingerprint = "7b03b0232209b21b10a30a63897d3c6bca4f58fe29bc3477e8e3d8cf8e304028" + ), + defaultRepository( + address = "https://fdroid.typeblog.net", + name = "PeterCxy's F-Droid", + description = "You have landed on PeterCxy's F-Droid repo. T" + + "o use this repository, please add the page's URL to your F-Droid client.", + fingerprint = "1a7e446c491c80bc2f83844a26387887990f97f2f379ae7b109679feae3dbc8c" + ), + defaultRepository( + address = "https://s2.spiritcroc.de/fdroid/repo", + name = "SpiritCroc.de", + description = "While some of my apps are available from" + + " the official F-Droid repository, I also maintain my" + + " own repository for a small selection of apps. These" + + " might be forks of other apps with only minor change" + + "s, or apps that are not published on the Play Store f" + + "or other reasons. In contrast to the official F-Droid" + + " repos, these might also include proprietary librarie" + + "s, e.g. for push notifications.", + fingerprint = "6612ade7e93174a589cf5ba26ed3ab28231a789640546c8f30375ef045bc9242" + ), + defaultRepository( + address = "https://s2.spiritcroc.de/testing/fdroid/repo", + name = "SpiritCroc.de Test Builds", + description = "SpiritCroc.de Test Builds", + fingerprint = "52d03f2fab785573bb295c7ab270695e3a1bdd2adc6a6de8713250b33f231225" + ), + defaultRepository( + address = "https://static.cryptomator.org/android/fdroid/repo", + name = "Cryptomator", + description = "No Description", + fingerprint = "f7c3ec3b0d588d3cb52983e9eb1a7421c93d4339a286398e71d7b651e8d8ecdd" + ), + defaultRepository( + address = "https://divestos.org/apks/unofficial/fdroid/repo", + name = "DivestOS Unofficial", + description = "This repository contains unofficial builds of open source apps" + + " that are not included in the other repos.", + fingerprint = "a18cdb92f40ebfbbf778a54fd12dbd74d90f1490cb9ef2cc6c7e682dd556855d" + ), + defaultRepository( + address = "https://cdn.kde.org/android/stable-releases/fdroid/repo", + name = "KDE Stables", + description = "This repository contains unofficial builds of open source apps" + + " that are not included in the other repos.", + fingerprint = "13784ba6c80ff4e2181e55c56f961eed5844cea16870d3b38d58780b85e1158f" + ), + defaultRepository( + address = "https://julianfairfax.gitlab.io/fdroid-repo/fdroid/repo", + name = "Julian's F-Droid Repo (Proton, GrapheneOS)", + description = "Repository for installing apps more easily.", + fingerprint = "83ABB548CAA6F311CE3591DDCA466B65213FD0541352502702B1908F0C84206D" + ), + defaultRepository( + address = "https://zimbelstern.eu/fdroid/repo", + name = "Zimbelstern's F-Droid repository", + description = "This is the official repository of apps from zimbelstern.eu," + + " to be used with F-Droid.", + fingerprint = "285158DECEF37CB8DE7C5AF14818ACBF4A9B1FBE63116758EFC267F971CA23AA" + ), + defaultRepository( + address = "https://app.simplex.chat/fdroid/repo", + name = "SimpleX Chat F-Droid", + description = "SimpleX Chat official F-Droid repository.", + fingerprint = "9F358FF284D1F71656A2BFAF0E005DEAE6AA14143720E089F11FF2DDCFEB01BA" + ) + ) + + val newlyAdded = listOf( + defaultRepository( + address = "https://repo.samourai.io/fdroid/repo", + name = "Samourai Wallet", + description = "Samourai Bitcoin Wallet official F-Droid repository.", + fingerprint = "5318AFA280284855CF5D0027AA54517769F461D735980B1FB0854CEAE8E072A5" + ), + defaultRepository( + address = "https://f-droid.monerujo.io/fdroid/repo", + name = "Monerujo Wallet", + description = "Monerujo Monero Wallet official F-Droid repository.", + fingerprint = "A82C68E14AF0AA6A2EC20E6B272EFF25E5A038F3F65884316E0F5E0D91E7B713" + ), + defaultRepository( + address = "https://fdroid.cakelabs.com/fdroid/repo", + name = "Cake Labs", + description = "Cake Labs official F-Droid repository for Cake Wallet and Monero.com", + fingerprint = "EA44EFAEE0B641EE7A032D397D5D976F9C4E5E1ED26E11C75702D064E55F8755" + ), + ) + } +} diff --git a/core/domain/src/main/kotlin/com/looker/core/domain/Syncable.kt b/core/domain/src/main/kotlin/com/looker/core/domain/Syncable.kt new file mode 100644 index 0000000..e11ab41 --- /dev/null +++ b/core/domain/src/main/kotlin/com/looker/core/domain/Syncable.kt @@ -0,0 +1,14 @@ +package com.leos.core.domain + +import com.leos.core.domain.newer.App +import com.leos.core.domain.newer.Repo + +interface Syncable { + + val repo: Repo + + suspend fun getApps(): List + + suspend fun getUpdatedRepo(): Repo + +} diff --git a/core/domain/src/main/kotlin/com/looker/core/domain/newer/App.kt b/core/domain/src/main/kotlin/com/looker/core/domain/newer/App.kt new file mode 100644 index 0000000..b762fee --- /dev/null +++ b/core/domain/src/main/kotlin/com/looker/core/domain/newer/App.kt @@ -0,0 +1,76 @@ +package com.leos.core.domain.newer + +import com.leos.core.common.PackageName + +data class App( + val repoId: Long, + val categories: List, + val links: Links, + val metadata: Metadata, + val author: Author, + val screenshots: Screenshots, + val graphics: Graphics, + val donation: Donation, + val preferredSigner: String = "", + val packages: List +) + +data class Author( + val name: String, + val email: String, + val web: String +) + +data class Donation( + val regularUrl: String? = null, + val bitcoinAddress: String? = null, + val flattrId: String? = null, + val liteCoinAddress: String? = null, + val openCollectiveId: String? = null, + val librePayId: String? = null, + val librePayAddress: String? = null +) + +data class Graphics( + val featureGraphic: String = "", + val promoGraphic: String = "", + val tvBanner: String = "", + val video: String = "" +) + +data class Links( + val changelog: String = "", + val issueTracker: String = "", + val sourceCode: String = "", + val translation: String = "", + val webSite: String = "" +) + +data class Metadata( + val name: String, + val packageName: PackageName, + val added: Long, + val description: String, + val icon: String, + val lastUpdated: Long, + val license: String, + val suggestedVersionCode: Long, + val suggestedVersionName: String, + val summary: String +) + +data class Screenshots( + val phone: List = emptyList(), + val sevenInch: List = emptyList(), + val tenInch: List = emptyList(), + val tv: List = emptyList(), + val wear: List = emptyList() +) + +data class AppMinimal( + val name: String, + val summary: String, + val icon: String +) + +fun App.minimal(): AppMinimal = AppMinimal(metadata.name, metadata.summary, metadata.icon) diff --git a/core/domain/src/main/kotlin/com/looker/core/domain/newer/DataFile.kt b/core/domain/src/main/kotlin/com/looker/core/domain/newer/DataFile.kt new file mode 100644 index 0000000..e38de2f --- /dev/null +++ b/core/domain/src/main/kotlin/com/looker/core/domain/newer/DataFile.kt @@ -0,0 +1,7 @@ +package com.leos.core.domain.newer + +interface DataFile { + val name: String + val hash: String + val size: Long +} diff --git a/core/domain/src/main/kotlin/com/looker/core/domain/newer/Package.kt b/core/domain/src/main/kotlin/com/looker/core/domain/newer/Package.kt new file mode 100644 index 0000000..85b5211 --- /dev/null +++ b/core/domain/src/main/kotlin/com/looker/core/domain/newer/Package.kt @@ -0,0 +1,41 @@ +package com.leos.core.domain.newer + +data class Package( + val installed: Boolean, + val added: Long, + val apk: ApkFile, + val platforms: Platforms, + val features: List, + val antiFeatures: List, + val manifest: Manifest, + val whatsNew: String +) + +data class ApkFile( + override val name: String, + override val hash: String, + override val size: Long +) : DataFile + +data class Manifest( + val versionCode: Long, + val versionName: String, + val usesSDKs: SDKs, + val signer: Set, + val permissions: List +) + +@JvmInline +value class Platforms(val value: List) + +data class SDKs( + val min: Int = -1, + val max: Int = -1, + val target: Int = -1 +) + +// means the max sdk here and any sdk value as -1 means not valid +data class Permission( + val name: String, + val sdKs: SDKs +) diff --git a/core/domain/src/main/kotlin/com/looker/core/domain/newer/Repo.kt b/core/domain/src/main/kotlin/com/looker/core/domain/newer/Repo.kt new file mode 100644 index 0000000..d9cc263 --- /dev/null +++ b/core/domain/src/main/kotlin/com/looker/core/domain/newer/Repo.kt @@ -0,0 +1,50 @@ +package com.leos.core.domain.newer + +data class Repo( + val id: Long, + val enabled: Boolean, + val address: String, + val name: String, + val description: String, + val fingerprint: String, + val authentication: Authentication, + val versionInfo: VersionInfo, + val mirrors: List, + val antiFeatures: List, + val categories: List +) { + val shouldAuthenticate = + authentication.username.isNotEmpty() && authentication.password.isNotEmpty() + + fun update(fingerprint: String, timestamp: Long? = null, etag: String? = null): Repo { + return copy( + fingerprint = fingerprint, + versionInfo = timestamp?.let { VersionInfo(timestamp = it, etag = etag) } ?: versionInfo + ) + } +} + +val String.isOnion: Boolean + get() = endsWith(".onion") + +data class AntiFeature( + val name: String, + val icon: String = "", + val description: String = "" +) + +data class Category( + val name: String, + val icon: String = "", + val description: String = "" +) + +data class Authentication( + val username: String, + val password: String +) + +data class VersionInfo( + val timestamp: Long, + val etag: String? +) diff --git a/core/network/.gitignore b/core/network/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/core/network/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts new file mode 100644 index 0000000..fe00407 --- /dev/null +++ b/core/network/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + alias(libs.plugins.looker.android.library) + alias(libs.plugins.looker.hilt) + alias(libs.plugins.looker.lint) +} + +android { + namespace = "com.leos.network" + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) + } + create("alpha") { + initWith(getByName("debug")) + isMinifyEnabled = true + } + } +} + +dependencies { + modules(Modules.coreCommon) + + implementation(libs.kotlinx.coroutines.android) + implementation(libs.ktor.core) + implementation(libs.ktor.okhttp) +} diff --git a/core/network/src/main/java/com/looker/network/Downloader.kt b/core/network/src/main/java/com/looker/network/Downloader.kt new file mode 100644 index 0000000..4f15c7c --- /dev/null +++ b/core/network/src/main/java/com/looker/network/Downloader.kt @@ -0,0 +1,32 @@ +package com.leos.network + +import com.leos.core.common.DataSize +import com.leos.core.common.signature.FileValidator +import com.leos.network.header.HeadersBuilder +import java.io.File +import java.net.Proxy + +interface Downloader { + + fun setProxy(proxy: Proxy) + + suspend fun headCall( + url: String, + headers: HeadersBuilder.() -> Unit = {} + ): NetworkResponse + + suspend fun downloadToFile( + url: String, + target: File, + validator: FileValidator? = null, + headers: HeadersBuilder.() -> Unit = {}, + block: ProgressListener? = null + ): NetworkResponse + + companion object { + internal const val CONNECTION_TIMEOUT = 30_000L + internal const val SOCKET_TIMEOUT = 15_000L + } +} + +typealias ProgressListener = suspend (bytesReceived: DataSize, contentLength: DataSize) -> Unit diff --git a/core/network/src/main/java/com/looker/network/KtorDownloader.kt b/core/network/src/main/java/com/looker/network/KtorDownloader.kt new file mode 100644 index 0000000..d3eb4f8 --- /dev/null +++ b/core/network/src/main/java/com/looker/network/KtorDownloader.kt @@ -0,0 +1,152 @@ +package com.leos.network + +import com.leos.core.common.DataSize +import com.leos.core.common.extension.exceptCancellation +import com.leos.core.common.extension.size +import com.leos.core.common.signature.FileValidator +import com.leos.core.common.signature.ValidationException +import com.leos.network.Downloader.Companion.CONNECTION_TIMEOUT +import com.leos.network.Downloader.Companion.SOCKET_TIMEOUT +import com.leos.network.header.HeadersBuilder +import com.leos.network.header.KtorHeadersBuilder +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.engine.okhttp.OkHttpConfig +import io.ktor.client.network.sockets.ConnectTimeoutException +import io.ktor.client.network.sockets.SocketTimeoutException +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.onDownload +import io.ktor.client.request.head +import io.ktor.client.request.headers +import io.ktor.client.request.prepareGet +import io.ktor.client.request.request +import io.ktor.client.request.url +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsChannel +import io.ktor.http.HttpStatusCode +import io.ktor.http.URLParserException +import io.ktor.http.etag +import io.ktor.http.isSuccess +import io.ktor.http.lastModified +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.core.ByteReadPacket +import io.ktor.utils.io.core.isEmpty +import io.ktor.utils.io.core.readBytes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import java.io.File +import java.io.IOException +import java.net.Proxy + +internal class KtorDownloader : Downloader { + + private var client = HttpClient(OkHttp) { timeoutConfig() } + set(newClient) { + field.close() + field = newClient + } + + override fun setProxy(proxy: Proxy) { + client = HttpClient(OkHttp) { + timeoutConfig() + engine { this.proxy = proxy } + } + } + + override suspend fun headCall( + url: String, + headers: HeadersBuilder.() -> Unit + ): NetworkResponse { + val headRequest = createRequest( + url = url, + headers = headers + ) + return client.head(headRequest).asNetworkResponse() + } + + override suspend fun downloadToFile( + url: String, + target: File, + validator: FileValidator?, + headers: HeadersBuilder.() -> Unit, + block: ProgressListener? + ): NetworkResponse { + return try { + val request = createRequest( + url = url, + headers = { + inRange(target.size) + headers() + }, + fileSize = target.size, + block = block + ) + client.prepareGet(request).execute { response -> + response.bodyAsChannel() saveTo target + validator?.validate(target) + response.asNetworkResponse() + } + } catch (e: SocketTimeoutException) { + NetworkResponse.Error.SocketTimeout(e) + } catch (e: ConnectTimeoutException) { + NetworkResponse.Error.ConnectionTimeout(e) + } catch (e: IOException) { + NetworkResponse.Error.IO(e) + } catch (e: ValidationException) { + target.delete() + NetworkResponse.Error.Validation(e) + } catch (e: Exception) { + e.exceptCancellation() + NetworkResponse.Error.Unknown(e) + } + } + + companion object { + + private fun HttpClientConfig.timeoutConfig() = install(HttpTimeout) { + connectTimeoutMillis = CONNECTION_TIMEOUT + socketTimeoutMillis = SOCKET_TIMEOUT + } + + private fun createRequest( + url: String, + headers: HeadersBuilder.() -> Unit, + fileSize: Long? = null, + block: ProgressListener? = null + ) = request { + url(url) + headers { + KtorHeadersBuilder(this).headers() + } + onDownload { read, total -> + if (block != null) { + block(DataSize(read + (fileSize ?: 0L)), DataSize(total + (fileSize ?: 0L))) + } + } + } + + private suspend infix fun ByteReadChannel.saveTo(target: File) = + withContext(Dispatchers.IO) { + while (!isClosedForRead && isActive) { + val packet = readRemaining(DEFAULT_BUFFER_SIZE.toLong()) + packet.appendTo(target) + } + } + + private suspend fun ByteReadPacket.appendTo(file: File) = withContext(Dispatchers.IO) { + while (!isEmpty && isActive) { + val bytes = readBytes() + file.appendBytes(bytes) + } + } + + private fun HttpResponse.asNetworkResponse(): NetworkResponse = + if (status.isSuccess() || status == HttpStatusCode.NotModified) { + NetworkResponse.Success(status.value, lastModified(), etag()) + } else { + NetworkResponse.Error.Http(status.value) + } + } +} diff --git a/core/network/src/main/java/com/looker/network/NetworkResponse.kt b/core/network/src/main/java/com/looker/network/NetworkResponse.kt new file mode 100644 index 0000000..c07158f --- /dev/null +++ b/core/network/src/main/java/com/looker/network/NetworkResponse.kt @@ -0,0 +1,28 @@ +package com.leos.network + +import com.leos.core.common.signature.ValidationException +import java.util.Date + +sealed interface NetworkResponse { + + sealed interface Error : NetworkResponse { + + data class ConnectionTimeout(val exception: Exception) : Error + + data class SocketTimeout(val exception: Exception) : Error + + data class IO(val exception: Exception) : Error + + data class Validation(val exception: ValidationException) : Error + + data class Unknown(val exception: Exception) : Error + + data class Http(val statusCode: Int) : Error + } + + data class Success( + val statusCode: Int, + val lastModified: Date?, + val etag: String? + ) : NetworkResponse +} diff --git a/core/network/src/main/java/com/looker/network/di/NetworkModule.kt b/core/network/src/main/java/com/looker/network/di/NetworkModule.kt new file mode 100644 index 0000000..e959189 --- /dev/null +++ b/core/network/src/main/java/com/looker/network/di/NetworkModule.kt @@ -0,0 +1,18 @@ +package com.leos.network.di + +import com.leos.network.Downloader +import com.leos.network.KtorDownloader +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + @Singleton + @Provides + fun provideDownloader(): Downloader = KtorDownloader() +} diff --git a/core/network/src/main/java/com/looker/network/header/HeadersBuilder.kt b/core/network/src/main/java/com/looker/network/header/HeadersBuilder.kt new file mode 100644 index 0000000..b13ae7a --- /dev/null +++ b/core/network/src/main/java/com/looker/network/header/HeadersBuilder.kt @@ -0,0 +1,20 @@ +package com.leos.network.header + +import java.util.Date + +interface HeadersBuilder { + + infix fun String.headsWith(value: Any?) + + fun etag(etagString: String) + + fun ifModifiedSince(date: Date) + + fun ifModifiedSince(date: String) + + fun authentication(username: String, password: String) + + fun authentication(base64: String) + + fun inRange(start: Number?, end: Number? = null) +} diff --git a/core/network/src/main/java/com/looker/network/header/KtorHeadersBuilder.kt b/core/network/src/main/java/com/looker/network/header/KtorHeadersBuilder.kt new file mode 100644 index 0000000..57e4c38 --- /dev/null +++ b/core/network/src/main/java/com/looker/network/header/KtorHeadersBuilder.kt @@ -0,0 +1,44 @@ +package com.leos.network.header + +import com.leos.core.common.extension.toFormattedString +import io.ktor.http.HttpHeaders +import io.ktor.util.encodeBase64 +import java.util.Date + +internal class KtorHeadersBuilder( + private val builder: io.ktor.http.HeadersBuilder +) : HeadersBuilder { + + override fun String.headsWith(value: Any?) { + if (value == null) return + with(builder) { + append(this@headsWith, value.toString()) + } + } + + override fun etag(etagString: String) { + HttpHeaders.ETag headsWith etagString + } + + override fun ifModifiedSince(date: Date) { + HttpHeaders.IfModifiedSince headsWith date.toFormattedString() + } + + override fun ifModifiedSince(date: String) { + HttpHeaders.IfModifiedSince headsWith date + } + + override fun authentication(username: String, password: String) { + HttpHeaders.Authorization headsWith "Basic ${"$username:$password".encodeBase64()}" + } + + override fun authentication(base64: String) { + HttpHeaders.Authorization headsWith base64 + } + + override fun inRange(start: Number?, end: Number?) { + if (start == null) return + val valueString = if (end != null) "bytes=$start-$end" else "bytes=$start-" + HttpHeaders.Range headsWith valueString + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..8a72728 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,22 @@ +## For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx1024m -XX:MaxPermSize=256m +# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +# +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +#Sun Sep 11 10:12:12 IST 2022 +org.gradle.daemon=true +org.gradle.jvmargs=-Xmx6g -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g +android.useAndroidX=true +org.gradle.parallel=true +android.nonFinalResIds=true +android.enableJetifier=false +android.enableR8.fullMode=true +android.nonTransitiveRClass=true +org.gradle.unsafe.configuration-cache=true +android.defaults.buildfeatures.buildconfig=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..0b8bac0 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,111 @@ +# Taken from NIA sample app by Google +[versions] +androidDesugarJdkLibs = "2.0.4" +androidGradlePlugin = "8.2.0" +androidMaterial = "1.10.0" +androidxActivity = "1.8.2" +androidxAppCompat = "1.6.1" +androidxCore = "1.12.0" +androidxDataStore = "1.0.0" +androidxFragment = "1.6.2" +androidxEspresso = "3.5.1" +androidxLifecycle = "2.6.2" +androidxNavigation = "2.7.5" +androidxRecyclerView = "1.3.2" +androidxSqlite = "2.4.0" +androidxTestCore = "1.5.0" +androidxTestExt = "1.1.5" +androidxTestRules = "1.5.0" +androidxTestRunner = "1.5.2" +androidxWork = "2.9.0" +coil = "2.5.0" +fdroid = "0.1.1" +hilt = "2.49" +hiltExt = "1.1.0" +junit4 = "4.13.2" +jackson = "2.16.0" +kotlin = "1.9.21" +kotlinxCoroutines = "1.7.3" +kotlinxDatetime = "0.5.0" +kotlinxSerializationJson = "1.6.2" +ksp = "1.9.21-1.0.15" +ktlint = "12.0.3" +ktor = "2.3.7" +libsu = "5.2.2" +room = "2.6.1" +shizuku = "13.0.0" +image-viewer = "v1.0.1" + +[libraries] +android-desugarJdkLibs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "androidDesugarJdkLibs" } +android-material = { group = "com.google.android.material", name = "material", version.ref = "androidMaterial" } +androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "androidxActivity" } +androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "androidxFragment" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppCompat" } +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" } +androidx-dataStore-core = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidxDataStore" } +androidx-dataStore-proto = { group = "androidx.datastore", name = "datastore", version.ref = "androidxDataStore" } +androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "androidxLifecycle" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidxLifecycle" } +androidx-lifecycle-viewModel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "androidxLifecycle" } +androidx-navigation-ktx = { group = "androidx.navigation", name = "navigation-ktx", version.ref = "androidxNavigation" } +androidx-navigation-testing = { group = "androidx.navigation", name = "navigation-testing", version.ref = "androidxNavigation" } +androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "androidxRecyclerView" } +androidx-sqlite-ktx = { group = "androidx.sqlite", name = "sqlite-ktx", version.ref = "androidxSqlite" } +androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" } +androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidxEspresso" } +androidx-test-ext = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "androidxTestExt" } +androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" } +androidx-work-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "androidxWork" } +androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "androidxWork" } +coil-kt = { group = "io.coil-kt", name = "coil", version.ref = "coil" } +coil-kt-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" } +fdroid-index = { group = "org.fdroid", name = "index", version.ref = "fdroid" } +fdroid-download = { group = "org.fdroid", name = "download", version.ref = "fdroid" } +hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } +hilt-ext-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltExt" } +hilt-ext-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltExt" } +hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } +junit4 = { group = "junit", name = "junit", version.ref = "junit4" } +jackson-core = { group = "com.fasterxml.jackson.core", name = "jackson-core", version.ref = "jackson" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } +kotlinx-coroutines-guava = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-guava", version.ref = "kotlinxCoroutines" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } +kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +ktor-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } +ktor-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } +libsu-core = { group = "com.github.topjohnwu.libsu", name = "core", version.ref = "libsu" } +room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +shizuku-api = { group = "dev.rikka.shizuku", name = "api", version.ref = "shizuku" } +shizuku-provider = { group = "dev.rikka.shizuku", name = "provider", version.ref = "shizuku" } +image-viewer = { module = "com.github.stfalcon-studio:StfalconImageViewer", version.ref = "image-viewer" } + +# Dependencies of the included build-logic +android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } +kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } +kotlin-ktlint = { group = "org.jlleitschuh.gradle", name = "ktlint-gradle", version.ref = "ktlint" } +ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } +android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } +android-test = { id = "com.android.test", version.ref = "androidGradlePlugin" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } + +# Plugins defined by this project +looker-android-application = { id = "looker.android.application", version = "unspecified" } +looker-android-library = { id = "looker.android.library", version = "unspecified" } +looker-hilt = { id = "looker.hilt", version = "unspecified" } +looker-hilt-work = { id = "looker.hilt.work", version = "unspecified" } +looker-lint = { id = "looker.lint", version = "unspecified" } +looker-room = { id = "looker.room", version = "unspecified" } +looker-serialization = { id = "looker.serialization", version = "unspecified" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d64cd49 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1af9e09 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..1aa94a4 --- /dev/null +++ b/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# 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 +# +# https://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. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/installer/.gitignore b/installer/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/installer/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/installer/build.gradle.kts b/installer/build.gradle.kts new file mode 100644 index 0000000..74731de --- /dev/null +++ b/installer/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + alias(libs.plugins.looker.android.library) + alias(libs.plugins.looker.hilt) + alias(libs.plugins.looker.lint) +} + +android { + namespace = "com.leos.installer" + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) + } + create("alpha") { + initWith(getByName("debug")) + isMinifyEnabled = true + } + } +} + +dependencies { + modules(Modules.coreCommon, Modules.coreDatastore) + + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.coroutines.guava) + implementation(libs.libsu.core) + implementation(libs.shizuku.api) + api(libs.shizuku.provider) +} diff --git a/installer/src/main/java/com/looker/installer/InstallManager.kt b/installer/src/main/java/com/looker/installer/InstallManager.kt new file mode 100644 index 0000000..9f00fb6 --- /dev/null +++ b/installer/src/main/java/com/looker/installer/InstallManager.kt @@ -0,0 +1,123 @@ +package com.leos.installer + +import android.content.Context +import com.leos.core.common.Constants +import com.leos.core.common.PackageName +import com.leos.core.common.extension.addAndCompute +import com.leos.core.common.extension.filter +import com.leos.core.common.extension.notificationManager +import com.leos.core.common.extension.updateAsMutable +import com.leos.core.datastore.SettingsRepository +import com.leos.core.datastore.get +import com.leos.core.datastore.model.InstallerType +import com.leos.installer.installers.Installer +import com.leos.installer.installers.LegacyInstaller +import com.leos.installer.installers.root.RootInstaller +import com.leos.installer.installers.session.SessionInstaller +import com.leos.installer.installers.shizuku.ShizukuInstaller +import com.leos.installer.model.InstallItem +import com.leos.installer.model.InstallState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +// TODO: Fix the stuck state, and other installer +class InstallManager( + private val context: Context, + settingsRepository: SettingsRepository +) { + + private val installItems = Channel() + private val uninstallItems = Channel() + + val state = MutableStateFlow>(emptyMap()) + + private var _installer: Installer? = null + set(value) { + field?.cleanup() + field = value + } + private val installer: Installer get() = _installer!! + + private val lock = Mutex() + private val installerPreference = settingsRepository.get { installerType } + + suspend operator fun invoke() = coroutineScope { + setupInstaller() + installer() + uninstaller() + } + + fun close() { + _installer = null + uninstallItems.close() + installItems.close() + } + + suspend infix fun install(installItem: InstallItem) { + installItems.send(installItem) + } + + suspend infix fun uninstall(packageName: PackageName) { + uninstallItems.send(packageName) + } + + infix fun remove(packageName: PackageName) { + updateState { remove(packageName) } + } + + private fun CoroutineScope.setupInstaller() = launch { + installerPreference.collectLatest(::setInstaller) + } + + private fun CoroutineScope.installer() = launch { + val currentQueue = mutableSetOf() + installItems.filter { item -> + currentQueue.addAndCompute(item.packageName.name) { isAdded -> + if (isAdded) { + updateState { put(item.packageName, InstallState.Pending) } + } + } + }.consumeEach { item -> + if (state.value.containsKey(item.packageName)) { + updateState { put(item.packageName, InstallState.Installing) } + val success = installer.install(item) + installer.cleanup() + updateState { put(item.packageName, success) } + context.notificationManager?.cancel( + "download-${item.packageName.name}", + Constants.NOTIFICATION_ID_DOWNLOADING + ) + currentQueue.remove(item.packageName.name) + } + } + } + + private fun CoroutineScope.uninstaller() = launch { + uninstallItems.consumeEach { + installer.uninstall(it) + } + } + + private suspend fun setInstaller(installerType: InstallerType) { + lock.withLock { + _installer = when (installerType) { + InstallerType.LEGACY -> LegacyInstaller(context) + InstallerType.SESSION -> SessionInstaller(context) + InstallerType.SHIZUKU -> ShizukuInstaller(context) + InstallerType.ROOT -> RootInstaller(context) + } + } + } + + private inline fun updateState(block: MutableMap.() -> Unit) { + state.update { it.updateAsMutable(block) } + } +} diff --git a/installer/src/main/java/com/looker/installer/InstallModule.kt b/installer/src/main/java/com/looker/installer/InstallModule.kt new file mode 100644 index 0000000..7858f48 --- /dev/null +++ b/installer/src/main/java/com/looker/installer/InstallModule.kt @@ -0,0 +1,34 @@ +package com.leos.installer + +import android.content.Context +import com.leos.core.datastore.SettingsRepository +import com.leos.installer.installers.root.RootPermissionHandler +import com.leos.installer.installers.shizuku.ShizukuPermissionHandler +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object InstallModule { + + @Singleton + @Provides + fun providesInstaller( + @ApplicationContext context: Context, + settingsRepository: SettingsRepository + ): InstallManager = InstallManager(context, settingsRepository) + + @Singleton + @Provides + fun provideShizukuPermissionHandler( + @ApplicationContext context: Context + ): ShizukuPermissionHandler = ShizukuPermissionHandler(context) + + @Singleton + @Provides + fun provideRootPermissionHandler(): RootPermissionHandler = RootPermissionHandler() +} diff --git a/installer/src/main/java/com/looker/installer/installers/Installer.kt b/installer/src/main/java/com/looker/installer/installers/Installer.kt new file mode 100644 index 0000000..75e1c01 --- /dev/null +++ b/installer/src/main/java/com/looker/installer/installers/Installer.kt @@ -0,0 +1,14 @@ +package com.leos.installer.installers + +import com.leos.core.common.PackageName +import com.leos.installer.model.InstallItem +import com.leos.installer.model.InstallState + +interface Installer { + + suspend fun install(installItem: InstallItem): InstallState + + suspend fun uninstall(packageName: PackageName) + + fun cleanup() +} diff --git a/installer/src/main/java/com/looker/installer/installers/LegacyInstaller.kt b/installer/src/main/java/com/looker/installer/installers/LegacyInstaller.kt new file mode 100644 index 0000000..0fafdba --- /dev/null +++ b/installer/src/main/java/com/looker/installer/installers/LegacyInstaller.kt @@ -0,0 +1,70 @@ +package com.leos.installer.installers + +import android.content.Context +import android.content.Intent +import android.util.AndroidRuntimeException +import androidx.core.net.toUri +import com.leos.core.common.PackageName +import com.leos.core.common.SdkCheck +import com.leos.core.common.cache.Cache +import com.leos.installer.model.InstallItem +import com.leos.installer.model.InstallState +import kotlin.coroutines.resume +import kotlinx.coroutines.suspendCancellableCoroutine + +@Suppress("DEPRECATION") +internal class LegacyInstaller(private val context: Context) : Installer { + + companion object { + private const val APK_MIME = "application/vnd.android.package-archive" + } + + override suspend fun install( + installItem: InstallItem + ): InstallState = suspendCancellableCoroutine { cont -> + val (uri, flags) = if (SdkCheck.isNougat) { + Cache.getReleaseUri( + context, + installItem.installFileName + ) to Intent.FLAG_GRANT_READ_URI_PERMISSION + } else { + val file = Cache.getReleaseFile(context, installItem.installFileName) + file.toUri() to 0 + } + try { + context.startActivity( + Intent(Intent.ACTION_INSTALL_PACKAGE).setDataAndType(uri, APK_MIME).setFlags(flags) + ) + cont.resume(InstallState.Installed) + } catch (e: AndroidRuntimeException) { + context.startActivity( + Intent(Intent.ACTION_INSTALL_PACKAGE).setDataAndType(uri, APK_MIME) + .setFlags(flags or Intent.FLAG_ACTIVITY_NEW_TASK) + ) + cont.resume(InstallState.Installed) + } catch (e: Exception) { + cont.resume(InstallState.Failed) + } + } + + override suspend fun uninstall(packageName: PackageName) = + context.uninstallPackage(packageName) + + override fun cleanup() {} +} + +internal suspend fun Context.uninstallPackage(packageName: PackageName) = + suspendCancellableCoroutine { cont -> + try { + startActivity( + Intent( + Intent.ACTION_UNINSTALL_PACKAGE, + "package:${packageName.name}".toUri() + ).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + cont.resume(Unit) + } catch (e: Exception) { + e.printStackTrace() + cont.resume(Unit) + } + } diff --git a/installer/src/main/java/com/looker/installer/installers/root/RootInstaller.kt b/installer/src/main/java/com/looker/installer/installers/root/RootInstaller.kt new file mode 100644 index 0000000..9a430c9 --- /dev/null +++ b/installer/src/main/java/com/looker/installer/installers/root/RootInstaller.kt @@ -0,0 +1,78 @@ +package com.leos.installer.installers.root + +import android.content.Context +import com.leos.core.common.PackageName +import com.leos.core.common.SdkCheck +import com.leos.core.common.cache.Cache +import com.leos.installer.installers.Installer +import com.leos.installer.installers.uninstallPackage +import com.leos.installer.model.InstallItem +import com.leos.installer.model.InstallState +import com.topjohnwu.superuser.Shell +import kotlinx.coroutines.suspendCancellableCoroutine +import java.io.File +import kotlin.coroutines.resume + +internal class RootInstaller(private val context: Context) : Installer { + + companion object { + private const val ROOT_INSTALL_PACKAGE = "cat %s | pm install --user %s -t -r -S %s" + private const val DELETE_PACKAGE = "%s rm %s" + + private val getCurrentUserState: String + get() = if (SdkCheck.isOreo) { + Shell.cmd("am get-current-user").exec().out[0] + } else { + Shell.cmd("dumpsys activity | grep -E \"mUserLru\"") + .exec().out[0].trim() + .removePrefix("mUserLru: [").removeSuffix("]") + } + + private val String.quote + get() = "\"${this.replace(Regex("""[\\$"`]""")) { c -> "\\${c.value}" }}\"" + + private val getUtilBoxPath: String + get() { + listOf("toybox", "busybox").forEach { + val shellResult = Shell.cmd("which $it").exec() + if (shellResult.out.isNotEmpty()) { + val utilBoxPath = shellResult.out.joinToString("") + if (utilBoxPath.isNotEmpty()) return utilBoxPath.quote + } + } + return "" + } + + private val File.install + get() = String.format( + ROOT_INSTALL_PACKAGE, + absolutePath, + getCurrentUserState, + length() + ) + + private val File.deletePackage + get() = String.format( + DELETE_PACKAGE, + getUtilBoxPath, + absolutePath.quote + ) + } + + override suspend fun install( + installItem: InstallItem + ): InstallState = suspendCancellableCoroutine { cont -> + val releaseFile = Cache.getReleaseFile(context, installItem.installFileName) + Shell.cmd(releaseFile.install).submit { shellResult -> + val result = if (shellResult.isSuccess) InstallState.Installed + else InstallState.Failed + cont.resume(result) + Shell.cmd(releaseFile.deletePackage).submit() + } + } + + override suspend fun uninstall(packageName: PackageName) = + context.uninstallPackage(packageName) + + override fun cleanup() {} +} diff --git a/installer/src/main/java/com/looker/installer/installers/root/RootPermissionHandler.kt b/installer/src/main/java/com/looker/installer/installers/root/RootPermissionHandler.kt new file mode 100644 index 0000000..9923497 --- /dev/null +++ b/installer/src/main/java/com/looker/installer/installers/root/RootPermissionHandler.kt @@ -0,0 +1,12 @@ +package com.leos.installer.installers.root + +import com.topjohnwu.superuser.Shell + +class RootPermissionHandler { + + val isGranted: Boolean + get() { + Shell.getCachedShell() ?: Shell.getShell() + return Shell.isAppGrantedRoot() ?: false + } +} diff --git a/installer/src/main/java/com/looker/installer/installers/session/SessionInstaller.kt b/installer/src/main/java/com/looker/installer/installers/session/SessionInstaller.kt new file mode 100644 index 0000000..3255fd0 --- /dev/null +++ b/installer/src/main/java/com/looker/installer/installers/session/SessionInstaller.kt @@ -0,0 +1,108 @@ +package com.leos.installer.installers.session + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.leos.core.common.PackageName +import com.leos.core.common.SdkCheck +import com.leos.core.common.cache.Cache +import com.leos.core.common.log +import com.leos.core.common.sdkAbove +import com.leos.installer.installers.Installer +import com.leos.installer.model.InstallItem +import com.leos.installer.model.InstallState +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +internal class SessionInstaller(private val context: Context) : Installer { + + private val installer = context.packageManager.packageInstaller + private val intent = Intent(context, SessionInstallerService::class.java) + + companion object { + private var installerCallbacks: PackageInstaller.SessionCallback? = null + private val flags = if (SdkCheck.isSnowCake) PendingIntent.FLAG_MUTABLE else 0 + private val sessionParams = + PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply { + sdkAbove(sdk = Build.VERSION_CODES.S) { + setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED) + } + } + } + + override suspend fun install( + installItem: InstallItem + ): InstallState = suspendCancellableCoroutine { cont -> + val cacheFile = Cache.getReleaseFile(context, installItem.installFileName) + val id = installer.createSession(sessionParams) + val installerCallback = object : PackageInstaller.SessionCallback() { + override fun onCreated(sessionId: Int) {} + override fun onBadgingChanged(sessionId: Int) {} + override fun onActiveChanged(sessionId: Int, active: Boolean) {} + override fun onProgressChanged(sessionId: Int, progress: Float) {} + override fun onFinished(sessionId: Int, success: Boolean) { + if (sessionId == id) cont.resume(InstallState.Installed) + } + } + installerCallbacks = installerCallback + + installer.registerSessionCallback( + installerCallbacks!!, + Handler(Looper.getMainLooper()) + ) + + val session = installer.openSession(id) + + session.use { activeSession -> + val sizeBytes = cacheFile.length() + cacheFile.inputStream().use { fileStream -> + activeSession.openWrite(cacheFile.name, 0, sizeBytes).use { outputStream -> + if (cont.isActive) { + fileStream.copyTo(outputStream) + activeSession.fsync(outputStream) + } + } + } + + val pendingIntent = PendingIntent.getService(context, id, intent, flags) + + if (cont.isActive) activeSession.commit(pendingIntent.intentSender) + } + + cont.invokeOnCancellation { + try { + installer.abandonSession(id) + } catch (e: SecurityException) { + e.printStackTrace() + } + } + } + + @SuppressLint("MissingPermission") + override suspend fun uninstall(packageName: PackageName) = + suspendCancellableCoroutine { cont -> + intent.putExtra(SessionInstallerService.ACTION_UNINSTALL, true) + val pendingIntent = PendingIntent.getService(context, -1, intent, flags) + + installer.uninstall(packageName.name, pendingIntent.intentSender) + cont.resume(Unit) + } + + override fun cleanup() { + installerCallbacks?.let { + installer.unregisterSessionCallback(it) + installerCallbacks = null + } + try { + installer.mySessions.forEach { installer.abandonSession(it.sessionId) } + } catch (e: SecurityException) { + log(e.message, type = Log.ERROR) + } + } +} diff --git a/installer/src/main/java/com/looker/installer/installers/session/SessionInstallerService.kt b/installer/src/main/java/com/looker/installer/installers/session/SessionInstallerService.kt new file mode 100644 index 0000000..7ab6afe --- /dev/null +++ b/installer/src/main/java/com/looker/installer/installers/session/SessionInstallerService.kt @@ -0,0 +1,123 @@ +package com.leos.installer.installers.session + +import android.app.Service +import android.content.Intent +import android.content.pm.PackageInstaller +import android.content.pm.PackageManager +import android.os.IBinder +import android.view.ContextThemeWrapper +import androidx.core.app.NotificationCompat +import com.leos.core.common.Constants.NOTIFICATION_CHANNEL_DOWNLOADING +import com.leos.core.common.Constants.NOTIFICATION_ID_DOWNLOADING +import com.leos.core.common.R as CommonR +import com.leos.core.common.extension.notificationManager + +class SessionInstallerService : Service() { + companion object { + const val ACTION_UNINSTALL = "action_uninstall" + } + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1) + + if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) { + // prompts user to enable unknown source + val promptIntent: Intent? = intent.getParcelableExtra(Intent.EXTRA_INTENT) + + promptIntent?.let { + it.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) + it.putExtra(Intent.EXTRA_INSTALLER_PACKAGE_NAME, "com.android.vending") + it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + startActivity(it) + } + } else { + notifyStatus(intent) + } + + stopSelf() + return START_NOT_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + /** + * Notifies user of installer outcome. + */ + private fun notifyStatus(intent: Intent) { + // unpack from intent + val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1) + val name = intent.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME) + val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) + val isUninstall = intent.getBooleanExtra(ACTION_UNINSTALL, false) + + // get application name for notifications + val appLabel = try { + if (name != null) { + packageManager.getApplicationLabel( + packageManager.getApplicationInfo( + name, + PackageManager.GET_META_DATA + ) + ) + } else { + null + } + } catch (_: Exception) { + null + } + + val notificationTag = "download-$name" + + // start building + val builder = NotificationCompat + .Builder(this, NOTIFICATION_CHANNEL_DOWNLOADING) + .setAutoCancel(true) + + when (status) { + PackageInstaller.STATUS_SUCCESS -> { + if (isUninstall) { + // remove any notification for this app + notificationManager?.cancel(notificationTag, NOTIFICATION_ID_DOWNLOADING) + } else { + val notification = builder + .setSmallIcon(CommonR.drawable.ic_check) + .setColor( + ContextThemeWrapper(this, CommonR.style.Theme_Main_Light) + .getColor(CommonR.color.md_theme_light_primaryContainer) + ) + .setContentTitle("Installed") + .setContentText(appLabel) + .build() + notificationManager?.notify( + notificationTag, + NOTIFICATION_ID_DOWNLOADING, + notification + ) + } + } + + PackageInstaller.STATUS_FAILURE_ABORTED -> { + // do nothing if user cancels + } + + else -> { + // problem occurred when installing/uninstalling package + val notification = builder + .setSmallIcon(android.R.drawable.stat_notify_error) + .setColor( + ContextThemeWrapper(this, CommonR.style.Theme_Main_Light) + .getColor(CommonR.color.md_theme_dark_errorContainer) + ) + .setContentTitle("Unknown Error") + .setContentText(message) + .build() + notificationManager?.notify( + notificationTag, + NOTIFICATION_ID_DOWNLOADING, + notification + ) + } + } + } +} diff --git a/installer/src/main/java/com/looker/installer/installers/shizuku/ShizukuInstaller.kt b/installer/src/main/java/com/looker/installer/installers/shizuku/ShizukuInstaller.kt new file mode 100644 index 0000000..a95a594 --- /dev/null +++ b/installer/src/main/java/com/looker/installer/installers/shizuku/ShizukuInstaller.kt @@ -0,0 +1,91 @@ +package com.leos.installer.installers.shizuku + +import android.content.Context +import com.leos.core.common.PackageName +import com.leos.core.common.SdkCheck +import com.leos.core.common.cache.Cache +import com.leos.installer.installers.Installer +import com.leos.installer.installers.uninstallPackage +import com.leos.installer.model.InstallItem +import com.leos.installer.model.InstallState +import java.io.BufferedReader +import java.io.InputStream +import kotlin.coroutines.resume +import kotlinx.coroutines.suspendCancellableCoroutine +import rikka.shizuku.Shizuku + +@Suppress("DEPRECATION") +internal class ShizukuInstaller(private val context: Context) : Installer { + + companion object { + private val SESSION_ID_REGEX = Regex("(?<=\\[).+?(?=])") + } + + override suspend fun install( + installItem: InstallItem + ): InstallState = suspendCancellableCoroutine { cont -> + var sessionId: String? = null + val uri = Cache.getReleaseUri(context, installItem.installFileName) + val releaseFileLength = + Cache.getReleaseFile(context, installItem.installFileName).length() + val packageName = installItem.packageName.name + try { + val size = + releaseFileLength.takeIf { it >= 0 } ?: run { + cont.cancel() + throw IllegalStateException() + } + if (cont.isCompleted) return@suspendCancellableCoroutine + context.contentResolver.openInputStream(uri).use { + val createCommand = + if (SdkCheck.isNougat) { + "pm install-create --user current -i $packageName -S $size" + } else { + "pm install-create -i $packageName -S $size" + } + val createResult = exec(createCommand) + sessionId = SESSION_ID_REGEX.find(createResult.out)?.value + ?: run { + cont.cancel() + throw RuntimeException("Failed to create install session") + } + if (cont.isCompleted) return@suspendCancellableCoroutine + + val writeResult = exec("pm install-write -S $size $sessionId base -", it) + if (writeResult.resultCode != 0) { + cont.cancel() + throw RuntimeException("Failed to write APK to session $sessionId") + } + if (cont.isCompleted) return@suspendCancellableCoroutine + + val commitResult = exec("pm install-commit $sessionId") + if (commitResult.resultCode != 0) { + cont.cancel() + throw RuntimeException("Failed to commit install session $sessionId") + } + if (cont.isCompleted) return@suspendCancellableCoroutine + cont.resume(InstallState.Installed) + } + } catch (e: Exception) { + if (sessionId != null) exec("pm install-abandon $sessionId") + cont.resume(InstallState.Failed) + } + } + + override suspend fun uninstall(packageName: PackageName) = + context.uninstallPackage(packageName) + + override fun cleanup() {} + + private data class ShellResult(val resultCode: Int, val out: String) + + private fun exec(command: String, stdin: InputStream? = null): ShellResult { + val process = Shizuku.newProcess(arrayOf("sh", "-c", command), null, null) + if (stdin != null) { + process.outputStream.use { stdin.copyTo(it) } + } + val output = process.inputStream.bufferedReader().use(BufferedReader::readText) + val resultCode = process.waitFor() + return ShellResult(resultCode, output) + } +} diff --git a/installer/src/main/java/com/looker/installer/installers/shizuku/ShizukuPermissionHandler.kt b/installer/src/main/java/com/looker/installer/installers/shizuku/ShizukuPermissionHandler.kt new file mode 100644 index 0000000..166de21 --- /dev/null +++ b/installer/src/main/java/com/looker/installer/installers/shizuku/ShizukuPermissionHandler.kt @@ -0,0 +1,81 @@ +package com.leos.installer.installers.shizuku + +import android.content.Context +import android.content.pm.PackageManager +import com.leos.core.common.extension.getPackageInfoCompat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import rikka.shizuku.Shizuku +import rikka.shizuku.ShizukuProvider + +class ShizukuPermissionHandler( + private val context: Context +) { + + fun isInstalled(): Boolean = + context.packageManager.getPackageInfoCompat(ShizukuProvider.MANAGER_APPLICATION_ID) != null + + val isBinderAlive: Flow = callbackFlow { + send(Shizuku.pingBinder()) + val listener = Shizuku.OnBinderReceivedListener { + trySend(true) + } + Shizuku.addBinderReceivedListener(listener) + val deadListener = Shizuku.OnBinderDeadListener { + trySend(false) + } + Shizuku.addBinderDeadListener(deadListener) + awaitClose { + Shizuku.removeBinderReceivedListener(listener) + Shizuku.removeBinderDeadListener(deadListener) + } + }.flowOn(Dispatchers.Default).distinctUntilChanged().conflate() + + private val isGranted: Flow = callbackFlow { + if (Shizuku.pingBinder()) { + send(Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) + } else { + send(false) + } + val listener = Shizuku.OnRequestPermissionResultListener { requestCode, grantResult -> + if (requestCode == SHIZUKU_PERMISSION_REQUEST_CODE) { + trySend(grantResult == PackageManager.PERMISSION_GRANTED) + } + } + Shizuku.addRequestPermissionResultListener(listener) + awaitClose { + Shizuku.removeRequestPermissionResultListener(listener) + } + }.flowOn(Dispatchers.Default).distinctUntilChanged().conflate() + + fun requestPermission() { + if (Shizuku.shouldShowRequestPermissionRationale()) { + } + Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE) + } + + val state: Flow = combine( + flowOf(isInstalled()), + isBinderAlive, + isGranted + ) { isInstalled, isAlive, isGranted -> + State(isGranted, isAlive, isInstalled) + }.distinctUntilChanged() + + companion object { + private const val SHIZUKU_PERMISSION_REQUEST_CODE = 87263 + } + + data class State( + val isPermissionGranted: Boolean, + val isAlive: Boolean, + val isInstalled: Boolean + ) +} diff --git a/installer/src/main/java/com/looker/installer/model/InstallItem.kt b/installer/src/main/java/com/looker/installer/model/InstallItem.kt new file mode 100644 index 0000000..07a42f6 --- /dev/null +++ b/installer/src/main/java/com/looker/installer/model/InstallItem.kt @@ -0,0 +1,11 @@ +package com.leos.installer.model + +import com.leos.core.common.PackageName +import com.leos.core.common.toPackageName + +data class InstallItem( + val packageName: PackageName, + val installFileName: String +) + +infix fun String.installFrom(fileName: String) = InstallItem(this.toPackageName(), fileName) diff --git a/installer/src/main/java/com/looker/installer/model/InstallState.kt b/installer/src/main/java/com/looker/installer/model/InstallState.kt new file mode 100644 index 0000000..517f983 --- /dev/null +++ b/installer/src/main/java/com/looker/installer/model/InstallState.kt @@ -0,0 +1,6 @@ +package com.leos.installer.model + +enum class InstallState { Failed, Pending, Installing, Installed } + +inline val InstallState.isCancellable: Boolean + get() = this == InstallState.Pending diff --git a/metadata/en-US/changelogs/42.txt b/metadata/en-US/changelogs/42.txt new file mode 100644 index 0000000..d247e29 --- /dev/null +++ b/metadata/en-US/changelogs/42.txt @@ -0,0 +1,6 @@ +- More of Material Design 3 +- Fix: Root and default installer +- Fix: Delete Cached File After Uninstall +- Fix: Crash on pressing download when offline +- Update translations of over 17 languages +- Many small fixes and tweaks… diff --git a/metadata/en-US/changelogs/43.txt b/metadata/en-US/changelogs/43.txt new file mode 100644 index 0000000..e386bc0 --- /dev/null +++ b/metadata/en-US/changelogs/43.txt @@ -0,0 +1,4 @@ +- Fix: Screenshot placeholder icon +- Fix: Soft crash when starting AppDetails +- Fix: Run only one instance of root installer +- Fix: Root installer diff --git a/metadata/en-US/changelogs/47.txt b/metadata/en-US/changelogs/47.txt new file mode 100644 index 0000000..13467a8 --- /dev/null +++ b/metadata/en-US/changelogs/47.txt @@ -0,0 +1,12 @@ +- Add Shizuku Installer +- Bump Minimum Android Version to Android 6 +- Fix "Show Less" (#15) +- Localized Date Format (#24) +- Remove UnifiedPush Repository (#23) +- New App Info Header +- Top Bar is rounded +- Notification will open app page +- Lightweight Downloader +- Revert Cache to save data +- Drop Edge-to-Edge Support (#27) +- Some minor improvements \ No newline at end of file diff --git a/metadata/en-US/changelogs/48.txt b/metadata/en-US/changelogs/48.txt new file mode 100644 index 0000000..9b29ee8 --- /dev/null +++ b/metadata/en-US/changelogs/48.txt @@ -0,0 +1,5 @@ +- New Settings backend (Your old settings would be erased) +- Add option to add Cleanup duration +- Fixed blank screenshots +- Fixed crash on the bottom of the screen +- Fix `Installed` notification \ No newline at end of file diff --git a/metadata/en-US/changelogs/49.txt b/metadata/en-US/changelogs/49.txt new file mode 100644 index 0000000..2d3fc00 --- /dev/null +++ b/metadata/en-US/changelogs/49.txt @@ -0,0 +1,2 @@ +- Fix weird icon transformation #83 +- Fix crash if the repository provides a corrupt index #77 \ No newline at end of file diff --git a/metadata/en-US/changelogs/50.txt b/metadata/en-US/changelogs/50.txt new file mode 100644 index 0000000..b87b4f3 --- /dev/null +++ b/metadata/en-US/changelogs/50.txt @@ -0,0 +1,50 @@ +- App needs to be reinstalled (This is totally my fault sorry) +- Android 13 is now supported +- Add Android 13 Themed icon +- Fix some animations and other UI/UX changes +- Fix cache cleanup +- Fix navigation bar color +- Replace "Available" -> "Explore" +- Added "Source code" to `Links` section +- Fix Root/Shizuku Installer (Shizuku Installer is really buggy for now) +- Fix the random crash on returning to app +- Remove Alefvanoon repository +- Fix double prompt for installation +- App Header will highlight if the app needs to be updated +- Add "Scroll to Top" button +- Added Languages: + Bengali + Catalan + Croatian + Galician + Hebrew + Japanese + Lithuanian + Odia + Swedish +- Updated Languages: + Arabic + Bulgarian + Chinese (Simplified) + Chinese (Traditional) + Czech + Finnish + French + Galician + German + Greek + Hindi + Hungarian + Italian + Norwegian Bokmal + Persian + Polish + Portuguese + Portuguese (Brazil) + Russian + Spanish + Tagalog + Turkish + Ukrainian +- Update dependencies +- Cleanup some code \ No newline at end of file diff --git a/metadata/en-US/changelogs/51.txt b/metadata/en-US/changelogs/51.txt new file mode 100644 index 0000000..ff1062f --- /dev/null +++ b/metadata/en-US/changelogs/51.txt @@ -0,0 +1,6 @@ +- Scroll to Top will scroll smoothly +- Some languages have been updated +- + Odia + Japanese + Croatian \ No newline at end of file diff --git a/metadata/en-US/changelogs/52.txt b/metadata/en-US/changelogs/52.txt new file mode 100644 index 0000000..7b62470 --- /dev/null +++ b/metadata/en-US/changelogs/52.txt @@ -0,0 +1,34 @@ +## UI Changes +- Add: Material You support +- Add: Edge-to-Edge support (Android 11+) +- Add: Snackbar if the address is not valid +- Add: Snackbar to represent Internet Connectivity +- Add: Indicator for installed apps +- Add: Favourites list [In menu] +- Add: App bar controls +- Add: New Error icon +- Improve: `Repository Details` page +- Improve: `Repository List` page +- Fix: Theme Changing +- Fix: App bar not scrolling +- Fix: Navigation bar color + +## Core +- Add: Favourites list in menu +- Add: `Never` cleanup option +- Add: Session and FluffyChat Repository +- Remove: Some dead repositories +- Remove: Support for legacy `index.xml` (For security purposes) +- Fix: Many installer bug +- Fix: App not visible in Android TV +- Fix: A very annoying random crash +- Fix: Shizuku and Root Installer + +## Misc +- Better Install notification icon +- Click App Icon to switch Author and Package name +- Use legacy installer on first time MiUi users +- Shizuku dropped support for pre-Android 11 +- Fix: Install dialog not visible when notification is clicked +- Fix: Crash on bad repository address +- Fix: No back button in settings page \ No newline at end of file diff --git a/metadata/en-US/changelogs/53.txt b/metadata/en-US/changelogs/53.txt new file mode 100644 index 0000000..d248665 --- /dev/null +++ b/metadata/en-US/changelogs/53.txt @@ -0,0 +1,5 @@ +- Shizuku is not deprecated on pre-Android 11 +- Fix icons for many apps +- Some UI changes to repository page +- Fix a weird behaviour caused by Material You switch +- Check out old changelog at - https://github.com/Iamlooker/Droid-ify/releases/tag/v0.5.2 \ No newline at end of file diff --git a/metadata/en-US/changelogs/54.txt b/metadata/en-US/changelogs/54.txt new file mode 100644 index 0000000..5a63190 --- /dev/null +++ b/metadata/en-US/changelogs/54.txt @@ -0,0 +1,3 @@ +- Fix the crash when going back from Settings page +- Allow Shizuku on Android 7+ +- Some UI improvements to Repository page \ No newline at end of file diff --git a/metadata/en-US/changelogs/55.txt b/metadata/en-US/changelogs/55.txt new file mode 100644 index 0000000..5a63190 --- /dev/null +++ b/metadata/en-US/changelogs/55.txt @@ -0,0 +1,3 @@ +- Fix the crash when going back from Settings page +- Allow Shizuku on Android 7+ +- Some UI improvements to Repository page \ No newline at end of file diff --git a/metadata/en-US/changelogs/56.txt b/metadata/en-US/changelogs/56.txt new file mode 100644 index 0000000..d0a5a64 --- /dev/null +++ b/metadata/en-US/changelogs/56.txt @@ -0,0 +1,10 @@ +Add: Update All button +Improve: Re-wrote installer +Improve: Auto-Install Apps if possible +Improve: Allow user to remove `Tap to Install` +Improve: Color-logic in Material You +Improve: Favourites Icon +Fix: Sort-order issue +Fix: Skipping installation with root installer +Fix: Summary in Favourites List +Fix: Memory Leak \ No newline at end of file diff --git a/metadata/en-US/changelogs/57.txt b/metadata/en-US/changelogs/57.txt new file mode 100644 index 0000000..c435914 --- /dev/null +++ b/metadata/en-US/changelogs/57.txt @@ -0,0 +1,14 @@ +### Improvements: +- Reduce app size a little +- Allow removal of `Tap to Install` notification +- Remove Large header +- Better downloader logic +- Better State handling +- Improve Shizuku and Root Installer + +### Fixes: +- Background crash due to session installer +- Redundant `Update All` button visibility +- Overlapping buttons +- Language changelist +- Empty Notifications \ No newline at end of file diff --git a/metadata/en-US/changelogs/58.txt b/metadata/en-US/changelogs/58.txt new file mode 100644 index 0000000..b6ad10c --- /dev/null +++ b/metadata/en-US/changelogs/58.txt @@ -0,0 +1,14 @@ +## New +- Downloader Implementation +- Material You Switches +- Need restart after setting `Proxy` +- Padding for Floating Button +- Localized Metadata for apps (Thanks @BLumia) + +## Improved +- Enabled Repositories at top +- Responsive "Update All" button +- Live Installer change +- Faster page opening + +- Many more changes which I forgot \ No newline at end of file diff --git a/metadata/en-US/changelogs/581.txt b/metadata/en-US/changelogs/581.txt new file mode 100644 index 0000000..2d2cfbf --- /dev/null +++ b/metadata/en-US/changelogs/581.txt @@ -0,0 +1,15 @@ +## New +- Downloader Implementation +- Material You Switches +- Need restart after setting `Proxy` +- Padding for Floating Button +- Localized Metadata for apps (Thanks @BLumia) + +## Improved +- Favourites List Crash +- Enabled Repositories at top +- Responsive "Update All" button +- Live Installer change +- Faster page opening + +- Many more changes which I forgot \ No newline at end of file diff --git a/metadata/en-US/changelogs/582.txt b/metadata/en-US/changelogs/582.txt new file mode 100644 index 0000000..02697d3 --- /dev/null +++ b/metadata/en-US/changelogs/582.txt @@ -0,0 +1,5 @@ +## Fixes +- Category Resetting on page change +- Proxy Change Causing Crash +- Update All Crash +- Android 7 Crash \ No newline at end of file diff --git a/metadata/en-US/changelogs/583.txt b/metadata/en-US/changelogs/583.txt new file mode 100644 index 0000000..fb9e7a5 --- /dev/null +++ b/metadata/en-US/changelogs/583.txt @@ -0,0 +1,13 @@ +## Improvements + +- Brand new Shizuku Permission Handling +- Display "NSFW" Anti-feature +- Faster APK validation +- Better download-state handling +- Optimized Layout for Arabic languages + +## Fixes + +- App not installing ** + +** Apps were not installing after 20 seconds of wait. \ No newline at end of file diff --git a/metadata/en-US/changelogs/584.txt b/metadata/en-US/changelogs/584.txt new file mode 100644 index 0000000..1332316 --- /dev/null +++ b/metadata/en-US/changelogs/584.txt @@ -0,0 +1,15 @@ +Improvements: +- Shizuku permission handling +- User detection with root +- Add setting to block swiping between pages +- Update Droid-ify last in list +- Misc. changes + +Fixes: +- Sui permission handling +- Category clickable area +- Cleanup crash +- Chinese translation selection +- Crash on screenshot +- Cleanup interval +- False install state on different pages \ No newline at end of file diff --git a/metadata/en-US/changelogs/590.txt b/metadata/en-US/changelogs/590.txt new file mode 100644 index 0000000..25f4e4a --- /dev/null +++ b/metadata/en-US/changelogs/590.txt @@ -0,0 +1,16 @@ +Brand New Sharing Feel: +- Universal sharing with `droidify.eu.org` +- New missing app page +- Add missing repository from the detail page + +Improvements: +- Add back icon to screenshot +- Improve database performance +- Improve settings page performance + +Fixes: +- Crash when validating +- Crash after download cancellation +- Crash related to Shizuku +- Fix setting switches +- Fix calls for empty icons \ No newline at end of file diff --git a/metadata/en-US/changelogs/591.txt b/metadata/en-US/changelogs/591.txt new file mode 100644 index 0000000..479993b --- /dev/null +++ b/metadata/en-US/changelogs/591.txt @@ -0,0 +1,16 @@ +Improvements: +- Improve icons based on usage +- Replace screenshot placeholder +- Improvements to title of link items +- Add ability to toggle switch by clicking the whole item +- Improve background updates +- Load icons from different mirrors +- Cleanup after changing proxy + +Fixes: +- Monet theme switch +- Screenshot Size +- Fix Tor/Onion Repositories +- Fix Shizuku permission issue +- Fix Installer reverting indefinitely +- Fix calls to tor repositories \ No newline at end of file diff --git a/metadata/en-US/changelogs/592.txt b/metadata/en-US/changelogs/592.txt new file mode 100644 index 0000000..6cb7a88 --- /dev/null +++ b/metadata/en-US/changelogs/592.txt @@ -0,0 +1,18 @@ +Installer Improvements: +- You can now remove apps from install queue +- Cleanup some memory after using installer + +Improvements: +- Add initial support for Android 14 +- Added 11 New Repositories +- Pressing back will clear categories before exiting +- Faster Icon/Screenshot loading +- Faster Selection of Download File + +Fixes: +- Repeated Loading of images +- Install button not working one second click +- Crashes with syncing +- Crashes related to system service +- Improve performance of App Detail Screen +- Improve performance of Home Screen \ No newline at end of file diff --git a/metadata/en-US/changelogs/593.txt b/metadata/en-US/changelogs/593.txt new file mode 100644 index 0000000..ded8a56 --- /dev/null +++ b/metadata/en-US/changelogs/593.txt @@ -0,0 +1,19 @@ +Installer Improvements: +- You can now remove apps from install queue +- Cleanup some memory after using installer + +Improvements: +- Add initial support for Android 14 +- Added 11 New Repositories +- Pressing back will clear categories before exiting +- Faster Icon/Screenshot loading +- Faster Selection of Download File + +Fixes: +- Crash on Android 14 +- Repeated Loading of images +- Install button not working one second click +- Crashes with syncing +- Crashes related to system service +- Improve performance of App Detail Screen +- Improve performance of Home Screen \ No newline at end of file diff --git a/metadata/en-US/changelogs/594.txt b/metadata/en-US/changelogs/594.txt new file mode 100644 index 0000000..70b7155 --- /dev/null +++ b/metadata/en-US/changelogs/594.txt @@ -0,0 +1,2 @@ +Fixes: +- Duplicate Repositories \ No newline at end of file diff --git a/metadata/en-US/changelogs/595.txt b/metadata/en-US/changelogs/595.txt new file mode 100644 index 0000000..85872a5 --- /dev/null +++ b/metadata/en-US/changelogs/595.txt @@ -0,0 +1,9 @@ +💾 Backup Arsenal +- Add support for Exporting/Importing Repos and Settings + +# Improvements +- App details page performance + +# Fixes +- Theme not applying on startup +- Root Installer stuck \ No newline at end of file diff --git a/metadata/en-US/full_description.txt b/metadata/en-US/full_description.txt new file mode 100644 index 0000000..fea1c34 --- /dev/null +++ b/metadata/en-US/full_description.txt @@ -0,0 +1,31 @@ +A quick material F-Droid client + +Features: + +* Material F-Droid style +* No cards or inappropriate animations +* Fast repository syncing +* Standard Android components and minimal dependencies + +Available in: + +- Brazillian Portugese +- English +- Finnish +- French +- German +- Greek +- Hungarian +- Italian +- Norwegian Bokmål +- Peninsular Arabic +- Polish +- Russian +- Simplified Chinese +- Spanish +- Turkish + +A direct adaptation/modification of Foxy-Droid + +Copylefted libre software, licensed GPLv3+. \ +Use, study, change, and share; with all. diff --git a/metadata/en-US/images/featureGraphic.png b/metadata/en-US/images/featureGraphic.png new file mode 100644 index 0000000..9640bb7 Binary files /dev/null and b/metadata/en-US/images/featureGraphic.png differ diff --git a/metadata/en-US/images/icon.png b/metadata/en-US/images/icon.png new file mode 100644 index 0000000..be7d161 Binary files /dev/null and b/metadata/en-US/images/icon.png differ diff --git a/metadata/en-US/images/phoneScreenshots/1.png b/metadata/en-US/images/phoneScreenshots/1.png new file mode 100644 index 0000000..1de1e4b Binary files /dev/null and b/metadata/en-US/images/phoneScreenshots/1.png differ diff --git a/metadata/en-US/images/phoneScreenshots/2.png b/metadata/en-US/images/phoneScreenshots/2.png new file mode 100644 index 0000000..3b3f0d5 Binary files /dev/null and b/metadata/en-US/images/phoneScreenshots/2.png differ diff --git a/metadata/en-US/images/phoneScreenshots/3.png b/metadata/en-US/images/phoneScreenshots/3.png new file mode 100644 index 0000000..47bbaea Binary files /dev/null and b/metadata/en-US/images/phoneScreenshots/3.png differ diff --git a/metadata/en-US/images/phoneScreenshots/4.png b/metadata/en-US/images/phoneScreenshots/4.png new file mode 100644 index 0000000..7cd014a Binary files /dev/null and b/metadata/en-US/images/phoneScreenshots/4.png differ diff --git a/metadata/en-US/images/promoGraphic.png b/metadata/en-US/images/promoGraphic.png new file mode 100644 index 0000000..9640bb7 Binary files /dev/null and b/metadata/en-US/images/promoGraphic.png differ diff --git a/metadata/en-US/short_description.txt b/metadata/en-US/short_description.txt new file mode 100644 index 0000000..ff151f0 --- /dev/null +++ b/metadata/en-US/short_description.txt @@ -0,0 +1 @@ +Material-ify with LeOS-Droid. diff --git a/metadata/es-AR/changelogs/4.txt b/metadata/es-AR/changelogs/4.txt new file mode 100644 index 0000000..1218c1d --- /dev/null +++ b/metadata/es-AR/changelogs/4.txt @@ -0,0 +1 @@ +* ¡Español agregado! ;) diff --git a/metadata/es-AR/full_description.txt b/metadata/es-AR/full_description.txt new file mode 100644 index 0000000..874a2b5 --- /dev/null +++ b/metadata/es-AR/full_description.txt @@ -0,0 +1,10 @@ +Cliente F-Droid no oficial con Diseño Material. + +Esta app es una Adaptación/Modificación Directa de Foxy-Droid. + +Características: + +* Estilo F-Droid Material +* Sin cartas o animaciones inapropiadas +* Sincronización rápida de repositorios +* Componentes Android estándar y dependencias mínimas diff --git a/metadata/es-AR/short_description.txt b/metadata/es-AR/short_description.txt new file mode 100644 index 0000000..0766f85 --- /dev/null +++ b/metadata/es-AR/short_description.txt @@ -0,0 +1 @@ +¡Droid-ificá con Droid-ify! diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..ac43d2a --- /dev/null +++ b/renovate.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:base" + ], + "ignoreDeps": ["shizuku"] +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..dd314b4 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,30 @@ +pluginManagement { + includeBuild("build-logic") + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven(url = "https://jitpack.io") + } +} + +rootProject.name = "LeOS-Droid" +include( + ":app", + ":core:common", + ":core:data", + ":core:database", + ":core:datastore", + ":core:di", + ":core:domain", + ":core:network", + ":installer" +) +include(":sync:fdroid") diff --git a/sync/fdroid/.gitignore b/sync/fdroid/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/sync/fdroid/.gitignore @@ -0,0 +1 @@ +/build diff --git a/sync/fdroid/build.gradle.kts b/sync/fdroid/build.gradle.kts new file mode 100644 index 0000000..7eee77b --- /dev/null +++ b/sync/fdroid/build.gradle.kts @@ -0,0 +1,35 @@ +plugins { + alias(libs.plugins.looker.android.library) + alias(libs.plugins.looker.hilt) + alias(libs.plugins.looker.lint) +} + +android { + namespace = "com.leos.sync.fdroid" + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) + } + create("alpha") { + initWith(getByName("debug")) + isMinifyEnabled = true + } + } +} + +dependencies { + modules( + Modules.coreCommon, + Modules.coreDomain, + Modules.coreNetwork, + ) + + implementation(libs.kotlinx.coroutines.android) + implementation(libs.fdroid.index) + implementation(libs.fdroid.download) + testImplementation(libs.junit4) + androidTestImplementation(libs.androidx.test.ext) + androidTestImplementation(libs.androidx.test.espresso.core) +} diff --git a/sync/fdroid/src/androidTest/kotlin/com/looker/sync/fdroid/ExampleInstrumentedTest.kt b/sync/fdroid/src/androidTest/kotlin/com/looker/sync/fdroid/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..04a6ca9 --- /dev/null +++ b/sync/fdroid/src/androidTest/kotlin/com/looker/sync/fdroid/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.leos.sync.fdroid + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.leos.sync.fdroid.test", appContext.packageName) + } +} diff --git a/sync/fdroid/src/main/AndroidManifest.xml b/sync/fdroid/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8bdb7e1 --- /dev/null +++ b/sync/fdroid/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/FdroidSyncable.kt b/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/FdroidSyncable.kt new file mode 100644 index 0000000..55b4e9e --- /dev/null +++ b/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/FdroidSyncable.kt @@ -0,0 +1,13 @@ +package com.leos.sync.fdroid + +import com.leos.core.domain.Syncable +import com.leos.core.domain.newer.App +import com.leos.core.domain.newer.Repo + +class FdroidSyncable(override val repo: Repo) : Syncable { + + override suspend fun getApps(): List = emptyList() + + override suspend fun getUpdatedRepo(): Repo = repo + +} diff --git a/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/IndexValidator.kt b/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/IndexValidator.kt new file mode 100644 index 0000000..b9f8440 --- /dev/null +++ b/sync/fdroid/src/main/kotlin/com/looker/sync/fdroid/IndexValidator.kt @@ -0,0 +1,70 @@ +package com.leos.sync.fdroid + +import com.leos.core.common.extension.certificate +import com.leos.core.common.extension.codeSigner +import com.leos.core.common.extension.fingerprint +import com.leos.core.common.extension.toJarFile +import com.leos.core.common.signature.FileValidator +import com.leos.core.common.signature.ValidationException +import com.leos.core.domain.newer.Repo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.fdroid.index.IndexParser +import org.fdroid.index.parseEntry +import org.fdroid.index.parseV1 +import org.fdroid.index.v1.IndexV1 +import org.fdroid.index.v2.Entry +import java.io.File +import java.io.InputStream + +class IndexValidator( + private val repo: Repo, + private val config: ValidatorConfig, + private val block: (T, String) -> Unit +) : FileValidator { + override suspend fun validate(file: File) = withContext(Dispatchers.Default) { + val (entry, fingerprint) = getEntryStream(file, config.parser, config.jsonName) + if (repo.fingerprint.isNotBlank() && + !repo.fingerprint.equals(fingerprint, ignoreCase = true) + ) { + throw ValidationException( + "Expected Fingerprint: ${repo.fingerprint}, " + + "Acquired Fingerprint: $fingerprint" + ) + } + block(entry, fingerprint) + } + + companion object { + private suspend fun getEntryStream( + file: File, + getIndexValue: (InputStream) -> T, + entryName: String + ): Pair = withContext(Dispatchers.IO) { + val jar = file.toJarFile() + val jarEntry = jar.getJarEntry(entryName) + ?: throw ValidationException("No entry for: $entryName") + + val entry = jar + .getInputStream(jarEntry) + .use(getIndexValue) + + val fingerprint = jarEntry + .codeSigner + .certificate + .fingerprint() + entry to fingerprint + } + } +} + +sealed class ValidatorConfig( + val jsonName: String, + val parser: (InputStream) -> T +) { + + data object EntryConfig : ValidatorConfig("entry.json", IndexParser::parseEntry) + + data object IndexConfig : ValidatorConfig("index-v1.json", IndexParser::parseV1) + +} diff --git a/sync/fdroid/src/test/kotlin/com/looker/sync/fdroid/ExampleUnitTest.kt b/sync/fdroid/src/test/kotlin/com/looker/sync/fdroid/ExampleUnitTest.kt new file mode 100644 index 0000000..5634b8c --- /dev/null +++ b/sync/fdroid/src/test/kotlin/com/looker/sync/fdroid/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.leos.sync.fdroid + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} diff --git a/update.sh b/update.sh new file mode 100644 index 0000000..6ebc015 --- /dev/null +++ b/update.sh @@ -0,0 +1,86 @@ +#!/bin/bash + +# Default values +version="" +changelog_directory="./metadata/en-US/changelogs" +kotlin_file="./build-logic/structure/src/main/kotlin/DefaultConfig.kt" + +# Pull commits from origin +echo "Pulling commits from GitHub" +git pull --rebase + +# Parse command-line arguments +while [[ $# -gt 0 ]]; do + case $1 in + -v=*|--version=*) + version="${1#*=}" + shift + ;; + *) + echo "Invalid argument: $1" + exit 1 + ;; + esac + shift +done + +# Validate version format +if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then + echo "Invalid version format. Please use X.Y.Z or X.Y.Z.W" + exit 1 +fi + +# Extract major, minor, release, and patch numbers +IFS='.' read -r -a version_parts <<< "$version" +major="${version_parts[0]}" +minor="${version_parts[1]}" +release="${version_parts[2]}" +patch="${version_parts[3]-0}" + +# Calculate version code +version_code="$((major * 1000 + minor * 100 + release * 10 + patch))" + +# Generate version name +if [ -z "$patch" ]; then + version_name="$major.$minor.$release" + changelog_file="$changelog_directory/$version_code" + git_tag="v$version" +else + if [ "$patch" -eq 0 ]; then + version_name="$major.$minor.$release" + else + version_name="$major.$minor.$release Patch $patch" + fi + changelog_file="$changelog_directory/$version_code.txt" + git_tag="v$version" +fi + +# Update the Kotlin file with new version code and name +sed -i "s/const val versionCode = [0-9]*/const val versionCode = $version_code/" "$kotlin_file" +sed -i "s/const val versionName = \"[^\"]*\"/const val versionName = \"$version_name\"/" "$kotlin_file" + +# Line ending to CRLF +sed -i ':a;N;$!ba;s/\n/\r\n/g' "$kotlin_file" + +# Create a changelog file +mkdir -p "$changelog_directory" +touch "$changelog_file" + +echo "Version Code: $version_code" +echo "Version Name: $version_name" +echo "Changelog file name: $changelog_file" +echo "Git tag: $git_tag" + +code $changelog_file + +# Ask for confirmation before creating a Git tag +read -p "Do you want to create a Git tag for version $git_tag? (y/n): " -r +if [[ $REPLY =~ ^[Yy]$ ]]; then + git add -A + git commit -m "Release $version_name" + # Create a Git tag + git tag "$git_tag" + echo "Git tag '$git_tag' created." +else + echo "Git tag not created." +fi