diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..5980e5f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,32 @@ +--- +name: Bug report +about: Something isn't working right. +title: '' +labels: bug, to check +assignees: '' + +--- + +**Prerequisites** + + +**Describe the bug** + + +**To Reproduce** + + +**Screenshots and Logs** + + +**Please complete the following information:** + - Device: + - OS: + - Obtainium Version: + +**Additional context** + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..44e16b8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,29 @@ +--- +name: Feature request +about: Suggest a new Source, setting, or other feature. +title: '' +labels: enhancement, to check +assignees: '' + +--- + +**Prerequisites** + + +**Describe the feature** + + +**Describe alternatives you've considered (if applicable)** + + +**Additional context** + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..00a4cce --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,69 @@ +name: Build and Release + +on: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + + - uses: actions/checkout@v3 + - uses: subosito/flutter-action@v2 + + - name: Import GPG key + id: import_pgp_key + uses: crazy-max/ghaction-import-gpg@v6 + with: + gpg_private_key: ${{ secrets.PGP_KEY_BASE64 }} + passphrase: ${{ secrets.PGP_PASSPHRASE }} + + - name: Build APKs + run: | + flutter build apk && flutter build apk --split-per-abi + rm ./build/app/outputs/flutter-apk/*.sha1 + ls -l ./build/app/outputs/flutter-apk/ + + - name: Sign APKs + env: + KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} + run: | + echo "${KEYSTORE_BASE64}" | base64 -d > apksign.keystore + for apk in ./build/app/outputs/flutter-apk/*-release*.apk; do + unsignedFn=${apk/-release/-unsigned} + mv "$apk" "$unsignedFn" + ${ANDROID_HOME}/build-tools/30.0.2/apksigner sign --ks apksign.keystore --ks-pass pass:"${KEYSTORE_PASSWORD}" --out "${apk}" "${unsignedFn}" + sha256sum ${apk} | cut -d " " -f 1 > "$apk".sha256 + gpg --batch --pinentry-mode loopback --passphrase "${PGP_PASSPHRASE}" --sign --detach-sig "$apk".sha256 + done + rm apksign.keystore + PGP_KEY_FINGERPRINT="${{ steps.import_pgp_key.outputs.fingerprint }}" + + - name: Extract Version + id: extract_version + run: | + VERSION=$(grep -oP "currentVersion = '\K[^']+" lib/main.dart) + echo "version=$VERSION" >> $GITHUB_OUTPUT + TAG=$(grep -oP "'.*\\\$currentVersion.*'" lib/main.dart | head -c -2 | tail -c +2 | sed "s/\$currentVersion/$VERSION/g") + echo "tag=$TAG" >> $GITHUB_OUTPUT + if [ -n "$(echo $TAG | grep -oP '\-beta$')" ]; then BETA=true; else BETA=false; fi + echo "beta=$BETA" >> $GITHUB_OUTPUT + + - name: Create Tag + uses: mathieudutour/github-tag-action@v6.1 + with: + github_token: ${{ secrets.GH_ACCESS_TOKEN }} + custom_tag: "${{ steps.extract_version.outputs.tag }}" + tag_prefix: "" + + - name: Create Release And Upload APKs + uses: ncipollo/release-action@v1 + with: + token: ${{ secrets.GH_ACCESS_TOKEN }} + tag: "${{ steps.extract_version.outputs.tag }}" + prerelease: "${{ steps.extract_version.outputs.beta }}" + artifacts: ./build/app/outputs/flutter-apk/*-release*.apk* + generateReleaseNotes: true diff --git a/README.md b/README.md index 4337bde..762d485 100644 --- a/README.md +++ b/README.md @@ -2,33 +2,50 @@ Get Android App Updates Directly From the Source. -Obtainium allows you to install and update Open-Source Apps directly from their releases pages, and receive notifications when new releases are made available. +Obtainium allows you to install and update Apps directly from their releases pages, and receive notifications when new releases are made available. Motivation: [Side Of Burritos - You should use this instead of F-Droid | How to use app RSS feed](https://youtu.be/FFz57zNR_M0) Currently supported App sources: -- [GitHub](https://github.com/) -- [GitLab](https://gitlab.com/) -- [Codeberg](https://codeberg.org/) -- [F-Droid](https://f-droid.org/) -- [IzzyOnDroid](https://android.izzysoft.de/) -- [Mullvad](https://mullvad.net/en/) -- [Signal](https://signal.org/) -- [SourceForge](https://sourceforge.net/) -- [APKMirror](https://apkmirror.com/) (Track-Only) -- Third Party F-Droid Repos - - Any URLs ending with `/fdroid/`, where `` can be anything - most often `repo` -- [Steam](https://store.steampowered.com/mobile) -- "HTML" (Fallback) - - Any other URL that returns an HTML page with links to APK files (if multiple, the last file alphabetically is picked) +- Open Source - General: + - [GitHub](https://github.com/) + - [GitLab](https://gitlab.com/) + - [Codeberg](https://codeberg.org/) + - [F-Droid](https://f-droid.org/) + - Third Party F-Droid Repos + - [IzzyOnDroid](https://android.izzysoft.de/) + - [SourceForge](https://sourceforge.net/) + - [SourceHut](https://git.sr.ht/) +- Other - General: + - [APKPure](https://apkpure.com/) + - [Aptoide](https://aptoide.com/) + - [Uptodown](https://uptodown.com/) + - [APKMirror](https://apkmirror.com/) (Track-Only) + - [Huawei AppGallery](https://appgallery.huawei.com/) + - Jenkins Jobs +- Open Source - App-Specific: + - [Mullvad](https://mullvad.net/en/) + - [Signal](https://signal.org/) + - [VLC](https://videolan.org/) +- Other - App-Specific: + - [Telegram App](https://telegram.org) + - [Steam Mobile Apps](https://store.steampowered.com/mobile) + - [Neutron Code](https://neutroncode.com) +- "HTML" (Fallback): Any other URL that returns an HTML page with links to APK files + +## Installation + +[Get it on GitHub](https://github.com/ImranR98/Obtainium/releases) + +[PGP Public Key](https://keyserver.ubuntu.com/pks/lookup?search=contact%40imranr.dev&fingerprint=on&op=index) ## Limitations -- App installs happen asynchronously and the success/failure of an install cannot be determined directly. This results in install statuses and versions sometimes being out of sync with the OS until the next launch or until the problem is manually corrected. -- Auto (unattended) updates are unsupported due to a lack of any capable Flutter plugin. - For some sources, data is gathered using Web scraping and can easily break due to changes in website design. In such cases, more reliable methods may be unavailable. ## Screenshots | Apps Page | Dark Theme | Material You | | ------------------------------------------------------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------- | -| App Page | Multiple APK Support | App Installation | +| App Page | App Options | App Web View | diff --git a/android/app/build.gradle b/android/app/build.gradle index d64b17f..6765d49 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -49,7 +49,6 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "dev.imranr.obtainium" // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration. @@ -58,7 +57,7 @@ android { versionCode flutterVersionCode.toInteger() versionName flutterVersionName } - + flavorDimensions "flavor" productFlavors { @@ -71,17 +70,10 @@ android { applicationIdSuffix ".fdroid" } } - signingConfigs { - release { - keyAlias keystoreProperties['keyAlias'] - keyPassword keystoreProperties['keyPassword'] - storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null - storePassword keystoreProperties['storePassword'] - } - } + buildTypes { release { - signingConfig signingConfigs.release + } } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1e2f4d3..d48e3f5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,7 +3,8 @@ + android:icon="@mipmap/ic_launcher" + android:requestLegacyExternalStorage="true"> + + + @@ -45,13 +51,25 @@ + + + + + + + android:maxSdkVersion="29"/> + \ No newline at end of file diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png deleted file mode 100644 index f0c677a..0000000 Binary files a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png deleted file mode 100644 index 3bc1e53..0000000 Binary files a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png deleted file mode 100644 index a59c885..0000000 Binary files a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 748f473..0000000 Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png deleted file mode 100644 index b08c864..0000000 Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..eb5ee1b --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..b26e945 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 09f69ad..c915a75 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 467c4b8..d739ed1 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index bce23a7..f279277 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index e074d5c..6ceb87e 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 37228eb..652ac54 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml index 670a26f..d0973ae 100644 --- a/android/app/src/main/res/xml/file_paths.xml +++ b/android/app/src/main/res/xml/file_paths.xml @@ -2,4 +2,5 @@ + \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index 58a8c74..713d7f6 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -26,6 +26,6 @@ subprojects { project.evaluationDependsOn(':app') } -task clean(type: Delete) { +tasks.register("clean", Delete) { delete rootProject.buildDir } diff --git a/assets/ca/lets-encrypt-r3.pem b/assets/ca/lets-encrypt-r3.pem new file mode 100644 index 0000000..43b222a --- /dev/null +++ b/assets/ca/lets-encrypt-r3.pem @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFFjCCAv6gAwIBAgIRAJErCErPDBinU/bWLiWnX1owDQYJKoZIhvcNAQELBQAw +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjAwOTA0MDAwMDAw +WhcNMjUwOTE1MTYwMDAwWjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg +RW5jcnlwdDELMAkGA1UEAxMCUjMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK +AoIBAQC7AhUozPaglNMPEuyNVZLD+ILxmaZ6QoinXSaqtSu5xUyxr45r+XXIo9cP +R5QUVTVXjJ6oojkZ9YI8QqlObvU7wy7bjcCwXPNZOOftz2nwWgsbvsCUJCWH+jdx +sxPnHKzhm+/b5DtFUkWWqcFTzjTIUu61ru2P3mBw4qVUq7ZtDpelQDRrK9O8Zutm +NHz6a4uPVymZ+DAXXbpyb/uBxa3Shlg9F8fnCbvxK/eG3MHacV3URuPMrSXBiLxg +Z3Vms/EY96Jc5lP/Ooi2R6X/ExjqmAl3P51T+c8B5fWmcBcUr2Ok/5mzk53cU6cG +/kiFHaFpriV1uxPMUgP17VGhi9sVAgMBAAGjggEIMIIBBDAOBgNVHQ8BAf8EBAMC +AYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMBIGA1UdEwEB/wQIMAYB +Af8CAQAwHQYDVR0OBBYEFBQusxe3WFbLrlAJQOYfr52LFMLGMB8GA1UdIwQYMBaA +FHm0WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcw +AoYWaHR0cDovL3gxLmkubGVuY3Iub3JnLzAnBgNVHR8EIDAeMBygGqAYhhZodHRw +Oi8veDEuYy5sZW5jci5vcmcvMCIGA1UdIAQbMBkwCAYGZ4EMAQIBMA0GCysGAQQB +gt8TAQEBMA0GCSqGSIb3DQEBCwUAA4ICAQCFyk5HPqP3hUSFvNVneLKYY611TR6W +PTNlclQtgaDqw+34IL9fzLdwALduO/ZelN7kIJ+m74uyA+eitRY8kc607TkC53wl +ikfmZW4/RvTZ8M6UK+5UzhK8jCdLuMGYL6KvzXGRSgi3yLgjewQtCPkIVz6D2QQz +CkcheAmCJ8MqyJu5zlzyZMjAvnnAT45tRAxekrsu94sQ4egdRCnbWSDtY7kh+BIm +lJNXoB1lBMEKIq4QDUOXoRgffuDghje1WrG9ML+Hbisq/yFOGwXD9RiX8F6sw6W4 +avAuvDszue5L3sz85K+EC4Y/wFVDNvZo4TYXao6Z0f+lQKc0t8DQYzk1OXVu8rp2 +yJMC6alLbBfODALZvYH7n7do1AZls4I9d1P4jnkDrQoxB3UqQ9hVl3LEKQ73xF1O +yK5GhDDX8oVfGKF5u+decIsH4YaTw7mP3GFxJSqv3+0lUFJoi5Lc5da149p90Ids +hCExroL1+7mryIkXPeFM5TgO9r0rvZaBFOvV2z0gp35Z0+L4WPlbuEjN/lxPFin+ +HlUjr8gRsI3qfJOQFy/9rKIJR0Y/8Omwt/8oTWgy1mdeHmmjk7j1nYsvC9JSQ6Zv +MldlTTKB3zhThV1+XWYp6rjd5JW1zbVWEkLNxE7GJThEUG3szgBVGP7pSWTUTsqX +nLRbwHOoq7hHwg== +-----END CERTIFICATE----- diff --git a/assets/graphics/icon.png b/assets/graphics/icon.png old mode 100755 new mode 100644 index b7b1e06..bbb75cf Binary files a/assets/graphics/icon.png and b/assets/graphics/icon.png differ diff --git a/assets/graphics/icon.psd b/assets/graphics/icon.psd deleted file mode 100755 index 9c89925..0000000 Binary files a/assets/graphics/icon.psd and /dev/null differ diff --git a/assets/graphics/icon.svg b/assets/graphics/icon.svg new file mode 100644 index 0000000..091d825 --- /dev/null +++ b/assets/graphics/icon.svg @@ -0,0 +1,78 @@ + + + + diff --git a/assets/screenshots/1.apps.png b/assets/screenshots/1.apps.png index d46fa40..ecd7aa8 100644 Binary files a/assets/screenshots/1.apps.png and b/assets/screenshots/1.apps.png differ diff --git a/assets/screenshots/2.dark_theme.png b/assets/screenshots/2.dark_theme.png index fff96b9..699bbf7 100644 Binary files a/assets/screenshots/2.dark_theme.png and b/assets/screenshots/2.dark_theme.png differ diff --git a/assets/screenshots/3.material_you.png b/assets/screenshots/3.material_you.png index aee7e75..04b0ae1 100644 Binary files a/assets/screenshots/3.material_you.png and b/assets/screenshots/3.material_you.png differ diff --git a/assets/screenshots/4.app.png b/assets/screenshots/4.app.png index a996051..55ae937 100644 Binary files a/assets/screenshots/4.app.png and b/assets/screenshots/4.app.png differ diff --git a/assets/screenshots/5.apk_picker.png b/assets/screenshots/5.apk_picker.png deleted file mode 100644 index a6d2abf..0000000 Binary files a/assets/screenshots/5.apk_picker.png and /dev/null differ diff --git a/assets/screenshots/5.app_opts.png b/assets/screenshots/5.app_opts.png new file mode 100644 index 0000000..5e5e7fe Binary files /dev/null and b/assets/screenshots/5.app_opts.png differ diff --git a/assets/screenshots/6.apk_install.png b/assets/screenshots/6.apk_install.png deleted file mode 100644 index 756c667..0000000 Binary files a/assets/screenshots/6.apk_install.png and /dev/null differ diff --git a/assets/screenshots/6.app_webview.png b/assets/screenshots/6.app_webview.png new file mode 100644 index 0000000..636eaf2 Binary files /dev/null and b/assets/screenshots/6.app_webview.png differ diff --git a/assets/translations/bs.json b/assets/translations/bs.json new file mode 100644 index 0000000..d3bf65a --- /dev/null +++ b/assets/translations/bs.json @@ -0,0 +1,330 @@ +{ + "invalidURLForSource": "Nije važeći URL aplikacije {}", + "noReleaseFound": "Nije moguće pronaći odgovarajuće izdanje", + "noVersionFound": "Nije moguće odrediti verziju izdanja", + "urlMatchesNoSource": "URL se ne podudara s poznatim izvorom", + "cantInstallOlderVersion": "Nije moguće instalirati stariju verziju aplikacije", + "appIdMismatch": "ID preuzetog paketa se ne podudara s postojećim ID-om aplikacije", + "functionNotImplemented": "Ova klasa nije implementirala ovu funkciju", + "placeholder": "Rezervirano mjesto", + "someErrors": "Došlo je do nekih grešaka", + "unexpectedError": "Neočekivana greška", + "ok": "Dobro", + "and": "i", + "githubPATLabel": "GitHub token za lični pristup (eng. PAT, povećava ograničenje stope)", + "includePrereleases": "Uključi preliminarna izdanja", + "fallbackToOlderReleases": "Povratak na starija izdanja", + "filterReleaseTitlesByRegEx": "Filtrirajte naslove izdanja prema regularnom izrazu", + "invalidRegEx": "Nevažeći regularni izraz", + "noDescription": "Bez opisa", + "cancel": "Otkaži", + "continue": "Nastavite", + "requiredInBrackets": "(obavezno)", + "dropdownNoOptsError": "GREŠKA: PADAJUĆI MENI MORA IMATI NAJMANJE JEDNU OPCIJU", + "colour": "Boja", + "githubStarredRepos": "GitHub repo-i sa zvjezdicom", + "uname": "Korisničko ime", + "wrongArgNum": "Naveden je pogrešan broj argumenata", + "xIsTrackOnly": "{} je samo za praćenje", + "source": "Izvor", + "app": "Aplikacija. ", + "appsFromSourceAreTrackOnly": "Aplikacije iz ovog izvora su 'Samo za praćenje'.", + "youPickedTrackOnly": "Odabrali ste opciju „Samo za praćenje”.", + "trackOnlyAppDescription": "Aplikacija će se pratiti radi ažuriranja, ali Obtainium neće moći da je preuzme ili instalira.", + "cancelled": "Otkazano", + "appAlreadyAdded": "Aplikacija je već dodana", + "alreadyUpToDateQuestion": "Aplikacija je već ažurirana?", + "addApp": "Dodaj aplikaciju", + "appSourceURL": "Izvorni URL aplikacije", + "error": "Greška", + "add": "Dodaj", + "searchSomeSourcesLabel": "Pretraživanje (samo neki izvori)", + "search": "Pretraživanje", + "additionalOptsFor": "Dodatne opcije za {}", + "supportedSources": "Podržani izvori", + "trackOnlyInBrackets": "(Samo za praćenje)", + "searchableInBrackets": "(Može se pretraživati)", + "appsString": "Aplikacije", + "noApps": "Nema aplikacija", + "noAppsForFilter": "Nema aplikacija za filter", + "byX": "Autor {}", + "percentProgress": "Napredak: {}%", + "pleaseWait": "Molimo sačekajte", + "updateAvailable": "Ažuriranje dostupno", + "estimateInBracketsShort": "(Procjena)", + "notInstalled": "Nije instalirano", + "estimateInBrackets": "(Procjena)", + "selectAll": "Označi sve", + "deselectN": "Poništi odabir {}", + "xWillBeRemovedButRemainInstalled": "{} će biti uklonjen iz Obtainiuma, ali će ostati instaliran na uređaju.", + "removeSelectedAppsQuestion": "Želite li ukloniti odabrane aplikacije?", + "removeSelectedApps": "Ukloni odabrane aplikacije", + "updateX": "Nadogradi {}", + "installX": "Instaliraj {}", + "markXTrackOnlyAsUpdated": "Označi {}\n(samo za praćenje)\nkao ažurirano", + "changeX": "Promjena {}", + "installUpdateApps": "Instalirajte/ažurirajte aplikacije", + "installUpdateSelectedApps": "Instalirajte/ažurirajte odabrane aplikacije", + "markXSelectedAppsAsUpdated": "Označite {} odabrane aplikacije kao ažurirane?", + "no": "Ne", + "yes": "Da", + "markSelectedAppsUpdated": "Označi odabrane aplikacije kao ažurirane", + "pinToTop": "Prikvači na vrh", + "unpinFromTop": "Otkvači sa vrha", + "resetInstallStatusForSelectedAppsQuestion": "Resetujte status instalacije za odabrane aplikacije?", + "installStatusOfXWillBeResetExplanation": "Status instalacije bilo koje odabrane aplikacije će se resetovati.\n\nTo može pomoći kada je verzija aplikacije prikazana u Obtainiumu netačna zbog neuspjelih ažuriranja ili drugih problema.", + "shareSelectedAppURLs": "Podijeli odabrane URL-ove aplikacija", + "resetInstallStatus": "Resetujte status instalacije", + "more": "Više", + "removeOutdatedFilter": "Uklonite zastarjeli filter aplikacija", + "showOutdatedOnly": "Prikaži samo zastarjele aplikacije", + "filter": "Filtriranje", + "filterActive": "Filtriranje", + "filterApps": "Filtriraj aplikacije", + "appName": "Naziv aplikacije", + "author": "Autor", + "upToDateApps": "Ažurirane aplikacije", + "nonInstalledApps": "Neinstalirane aplikacije", + "importExport": "Uvoz/izvoz", + "settings": "Postavke", + "exportedTo": "Izvezeno u {}", + "obtainiumExport": "Obtainium Export", + "invalidInput": "Neispravan unos.", + "importedX": "Uvezeno {}", + "obtainiumImport": "Obtainium uvoz", + "importFromURLList": "Uvoz iz URL liste", + "searchQuery": "Pretraga za: ", + "appURLList": "Lista URL adresa aplikacija", + "line": "Linija", + "searchX": "Pretraživanje {}", + "noResults": "Nema rezultata", + "importX": "Uvoz {}", + "importedAppsIdDisclaimer": "Uvezene aplikacije mogu se pogrešno prikazati kao „Nije instalirano”.\nDa biste to riješili, ponovo ih instalirajte putem aplikacije Obtainium.\nTo ne bi trebalo uticati na podatke aplikacije.\n\nUtječe samo na URL i metode uvoza treće strane.", + "importErrors": "Uvezi greške", + "importedXOfYApps": "{} od {} aplikacija uvezeno.", + "followingURLsHadErrors": "Sljedeći URL-ovi su imali greške:", + "okay": "Dobro", + "selectURL": "Odaberite URL", + "selectURLs": "Odaberite URL-ove", + "pick": "Odaberi", + "theme": "Tema", + "dark": "Tamna", + "light": "Svijetla", + "followSystem": "Pratite sistem", + "obtainium": "Obtainium", + "materialYou": "Material You", + "useBlackTheme": "Koristite čisto crnu tamnu temu", + "appSortBy": "Aplikacije sortirane po", + "authorName": "Autor/Ime", + "nameAuthor": "Ime/Autor", + "asAdded": "Kao što je dodano", + "appSortOrder": "Redoslijed sortiranja aplikacija", + "ascending": "Uzlazno", + "descending": "Silazno", + "bgUpdateCheckInterval": "Interval provjere ažuriranja u pozadini", + "neverManualOnly": "Nikada - samo ručno", + "appearance": "Izgled", + "showWebInAppView": "Prikaži izvornu web stranicu u prikazu aplikacije", + "pinUpdates": "Prikvačite ažuriranja na vrh prikaza aplikacija", + "updates": "Nadogradnje", + "sourceSpecific": "Specifično za izvor", + "appSource": "Izvor aplikacije", + "noLogs": "Nema evidencije", + "appLogs": "Evidencije aplikacija", + "close": "Zatvori", + "share": "Podijeli", + "appNotFound": "Aplikacija nije pronađena", + "obtainiumExportHyphenatedLowercase": "obtainium-export", + "pickAnAPK": "Odaberite APK", + "appHasMoreThanOnePackage": "{} ima više od jednog paketa:", + "deviceSupportsXArch": "Vaš uređaj podržava {} arhitekturu procesora.", + "deviceSupportsFollowingArchs": "Vaš uređaj podržava sljedeće arhitekture procesora:", + "warning": "Upozorenje", + "sourceIsXButPackageFromYPrompt": "Izvor aplikacije je '{}', ali paket za izdavanje dolazi iz '{}'. Želite li nastaviti?", + "updatesAvailable": "Dostupna ažuriranja", + "updatesAvailableNotifDescription": "Obavještava korisnika da su ažuriranja dostupna za jednu ili više aplikacija koje prati Obtainium", + "noNewUpdates": "Nema novih ažuriranja.", + "xHasAnUpdate": "{} ima ažuriranje.", + "appsUpdated": "Aplikacije su ažurirane", + "appsUpdatedNotifDescription": "Obavještava korisnika da su u pozadini primijenjena ažuriranja na jednu ili više aplikacija", + "xWasUpdatedToY": "{} je ažuriran na {}.", + "errorCheckingUpdates": "Greška pri provjeri ažuriranja", + "errorCheckingUpdatesNotifDescription": "Obavijest koja se prikazuje kada provjera sigurnosnog ažuriranja ne uspije", + "appsRemoved": "Aplikacije su uklonjene", + "appsRemovedNotifDescription": "Obavještava korisnika da je jedna ili više aplikacija uklonjeno zbog grešaka prilikom učitavanja", + "xWasRemovedDueToErrorY": "{} je uklonjen zbog ove greške: {}", + "completeAppInstallation": "Dovršite instalaciju aplikacije", + "obtainiumMustBeOpenToInstallApps": "Obtainium mora biti otvoren za instalaciju aplikacija", + "completeAppInstallationNotifDescription": "Traži od korisnika da se vrati u Obtainium kako bi dovršio instalaciju aplikacije", + "checkingForUpdates": "Tražim moguće nadogradnje", + "checkingForUpdatesNotifDescription": "Privremeno obavještenje koje se pojavljuje prilikom provjere ažuriranja", + "pleaseAllowInstallPerm": "Dozvolite Obtainiumu da instalira aplikacije", + "trackOnly": "Samo za praćenje", + "errorWithHttpStatusCode": "Greška {}", + "versionCorrectionDisabled": "Ispravka verzije je onemogućena (izgleda da plugin ne radi)", + "unknown": "Nepoznato", + "none": "Ništa", + "never": "Nikad", + "latestVersionX": "Najnovija verzija: {}", + "installedVersionX": "Instalirana verzija: {}", + "lastUpdateCheckX": "Posljednja provjera ažuriranja: {}", + "remove": "Izbriši", + "yesMarkUpdated": "Da, označi kao ažurirano", + "fdroid": "F-Droid Official", + "appIdOrName": "ID ili ime aplikacije", + "appId": "Apl ID", + "appWithIdOrNameNotFound": "Nije pronađena aplikacija s tim ID-om ili imenom", + "reposHaveMultipleApps": "Repo-i mogu sadržavati više aplikacija", + "fdroidThirdPartyRepo": "F-Droid Repo treće strane", + "steam": "Steam", + "steamMobile": "Steam Mobile", + "steamChat": "Razgovor na Steamu (chat)", + "install": "Instaliraj", + "markInstalled": "Označi kao instalirano", + "update": "Nadogradi", + "markUpdated": "Označi kao ažurirano", + "additionalOptions": "Dodatne opcije", + "disableVersionDetection": "Onemogući detekciju verzije", + "noVersionDetectionExplanation": "Ova opcija bi se trebala koristiti samo za aplikacije gdje detekcija verzije ne radi ispravno.", + "downloadingX": "Preuzimanje {}", + "downloadNotifDescription": "Obavještava korisnika o napretku u preuzimanju aplikacije", + "noAPKFound": "APK nije pronađen", + "noVersionDetection": "Nema detekcije verzije", + "categorize": "Kategoriziraj", + "categories": "Kategorije", + "category": "Kategorija", + "noCategory": "Nema kategorije", + "noCategories": "Nema kategorija", + "deleteCategoriesQuestion": "Želite li izbrisati kategorije?", + "categoryDeleteWarning": "Sve aplikacije u izbrisanim kategorijama će biti postavljene kao nekategorisane.", + "addCategory": "Dodaj kategoriju", + "label": "Oznaka", + "language": "Jezik", + "copiedToClipboard": "Podaci kopirani u međuspremnik", + "storagePermissionDenied": "Dozvola za pohranu je odbijena", + "selectedCategorizeWarning": "Ovo će zamijeniti sve postojeće postavke kategorije za odabrane aplikacije.", + "filterAPKsByRegEx": "Filtrirajte APK-ove prema regularnom izrazu", + "removeFromObtainium": "Ukloni iz Obtainiuma", + "uninstallFromDevice": "Deinstaliraj s uređaja", + "onlyWorksWithNonVersionDetectApps": "Radi samo za aplikacije s onemogućenom detekcijom verzije.", + "releaseDateAsVersion": "Koristi datum izdanja kao verziju", + "releaseDateAsVersionExplanation": "Ova opcija bi se trebala koristiti samo za aplikacije gdje detekcija verzije ne radi ispravno, ali je datum izdavanja dostupan.", + "changes": "Promjene", + "releaseDate": "Datum izdavanja", + "importFromURLsInFile": "Uvoz iz URL-ova u datoteci (kao što je OPML)", + "versionDetection": "Otkrivanje verzije", + "standardVersionDetection": "Detekcija standardne verzije", + "groupByCategory": "Grupiši po kategoriji", + "autoApkFilterByArch": "Pokušajte filtrirati APK-ove po arhitekturi procesora ako je moguće", + "overrideSource": "Premosti izvor", + "dontShowAgain": "Ne prikazuj ovo ponovo", + "dontShowTrackOnlyWarnings": "Ne prikazuj upozorenja „Samo za praćenje”", + "dontShowAPKOriginWarnings": "Ne prikazuj upozorenja o porijeklu APK-a", + "moveNonInstalledAppsToBottom": "Premjesti neinstalirane aplikacije na dno prikaza aplikacija", + "gitlabPATLabel": "GitLab token za lični pristup\n(Omogućava pretraživanje i bolje otkrivanje APK-a)", + "about": "O nama", + "requiresCredentialsInSettings": "Za ovo su potrebni dodatni akreditivi (u Postavkama)", + "checkOnStart": "Provjerite ima li novosti pri pokretanju", + "tryInferAppIdFromCode": "Pokušati otkriti ID aplikacije iz izvornog koda", + "removeOnExternalUninstall": "Automatski ukloni eksterno deinstalirane aplikacije", + "pickHighestVersionCode": "Automatski odaberite najviši kôd verzije APK-a", + "checkUpdateOnDetailPage": "Provjerite ima li novosti pri otvaranju stranice s detaljima aplikacije", + "disablePageTransitions": "Ugasite animaciju prijelaza stranice", + "reversePageTransitions": "Reverzne animacije prijelaza stranice", + "minStarCount": "Minimum Star Count", + "addInfoBelow": "Add this info below.", + "addInfoInSettings": "Add this info in the Settings.", + "githubSourceNote": "GitHub rate limiting can be avoided using an API key.", + "gitlabSourceNote": "GitLab APK extraction may not work without an API key.", + "sortByFileNamesNotLinks": "Sort by file names instead of full links", + "filterReleaseNotesByRegEx": "Filter Release Notes by Regular Expression", + "customLinkFilterRegex": "Custom APK Link Filter by Regular Expression (Default '.apk$')", + "appsPossiblyUpdated": "App Updates Attempted", + "appsPossiblyUpdatedNotifDescription": "Notifies the user that updates to one or more Apps were potentially applied in the background", + "xWasPossiblyUpdatedToY": "{} may have been updated to {}.", + "enableBackgroundUpdates": "Enable background updates", + "backgroundUpdateReqsExplanation": "Background updates may not be possible for all apps.", + "backgroundUpdateLimitsExplanation": "The success of a background install can only be determined when Obtainium is opened.", + "verifyLatestTag": "Verify the 'latest' tag", + "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit First", + "intermediateLinkNotFound": "Intermediate link not found", + "exemptFromBackgroundUpdates": "Exempt from background updates (if enabled)", + "bgUpdatesOnWiFiOnly": "Disable background updates when not on WiFi", + "autoSelectHighestVersionCode": "Auto-select highest versionCode APK", + "versionExtractionRegEx": "Version Extraction RegEx", + "matchGroupToUse": "Match Group to Use", + "highlightTouchTargets": "Highlight less obvious touch targets", + "pickExportDir": "Pick Export Directory", + "autoExportOnChanges": "Auto-export on changes", + "filterVersionsByRegEx": "Filter Versions by Regular Expression", + "trySelectingSuggestedVersionCode": "Try selecting suggested versionCode APK", + "dontSortReleasesList": "Retain release order from API", + "reverseSort": "Reverse sorting", + "debugMenu": "Debug Menu", + "bgTaskStarted": "Background task started - check logs.", + "runBgCheckNow": "Run Background Update Check Now", + "versionExtractWholePage": "Apply Version Extraction Regex to Entire Page", + "installing": "Installing", + "skipUpdateNotifications": "Skip update notifications", + "updatesAvailableNotifChannel": "Dostupna ažuriranja", + "appsUpdatedNotifChannel": "Aplikacije su ažurirane", + "appsPossiblyUpdatedNotifChannel": "App Updates Attempted", + "errorCheckingUpdatesNotifChannel": "Greška pri provjeri ažuriranja", + "appsRemovedNotifChannel": "Aplikacije su uklonjene", + "downloadingXNotifChannel": "Preuzimanje {}", + "completeAppInstallationNotifChannel": "Dovršite instalaciju aplikacije", + "checkingForUpdatesNotifChannel": "Tražim moguće nadogradnje", + "onlyCheckInstalledOrTrackOnlyApps": "Only check installed and Track-Only apps for updates", + "removeAppQuestion": { + "one": "Želite li ukloniti aplikaciju?", + "other": "Želite li ukloniti aplikacije?" + }, + "tooManyRequestsTryAgainInMinutes": { + "one": "Previše zahtjeva (ograničena broj zahteva) - pokušajte ponovo za {} minutu", + "other": "Previše zahtjeva (ograničena cijena) - pokušajte ponovo za {} min." + }, + "bgUpdateGotErrorRetryInMinutes": { + "one": "Provjera ažuriranja u pozadini naišla je na {}, zakazuje se ponovni pokušaj za {} minutu", + "other": "Provjera ažuriranja u pozadini naišla je na {}, zakazuje se ponovni pokušaj za {} min." + }, + "bgCheckFoundUpdatesWillNotifyIfNeeded": { + "one": "Provjera ažuriranja u pozadini je pronašla {} ažuriranje - korisnik će biti obavješten ako je to potrebno", + "other": "Provjera ažuriranja u pozadini je pronašla {} ažuriranja - korisnik će biti obavješten ako je to potrebno" + }, + "apps": { + "one": "{} aplikacija", + "other": "{} aplikacije" + }, + "url": { + "one": "{} URL", + "other": "{} URL-ovi" + }, + "minute": { + "one": "{} minuta", + "other": "min." + }, + "hour": { + "one": "{} sat", + "other": "{} sat/i" + }, + "day": { + "one": "{} dan", + "other": "{} dana" + }, + "clearedNLogsBeforeXAfterY": { + "one": "Izbrisan {n} log (prije = {before}, nakon = {after})", + "other": "Izbrisano {n} log-ova (prije = {before}, nakon = {after})" + }, + "xAndNMoreUpdatesAvailable": { + "one": "{} i još 1 aplikacija ima ažuriranja.", + "other": "{} i još {} aplikacija imaju ažuriranja." + }, + "xAndNMoreUpdatesInstalled": { + "one": "{} i još 1 aplikacija je ažurirana.", + "other": "{} i još {} aplikacija je ažurirano." + }, + "xAndNMoreUpdatesPossiblyInstalled": { + "one": "{} and 1 more app may have been updated.", + "other": "{} and {} more apps may have been updated." + } +} diff --git a/assets/translations/cs.json b/assets/translations/cs.json new file mode 100644 index 0000000..b4b052e --- /dev/null +++ b/assets/translations/cs.json @@ -0,0 +1,330 @@ +{ + "invalidURLForSource": "Žádná platná {} adresa URL aplikace", + "noReleaseFound": "Nebyla nalezena odpovídající verze", + "noVersionFound": "Nelze určit verzi vydání", + "urlMatchesNoSource": "URL neodpovídá žádnému známému zdroji", + "cantInstallOlderVersion": "Nelze nainstalovat starší verzi aplikace", + "appIdMismatch": "ID staženého balíčku neodpovídá ID existující aplikace", + "functionNotImplemented": "Tato třída nemá implementovánu tuto funkci", + "placeholder": "Zástupce", + "someErrors": "Vyskytly se nějaké chyby", + "unexpectedError": "Neočekávaná chyba", + "ok": "Okay", + "and": "a", + "githubPATLabel": "GitHub Personal Access Token (Raises Rate Limit)", + "includePrereleases": "includepreleases", + "fallbackToOlderReleases": "Fallback to older releases", + "filterReleaseTitlesByRegEx": "Názvy vydání podle regulárního výrazu\filtr", + "invalidRegEx": "Neplatný regulární výraz", + "noDescription": "Žádný popis", + "cancel": "Zrušit", + "continue": "Pokračovat", + "requiredInBracets": "(Required)", + "dropdownNoOptsError": "ERROR: DROPDOWN MUSÍ MÍT AŽ JEDNU MOŽNOST", + "color": "barva", + "githubStarredRepos": "GitHub Starred Repos", + "uname": "username", + "wrongArgNum": "Špatný počet předložených argumentů", + "xIsTrackOnly": "{} je určeno pouze pro sledování", + "source": "zdroj", + "app": "App", + "appsFromSourceAreTrackOnly": "Aplikace z tohoto zdroje jsou 'Jen sledovány'.", + "youPickedTrackOnly": "Vybrali jste možnost 'Jen sledovat'.", + "trackOnlyAppDescription": "Aplikace je sledována kvůli aktualizacím, ale Obtainium ji nebude stahovat ani instalovat.", + "cancelled": "Zrušeno", + "appAlreadyAdded": "Aplikace již přidána", + "alreadyUpToDateQuestion": "App already up to date?", + "addApp": "Přidat aplikaci", + "appSourceURL": "zdrojová adresa URL aplikace", + "error": "Chyba", + "add": "Přidat", + "searchSomeSourcesLabel": "Vyhledávání (pouze konkrétní zdroje)", + "search": "Hledat", + "additionalOptsFor": "Další možnosti pro {}", + "supportedSources": "Podporované zdroje", + "trackOnlyInBrackets": "(Pouze stopy)", + "searchableInBrackets": "(s možností vyhledávání)", + "appsString": "Apky", + "noApps": "Žádné aplikace", + "noAppsForFilter": "žádné aplikace pro vybraný filtr", + "byX": "By {}", + "percentProgress": "Pokrok: {}%", + "pleaseWait": "Počkejte prosím", + "updateAvailable": "Aktualizace je k dispozici", + "estimateInBracketsShort": "(approx.)", + "notInstalled": "Není nainstalováno", + "estimateInBrackets": "(přibližně)", + "selectAll": "Vybrat Vše", + "deselectN": "{} deselected", + "xWillBeRemovedButRemainInstalled": "{} bude odstraněn z Obtainium, ale zůstane nainstalován v zařízení.", + "removeSelectedAppsQuestion": "Odebrat vybrané aplikace?", + "removeSelectedApps": "Odebrat vybrané aplikace", + "updateX": "Aktualizovat {}", + "installX": "Instalovat {}", + "markXTrackOnlyAsUpdated": "Označit {}\n(Track-Only)\njako aktualizované", + "changeX": "Změnit {}", + "installUpdateApps": "Instalovat/aktualizovat aplikace", + "installUpdateSelectedApps": "Instalovat/aktualizovat vybrané aplikace", + "markXSelectedAppsAsUpdated": "označit {} vybrané aplikace jako aktuální?", + "no": "Ne", + "yes": "ano", + "markSelectedAppsUpdated": "označit vybrané aplikace jako aktuální", + "pinToTop": "Připnout nahoru", + "unpinFromTop": "'Unpin Top'", + "resetInstallStatusForSelectedAppsQuestion": "Obnovit stav instalace vybraných aplikací?", + "installStatusOfXWillBeResetExplanation": "Stav instalace vybraných aplikací bude resetován. To může být užitečné, pokud je verze aplikace zobrazená v Obtainium nesprávná z důvodu neúspěšných aktualizací nebo jiných problémů.", + "shareSelectedAppURLs": "Sdílet adresy URL vybraných aplikací", + "resetInstallStatus": "Obnovení stavu instalace", + "more": "more", + "removeOutdatedFilter": "Odstranit filtr aplikace 'Not Current'", + "showOutdatedOnly": "Zobrazit pouze aplikace, které nejsou aktuální", + "filter": "Filtr", + "filterActive": "Filtr *", + "filterApps": "Filtrovat aplikace", + "appName": "název aplikace", + "author": "Autor", + "upToDateApps": "Apps with current version", + "nonInstalledApps": "Apps not installed", + "importExport": "Import/Export", + "settings": "Nastavení", + "exportedTo": "Exportováno do {}", + "obtainiumExport": "Obtainium Export", + "invalidInput": "Neplatný vstup", + "importedX": "Importováno {}", + "obtainiumImport": "Obtainium Import", + "importFromURLList": "Import ze seznamu URL", + "searchQuery": "Search Query", + "appURLList": "App URL List", + "line": "line", + "searchX": "Search {}", + "noResults": "Nebyly nalezeny žádné výsledky", + "importX": "Import {}", + "importedAppsIdDisclaimer": "Importované aplikace mohou být nesprávně zobrazeny jako \"Neinstalované\". Chcete-li to opravit, nainstalujte je znovu prostřednictvím Obtainium. To nemá vliv na data aplikací. Ovlivňuje pouze metody importu URL a třetích stran.", + "importErrors": "Import Errors", + "importedXOfYApps": "{}importováno {}aplikací.", + "followingURLsHadErrors": "U následujících adres URL došlo k chybám:", + "okay": "Okay", + "selectURL": "Select URL", + "selectURLs": "Select URLs", + "pick": "Vybrat", + "theme": "Téma", + "dark": "Tmavé", + "light": "Světlé", + "followSystem": "Follow System", + "obtainium": "Obtainium", + "materialYou": "Material You", + "useBlackTheme": "Použít čistě černé tmavé téma", + "appSortBy": "Seřadit aplikaci podle", + "authorName": "autor/jméno", + "nameAuthor": "jméno/autor", + "asAdded": "AsAdded", + "appSortOrder": "Sort App By", + "ascending": "Vzestupně", + "descending": "Sestupně", + "bgUpdateCheckInterval": "Background Update Check Interval", + "neverManualOnly": "Nikdy - pouze ručně", + "appearance": "Vzhled", + "showWebInAppView": "Zobrazit zdrojové webové stránky v zobrazení aplikace", + "pinUpdates": "Připnout aplikace s aktualizacemi nahoře", + "updates": "Updates", + "sourceSpecific": "source specific", + "appSource": "zdroj aplikace", + "noLogs": "Žádné protokoly", + "appLogs": "App Logs", + "close": "Zavřít", + "share": "Sdílet", + "appNotFound": "App not found", + "obtainiumExportHyphenatedLowercase": "obtainium-export", + "pickAnAPK": "Vybrat APK", + "appHasMoreThanOnePackage": "{} má více než jeden balíček:", + "deviceSupportsXArch": "Vaše zařízení podporuje architekturu CPU {}.", + "deviceSupportsFollowingArchs": "Vaše zařízení podporuje následující architektury CPU:", + "warning": "Varování", + "sourceIsXButPackageFromYPrompt": "The app source is '{}' but the release package is from '{}'. Pokračovat?", + "updatesAvailable": "dostupné aktualizace", + "updatesAvailableNotifDescription": "Upozorňuje uživatele, že jsou k dispozici aktualizace pro jednu nebo více aplikací sledovaných Obtainium", + "noNewUpdates": "Žádné nové aktualizace.", + "xHasAnUpdate": "{} má aktualizaci.", + "appsUpdated": "Aplikace aktualizovány", + "appsUpdatedNotifDescription": "Upozorňuje uživatele, že byly provedeny aktualizace jedné nebo více aplikací na pozadí", + "xWasUpdatedToY": "{} byl aktualizován na {}", + "errorCheckingUpdates": "Chybová kontrola aktualizací", + "errorCheckingUpdatesNotifDescription": "Oznámení zobrazené při neúspěšné kontrole aktualizací na pozadí", + "appsRemoved": "Odstraněné aplikace", + "appsRemovedNotifDescription": "Oznámení uživateli, že jedna nebo více aplikací byly odstraněny z důvodu chyb při načítání", + "xWasRemovedDueToErrorY": "{} byla odstraněna z důvodu následující chyby: {}", + "completeAppInstallation": "Dokončit instalaci aplikace", + "obtainiumMustBeOpenToInstallApps": "Obtainium musí být otevřeno, aby bylo možné instalovat aplikace", + "completeAppInstallationNotifDescription": "Vyzvat uživatele k návratu do Obtainium pro dokončení instalace aplikací", + "checkingForUpdates": "Zkontrolovat aktualizace", + "checkingForUpdatesNotifDescription": "Dočasné oznámení zobrazené při kontrole aktualizací", + "pleaseAllowInstallPerm": "Povolte prosím Obtainium instalovat aplikace", + "trackOnly": "Jen sledovat", + "errorWithHttpStatusCode": "error {}", + "versionCorrectionDisabled": "Oprava verze zakázána (zásuvný modul zřejmě nefunguje)", + "unknown": "Unknown", + "none": "None", + "never": "Nikdy", + "latestVersionX": "Nejnovější verze: {}", + "installedVersionX": "Nainstalovaná verze: {}", + "lastUpdateCheckX": "Poslední kontrola aktualizace: {}", + "remove": "Odebrat", + "yesMarkUpdated": "Ano, označit jako aktualizované", + "fdroid": "F-Droid Official", + "appIdOrName": "App ID or Name", + "appId": "App ID", + "appWithIdOrNameNotFound": "Žádná aplikace s tímto ID nebo názvem nebyla nalezena", + "reposHaveMultipleApps": "Repozitáře mohou obsahovat více aplikací", + "fdroidThirdPartyRepo": "F-Droid Third-Party Repo", + "steam": "Steam", + "steamMobile": "Steam Mobile", + "steamChat": "Steam Chat", + "install": "Install", + "markInstalled": "Označit jako nainstalovaný", + "update": "Aktualizovat", + "markUpdated": "Označit jako aktuální", + "additionalOptions": "Additional Options", + "disableVersionDetection": "Zakázat detekci verze", + "noVersionDetectionExplanation": "Tato volba by měla být použita pouze u aplikací, kde detekce verzí nefunguje správně.", + "downloadingX": "download {}", + "downloadNotifDescription": "Informuje uživatele o průběhu stahování aplikace", + "noAPKFound": "Žádná APK nebyla nalezena", + "noVersionDetection": "Žádná detekce verze", + "categorize": "Kategorizovat", + "categories": "Kategorie", + "category": "kategorie", + "noCategory": "Žádná kategorie", + "noCategories": "Žádné kategorie", + "deleteCategoriesQuestion": "Smazat kategorie?", + "categoryDeleteWarning": "Všechny aplikace v odstraněných kategoriích budou nastaveny na nekategorizované.", + "addCategory": "přidat kategorii", + "label": "štítek", + "language": "Jazyk", + "copiedToClipboard": "zkopírováno do schránky", + "storagePermissionDenied": "povolení k ukládání odepřeno", + "selectedCategorizeWarning": "Toto nahradí všechna stávající nastavení kategorií pro vybrané aplikace.", + "filterAPKsByRegEx": "Filtrovat APK podle regulárního výrazu", + "removeFromObtainium": "Odebrat z Obtainium", + "uninstallFromDevice": "Odinstalovat ze zařízení", + "onlyWorksWithNonVersionDetectApps": "Funguje pouze pro aplikace s vypnutou detekcí verze.", + "releaseDateAsVersion": "Použít datum vydání jako verzi", + "releaseDateAsVersionExplanation": "Tato možnost by měla být použita pouze u aplikací, u kterých detekce verze nefunguje správně, ale je k dispozici datum vydání.", + "changes": "Změny", + "releaseDate": "datum vydání", + "importFromURLsInFile": "Importovat adresy URL ze souboru (např. OPML)", + "versionDetection": "detekce verze", + "standardVersionDetection": "standardní detekce verze", + "groupByCategory": "Seskupit podle kategorie", + "autoApkFilterByArch": "Pokud je to možné, pokuste se filtrovat soubory APK podle architektury procesoru", + "overrideSource": "Přepsat zdroj", + "dontShowAgain": "Nezobrazovat znovu", + "dontShowTrackOnlyWarnings": "Nezobrazovat varování pro 'Track Only'", + "dontShowAPKOriginWarnings": "Nezobrazovat varování pro původ APK", + "moveNonInstalledAppsToBottom": "Přesunout nenainstalované aplikace na konec zobrazení Aplikace", + "gitlabPATLabel": "GitLab Personal Access Token\n(Umožňuje vyhledávání a lepší zjišťování APK)", + "about": "About", + "requiresCredentialsInSettings": "Vyžaduje další pověření (v nastavení)", + "checkOnStart": "Zkontrolovat jednou při spuštění", + "tryInferAppIdFromCode": "Pokusit se určit ID aplikace ze zdrojového kódu", + "removeOnExternalUninstall": "Automaticky odstranit externě odinstalované aplikace", + "pickHighestVersionCode": "Automaticky vybrat APK s kódem nejvyšší verze", + "checkUpdateOnDetailPage": "Zkontrolovat aktualizace při otevření stránky s podrobnostmi aplikace", + "disablePageTransitions": "Zakázat animace pro přechody stránek", + "reversePageTransitions": "Obrátit animace pro přechody stránek", + "minStarCount": "Minimální počet hvězdiček", + "addInfoBelow": "Přidat tuto informaci na konec stránky", + "addInfoInSettings": "Přidat tuto informaci do nastavení.", + "githubSourceNote": "Omezení rychlosti GitHub lze obejít pomocí klíče API.", + "gitlabSourceNote": "Extrakce GitLab APK nemusí fungovat bez klíče API", + "sortByFileNamesNotLinks": "Řadit podle názvů souborů místo celých odkazů", + "filterReleaseNotesByRegEx": "Filtrovat poznámky k vydání podle regulárního výrazu", + "customLinkFilterRegex": "Vlastní filtr odkazů APK podle regulárního výrazu (výchozí '.apk$')", + "appsPossiblyUpdated": "Byly provedeny pokusy o aktualizaci aplikací", + "appsPossiblyUpdatedNotifDescription": "Upozorňuje uživatele, že na pozadí mohly být provedeny aktualizace jedné nebo více aplikací", + "xWasPossiblyUpdatedToY": "{} mohlo být aktualizováno na {}.", + "enableBackgroundUpdates": "Povolit aktualizace na pozadí", + "backgroundUpdateReqsExplanation": "Aktualizace na pozadí nemusí být možné pro všechny aplikace.", + "backgroundUpdateLimitsExplanation": "Úspěšnost instalace na pozadí lze určit pouze v případě, že je otevřen Obtainium.", + "verifyLatestTag": "Ověřit značku 'latest'", + "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit First", + "intermediateLinkNotFound": "Intermediate link not found", + "exemptFromBackgroundUpdates": "Vyloučit aktualizace na pozadí (pokud jsou povoleny)", + "bgUpdatesOnWiFiOnly": "Zakázat aktualizace na pozadí, pokud není přítomna Wi-Fi", + "autoSelectHighestVersionCode": "Automatický výběr nejvyššího kódu verze APK", + "versionExtractionRegEx": "Version Extraction RegEx", + "matchGroupToUse": "Match Group to Use", + "highlightTouchTargets": "Zvýraznit méně zjevné cíle dotyku", + "pickExportDir": "Vybrat adresář pro export", + "autoExportOnChanges": "Automatický export při změnách", + "filterVersionsByRegEx": "Filtrovat verze podle regulárního výrazu", + "trySelectingSuggestedVersionCode": "Zkusit vybrat navrhovaný kód verze APK", + "dontSortReleasesList": "Retain release order from API", + "reverseSort": "Reverse sorting", + "debugMenu": "Debug Menu", + "bgTaskStarted": "Background task started - check logs.", + "runBgCheckNow": "Run Background Update Check Now", + "versionExtractWholePage": "Apply Version Extraction Regex to Entire Page", + "installing": "Installing", + "skipUpdateNotifications": "Skip update notifications", + "updatesAvailableNotifChannel": "dostupné aktualizace", + "appsUpdatedNotifChannel": "Aplikace aktualizovány", + "appsPossiblyUpdatedNotifChannel": "Byly provedeny pokusy o aktualizaci aplikací", + "errorCheckingUpdatesNotifChannel": "Chybová kontrola aktualizací", + "appsRemovedNotifChannel": "Odstraněné aplikace", + "downloadingXNotifChannel": "download {}", + "completeAppInstallationNotifChannel": "Dokončit instalaci aplikace", + "checkingForUpdatesNotifChannel": "Zkontrolovat aktualizace", + "onlyCheckInstalledOrTrackOnlyApps": "Only check installed and Track-Only apps for updates", + "removeAppQuestion": { + "one": "Odstranit Apku?", + "other": "Odstranit Apky?" + }, + "tooManyRequestsTryAgainInMinutes": { + "one": "Příliš mnoho požadavků (omezená rychlost) - zkuste to znovu za {} minutu", + "other": "Příliš mnoho požadavků (omezená rychlost) - zkuste to znovu za {} minut" + }, + "bgUpdateGotErrorRetryInMinutes": { + "one": "Při kontrole aktualizace na pozadí byla zjištěna chyba {}, opakování pokusu bude naplánováno za {} minut", + "other": "Během kontroly aktualizace na pozadí byla zjištěna chyba {}, opakování bude naplánováno za {} minut" + }, + "bgCheckFoundUpdatesWillNotifyIfNeeded": { + "one": "Při kontrole aktualizací na pozadí nalezena {}aktualizace - v případě potřeby upozorní uživatele", + "other": "Kontrola aktualizací na pozadí našla {} aktualizací - v případě potřeby upozorní uživatele" + }, + "apps": { + "one": "{} App", + "other": "{} apps" + }, + "url": { + "jedna": "{} URL", + "other": "{} URLs" + }, + "minute": { + "one": "{} minute", + "other": "{} minutes" + }, + "hour": { + "jedna": "{} hodina", + "other": "{} hours" + }, + "day": { + "jedna": "{} den", + "other": "{} dny" + }, + "clearedNLogsBeforeXAfterY": { + "one": "{n} log vymazán (před = {před}, po = {po})", + "other": "{n} logů vymazáno (před = {před}, po = {po})" + }, + "xAndNMoreUpdatesAvailable": { + "one": "{} a 1 další aplikace mají aktualizace.", + "other": "{} a {} další aplikace mají aktualizace." + }, + "xAndNMoreUpdatesInstalled": { + "one": "{} a {} další aplikace mají aktualizace.", + "další": "{} a {} další aplikace byly aktualizovány." + }, + "xAndNMoreUpdatesPossiblyInstalled": { + "one": "{} a {} další aplikace byly možná aktualizovány", + "other": "{} a {} další aplikace mohly být aktualizovány." + } +} \ No newline at end of file diff --git a/assets/translations/de.json b/assets/translations/de.json index 5a3bc13..5b22bfd 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -11,16 +11,7 @@ "unexpectedError": "Unerwarteter Fehler", "ok": "Okay", "and": "und", - "startedBgUpdateTask": "Hintergrundaktualisierungsprüfung gestartet", - "bgUpdateIgnoreAfterIs": "Hintergrundaktualisierung 'ignoreAfter' ist {}", - "startedActualBGUpdateCheck": "Überprüfung der Hintergrundaktualisierung gestartet", - "bgUpdateTaskFinished": "Hintergrundaktualisierungsprüfung abgeschlossen", - "firstRun": "Dies ist der erste Start von Obtainium überhaupt", - "settingUpdateCheckIntervalTo": "Aktualisierungsintervall auf {} stellen", "githubPATLabel": "GitHub Personal Access Token (Erhöht das Ratenlimit)", - "githubPATHint": "PAT muss in diesem Format sein: Benutzername:Token", - "githubPATFormat": "Benutzername:Token", - "githubPATLinkText": "Über GitHub PATs", "includePrereleases": "Vorabversionen einbeziehen", "fallbackToOlderReleases": "Fallback auf ältere Versionen", "filterReleaseTitlesByRegEx": "Release-Titel nach regulärem Ausdruck\nfiltern", @@ -37,8 +28,8 @@ "xIsTrackOnly": "{} ist nur zur Nachverfolgung", "source": "Quelle", "app": "App", - "appsFromSourceAreTrackOnly": "Apps aus dieser Quelle sind 'Nur Nachverfolgen'.", - "youPickedTrackOnly": "Sie haben die Option 'Nur Nachverfolgen' gewählt.", + "appsFromSourceAreTrackOnly": "Apps aus dieser Quelle sind nur zum Nachverfolgen.", + "youPickedTrackOnly": "Sie haben die Option „Nur Nachverfolgen“ gewählt.", "trackOnlyAppDescription": "Die App wird auf Updates überwacht, aber Obtainium wird sie nicht herunterladen oder installieren.", "cancelled": "Abgebrochen", "appAlreadyAdded": "App bereits hinzugefügt", @@ -47,10 +38,10 @@ "appSourceURL": "Quell-URL der App", "error": "Fehler", "add": "Hinzufügen", - "searchSomeSourcesLabel": "Suche (nur bestimmte Quellen)", + "searchSomeSourcesLabel": "Suche (nur für bestimmte Quellen)", "search": "Suchen", "additionalOptsFor": "Zusatzoptionen für {}", - "supportedSourcesBelow": "Unterstützte Quellen:", + "supportedSources": "Unterstützte Quellen", "trackOnlyInBrackets": "(Nur Nachverfolgen)", "searchableInBrackets": "(Durchsuchbar)", "appsString": "Apps", @@ -71,16 +62,15 @@ "updateX": "Aktualisiere {}", "installX": "Installiere {}", "markXTrackOnlyAsUpdated": "Markiere {}\n(Nur Nachverfolgen)\nals aktualisiert", - "changeX": "Ändern {}", + "changeX": "Ändere {}", "installUpdateApps": "Apps installieren/aktualisieren", "installUpdateSelectedApps": "Ausgewählte Apps installieren/aktualisieren", - "onlyWorksWithNonEVDApps": "Funktioniert nur bei Apps, deren Installationsstatus nicht automatisch erkannt werden kann (ungewöhnlich).", "markXSelectedAppsAsUpdated": "Markiere {} ausgewählte Apps als aktuell?", "no": "Nein", "yes": "Ja", "markSelectedAppsUpdated": "Markiere ausgewählte Apps als aktuell", "pinToTop": "Oben anheften", - "unpinFromTop": "'Oben anheften' aufheben", + "unpinFromTop": "„Oben anheften“ aufheben", "resetInstallStatusForSelectedAppsQuestion": "Installationsstatus für ausgewählte Apps zurücksetzen?", "installStatusOfXWillBeResetExplanation": "Der Installationsstatus der ausgewählten Apps wird zurückgesetzt. Dies kann hilfreich sein, wenn die in Obtainium angezeigte App-Version aufgrund fehlgeschlagener Aktualisierungen oder anderer Probleme falsch ist.", "shareSelectedAppURLs": "Ausgewählte App-URLs teilen", @@ -123,23 +113,24 @@ "followSystem": "System folgen", "obtainium": "Obtainium", "materialYou": "Material You", + "useBlackTheme": "Verwende Pure Black Dark Theme", "appSortBy": "App sortieren nach", "authorName": "Autor/Name", "nameAuthor": "Name/Autor", "asAdded": "Wie hinzugefügt", - "appSortOrder": "App Sortierung nach", + "appSortOrder": "App sortieren nach", "ascending": "Aufsteigend", "descending": "Absteigend", "bgUpdateCheckInterval": "Prüfintervall für Hintergrundaktualisierung", - "neverManualOnly": "Nie - nur manuell", + "neverManualOnly": "Nie – nur manuell", "appearance": "Aussehen", "showWebInAppView": "Quellwebseite in der App-Ansicht anzeigen", "pinUpdates": "Apps mit Aktualisierungen oben anheften", "updates": "Aktualisierungen", "sourceSpecific": "Quellenspezifisch", "appSource": "App-Quelle", - "noLogs": "Keine Protokolle", - "appLogs": "App Protokolle", + "noLogs": "Keine Logs", + "appLogs": "App-Logs", "close": "Schließen", "share": "Teilen", "appNotFound": "App nicht gefunden", @@ -178,13 +169,13 @@ "installedVersionX": "Installierte Version: {}", "lastUpdateCheckX": "Letzte Aktualisierungsprüfung: {}", "remove": "Entfernen", - "removeAppQuestion": "App entfernen?", "yesMarkUpdated": "Ja, als aktualisiert markieren", - "fdroid": "F-Droid", + "fdroid": "offizielles F-Droid-Repo", "appIdOrName": "App ID oder Name", + "appId": "App ID", "appWithIdOrNameNotFound": "Es wurde keine App mit dieser ID oder diesem Namen gefunden", "reposHaveMultipleApps": "Repos können mehrere Apps enthalten", - "fdroidThirdPartyRepo": "F-Droid Third-Party Repo", + "fdroidThirdPartyRepo": "F-Droid Drittparteienrepo", "steam": "Steam", "steamMobile": "Steam Mobile", "steamChat": "Steam Chat", @@ -209,19 +200,96 @@ "addCategory": "Kategorie hinzufügen", "label": "Bezeichnung", "language": "Sprache", - "storagePermissionDenied": "Storage permission denied", - "selectedCategorizeWarning": "This will replace any existing category settings for the selected Apps.", + "copiedToClipboard": "In die Zwischenablage kopiert", + "storagePermissionDenied": "Speicherberechtigung verweigert", + "selectedCategorizeWarning": "Dadurch werden alle bestehenden Kategorieeinstellungen für die ausgewählten Apps ersetzt.", + "filterAPKsByRegEx": "APKs nach regulärem Ausdruck filtern", + "removeFromObtainium": "Aus Obtainium entfernen", + "uninstallFromDevice": "Vom Gerät deinstallieren", + "onlyWorksWithNonVersionDetectApps": "Funktioniert nur bei Apps mit deaktivierter Versionserkennung.", + "releaseDateAsVersion": "Veröffentlichungsdatum als Version verwenden", + "releaseDateAsVersionExplanation": "Diese Option sollte nur für Apps verwendet werden, bei denen die Versionserkennung nicht korrekt funktioniert, aber ein Veröffentlichungsdatum verfügbar ist.", + "changes": "Änderungen", + "releaseDate": "Veröffentlichungsdatum", + "importFromURLsInFile": "Importieren von URLs aus Datei (z. B. OPML)", + "versionDetection": "Versionserkennung", + "standardVersionDetection": "Standardversionserkennung", + "groupByCategory": "Nach Kategorie gruppieren", + "autoApkFilterByArch": "Nach Möglichkeit versuchen, APKs nach CPU-Architektur zu filtern", + "overrideSource": "Quelle überschreiben", + "dontShowAgain": "Nicht noch einmal zeigen", + "dontShowTrackOnlyWarnings": "Warnung für 'Nur Nachverfolgen' nicht anzeigen", + "dontShowAPKOriginWarnings": "Warnung für APK-Herkunft nicht anzeigen", + "moveNonInstalledAppsToBottom": "Nicht installierte Apps ans Ende der Apps Ansicht verschieben", + "gitlabPATLabel": "GitLab Personal Access Token\n(Aktiviert Suche und bessere APK Entdeckung)", + "about": "Über", + "requiresCredentialsInSettings": "Benötigt zusätzliche Anmeldedaten (in den Einstellungen)", + "checkOnStart": "Überprüfe einmalig beim Start", + "tryInferAppIdFromCode": "Versuche, die App-ID aus dem Quellcode zu ermitteln", + "removeOnExternalUninstall": "Automatisches Entfernen von extern deinstallierten Apps", + "pickHighestVersionCode": "Automatische Auswahl des APK mit höchstem Versionscode", + "checkUpdateOnDetailPage": "Nach Updates suchen, wenn eine App-Detailseite geöffnet wird", + "disablePageTransitions": "Animationen für Seitenübergänge deaktivieren", + "reversePageTransitions": "Umgekehrte Animationen für Seitenübergänge", + "minStarCount": "Minimale Anzahl von Sternen", + "addInfoBelow": "Fügen Sie diese Informationen unten hinzu.", + "addInfoInSettings": "Fügen Sie diese Info in den Einstellungen hinzu.", + "githubSourceNote": "Die GitHub-Ratenbegrenzung kann mit einem API-Schlüssel umgangen werden.", + "gitlabSourceNote": "GitLab APK-Extraktion funktioniert möglicherweise nicht ohne API-Schlüssel", + "sortByFileNamesNotLinks": "Sortiere nach Dateinamen, anstelle von ganzen Links", + "filterReleaseNotesByRegEx": "Versionshinweise nach regulärem Ausdruck filtern", + "customLinkFilterRegex": "Benutzerdefinierter APK Link Filter nach Regulärem Ausdruck (Standard '.apk$')", + "appsPossiblyUpdated": "App Aktualisierungen wurden versucht", + "appsPossiblyUpdatedNotifDescription": "Benachrichtigt den Benutzer, dass Updates für eine oder mehrere Apps möglicherweise im Hintergrund durchgeführt wurden", + "xWasPossiblyUpdatedToY": "{} wurde möglicherweise aktualisiert auf {}.", + "enableBackgroundUpdates": "Aktiviere Hintergrundaktualisierungen", + "backgroundUpdateReqsExplanation": "Die Hintergrundaktualisierung ist möglicherweise nicht für alle Apps möglich.", + "backgroundUpdateLimitsExplanation": "Der Erfolg einer Hintergrundinstallation kann nur festgestellt werden, wenn Obtainium geöffnet wird.", + "verifyLatestTag": "Überprüfe das „latest“ Tag", + "intermediateLinkRegex": "Filter für einen „Zwischen“-Link, der zuerst besucht werden soll", + "intermediateLinkNotFound": "„Zwischen“link nicht gefunden", + "exemptFromBackgroundUpdates": "Ausschluss von Hintergrundaktualisierungen (falls aktiviert)", + "bgUpdatesOnWiFiOnly": "Hintergrundaktualisierungen deaktivieren, wenn kein WLAN vorhanden ist", + "autoSelectHighestVersionCode": "Automatisch höchste APK-Version auswählen", + "versionExtractionRegEx": "Versions-Extraktion per RegEx", + "matchGroupToUse": "zu verwendende Gruppe abgleichen", + "highlightTouchTargets": "Weniger offensichtliche Touch-Ziele hervorheben", + "pickExportDir": "Export-Verzeichnis wählen", + "autoExportOnChanges": "Automatischer Export bei Änderung(en)", + "filterVersionsByRegEx": "Versionen nach regulären Ausdrücken filtern", + "trySelectingSuggestedVersionCode": "Versuchen, den vorgeschlagenen APK-Versionscode auszuwählen", + "dontSortReleasesList": "Freigaberelease von der API ordern", + "reverseSort": "Umgekehrtes Sortieren", + "debugMenu": "Debug-Menü", + "bgTaskStarted": "Hintergrundaufgabe gestartet – Logs prüfen.", + "runBgCheckNow": "Hintergrundaktualisierungsprüfung jetzt durchführen", + "versionExtractWholePage": "Versions-Extraktion per RegEx auf die gesamte Seite anwenden", + "installing": "Installiere", + "skipUpdateNotifications": "Keine Benachrichtigung zu App-Updates geben", + "updatesAvailableNotifChannel": "Aktualisierungen verfügbar", + "appsUpdatedNotifChannel": "Apps aktualisiert", + "appsPossiblyUpdatedNotifChannel": "App Aktualisierungen wurden versucht", + "errorCheckingUpdatesNotifChannel": "Fehler beim Prüfen auf Aktualisierungen", + "appsRemovedNotifChannel": "Apps entfernt", + "downloadingXNotifChannel": "Lade {} herunter", + "completeAppInstallationNotifChannel": "App Installation abschließen", + "checkingForUpdatesNotifChannel": "Nach Aktualisierungen suchen", + "onlyCheckInstalledOrTrackOnlyApps": "Überprüfe nur installierte und mit „nur Nachverfolgen“ markierte Apps nach Aktualisierungen", + "removeAppQuestion": { + "one": "App entfernen?", + "other": "Apps entfernen?" + }, "tooManyRequestsTryAgainInMinutes": { - "one": "Zu viele Anfragen (Rate begrenzt) - versuchen Sie es in {} Minute erneut", - "other": "Zu viele Anfragen (Rate begrenzt) - versuchen Sie es in {} Minuten erneut" + "one": "Zu viele Anfragen (Rate begrenzt) – versuchen Sie es in {} Minute erneut", + "other": "Zu viele Anfragen (Rate begrenzt) – versuchen Sie es in {} Minuten erneut" }, "bgUpdateGotErrorRetryInMinutes": { "one": "Bei der Aktualisierungsprüfung im Hintergrund wurde ein {} festgestellt, eine erneute Prüfung wird in {} Minute geplant", "other": "Bei der Aktualisierungsprüfung im Hintergrund wurde ein {} festgestellt, eine erneute Prüfung wird in {} Minuten geplant" }, "bgCheckFoundUpdatesWillNotifyIfNeeded": { - "one": "Hintergrundaktualisierungsprüfung fand {} Aktualisierung - benachrichtigt den Benutzer, falls erforderlich", - "other": "Hintergrundaktualisierungsprüfung fand {} Aktualisierungen - benachrichtigt den Benutzer, falls erforderlich" + "one": "Die Hintergrundaktualisierungsprüfung fand {} Aktualisierung – benachrichtigt den Benutzer, falls erforderlich", + "other": "Die Hintergrundaktualisierungsprüfung fand {} Aktualisierungen – benachrichtigt den Benutzer, falls erforderlich" }, "apps": { "one": "{} App", @@ -233,7 +301,7 @@ }, "minute": { "one": "{} Minute", - "other": "{} Minutes" + "other": "{} Minuten" }, "hour": { "one": "{} Stunde", @@ -244,8 +312,8 @@ "other": "{} Tage" }, "clearedNLogsBeforeXAfterY": { - "one": "{n} Protokoll gelöscht (vorher = {vorher}, nachher = {nachher})", - "other": "{n} Protokolle gelöscht (vorher = {vorher}, nachher = {nachher})" + "one": "{n} Log gelöscht (vorher = {before}, nachher = {after})", + "other": "{n} Logs gelöscht (vorher = {before}, nachher = {after})" }, "xAndNMoreUpdatesAvailable": { "one": "{} und 1 weitere App haben Aktualisierungen.", @@ -254,5 +322,9 @@ "xAndNMoreUpdatesInstalled": { "one": "{} und 1 weitere Anwendung wurden aktualisiert.", "other": "{} und {} weitere Anwendungen wurden aktualisiert." + }, + "xAndNMoreUpdatesPossiblyInstalled": { + "one": "{} und 1 weitere Anwendung wurden möglicherweise aktualisiert.", + "other": "{} und {} weitere Anwendungen wurden möglicherweise aktualisiert." } -} \ No newline at end of file +} diff --git a/assets/translations/en.json b/assets/translations/en.json index 1fae8c8..4e4626a 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -11,16 +11,7 @@ "unexpectedError": "Unexpected Error", "ok": "Okay", "and": "and", - "startedBgUpdateTask": "Started BG update check task", - "bgUpdateIgnoreAfterIs": "Bg update ignoreAfter is {}", - "startedActualBGUpdateCheck": "Started actual BG update checking", - "bgUpdateTaskFinished": "Finished BG update check task", - "firstRun": "This is the first ever run of Obtainium", - "settingUpdateCheckIntervalTo": "Setting update interval to {}", "githubPATLabel": "GitHub Personal Access Token (Increases Rate Limit)", - "githubPATHint": "PAT must be in this format: username:token", - "githubPATFormat": "username:token", - "githubPATLinkText": "About GitHub PATs", "includePrereleases": "Include prereleases", "fallbackToOlderReleases": "Fallback to older releases", "filterReleaseTitlesByRegEx": "Filter Release Titles by Regular Expression", @@ -50,7 +41,7 @@ "searchSomeSourcesLabel": "Search (Some Sources Only)", "search": "Search", "additionalOptsFor": "Additional Options for {}", - "supportedSourcesBelow": "Supported Sources:", + "supportedSources": "Supported Sources", "trackOnlyInBrackets": "(Track-Only)", "searchableInBrackets": "(Searchable)", "appsString": "Apps", @@ -74,7 +65,6 @@ "changeX": "Change {}", "installUpdateApps": "Install/Update Apps", "installUpdateSelectedApps": "Install/Update Selected Apps", - "onlyWorksWithNonEVDApps": "Only works for Apps whose install status cannot be automatically detected (uncommon).", "markXSelectedAppsAsUpdated": "Mark {} Selected Apps as Updated?", "no": "No", "yes": "Yes", @@ -123,6 +113,7 @@ "followSystem": "Follow System", "obtainium": "Obtainium", "materialYou": "Material You", + "useBlackTheme": "Use pure black dark theme", "appSortBy": "App Sort By", "authorName": "Author/Name", "nameAuthor": "Name/Author", @@ -133,8 +124,8 @@ "bgUpdateCheckInterval": "Background Update Checking Interval", "neverManualOnly": "Never - Manual Only", "appearance": "Appearance", - "showWebInAppView": "Show Source Webpage in App View", - "pinUpdates": "Pin Updates to Top of Apps View", + "showWebInAppView": "Show Source webpage in App view", + "pinUpdates": "Pin updates to top of Apps view", "updates": "Updates", "sourceSpecific": "Source-Specific", "appSource": "App Source", @@ -178,10 +169,10 @@ "installedVersionX": "Installed Version: {}", "lastUpdateCheckX": "Last Update Check: {}", "remove": "Remove", - "removeAppQuestion": "Remove App?", "yesMarkUpdated": "Yes, Mark as Updated", - "fdroid": "F-Droid", + "fdroid": "F-Droid Official", "appIdOrName": "App ID or Name", + "appId": "App ID", "appWithIdOrNameNotFound": "No App was found with that ID or Name", "reposHaveMultipleApps": "Repos may contain multiple Apps", "fdroidThirdPartyRepo": "F-Droid Third-Party Repo", @@ -209,8 +200,85 @@ "addCategory": "Add Category", "label": "Label", "language": "Language", + "copiedToClipboard": "Copied to Clipboard", "storagePermissionDenied": "Storage permission denied", "selectedCategorizeWarning": "This will replace any existing category settings for the selected Apps.", + "filterAPKsByRegEx": "Filter APKs by Regular Expression", + "removeFromObtainium": "Remove from Obtainium", + "uninstallFromDevice": "Uninstall from Device", + "onlyWorksWithNonVersionDetectApps": "Only works for Apps with version detection disabled.", + "releaseDateAsVersion": "Use Release Date as Version", + "releaseDateAsVersionExplanation": "This option should only be used for Apps where version detection does not work correctly, but a release date is available.", + "changes": "Changes", + "releaseDate": "Release Date", + "importFromURLsInFile": "Import from URLs in File (like OPML)", + "versionDetection": "Version Detection", + "standardVersionDetection": "Standard version detection", + "groupByCategory": "Group by Category", + "autoApkFilterByArch": "Attempt to filter APKs by CPU architecture if possible", + "overrideSource": "Override Source", + "dontShowAgain": "Don't show this again", + "dontShowTrackOnlyWarnings": "Don't show 'Track-Only' warnings", + "dontShowAPKOriginWarnings": "Don't show APK origin warnings", + "moveNonInstalledAppsToBottom": "Move non-installed Apps to bottom of Apps view", + "gitlabPATLabel": "GitLab Personal Access Token\n(Enables Search and Better APK Discovery)", + "about": "About", + "requiresCredentialsInSettings": "This needs additional credentials (in Settings)", + "checkOnStart": "Check for updates on startup", + "tryInferAppIdFromCode": "Try inferring App ID from source code", + "removeOnExternalUninstall": "Automatically remove externally uninstalled Apps", + "pickHighestVersionCode": "Auto-select highest version code APK", + "checkUpdateOnDetailPage": "Check for updates on opening an App detail page", + "disablePageTransitions": "Disable page transition animations", + "reversePageTransitions": "Reverse page transition animations", + "minStarCount": "Minimum Star Count", + "addInfoBelow": "Add this info below.", + "addInfoInSettings": "Add this info in the Settings.", + "githubSourceNote": "GitHub rate limiting can be avoided using an API key.", + "gitlabSourceNote": "GitLab APK extraction may not work without an API key.", + "sortByFileNamesNotLinks": "Sort by file names instead of full links", + "filterReleaseNotesByRegEx": "Filter Release Notes by Regular Expression", + "customLinkFilterRegex": "Custom APK Link Filter by Regular Expression (Default '.apk$')", + "appsPossiblyUpdated": "App Updates Attempted", + "appsPossiblyUpdatedNotifDescription": "Notifies the user that updates to one or more Apps were potentially applied in the background", + "xWasPossiblyUpdatedToY": "{} may have been updated to {}.", + "enableBackgroundUpdates": "Enable background updates", + "backgroundUpdateReqsExplanation": "Background updates may not be possible for all apps.", + "backgroundUpdateLimitsExplanation": "The success of a background install can only be determined when Obtainium is opened.", + "verifyLatestTag": "Verify the 'latest' tag", + "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit First", + "intermediateLinkNotFound": "Intermediate link not found", + "exemptFromBackgroundUpdates": "Exempt from background updates (if enabled)", + "bgUpdatesOnWiFiOnly": "Disable background updates when not on WiFi", + "autoSelectHighestVersionCode": "Auto-select highest versionCode APK", + "versionExtractionRegEx": "Version Extraction RegEx", + "matchGroupToUse": "Match Group to Use for Version Extraction Regex", + "highlightTouchTargets": "Highlight less obvious touch targets", + "pickExportDir": "Pick Export Directory", + "autoExportOnChanges": "Auto-export on changes", + "filterVersionsByRegEx": "Filter Versions by Regular Expression", + "trySelectingSuggestedVersionCode": "Try selecting suggested versionCode APK", + "dontSortReleasesList": "Retain release order from API", + "reverseSort": "Reverse sorting", + "debugMenu": "Debug Menu", + "bgTaskStarted": "Background task started - check logs.", + "runBgCheckNow": "Run Background Update Check Now", + "versionExtractWholePage": "Apply Version Extraction Regex to Entire Page", + "installing": "Installing", + "skipUpdateNotifications": "Skip update notifications", + "updatesAvailableNotifChannel": "Updates Available", + "appsUpdatedNotifChannel": "Apps Updated", + "appsPossiblyUpdatedNotifChannel": "App Updates Attempted", + "errorCheckingUpdatesNotifChannel": "Error Checking for Updates", + "appsRemovedNotifChannel": "Apps Removed", + "downloadingXNotifChannel": "Downloading {}", + "completeAppInstallationNotifChannel": "Complete App Installation", + "checkingForUpdatesNotifChannel": "Checking for Updates", + "onlyCheckInstalledOrTrackOnlyApps": "Only check installed and Track-Only apps for updates", + "removeAppQuestion": { + "one": "Remove App?", + "other": "Remove Apps?" + }, "tooManyRequestsTryAgainInMinutes": { "one": "Too many requests (rate limited) - try again in {} minute", "other": "Too many requests (rate limited) - try again in {} minutes" @@ -252,7 +320,11 @@ "other": "{} and {} more apps have updates." }, "xAndNMoreUpdatesInstalled": { - "one": "{} and 1 more app were updated.", + "one": "{} and 1 more app was updated.", "other": "{} and {} more apps were updated." + }, + "xAndNMoreUpdatesPossiblyInstalled": { + "one": "{} and 1 more app may have been updated.", + "other": "{} and {} more apps may have been updated." } } \ No newline at end of file diff --git a/assets/translations/es.json b/assets/translations/es.json new file mode 100644 index 0000000..481838a --- /dev/null +++ b/assets/translations/es.json @@ -0,0 +1,330 @@ +{ + "invalidURLForSource": "URL de la aplicación {} no válida", + "noReleaseFound": "No se ha podido encontrar una versión válida", + "noVersionFound": "No se ha podido determinar la versión de la publicación", + "urlMatchesNoSource": "La URL no coincide con ninguna fuente conocida", + "cantInstallOlderVersion": "No se puede instalar una versión previa de la aplicación", + "appIdMismatch": "La ID del paquete descargado no coincide con la ID de la aplicación instalada", + "functionNotImplemented": "Esta clase no ha implementado esta función", + "placeholder": "Espacio reservado", + "someErrors": "Han ocurrido algunos errores", + "unexpectedError": "Error Inesperado", + "ok": "Correcto", + "and": "y", + "githubPATLabel": "Token de Acceso Personal de GitHub (Reduce tiempos de espera)", + "includePrereleases": "Incluir versiones preliminares", + "fallbackToOlderReleases": "Retorceder a versiones previas", + "filterReleaseTitlesByRegEx": "Filtra Títulos de Versiones mediantes Expresiones Regulares", + "invalidRegEx": "Expresión regular inválida", + "noDescription": "Sin descripción", + "cancel": "Cancelar", + "continue": "Continuar", + "requiredInBrackets": "(Requerido)", + "dropdownNoOptsError": "ERROR: EL DESPLEGABLE DEBE TENER AL MENOS UNA OPCIÓN", + "colour": "Color", + "githubStarredRepos": "Repositorios favoritos de GitHub", + "uname": "Nombre de usuario", + "wrongArgNum": "Número de argumentos provistos inválido", + "xIsTrackOnly": "{} es de 'Solo Seguimiento'", + "source": "Origen", + "app": "Aplicación", + "appsFromSourceAreTrackOnly": "Las aplicaciones de este origen son de 'Solo Seguimiento'.", + "youPickedTrackOnly": "Debes seleccionar la opción de 'Solo Seguimiento'.", + "trackOnlyAppDescription": "Se monitorizará la aplicación en busca de actualizaciones, pero Obtainium no será capaz de descargarla o acutalizarla.", + "cancelled": "Cancelado", + "appAlreadyAdded": "Aplicación ya añadida", + "alreadyUpToDateQuestion": "¿Aplicación ya actualizada?", + "addApp": "Añadir Aplicación", + "appSourceURL": "URL de Origen de la Aplicación", + "error": "Error", + "add": "Añadir", + "searchSomeSourcesLabel": "Buscar (Solo Algunas Fuentes)", + "search": "Buscar", + "additionalOptsFor": "Opciones Adicionales para {}", + "supportedSources": "Fuentes Soportadas", + "trackOnlyInBrackets": "(Solo Seguimiento)", + "searchableInBrackets": "(Soporta Búsquedas)", + "appsString": "Aplicaciones", + "noApps": "Sin Aplicaciones", + "noAppsForFilter": "Sin Aplicaciones para Filtrar", + "byX": "Por {}", + "percentProgress": "Progreso: {}%", + "pleaseWait": "Por favor, espere", + "updateAvailable": "Actualización Disponible", + "estimateInBracketsShort": "(Aprox.)", + "notInstalled": "No Instalado", + "estimateInBrackets": "(Aproximado)", + "selectAll": "Seleccionar Todo", + "deselectN": "Deseleccionar {}", + "xWillBeRemovedButRemainInstalled": "{} será borrada de Obtainium pero continuará instalada en el dispositivo.", + "removeSelectedAppsQuestion": "¿Borrar aplicaciones seleccionadas?", + "removeSelectedApps": "Borrar Aplicaciones Seleccionadas", + "updateX": "Actualizar {}", + "installX": "Instalar {}", + "markXTrackOnlyAsUpdated": "Marcar {}\n(Solo Seguimient)\ncomo Actualizada", + "changeX": "Cambiar {}", + "installUpdateApps": "Instalar/Actualizar Aplicaciones", + "installUpdateSelectedApps": "Instalar/Actualizar Aplicaciones Seleccionadas", + "markXSelectedAppsAsUpdated": "¿Marcar {} Aplicaciones Seleccionadas como Actualizadas?", + "no": "No", + "yes": "Sí", + "markSelectedAppsUpdated": "Marcar Aplicaciones Seleccionadas como Actualizadas", + "pinToTop": "Fijar arriba", + "unpinFromTop": "Desfijar de arriba", + "resetInstallStatusForSelectedAppsQuestion": "¿Restuarar Estado de Instalación para las Aplicaciones Seleccionadas?", + "installStatusOfXWillBeResetExplanation": "El estado de instalación de las aplicaciones seleccionadas será restaurado.\n\nEsto puede ser de utilidad cuando la versión de la aplicación mostrada en Obtainium es incorrecta por actualizaciones fallidas u otros motivos.", + "shareSelectedAppURLs": "Compartir URLs de las Aplicaciones Seleccionadas", + "resetInstallStatus": "Restaurar Estado de Instalación", + "more": "Más", + "removeOutdatedFilter": "Elimiar Filtro de Aplicaciones Desactualizado", + "showOutdatedOnly": "Mostrar solo Aplicaciones Desactualizadas", + "filter": "Filtrar", + "filterActive": "Filtrar *", + "filterApps": "Filtrar Actualizaciones", + "appName": "Nombre de la Aplicación", + "author": "Autor", + "upToDateApps": "Aplicaciones Actualizadas", + "nonInstalledApps": "Aplicaciones No Instaladas", + "importExport": "Importar/Exportar", + "settings": "Ajustes", + "exportedTo": "Exportado a {}", + "obtainiumExport": "Exportar Obtainium", + "invalidInput": "Input incorrecto", + "importedX": "Importado {}", + "obtainiumImport": "Importar Obtainium", + "importFromURLList": "Importar desde lista de URLs", + "searchQuery": "Consulta de Búsqueda", + "appURLList": "Lista de URLs de Aplicaciones", + "line": "Línea", + "searchX": "Buscar {}", + "noResults": "Resultados no encontrados", + "importX": "Importar {}", + "importedAppsIdDisclaimer": "Las Aplicaciones Importadas pueden mostrarse incorrectamente como \"No Instalada\".\nPara arreglar esto, reinstálalas a través de Obtainium.\nEsto no debería afectar a los datos de las aplicaciones.\n\nSolo afecta a las URLs y a los métodos de importación mediante terceros.", + "importErrors": "Import Errors", + "importedXOfYApps": "{} de {} Aplicaciones importadas.", + "followingURLsHadErrors": "Las siguientes URLs tuvieron problemas:", + "okay": "Correcto", + "selectURL": "Seleccionar URL", + "selectURLs": "Seleccionar URLs", + "pick": "Escoger", + "theme": "Tema", + "dark": "Oscuro", + "light": "Claro", + "followSystem": "Seguir al Sistema", + "obtainium": "Obtainium", + "materialYou": "Material You", + "useBlackTheme": "Usar tema oscuro con negros puros", + "appSortBy": "Ordenar Aplicaciones Por", + "authorName": "Autor/Nombre", + "nameAuthor": "Nombre/Autor", + "asAdded": "Según se Añadieron", + "appSortOrder": "Orden de Clasificación de Aplicaciones", + "ascending": "Ascendente", + "descending": "Descendente", + "bgUpdateCheckInterval": "Intervalo de Comprobación de Actualizaciones en Segundo Plano", + "neverManualOnly": "Nunca - Solo Manual", + "appearance": "Apariencia", + "showWebInAppView": "Mostrar Vista de la Web de Origen", + "pinUpdates": "Fijar Actualizaciones en la Parte Superior de la Vista de Aplicaciones", + "updates": "Actualizaciones", + "sourceSpecific": "Fuente Específica", + "appSource": "Fuente de la Aplicación", + "noLogs": "Sin Logs", + "appLogs": "Logs de la Aplicación", + "close": "Cerrar", + "share": "Compartir", + "appNotFound": "Aplicación no encontrada", + "obtainiumExportHyphenatedLowercase": "obtainium-export", + "pickAnAPK": "Elige una APK", + "appHasMoreThanOnePackage": "{} tiene más de un paquete:", + "deviceSupportsXArch": "Tu dispositivo soporta las siguientes arquitecturas de procesador: {}.", + "deviceSupportsFollowingArchs": "Tu dispositivo soporta las siguientes arquitecturas de procesador:", + "warning": "Aviso", + "sourceIsXButPackageFromYPrompt": "La fuente de la aplicación es '{}' pero el paquete de la actualización viene de '{}'. ¿Desea continuar?", + "updatesAvailable": "Actualizaciones Disponibles", + "updatesAvailableNotifDescription": "Notifica al usuario de que hay actualizaciones para una o más aplicaciones monitorizadas por Obtainium", + "noNewUpdates": "No hay nuevas actualizaciones.", + "xHasAnUpdate": "{} tiene una actualización.", + "appsUpdated": "Aplicaciones Actualizadas", + "appsUpdatedNotifDescription": "Notifica al usuario de que una o más aplicaciones han sido actualizadas en segundo plano", + "xWasUpdatedToY": "{} ha sido actualizada a {}.", + "errorCheckingUpdates": "Error Buscando Actualizaciones", + "errorCheckingUpdatesNotifDescription": "Una notificación que muestra cuándo la comprobación de actualizaciones en segundo plano falla", + "appsRemoved": "Aplicaciones Eliminadas", + "appsRemovedNotifDescription": "Notifica al usuario que una o más aplicaciones fueron eliminadas por problemas al cargarlas", + "xWasRemovedDueToErrorY": "{} ha sido eliminada por: {}", + "completeAppInstallation": "Instalación Completa de la Aplicación", + "obtainiumMustBeOpenToInstallApps": "Obtainium debe estar abierta para instalar aplicaciones", + "completeAppInstallationNotifDescription": "Pide al usuario volver a Obtainium para teminar de instalar una aplicación", + "checkingForUpdates": "Buscando Actualizaciones", + "checkingForUpdatesNotifDescription": "Notificación temporal que aparece al buscar actualizaciones", + "pleaseAllowInstallPerm": "Por favor, permite a Obtainium instalar aplicaciones", + "trackOnly": "Solo Seguimiento", + "errorWithHttpStatusCode": "Error {}", + "versionCorrectionDisabled": "Corrección de versiones desactivada (el plugin parece no funcionar)", + "unknown": "Desconocido", + "none": "Ninguno", + "never": "Nunca", + "latestVersionX": "Última Versión: {}", + "installedVersionX": "Versión Instalada: {}", + "lastUpdateCheckX": "Última Comprobación: {}", + "remove": "Eliminar", + "yesMarkUpdated": "Sí, Marcar como Actualizada", + "fdroid": "Repositorio oficial de F-Droid", + "appIdOrName": "ID o Nombre de la Aplicación", + "appId": "ID de la Aplicación", + "appWithIdOrNameNotFound": "No se han encontrado aplicaciones con esa ID o nombre", + "reposHaveMultipleApps": "Los repositorios pueden contener varias aplicaciones", + "fdroidThirdPartyRepo": "Rpositorios de terceros de F-Droid", + "steam": "Steam", + "steamMobile": "Steam Mobile", + "steamChat": "Steam Chat", + "install": "Instalar", + "markInstalled": "Marcar como Instalda", + "update": "Actualizar", + "markUpdated": "Marcar como Actualizada", + "additionalOptions": "Opciones Adicionales", + "disableVersionDetection": "Descativar Detección de Versiones", + "noVersionDetectionExplanation": "Esta opción solo se debe usar en aplicaciones en las que la deteción de versiones pueda no funcionar correctamente.", + "downloadingX": "Descargando {}", + "downloadNotifDescription": "Notifica al usuario de progreso de descarga de una aplicación", + "noAPKFound": "APK no encontrada", + "noVersionDetection": "Sin detección de versiones", + "categorize": "Catogorizar", + "categories": "Categorías", + "category": "Categoría", + "noCategory": "Sin Categoría", + "noCategories": "Sin Categorías", + "deleteCategoriesQuestion": "¿Borrar Categorías?", + "categoryDeleteWarning": "Todas las aplicaciones en las categorías borradas serán margadas como 'Sin Categoría'.", + "addCategory": "Añadir Categoría", + "label": "Nombre", + "language": "Idioma", + "copiedToClipboard": "Copiado al Portapapeles", + "storagePermissionDenied": "Permiso de Almacenamiento rechazado", + "selectedCategorizeWarning": "Esto reemplazará cualquier ajuste de categoría para las aplicaicones seleccionadas.", + "filterAPKsByRegEx": "Filtrar APKs mediante Expresiones Regulares", + "removeFromObtainium": "Eliminar de Obtainium", + "uninstallFromDevice": "Desinstalar del Dispositivo", + "onlyWorksWithNonVersionDetectApps": "Solo funciona para aplicaciones con la detección de versiones desactivada.", + "releaseDateAsVersion": "Usar Fecha de Publicación como Versión", + "releaseDateAsVersionExplanation": "Esta opción solo se debería usar con aplicaciones en las que la detección de versiones no funciona pero hay disponible una fecha de publicación.", + "changes": "Cambios", + "releaseDate": "Fecha de Publicación", + "importFromURLsInFile": "Importar de URls en un Archivo (como OPML)", + "versionDetection": "Detección de Versiones", + "standardVersionDetection": "Detección de versiones estándar", + "groupByCategory": "Agrupar por Categoría", + "autoApkFilterByArch": "Tratar de filtrar las APKs mediante arquitecturas de procesador si es posible", + "overrideSource": "Sobrescribir Fuente", + "dontShowAgain": "No mostrar de nuevo", + "dontShowTrackOnlyWarnings": "No mostrar avisos de 'Solo Seguimiento'", + "dontShowAPKOriginWarnings": "No mostrar avisos de las fuentes de las APks", + "moveNonInstalledAppsToBottom": "Move non-installed Apps to bottom of Apps view", + "gitlabPATLabel": "GitLab Personal Access Token\n(Enables Search and Better APK Discovery)", + "about": "About", + "requiresCredentialsInSettings": "This needs additional credentials (in Settings)", + "checkOnStart": "Check for updates on startup", + "tryInferAppIdFromCode": "Try inferring App ID from source code", + "removeOnExternalUninstall": "Automatically remove externally uninstalled Apps", + "pickHighestVersionCode": "Auto-select highest version code APK", + "checkUpdateOnDetailPage": "Check for updates on opening an App detail page", + "disablePageTransitions": "Disable page transition animations", + "reversePageTransitions": "Reverse page transition animations", + "minStarCount": "Minimum Star Count", + "addInfoBelow": "Add this info below.", + "addInfoInSettings": "Add this info in the Settings.", + "githubSourceNote": "GitHub rate limiting can be avoided using an API key.", + "gitlabSourceNote": "GitLab APK extraction may not work without an API key.", + "sortByFileNamesNotLinks": "Sort by file names instead of full links", + "filterReleaseNotesByRegEx": "Filter Release Notes by Regular Expression", + "customLinkFilterRegex": "Custom APK Link Filter by Regular Expression (Default '.apk$')", + "appsPossiblyUpdated": "App Updates Attempted", + "appsPossiblyUpdatedNotifDescription": "Notifies the user that updates to one or more Apps were potentially applied in the background", + "xWasPossiblyUpdatedToY": "{} may have been updated to {}.", + "enableBackgroundUpdates": "Enable background updates", + "backgroundUpdateReqsExplanation": "Background updates may not be possible for all apps.", + "backgroundUpdateLimitsExplanation": "The success of a background install can only be determined when Obtainium is opened.", + "verifyLatestTag": "Verify the 'latest' tag", + "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit First", + "intermediateLinkNotFound": "Intermediate link not found", + "exemptFromBackgroundUpdates": "Exempt from background updates (if enabled)", + "bgUpdatesOnWiFiOnly": "Disable background updates when not on WiFi", + "autoSelectHighestVersionCode": "Auto-select highest versionCode APK", + "versionExtractionRegEx": "Version Extraction RegEx", + "matchGroupToUse": "Match Group to Use", + "highlightTouchTargets": "Highlight less obvious touch targets", + "pickExportDir": "Pick Export Directory", + "autoExportOnChanges": "Auto-export on changes", + "filterVersionsByRegEx": "Filter Versions by Regular Expression", + "trySelectingSuggestedVersionCode": "Try selecting suggested versionCode APK", + "dontSortReleasesList": "Retain release order from API", + "reverseSort": "Reverse sorting", + "debugMenu": "Debug Menu", + "bgTaskStarted": "Background task started - check logs.", + "runBgCheckNow": "Run Background Update Check Now", + "versionExtractWholePage": "Apply Version Extraction Regex to Entire Page", + "installing": "Installing", + "skipUpdateNotifications": "Skip update notifications", + "updatesAvailableNotifChannel": "Actualizaciones Disponibles", + "appsUpdatedNotifChannel": "Aplicaciones Actualizadas", + "appsPossiblyUpdatedNotifChannel": "App Updates Attempted", + "errorCheckingUpdatesNotifChannel": "Error Buscando Actualizaciones", + "appsRemovedNotifChannel": "Aplicaciones Eliminadas", + "downloadingXNotifChannel": "Descargando {}", + "completeAppInstallationNotifChannel": "Instalación Completa de la Aplicación", + "checkingForUpdatesNotifChannel": "Buscando Actualizaciones", + "onlyCheckInstalledOrTrackOnlyApps": "Only check installed and Track-Only apps for updates", + "removeAppQuestion": { + "one": "¿Eliminar Aplicación?", + "other": "¿Eliminar Aplicaciones?" + }, + "tooManyRequestsTryAgainInMinutes": { + "one": "Muchas peticiones (limitado) - prueba de nuevo en {} minuto", + "other": "Muchas peticiones (limitado) - prueba de nuevo en {} minutos" + }, + "bgUpdateGotErrorRetryInMinutes": { + "one": "La comprobación de actualizaciones en segundo plano se ha encontrado un {}, se volverá a probar en {} minuto", + "other": "La comprobación de actualizaciones en segundo plano se ha encontrado un {}, se volverá a probar en {} minutos" + }, + "bgCheckFoundUpdatesWillNotifyIfNeeded": { + "one": "La comprobación de actualizaciones en segundo plano ha encontrado {} actualización - se notificará al usuario si es necesario", + "other": "La comprobación de actualizaciones en segundo plano ha encontrado {} actualizaciones - se notificará al usuario si es necesario" + }, + "apps": { + "one": "{} Aplicación", + "other": "{} Aplicaciones" + }, + "url": { + "one": "{} URL", + "other": "{} URLs" + }, + "minute": { + "one": "{} Minuto", + "other": "{} Minutos" + }, + "hour": { + "one": "{} Hora", + "other": "{} Horas" + }, + "day": { + "one": "{} Día", + "other": "{} Días" + }, + "clearedNLogsBeforeXAfterY": { + "one": "Borrado {n} log (previo a = {before}, posterior a = {after})", + "other": "Borrados {n} logs (previos a = {before}, posteriores a = {after})" + }, + "xAndNMoreUpdatesAvailable": { + "one": "{} y 1 aplicación más tiene actualizaciones.", + "other": "{} y {} aplicaciones más tiene actualizaciones." + }, + "xAndNMoreUpdatesInstalled": { + "one": "{} y 1 aplicación más han sido actualizadas.", + "other": "{} y {} aplicaciones más han sido actualizadas." + }, + "xAndNMoreUpdatesPossiblyInstalled": { + "one": "{} and 1 more app may have been updated.", + "other": "{} and {} more apps may have been updated." + } +} \ No newline at end of file diff --git a/assets/translations/fa.json b/assets/translations/fa.json new file mode 100644 index 0000000..013ee34 --- /dev/null +++ b/assets/translations/fa.json @@ -0,0 +1,330 @@ +{ + "invalidURLForSource": "آدرس اینترنتی برنامه {} معتبر نیست", + "noReleaseFound": "نسخه مناسبی پیدا نشد", + "noVersionFound": "نمی توان نسخه منتشر شده را تعیین کرد", + "urlMatchesNoSource": "آدرس اینترنتی با منبع شناخته شده مطابقت ندارد", + "cantInstallOlderVersion": "نمی توان نسخه قدیمی یک برنامه را نصب کرد", + "appIdMismatch": "شناسه بسته دانلود شده با شناسه برنامه موجود مطابقت ندارد", + "functionNotImplemented": "این کلاس این تابع را پیاده سازی نکرده است", + "placeholder": "نگهدارنده مکان", + "someErrors": "برخی از خطاها رخ داده است", + "unexpectedError": "خطای غیرمنتظره", + "ok": "باشه", + "and": "و", + "githubPATLabel": "توکن دسترسی شخصی گیت هاب(محدودیت نرخ را افزایش میدهد)", + "includePrereleases": "شامل نسخه های اولیه", + "fallbackToOlderReleases": "بازگشت به نسخه های قدیمی تر", + "filterReleaseTitlesByRegEx": "عناوین انتشار را با بیان منظم فیلتر کنید", + "invalidRegEx": "عبارت منظم نامعتبر است", + "noDescription": "بدون توضیحات", + "cancel": "لغو", + "continue": "ادامه دهید", + "requiredInBrackets": "(ضروری)", + "dropdownNoOptsError": "خطا: کشویی باید حداقل یک گزینه داشته باشد", + "colour": "رنگ", + "githubStarredRepos": "مخازن ستاره دار گیتهاب", + "uname": "نام کاربری", + "wrongArgNum": "تعداد آرگومان های ارائه شده اشتباه است", + "xIsTrackOnly": "{} فقط ردیابی", + "source": "منبع", + "app": "برنامه", + "appsFromSourceAreTrackOnly": "برنامه‌های این منبع «فقط ردیابی» هستند", + "youPickedTrackOnly": "شما گزینه ی «فقط ردیابی» را انتخاب کرده اید", + "trackOnlyAppDescription": "برنامه برای به روز رسانی ها ردیابی می شود، اما Obtainium قادر به دانلود یا نصب آن نخواهد بود.", + "cancelled": "لغو شد", + "appAlreadyAdded": "برنامه قبلاً اضافه شده است", + "alreadyUpToDateQuestion": "برنامه از قبل به روز شده است؟", + "addApp": "افزودن برنامه", + "appSourceURL": "آدرس اینترنتی منبع برنامه", + "error": "خطا", + "add": "اضافه کردن", + "searchSomeSourcesLabel": "جستجو (فقط برخی منابع)", + "search": "جستجو کردن", + "additionalOptsFor": "گزینه های اضافی برای {}", + "supportedSources": "منابع پشتیبانی شده", + "trackOnlyInBrackets": "«فقط ردیابی»", + "searchableInBrackets": "(قابل جستجو)", + "appsString": "برنامه ها", + "noApps": "برنامه ای وجود ندارد", + "noAppsForFilter": "برنامه ای برای فیلتر کردن وجود ندارد", + "byX": "توسط {}", + "percentProgress": "پیش رفتن: {}%", + "pleaseWait": "لطفا صبر کنید", + "updateAvailable": "بروزرسانی در دسترس", + "estimateInBracketsShort": "(تخمین)", + "notInstalled": "نصب نشده", + "estimateInBrackets": "(تخمین زدن)", + "selectAll": "انتخاب همه", + "deselectN": "لغو انتخاب {}", + "xWillBeRemovedButRemainInstalled": "{} از Obtainium حذف می‌شود اما روی دستگاه نصب می‌ماند.", + "removeSelectedAppsQuestion": "برنامه های انتخابی حذف شود؟", + "removeSelectedApps": "حذف برنامه های انتخاب شده", + "updateX": "به روز رسانی {}", + "installX": "نصب {}", + "markXTrackOnlyAsUpdated": "علامت {}\n(فقط ردیابی)\nبروز شده", + "changeX": "تغییر دادن {}", + "installUpdateApps": "نصب/به‌روزرسانی برنامه‌ها", + "installUpdateSelectedApps": "برنامه‌های انتخابی را نصب/به‌روزرسانی کنید", + "markXSelectedAppsAsUpdated": "{} برنامه های انتخابی را به عنوان به روز علامت گذاری کنید؟", + "no": "خیر", + "yes": "بله", + "markSelectedAppsUpdated": "برنامه های انتخاب شده را به عنوان به روز علامت گذاری کنید", + "pinToTop": "پین به بالا", + "unpinFromTop": "برداشتن پین از بالا", + "resetInstallStatusForSelectedAppsQuestion": "وضعیت نصب برنامه‌های انتخابی بازنشانی شود؟", + "installStatusOfXWillBeResetExplanation": "وضعیت نصب برنامه‌های انتخاب‌شده بازنشانی می‌شود.\n\nاگر نسخه برنامه نشان‌داده‌شده در Obtainium به دلیل به‌روزرسانی‌های ناموفق یا مشکلات دیگر نادرست باشد، می‌تواند کمک کند.", + "shareSelectedAppURLs": "اشتراک گذاری آدرس اینترنتی برنامه های انتخاب شده", + "resetInstallStatus": "بازنشانی وضعیت نصب", + "more": "بیشتر", + "removeOutdatedFilter": "فیلتر برنامه قدیمی را حذف کنید", + "showOutdatedOnly": "فقط برنامه های قدیمی را نشان دهید", + "filter": "فیلتر", + "filterActive": "فیلتر *", + "filterApps": "فیلتر کردن برنامه ها", + "appName": "نام برنامه", + "author": "سازنده", + "upToDateApps": "برنامه های به روز", + "nonInstalledApps": "برنامه های نصب نشده", + "importExport": "وادر کردن/صادر کردن", + "settings": "تنظیمات", + "exportedTo": "صادر کردن به{}", + "obtainiumExport": "صادرکردن Obtainium", + "invalidInput": "ورودی نامعتبر", + "importedX": "وارد شده {}", + "obtainiumImport": "واردکردن Obtainium", + "importFromURLList": "وارد کردن از فهرست آدرس اینترنتی", + "searchQuery": "جستجوی سوال", + "appURLList": "فهرست آدرس اینترنتی برنامه", + "line": "خط", + "searchX": "جستجو {}", + "noResults": "نتیجه ای پیدا نشد", + "importX": "وارد کردن {}", + "importedAppsIdDisclaimer": "ممکن است برنامه‌های وارد شده به اشتباه به‌عنوان \"نصب نشده\" نشان داده شوند.\nبرای رفع این مشکل، آنها را دوباره از طریق Obtainium نصب کنید.\nاین نباید روی داده‌های برنامه تأثیر بگذارد.\n\nفقط بر روی آدرس اینترنتی و روش‌های وارد کردن شخص ثالث تأثیر می‌گذارد.", + "importErrors": "خطاهای وارد کردن", + "importedXOfYApps": "{} از {} برنامه وارد شد.", + "followingURLsHadErrors": "آدرس های اینترنتی زیر دارای خطا بودند:", + "okay": "باشه", + "selectURL": "آدرس اینترنتی انتخاب شده", + "selectURLs": "آدرس های اینترنتی انتخاب شده", + "pick": "انتخاب", + "theme": "تم", + "dark": "تاریک", + "light": "روشن", + "followSystem": "هماهنگ با سیستم", + "obtainium": "Obtainium", + "materialYou": "Material You", + "useBlackTheme": "استفاده از تم تیره سیاه خالص", + "appSortBy": "مرتب سازی برنامه بر اساس", + "authorName": "سازنده/اسم", + "nameAuthor": "اسم/سازنده", + "asAdded": "همانطور که اضافه شد", + "appSortOrder": "ترتیب مرتب سازی برنامه", + "ascending": "صعودی", + "descending": "نزولی", + "bgUpdateCheckInterval": "فاصله بررسی به‌روزرسانی در پس‌زمینه", + "neverManualOnly": "هرگز - فقط دستی", + "appearance": "ظاهر", + "showWebInAppView": "نمایش صفحه وب منبع در نمای برنامه", + "pinUpdates": "به‌روزرسانی‌ها را به نمای بالای برنامه‌ها پین کنید", + "updates": "به روز رسانی ها", + "sourceSpecific": "منبع خاص", + "appSource": "منبع برنامه", + "noLogs": "بدون گزارش", + "appLogs": "گزارش های برنامه", + "close": "بستن", + "share": "اشتراک گذاری", + "appNotFound": "برنامه پیدا نشد", + "obtainiumExportHyphenatedLowercase": "صادر کردن-obtainium", + "pickAnAPK": "یک APK انتخاب کنید", + "appHasMoreThanOnePackage": "{} بیش از یک بسته دارد:", + "deviceSupportsXArch": "دستگاه شما از معماری پردازنده {} پشتیبانی میکند", + "deviceSupportsFollowingArchs": "دستگاه شما از معماری های پردازنده زیر پشتیبانی می کند:", + "warning": "اخطار", + "sourceIsXButPackageFromYPrompt": "منبع برنامه \"{}\" است اما بسته انتشار از \"{}\" آمده است. ادامه هید؟", + "updatesAvailable": "بروزرسانی در دسترس ", + "updatesAvailableNotifDescription": "به کاربر اطلاع می دهد که به روز رسانی برای یک یا چند برنامه ردیابی شده توسط Obtainium در دسترس است", + "noNewUpdates": "به روز رسانی جدیدی وجود ندارد.", + "xHasAnUpdate": "{} یک به روز رسانی دارد.", + "appsUpdated": "برنامه ها به روز شدند", + "appsUpdatedNotifDescription": "به کاربر اطلاع می دهد که به روز رسانی یک یا چند برنامه در پس زمینه اعمال شده است", + "xWasUpdatedToY": "{} به {} به روز شد.", + "errorCheckingUpdates": "خطا در بررسی به‌روزرسانی‌ها", + "errorCheckingUpdatesNotifDescription": "اعلانی که وقتی بررسی به‌روزرسانی پس‌زمینه ناموفق است نشان می‌دهد", + "appsRemoved": "برنامه ها حذف شدند", + "appsRemovedNotifDescription": "به کاربر اطلاع می دهد که یک یا چند برنامه به دلیل خطا در هنگام بارگیری حذف شده است", + "xWasRemovedDueToErrorY": "{} به دلیل این خطا حذف شد: {}", + "completeAppInstallation": "نصب کامل برنامه", + "obtainiumMustBeOpenToInstallApps": "Obtainium باید برای نصب برنامه ها باز باشد", + "completeAppInstallationNotifDescription": "از کاربر می‌خواهد برای پایان نصب برنامه به Obtainium برگردد", + "checkingForUpdates": "بررسی به‌روزرسانی‌ها", + "checkingForUpdatesNotifDescription": "اعلان گذرا که هنگام بررسی به روز رسانی ظاهر می شود", + "pleaseAllowInstallPerm": "لطفاً به Obtainium اجازه دهید برنامه‌ها را نصب کند", + "trackOnly": "فقط ردیابی", + "errorWithHttpStatusCode": "خطا {}", + "versionCorrectionDisabled": "تصحیح نسخه غیرفعال شد (به نظر می رسد افزونه کار نمی کند)", + "unknown": "ناشناخته", + "none": "هیچ", + "never": "هرگز", + "latestVersionX": "آخرین نسخه: {}", + "installedVersionX": "نسخه نصب شده: {}", + "lastUpdateCheckX": "بررسی آخرین به‌روزرسانی: {}", + "remove": "حذف", + "yesMarkUpdated": "بله، علامت گذاری به عنوان به روز شده", + "fdroid": "F-Droid Official", + "appIdOrName": "شناسه یا نام برنامه", + "appId": "App ID", + "appWithIdOrNameNotFound": "هیچ برنامه ای با آن شناسه یا نام یافت نشد", + "reposHaveMultipleApps": "مخازن ممکن است شامل چندین برنامه باشد", + "fdroidThirdPartyRepo": "مخازن شخص ثالث F-Droid", + "steam": "Steam", + "steamMobile": "Steam Mobile", + "steamChat": "Steam Chat", + "install": "نصب", + "markInstalled": "علامت گذاری به عنوان نصب شده", + "update": "به روز رسانی", + "markUpdated": "علامت گذاری به روز شد", + "additionalOptions": "گزینه های اضافی", + "disableVersionDetection": "غیرفعال کردن تشخیص نسخه", + "noVersionDetectionExplanation": "این گزینه فقط باید برای برنامه هایی استفاده شود که تشخیص نسخه به درستی کار نمی کند.", + "downloadingX": "در حال دانلود {}", + "downloadNotifDescription": "کاربر را از پیشرفت دانلود یک برنامه مطلع می کند", + "noAPKFound": "APK پیدا نشد فایل", + "noVersionDetection": "بدون تشخیص نسخه", + "categorize": "دسته بندی کردن", + "categories": "دسته بندی ها", + "category": "دسته بندی", + "noCategory": "بدون دسته بندی", + "noCategories": "بدون دسته بندی ها", + "deleteCategoriesQuestion": "دسته بندی ها حذف شوند؟", + "categoryDeleteWarning": "همه برنامه‌ها در دسته‌های حذف شده روی دسته‌بندی نشده تنظیم می‌شوند.", + "addCategory": "اضافه کردن دسته", + "label": "برچسب", + "language": "زبان", + "copiedToClipboard": "در کلیپ بورد کپی شد", + "storagePermissionDenied": "مجوز ذخیره سازی رد شد", + "selectedCategorizeWarning": "این جایگزین تنظیمات دسته بندی موجود برای برنامه های انتخابی می شود.", + "filterAPKsByRegEx": "فایل‌های APK را با نظم فیلتر کنید", + "removeFromObtainium": "از Obtainium حذف کنید", + "uninstallFromDevice": "حذف نصب از دستگاه", + "onlyWorksWithNonVersionDetectApps": "فقط برای برنامه‌هایی کار می‌کند که تشخیص نسخه غیرفعال است.", + "releaseDateAsVersion": "از تاریخ انتشار به عنوان نسخه استفاده کنید", + "releaseDateAsVersionExplanation": "این گزینه فقط باید برای برنامه هایی استفاده شود که تشخیص نسخه به درستی کار نمی کند، اما تاریخ انتشار در دسترس است.", + "changes": "تغییرات", + "releaseDate": "تاریخ انتشار", + "importFromURLsInFile": "وارد کردن از آدرس های اینترنتی موجود در فایل (مانند OPML)", + "versionDetection": "تشخیص نسخه", + "standardVersionDetection": "تشخیص نسخه استاندارد", + "groupByCategory": "گروه بر اساس دسته", + "autoApkFilterByArch": "در صورت امکان سعی کنید APKها را بر اساس معماری CPU فیلتر کنید", + "overrideSource": "نادیده گرفتن منبع", + "dontShowAgain": "دوباره این را نشان نده", + "dontShowTrackOnlyWarnings": "هشدار 'فقط ردیابی' را نشان ندهید", + "dontShowAPKOriginWarnings": "هشدارهای منبع APK را نشان ندهید", + "moveNonInstalledAppsToBottom": "برنامه های نصب نشده را به نمای پایین برنامه ها منتقل کنید", + "gitlabPATLabel": "رمز دسترسی شخصی GitLab\n(جستجو و کشف بهتر APK را فعال میکند)", + "about": "درباره", + "requiresCredentialsInSettings": "این به اعتبارنامه های اضافی نیاز دارد (در تنظیمات)", + "checkOnStart": "بررسی در شروع", + "tryInferAppIdFromCode": "شناسه برنامه را از کد منبع استنباط کنید", + "removeOnExternalUninstall": "حذف خودکار برنامه های حذف نصب شده خارجی", + "pickHighestVersionCode": "انتخاب خودکار بالاترین کد نسخه APK", + "checkUpdateOnDetailPage": "برای باز کردن صفحه جزئیات برنامه، به‌روزرسانی‌ها را بررسی کنید", + "disablePageTransitions": "غیرفعال کردن انیمیشن های انتقال صفحه", + "reversePageTransitions": "انیمیشن های انتقال معکوس صفحه", + "minStarCount": "حداقل تعداد ستاره", + "addInfoBelow": "این اطلاعات را در زیر اضافه کنید", + "addInfoInSettings": "این اطلاعات را در تنظیمات اضافه کنید.", + "githubSourceNote": "با استفاده از کلید API می توان از محدودیت نرخ GitHub جلوگیری کرد.", + "gitlabSourceNote": "استخراج APK GitLab ممکن است بدون کلید API کار نکند.", + "sortByFileNamesNotLinks": "مرتب سازی بر اساس نام فایل به جای پیوندهای کامل", + "filterReleaseNotesByRegEx": "یادداشت های انتشار را با بیان منظم فیلتر کنید", + "customLinkFilterRegex": "فیلتر پیوند سفارشی بر اساس عبارت منظم (پیش‌فرض '.apk$')", + "appsPossiblyUpdated": "App Updates Attempted", + "appsPossiblyUpdatedNotifDescription": "Notifies the user that updates to one or more Apps were potentially applied in the background", + "xWasPossiblyUpdatedToY": "{} may have been updated to {}.", + "enableBackgroundUpdates": "Enable background updates", + "backgroundUpdateReqsExplanation": "Background updates may not be possible for all apps.", + "backgroundUpdateLimitsExplanation": "The success of a background install can only be determined when Obtainium is opened.", + "verifyLatestTag": "Verify the 'latest' tag", + "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit First", + "intermediateLinkNotFound": "Intermediate link not found", + "exemptFromBackgroundUpdates": "Exempt from background updates (if enabled)", + "bgUpdatesOnWiFiOnly": "Disable background updates when not on WiFi", + "autoSelectHighestVersionCode": "Auto-select highest versionCode APK", + "versionExtractionRegEx": "Version Extraction RegEx", + "matchGroupToUse": "Match Group to Use", + "highlightTouchTargets": "Highlight less obvious touch targets", + "pickExportDir": "Pick Export Directory", + "autoExportOnChanges": "Auto-export on changes", + "filterVersionsByRegEx": "Filter Versions by Regular Expression", + "trySelectingSuggestedVersionCode": "Try selecting suggested versionCode APK", + "dontSortReleasesList": "Retain release order from API", + "reverseSort": "Reverse sorting", + "debugMenu": "Debug Menu", + "bgTaskStarted": "Background task started - check logs.", + "runBgCheckNow": "Run Background Update Check Now", + "versionExtractWholePage": "Apply Version Extraction Regex to Entire Page", + "installing": "Installing", + "skipUpdateNotifications": "Skip update notifications", + "updatesAvailableNotifChannel": "بروزرسانی در دسترس ", + "appsUpdatedNotifChannel": "برنامه ها به روز شدند", + "appsPossiblyUpdatedNotifChannel": "App Updates Attempted", + "errorCheckingUpdatesNotifChannel": "خطا در بررسی به‌روزرسانی‌ها", + "appsRemovedNotifChannel": "برنامه ها حذف شدند", + "downloadingXNotifChannel": "در حال دانلود {}", + "completeAppInstallationNotifChannel": "نصب کامل برنامه", + "checkingForUpdatesNotifChannel": "بررسی به‌روزرسانی‌ها", + "onlyCheckInstalledOrTrackOnlyApps": "Only check installed and Track-Only apps for updates", + "removeAppQuestion": { + "one": "برنامه حذف شود؟", + "other": "برنامه ها حذف شوند؟" + }, + "tooManyRequestsTryAgainInMinutes": { + "one": "درخواست‌های بسیار زیاد (نرخ محدود) - {} دقیقه دیگر دوباره امتحان کنید", + "other": "درخواست های بسیار زیاد (نرخ محدود) - بعد از {} دقیقه دوباره امتحان کنید" + }, + "bgUpdateGotErrorRetryInMinutes": { + "one": "بررسی به‌روزرسانی BG با یک {} مواجه شد، یک بررسی مجدد را در {} دقیقه برنامه‌ریزی می‌کند", + "other": "بررسی به‌روزرسانی BG با {} مواجه شد، یک بررسی مجدد را در {} دقیقه برنامه‌ریزی می‌کند" + }, + "bgCheckFoundUpdatesWillNotifyIfNeeded": { + "one": "بررسی به‌روزرسانی BG پیدا شد {} به‌روزرسانی - در صورت نیاز به کاربر اطلاع می‌دهد", + "other": "بررسی به‌روزرسانی BG {} به‌روزرسانی‌های یافت شده - در صورت نیاز به کاربر اطلاع می‌دهد" + }, + "apps": { + "one": "برنامه {}", + "other": "{} برنامه ها" + }, + "url": { + "one": "{} آدرس اینترنتی", + "other": "{} آدرس های اینترنتی" + }, + "minute": { + "one": "{} دقیقه", + "other": "{} دقیقه" + }, + "hour": { + "one": "{} ساعت", + "other": "{} ساعت" + }, + "day": { + "one": "{} روز", + "other": "{} روز" + }, + "clearedNLogsBeforeXAfterY": { + "one": "گزارش {n} پاک شد (قبل از = {پیش از}، بعد = {بعد})", + "other": "{n} گزارش پاک شد (قبل از = {پیش از}، بعد = {بعد})" + }, + "xAndNMoreUpdatesAvailable": { + "one": "{} و 1 برنامه دیگر به‌روزرسانی دارند.", + "other": "{} و {} برنامه دیگر به روز رسانی دارند." + }, + "xAndNMoreUpdatesInstalled": { + "one": "{} و 1 برنامه دیگر به روز شدند.", + "other": "{} و {} برنامه دیگر به روز شدند." + }, + "xAndNMoreUpdatesPossiblyInstalled": { + "one": "{} and 1 more app may have been updated.", + "other": "{} and {} more apps may have been updated." + } +} \ No newline at end of file diff --git a/assets/translations/fr.json b/assets/translations/fr.json new file mode 100644 index 0000000..e5d3bba --- /dev/null +++ b/assets/translations/fr.json @@ -0,0 +1,330 @@ +{ + "invalidURLForSource": "URL d'application {} invalide", + "noReleaseFound": "Impossible de trouver une version appropriée", + "noVersionFound": "Impossible de déterminer la version de la version", + "urlMatchesNoSource": "L'URL ne correspond pas à une source connue", + "cantInstallOlderVersion": "Impossible d'installer une ancienne version d'une application", + "appIdMismatch": "L'ID de paquet téléchargé ne correspond pas à l'ID de l'application existante", + "functionNotImplemented": "Cette classe n'a pas implémenté cette fonction", + "placeholder": "Espace réservé", + "someErrors": "Des erreurs se sont produites", + "unexpectedError": "Erreur inattendue", + "ok": "Okay", + "and": "et", + "githubPATLabel": "Jeton d'Accès Personnel GitHub (Augmente la limite de débit)", + "includePrereleases": "Inclure les avant-premières", + "fallbackToOlderReleases": "Retour aux anciennes versions", + "filterReleaseTitlesByRegEx": "Filtrer les titres de version par expression régulière", + "invalidRegEx": "Expression régulière invalide", + "noDescription": "Pas de description", + "cancel": "Annuler", + "continue": "Continuer", + "requiredInBrackets": "(Requis)", + "dropdownNoOptsError": "ERREUR : LE DÉROULEMENT DOIT AVOIR AU MOINS UNE OPT", + "colour": "Couleur", + "githubStarredRepos": "Dépôts étoilés GitHub", + "uname": "Nom d'utilisateur", + "wrongArgNum": "Mauvais nombre d'arguments fournis", + "xIsTrackOnly": "{} est en 'Suivi uniquement'", + "source": "Source", + "app": "Application", + "appsFromSourceAreTrackOnly": "Les applications de cette source sont en 'Suivi uniquement'.", + "youPickedTrackOnly": "Vous avez sélectionné l'option 'Suivi uniquement'.", + "trackOnlyAppDescription": "L'application sera suivie pour les mises à jour, mais Obtainium ne pourra pas la télécharger ou l'installer.", + "cancelled": "Annulé", + "appAlreadyAdded": "Application déjà ajoutée", + "alreadyUpToDateQuestion": "Application déjà à jour ?", + "addApp": "Ajouter une application", + "appSourceURL": "URL de la source de l'application", + "error": "Erreur", + "add": "Ajouter", + "searchSomeSourcesLabel": "Rechercher (certaines sources uniquement)", + "search": "Rechercher", + "additionalOptsFor": "Options supplémentaires pour {}", + "supportedSources": "Sources prises en charge ", + "trackOnlyInBrackets": "(Suivi uniquement)", + "searchableInBrackets": "(Recherchable)", + "appsString": "Applications", + "noApps": "Aucune application", + "noAppsForFilter": "Aucune application pour le filtre", + "byX": "Par {}", + "percentProgress": "Progrès: {}%", + "pleaseWait": "Veuillez patienter", + "updateAvailable": "Mise à jour disponible", + "estimateInBracketsShort": "(Est.)", + "notInstalled": "Pas installé", + "estimateInBrackets": "(Estimation)", + "selectAll": "Tout sélectionner", + "deselectN": "Déselectionner {}", + "xWillBeRemovedButRemainInstalled": "{} sera supprimé d'Obtainium mais restera installé sur l'appareil.", + "removeSelectedAppsQuestion": "Supprimer les applications sélectionnées ?", + "removeSelectedApps": "Supprimer les applications sélectionnées", + "updateX": "Mise à jour {}", + "installX": "Installer {}", + "markXTrackOnlyAsUpdated": "Marquer {}\n(Suivi uniquement)\nas mis à jour", + "changeX": "Changer {}", + "installUpdateApps": "Installer/Mettre à jour les applications", + "installUpdateSelectedApps": "Installer/Mettre à jour les applications sélectionnées", + "markXSelectedAppsAsUpdated": "Marquer {} les applications sélectionnées comme mises à jour ?", + "no": "Non", + "yes": "Oui", + "markSelectedAppsUpdated": "Marquer les applications sélectionnées comme mises à jour", + "pinToTop": "Épingler en haut", + "unpinFromTop": "Détacher du haut", + "resetInstallStatusForSelectedAppsQuestion": "Réinitialiser l'état d'installation des applications sélectionnées ?", + "installStatusOfXWillBeResetExplanation": "L'état d'installation de toutes les applications sélectionnées sera réinitialisé.\n\nCela peut aider lorsque la version de l'application affichée dans Obtainium est incorrecte en raison d'échecs de mises à jour ou d'autres problèmes.", + "shareSelectedAppURLs": "Partager les URL d'application sélectionnées", + "resetInstallStatus": "Réinitialiser le statut d'installation", + "more": "Plus", + "removeOutdatedFilter": "Supprimer le filtre d'application obsolète", + "showOutdatedOnly": "Afficher uniquement les applications obsolètes", + "filter": "Filtre", + "filterActive": "Filtre *", + "filterApps": "Filtrer les applications", + "appName": "Nom de l'application", + "author": "Auteur", + "upToDateApps": "Applications à jour", + "nonInstalledApps": "Applications non installées", + "importExport": "Importer/Exporter", + "settings": "Paramètres", + "exportedTo": "Exporté vers {}", + "obtainiumExport": "Exportation d'Obtainium", + "invalidInput": "Entrée invalide", + "importedX": "Importé {}", + "obtainiumImport": "Importation d'Obtainium", + "importFromURLList": "Importer à partir de la liste d'URL", + "searchQuery": "Requête de recherche", + "appURLList": "Liste d'URL d'application", + "line": "Queue", + "searchX": "Rechercher {}", + "noResults": "Aucun résultat trouvé", + "importX": "Importer {}", + "importedAppsIdDisclaimer": "Les applications importées peuvent s'afficher à tort comme \"Non installées\".\nPour résoudre ce problème, réinstallez-les via Obtainium.\nCela ne devrait pas affecter les données de l'application.\n\nN'affecte que les URL et les méthodes d'importation tierces.", + "importErrors": "Erreurs d'importation", + "importedXOfYApps": "{} sur {} applications importées.", + "followingURLsHadErrors": "Les URL suivantes comportaient des erreurs :", + "okay": "Okay", + "selectURL": "Sélectionnez l'URL", + "selectURLs": "Sélectionnez les URLs", + "pick": "Prendre", + "theme": "Thème", + "dark": "Sombre", + "light": "Clair", + "followSystem": "Suivre le système", + "obtainium": "Obtainium", + "materialYou": "Material You", + "useBlackTheme": "Use pure black dark theme", + "appSortBy": "Applications triées par", + "authorName": "Auteur/Nom", + "nameAuthor": "Nom/Auteur", + "asAdded": "Comme ajouté", + "appSortOrder": "Ordre de tri des applications", + "ascending": "Ascendant", + "descending": "Descendanr", + "bgUpdateCheckInterval": "Intervalle de vérification des mises à jour en arrière-plan", + "neverManualOnly": "Jamais - Manuel uniquement", + "appearance": "Apparence", + "showWebInAppView": "Afficher la page Web source dans la vue de l'application", + "pinUpdates": "Épingler les mises à jour dans la vue Top des applications", + "updates": "Mises à jour", + "sourceSpecific": "Spécifique à la source", + "appSource": "Source de l'application", + "noLogs": "Aucun journal", + "appLogs": "Journaux d'application", + "close": "Fermer", + "share": "Partager", + "appNotFound": "Application introuvable", + "obtainiumExportHyphenatedLowercase": "obtainium-export", + "pickAnAPK": "Choisissez un APK", + "appHasMoreThanOnePackage": "{} a plus d'un paquet :", + "deviceSupportsXArch": "Votre appareil prend en charge l'architecture de processeur {}.", + "deviceSupportsFollowingArchs": "Votre appareil prend en charge les architectures CPU suivantes :", + "warning": "Avertissement", + "sourceIsXButPackageFromYPrompt": "La source de l'application est '{}' mais le paquet de version provient de '{}'. Continuer?", + "updatesAvailable": "Mises à jour disponibles", + "updatesAvailableNotifDescription": "Avertit l'utilisateur que des mises à jour sont disponibles pour une ou plusieurs applications suivies par Obtainium", + "noNewUpdates": "Aucune nouvelle mise à jour.", + "xHasAnUpdate": "{} a une mise à jour.", + "appsUpdated": "Applications mises à jour", + "appsUpdatedNotifDescription": "Avertit l'utilisateur que les mises à jour d'une ou plusieurs applications ont été appliquées en arrière-plan", + "xWasUpdatedToY": "{} a été mis à jour pour {}.", + "errorCheckingUpdates": "Erreur lors de la vérification des mises à jour", + "errorCheckingUpdatesNotifDescription": "Une notification qui s'affiche lorsque la vérification de la mise à jour en arrière-plan échoue", + "appsRemoved": "Applications supprimées", + "appsRemovedNotifDescription": "Avertit l'utilisateur qu'une ou plusieurs applications ont été supprimées en raison d'erreurs lors de leur chargement", + "xWasRemovedDueToErrorY": "{} a été supprimé en raison de cette erreur : {}", + "completeAppInstallation": "Installation complète de l'application", + "obtainiumMustBeOpenToInstallApps": "Obtainium doit être ouvert pour installer des applications", + "completeAppInstallationNotifDescription": "Demande à l'utilisateur de retourner sur Obtainium pour terminer l'installation d'une application", + "checkingForUpdates": "Vérification des mises à jour", + "checkingForUpdatesNotifDescription": "Notification transitoire qui apparaît lors de la recherche de mises à jour", + "pleaseAllowInstallPerm": "Veuillez autoriser Obtainium à installer des applications", + "trackOnly": "Suivi uniquement", + "errorWithHttpStatusCode": "Erreur {}", + "versionCorrectionDisabled": "Correction de version désactivée (le plugin ne semble pas fonctionner)", + "unknown": "Inconnu", + "none": "Aucun", + "never": "Jamais", + "latestVersionX": "Dernière version: {}", + "installedVersionX": "Version installée : {}", + "lastUpdateCheckX": "Vérification de la dernière mise à jour : {}", + "remove": "Retirer", + "yesMarkUpdated": "Oui, marquer comme mis à jour", + "fdroid": "F-Droid Official", + "appIdOrName": "ID ou nom de l'application", + "appId": "ID de l'application", + "appWithIdOrNameNotFound": "Aucune application n'a été trouvée avec cet identifiant ou ce nom", + "reposHaveMultipleApps": "Les dépôts peuvent contenir plusieurs applications", + "fdroidThirdPartyRepo": "Dépôt tiers F-Droid", + "steam": "Steam", + "steamMobile": "Steam Mobile", + "steamChat": "Steam Chat", + "install": "Installer", + "markInstalled": "Marquer installée", + "update": "Mettre à jour", + "markUpdated": "Marquer à jour", + "additionalOptions": "Options additionelles", + "disableVersionDetection": "Désactiver la détection de version", + "noVersionDetectionExplanation": "Cette option ne doit être utilisée que pour les applications où la détection de version ne fonctionne pas correctement.", + "downloadingX": "Téléchargement {}", + "downloadNotifDescription": "Avertit l'utilisateur de la progression du téléchargement d'une application", + "noAPKFound": "Aucun APK trouvé", + "noVersionDetection": "Pas de détection de version", + "categorize": "Catégoriser", + "categories": "Catégories", + "category": "Catégorie", + "noCategory": "No Category", + "noCategories": "Aucune catégorie", + "deleteCategoriesQuestion": "Supprimer les catégories ?", + "categoryDeleteWarning": "Toutes les applications dans les catégories supprimées seront définies sur non catégorisées.", + "addCategory": "Ajouter une catégorie", + "label": "Étiquette", + "language": "Langue", + "copiedToClipboard": "Copied to Clipboard", + "storagePermissionDenied": "Autorisation de stockage refusée", + "selectedCategorizeWarning": "Cela remplacera tous les paramètres de catégorie existants pour les applications sélectionnées.", + "filterAPKsByRegEx": "Filtrer les APK par expression régulière", + "removeFromObtainium": "Supprimer d'Obtainium", + "uninstallFromDevice": "Désinstaller de l'appareil", + "onlyWorksWithNonVersionDetectApps": "Fonctionne uniquement pour les applications avec la détection de version désactivée.", + "releaseDateAsVersion": "Utiliser la date de sortie comme version", + "releaseDateAsVersionExplanation": "Cette option ne doit être utilisée que pour les applications où la détection de version ne fonctionne pas correctement, mais une date de sortie est disponible.", + "changes": "Changements", + "releaseDate": "Date de sortie", + "importFromURLsInFile": "Importer à partir d'URL dans un fichier (comme OPML)", + "versionDetection": "Détection des versions", + "standardVersionDetection": "Détection de version standard", + "groupByCategory": "Group by Category", + "autoApkFilterByArch": "Attempt to filter APKs by CPU architecture if possible", + "overrideSource": "Override Source", + "dontShowAgain": "Don't show this again", + "dontShowTrackOnlyWarnings": "Don't Show the 'Track-Only' Warning", + "dontShowAPKOriginWarnings": "Don't show APK origin warnings", + "moveNonInstalledAppsToBottom": "Move non-installed Apps to bottom of Apps view", + "gitlabPATLabel": "GitLab Personal Access Token\n(Enables Search and Better APK Discovery)", + "about": "About", + "requiresCredentialsInSettings": "This needs additional credentials (in Settings)", + "checkOnStart": "Check for updates on startup", + "tryInferAppIdFromCode": "Try inferring App ID from source code", + "removeOnExternalUninstall": "Automatically remove externally uninstalled Apps", + "pickHighestVersionCode": "Auto-select highest version code APK", + "checkUpdateOnDetailPage": "Check for updates on opening an App detail page", + "disablePageTransitions": "Disable page transition animations", + "reversePageTransitions": "Reverse page transition animations", + "minStarCount": "Minimum Star Count", + "addInfoBelow": "Add this info below.", + "addInfoInSettings": "Add this info in the Settings.", + "githubSourceNote": "GitHub rate limiting can be avoided using an API key.", + "gitlabSourceNote": "GitLab APK extraction may not work without an API key.", + "sortByFileNamesNotLinks": "Sort by file names instead of full links", + "filterReleaseNotesByRegEx": "Filter Release Notes by Regular Expression", + "customLinkFilterRegex": "Custom APK Link Filter by Regular Expression (Default '.apk$')", + "appsPossiblyUpdated": "App Updates Attempted", + "appsPossiblyUpdatedNotifDescription": "Notifies the user that updates to one or more Apps were potentially applied in the background", + "xWasPossiblyUpdatedToY": "{} may have been updated to {}.", + "enableBackgroundUpdates": "Enable background updates", + "backgroundUpdateReqsExplanation": "Background updates may not be possible for all apps.", + "backgroundUpdateLimitsExplanation": "The success of a background install can only be determined when Obtainium is opened.", + "verifyLatestTag": "Verify the 'latest' tag", + "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit First", + "intermediateLinkNotFound": "Intermediate link not found", + "exemptFromBackgroundUpdates": "Exempt from background updates (if enabled)", + "bgUpdatesOnWiFiOnly": "Disable background updates when not on WiFi", + "autoSelectHighestVersionCode": "Auto-select highest versionCode APK", + "versionExtractionRegEx": "Version Extraction RegEx", + "matchGroupToUse": "Match Group to Use", + "highlightTouchTargets": "Highlight less obvious touch targets", + "pickExportDir": "Pick Export Directory", + "autoExportOnChanges": "Auto-export on changes", + "filterVersionsByRegEx": "Filter Versions by Regular Expression", + "trySelectingSuggestedVersionCode": "Try selecting suggested versionCode APK", + "dontSortReleasesList": "Retain release order from API", + "reverseSort": "Reverse sorting", + "debugMenu": "Debug Menu", + "bgTaskStarted": "Background task started - check logs.", + "runBgCheckNow": "Run Background Update Check Now", + "versionExtractWholePage": "Apply Version Extraction Regex to Entire Page", + "installing": "Installing", + "skipUpdateNotifications": "Skip update notifications", + "updatesAvailableNotifChannel": "Mises à jour disponibles", + "appsUpdatedNotifChannel": "Applications mises à jour", + "appsPossiblyUpdatedNotifChannel": "App Updates Attempted", + "errorCheckingUpdatesNotifChannel": "Erreur lors de la vérification des mises à jour", + "appsRemovedNotifChannel": "Applications supprimées", + "downloadingXNotifChannel": "Téléchargement {}", + "completeAppInstallationNotifChannel": "Installation complète de l'application", + "checkingForUpdatesNotifChannel": "Vérification des mises à jour", + "onlyCheckInstalledOrTrackOnlyApps": "Only check installed and Track-Only apps for updates", + "removeAppQuestion": { + "one": "Supprimer l'application ?", + "other": "Supprimer les applications ?" + }, + "tooManyRequestsTryAgainInMinutes": { + "one": "Trop de demandes (taux limité) - réessayez dans {} minute", + "other": "Trop de demandes (taux limité) - réessayez dans {} minutes" + }, + "bgUpdateGotErrorRetryInMinutes": { + "one": "La vérification de la mise à jour en arrière-plan a rencontré un {}, planifiera une nouvelle tentative de vérification dans {} minute", + "other": "La vérification de la mise à jour en arrière-plan a rencontré un {}, planifiera une nouvelle tentative de vérification dans {} minutes" + }, + "bgCheckFoundUpdatesWillNotifyIfNeeded": { + "one": "La vérification des mises à jour en arrière-plan trouvée {} mise à jour - avertira l'utilisateur si nécessaire", + "other": "La vérification des mises à jour en arrière-plan a trouvé {} mises à jour - avertira l'utilisateur si nécessaire" + }, + "apps": { + "one": "{} Application", + "other": "{} Applications" + }, + "url": { + "one": "{} URL", + "other": "{} URLs" + }, + "minute": { + "one": "{} Minute", + "other": "{} Minutes" + }, + "hour": { + "one": "{} Heure", + "other": "{} Heures" + }, + "day": { + "one": "{} Jour", + "other": "{} Jours" + }, + "clearedNLogsBeforeXAfterY": { + "one": "{n} journal effacé (avant = {before}, après = {after})", + "other": "{n} journaux effacés (avant = {before}, après = {after})" + }, + "xAndNMoreUpdatesAvailable": { + "one": "{} et 1 autre application ont des mises à jour.", + "other": "{} et {} autres applications ont des mises à jour." + }, + "xAndNMoreUpdatesInstalled": { + "one": "{} et 1 autre application ont été mises à jour.", + "other": "{} et {} autres applications ont été mises à jour." + }, + "xAndNMoreUpdatesPossiblyInstalled": { + "one": "{} and 1 more app may have been updated.", + "other": "{} and {} more apps may have been updated." + } +} \ No newline at end of file diff --git a/assets/translations/hu.json b/assets/translations/hu.json index 54e82e7..23ff3d3 100644 --- a/assets/translations/hu.json +++ b/assets/translations/hu.json @@ -1,257 +1,330 @@ -{ - "invalidURLForSource": "Érvénytelen a(z) {} app URL-je", - "noReleaseFound": "Nem található megfelelő kiadás", - "noVersionFound": "Nem sikerült meghatározni a kiadás verzióját", - "urlMatchesNoSource": "Az URL nem egyezik ismert forrással", - "cantInstallOlderVersion": "Nem telepíthető egy app régebbi verziója", - "appIdMismatch": "A letöltött csomagazonosító nem egyezik a meglévő app azonosítóval", - "functionNotImplemented": "Ez az osztály nem valósította meg ezt a függvényt", - "placeholder": "Helykitöltő", - "someErrors": "Néhány hiba történt", - "unexpectedError": "Váratlan hiba", - "ok": "Oké", - "and": "és", - "startedBgUpdateTask": "Háttérfrissítés ellenőrzési feladat elindítva", - "bgUpdateIgnoreAfterIs": "Háttérfrissítés ignoreAfter a következő: {}", - "startedActualBGUpdateCheck": "Elkezdődött a tényleges háttérfrissítés ellenőrzése", - "bgUpdateTaskFinished": "A háttérfrissítés ellenőrzési feladat befejeződött", - "firstRun": "Ez az Obtainium első futása", - "settingUpdateCheckIntervalTo": "A frissítési intervallum beállítása erre: {}", - "githubPATLabel": "GitHub személyes hozzáférési token (megnöveli a díjkorlátot)", - "githubPATHint": "A PAT-nak a következő formátumban kell lennie: felhasználónév:token", - "githubPATFormat": "felhasználónév:token", - "githubPATLinkText": "A GitHub PAT-okról", - "includePrereleases": "Tartalmazza az előzetes kiadásokat", - "fallbackToOlderReleases": "Visszatérés a régebbi kiadásokhoz", - "filterReleaseTitlesByRegEx": "A kiadás címeinek szűrése reguláris kifejezéssel", - "invalidRegEx": "Érvénytelen reguláris kifejezés", - "noDescription": "Nincs leírás", - "cancel": "Mégse", - "continue": "Tovább", - "requiredInBrackets": "(Kötelező)", - "dropdownNoOptsError": "HIBA: A LEDOBÁST LEGALÁBB EGY OPCIÓHOZ KELL RENDELNI", - "colour": "Szín", - "githubStarredRepos": "GitHub Csillagos Repo-k", - "uname": "Felh.név", - "wrongArgNum": "Rossz számú argumentumot adott meg", - "xIsTrackOnly": "A(z) {} csak nyomkövethető", - "source": "Forrás", - "app": "App", - "appsFromSourceAreTrackOnly": "Az ebből a forrásból származó alkalmazások 'Csak nyomon követhetőek'.", - "youPickedTrackOnly": "A 'Csak követés' opciót választotta.", - "trackOnlyAppDescription": "Az alkalmazás frissítéseit nyomon követi, de az Obtainium nem tudja letölteni vagy telepíteni.", - "cancelled": "Törölve", - "appAlreadyAdded": "Az app már hozzáadva", - "alreadyUpToDateQuestion": "Az app már naprakész?", - "addApp": "App hozzáadás", - "appSourceURL": "App forrás URL", - "error": "Hiba", - "add": "Hozzáadás", - "searchSomeSourcesLabel": "Keresés (csak egyes források)", - "search": "Keresés", - "additionalOptsFor": "További lehetőségek a következőhöz: {}", - "supportedSourcesBelow": "Támogatott források:", - "trackOnlyInBrackets": "(Csak nyomonkövetés)", - "searchableInBrackets": "(Kereshető)", - "appsString": "Appok", - "noApps": "Nincs App", - "noAppsForFilter": "Nincsenek appok a szűrőhöz", - "byX": "{} által", - "percentProgress": "Folyamat: {}%", - "pleaseWait": "Kis türelmet", - "updateAvailable": "Frissítés érhető el", - "estimateInBracketsShort": "(Becsült)", - "notInstalled": "Nem telepített", - "estimateInBrackets": "(Becslés)", - "selectAll": "Mindet kiválaszt", - "deselectN": "Törölje {} kijelölését", - "xWillBeRemovedButRemainInstalled": "A(z) {} el lesz távolítva az Obtainiumból, de továbbra is telepítve marad az eszközön.", - "removeSelectedAppsQuestion": "Eltávolítja a kiválasztott appokat?", - "removeSelectedApps": "Távolítsa el a kiválasztott appokat", - "updateX": "Frissítés: {}", - "installX": "Telepítés: {}", - "markXTrackOnlyAsUpdated": "Jelölje meg: {}\n(Csak nyomon követhető)\nmint Frissített", - "changeX": "Változás {}", - "installUpdateApps": "Appok telepítése/frissítése", - "installUpdateSelectedApps": "Telepítse/frissítse a kiválasztott appokat", - "onlyWorksWithNonEVDApps": "Csak azoknál az alkalmazásoknál működik, amelyek telepítési állapota nem észlelhető autom. (nem gyakori).", - "markXSelectedAppsAsUpdated": "Megjelöl {} kiválasztott alkalmazást frissítettként?", - "no": "Nem", - "yes": "Igen", - "markSelectedAppsUpdated": "Jelölje meg a kiválasztott appokat frissítettként", - "pinToTop": "Rögzítés a felülre", - "unpinFromTop": "Eltávolít felülről", - "resetInstallStatusForSelectedAppsQuestion": "Visszaállítja a kiválasztott appok telepítési állapotát?", - "installStatusOfXWillBeResetExplanation": "A kiválasztott appok telepítési állapota visszaáll.\n\nEz akkor segíthet, ha az Obtainiumban megjelenített app verzió hibás, frissítések vagy egyéb problémák miatt.", - "shareSelectedAppURLs": "Ossza meg a kiválasztott app URL címeit", - "resetInstallStatus": "Telepítési állapot visszaállítása", - "more": "További", - "removeOutdatedFilter": "Távolítsa el az elavult app szűrőt", - "showOutdatedOnly": "Csak az elavult appok megjelenítése", - "filter": "Szűrő", - "filterActive": "Szűrő *", - "filterApps": "Appok szűrése", - "appName": "App név", - "author": "Szerző", - "upToDateApps": "Naprakész appok", - "nonInstalledApps": "Nem telepített appok", - "importExport": "Import/Export", - "settings": "Beállítások", - "exportedTo": "Exportálva ide {}", - "obtainiumExport": "Obtainium Export", - "invalidInput": "Hibás bemenet", - "importedX": "Importálva innen {}", - "obtainiumImport": "Obtainium Import", - "importFromURLList": "Importálás URL listából", - "searchQuery": "Keresési lekérdezés", - "appURLList": "App URL lista", - "line": "Sor", - "searchX": "Keresés {}", - "noResults": "Nincs találat", - "importX": "Import {}", - "importedAppsIdDisclaimer": "Előfordulhat, hogy az importált appok helytelenül \"Nincs telepítve\" jelzéssel jelennek meg.\nA probléma megoldásához telepítse újra őket az Obtainiumon keresztül.\nEz nem érinti az alkalmazásadatokat.\n\nCsak az URL-ekre és a harmadik féltől származó importálási módszerekre vonatkozik..", - "importErrors": "Importálási hibák", - "importedXOfYApps": "{}/{} app importálva.", - "followingURLsHadErrors": "A következő URL-ek hibákat tartalmaztak:", - "okay": "Oké", - "selectURL": "Válassza ki az URL-t", - "selectURLs": "Kiválasztott URL-ek", - "pick": "Válasszon", - "theme": "Téma", - "dark": "Sötét", - "light": "Világos", - "followSystem": "Rendszer szerint", - "obtainium": "Obtainium", - "materialYou": "Material You", - "appSortBy": "App rendezés...", - "authorName": "Szerző/Név", - "nameAuthor": "Név/Szerző", - "asAdded": "Mint Hozzáadott", - "appSortOrder": "Appok rendezése", - "ascending": "Emelkedő", - "descending": "Csökkenő", - "bgUpdateCheckInterval": "Háttérfrissítés ellenőrzés időköze", - "neverManualOnly": "Soha – csak manuális", - "appearance": "Megjelenés", - "showWebInAppView": "Forrás megjelenítése az Appok nézetben", - "pinUpdates": "Frissítések kitűzése az App nézet tetejére", - "updates": "Frissítések", - "sourceSpecific": "Forrás-specifikus", - "appSource": "App forrás", - "noLogs": "Nincsenek naplók", - "appLogs": "App naplók", - "close": "Bezár", - "share": "Megoszt", - "appNotFound": "App nem található", - "obtainiumExportHyphenatedLowercase": "obtainium-export", - "pickAnAPK": "Válasszon egy APK-t", - "appHasMoreThanOnePackage": "A(z) {} egynél több csomaggal rendelkezik:", - "deviceSupportsXArch": "Eszköze támogatja a {} CPU architektúrát.", - "deviceSupportsFollowingArchs": "Az eszköze a következő CPU architektúrákat támogatja:", - "warning": "Figyelem", - "sourceIsXButPackageFromYPrompt": "Az alkalmazás forrása „{}”, de a kiadási csomag innen származik: „{}”. Folytatja?", - "updatesAvailable": "Frissítések érhetők el", - "updatesAvailableNotifDescription": "Értesíti a felhasználót, hogy frissítések állnak rendelkezésre egy vagy több, az Obtainium által nyomon követett alkalmazáshoz", - "noNewUpdates": "Nincsenek új frissítések.", - "xHasAnUpdate": "A(z) {} frissítést kapott.", - "appsUpdated": "Alkalmazások frissítve", - "appsUpdatedNotifDescription": "Értesíti a felhasználót, hogy egy/több app frissítése megtörtént a háttérben", - "xWasUpdatedToY": "{} frissítve a következőre: {}.", - "errorCheckingUpdates": "Hiba a frissítések keresésekor", - "errorCheckingUpdatesNotifDescription": "Értesítés, amely akkor jelenik meg, ha a háttérbeli frissítések ellenőrzése sikertelen", - "appsRemoved": "Alkalmazások eltávolítva", - "appsRemovedNotifDescription": "Értesíti a felhasználót egy vagy több alkalmazás eltávolításáról a betöltésük során fellépő hibák miatt", - "xWasRemovedDueToErrorY": "A(z) {} a következő hiba miatt lett eltávolítva: {}", - "completeAppInstallation": "Teljes app telepítés", - "obtainiumMustBeOpenToInstallApps": "Az Obtainiumnak megnyitva kell lennie az alkalmazások telepítéséhez", - "completeAppInstallationNotifDescription": "Megkéri a felhasználót, hogy térjen vissza az Obtainiumhoz, hogy befejezze az alkalmazás telepítését", - "checkingForUpdates": "Frissítések keresése", - "checkingForUpdatesNotifDescription": "Átmeneti értesítés, amely a frissítések keresésekor jelenik meg", - "pleaseAllowInstallPerm": "Kérjük, engedélyezze az Obtainiumnak az alkalmazások telepítését", - "trackOnly": "Csak követés", - "errorWithHttpStatusCode": "Hiba {}", - "versionCorrectionDisabled": "Verzió korrekció letiltva (úgy tűnik, a beépülő modul nem működik)", - "unknown": "Ismeretlen", - "none": "Egyik sem", - "never": "Soha", - "latestVersionX": "Legújabb verzió: {}", - "installedVersionX": "Telepített verzió: {}", - "lastUpdateCheckX": "Frissítés ellenőrizve: {}", - "remove": "Eltávolítás", - "removeAppQuestion": "Eltávolítja az alkalmazást?", - "yesMarkUpdated": "Igen, megjelölés frissítettként", - "fdroid": "F-Droid", - "appIdOrName": "App ID vagy név", - "appWithIdOrNameNotFound": "Nem található app ezzel az azonosítóval vagy névvel", - "reposHaveMultipleApps": "A repók több alkalmazást is tartalmazhatnak", - "fdroidThirdPartyRepo": "F-Droid Harmadik-fél Repo", - "steam": "Steam", - "steamMobile": "Steam Mobile", - "steamChat": "Steam Chat", - "install": "Telepít", - "markInstalled": "Telepítettnek jelöl", - "update": "Frissít", - "markUpdated": "Frissítettnek jelöl", - "additionalOptions": "További lehetőségek", - "disableVersionDetection": "Verzióérzékelés letiltása", - "noVersionDetectionExplanation": "Ezt a beállítást csak olyan alkalmazásoknál szabad használni, ahol a verzióérzékelés nem működik megfelelően.", - "downloadingX": "{} letöltés", - "downloadNotifDescription": "Értesíti a felhasználót az app letöltésének előrehaladásáról", - "noAPKFound": "Nem található APK", - "noVersionDetection": "Nincs verzió érzékelés", - "categorize": "Kategorizálás", - "categories": "Kategóriák", - "category": "Kategória", - "noCategory": "Nincs kategória", - "deleteCategoryQuestion": "Törli a kategóriát?", - "categoryDeleteWarning": "A(z) {} összes app kategorizálatlan állapotba kerül.", - "addCategory": "Új kategória", - "label": "Címke", - "language": "Nyelv", - "storagePermissionDenied": "Tárhely engedély megtagadva", - "selectedCategorizeWarning": "Ez felváltja a kiválasztott alkalmazások meglévő kategória-beállításait.", - "tooManyRequestsTryAgainInMinutes": { - "one": "Túl sok kérés (korlátozott arány) – próbálja újra {} perc múlva", - "other": "Túl sok kérés (korlátozott arány) – próbálja újra {} perc múlva" - }, - "bgUpdateGotErrorRetryInMinutes": { - "one": "A háttérfrissítések ellenőrzése {}-t észlelt, {} perc múlva ütemezi az újrapróbálkozást", - "other": "A háttérfrissítések ellenőrzése {}-t észlelt, {} perc múlva ütemezi az újrapróbálkozást" - }, - "bgCheckFoundUpdatesWillNotifyIfNeeded": { - "one": "A háttérfrissítés ellenőrzése {} frissítést talált – szükség esetén értesíti a felhasználót", - "other": "A háttérfrissítés ellenőrzése {} frissítést talált – szükség esetén értesíti a felhasználót" - }, - "apps": { - "one": "{} app", - "other": "{} app" - }, - "url": { - "one": "{} URL", - "other": "{} URL" - }, - "minute": { - "one": "{} perc", - "other": "{} perc" - }, - "hour": { - "one": "{} óra", - "other": "{} óra" - }, - "day": { - "one": "{} nap", - "other": "{} nap" - }, - "clearedNLogsBeforeXAfterY": { - "one": "{n} napló törölve (előtte = {előtte}, utána = {utána})", - "other": "{n} napló törölve (előtte = {előtte}, utána = {utána})" - }, - "xAndNMoreUpdatesAvailable": { - "one": "A(z) {} és 1 további alkalmazás frissítéseket kapott.", - "other": "{} és további {} alkalmazás frissítéseket kapott." - }, - "xAndNMoreUpdatesInstalled": { - "one": "A(z) {} és 1 további alkalmazás frissítve.", - "other": "{} és további {} alkalmazás frissítve." - } -} +{ + "invalidURLForSource": "Érvénytelen a(z) {} app URL-je", + "noReleaseFound": "Nem található megfelelő kiadás", + "noVersionFound": "Nem sikerült meghatározni a kiadás verzióját", + "urlMatchesNoSource": "Az URL nem egyezik ismert forrással", + "cantInstallOlderVersion": "Nem telepíthető egy app régebbi verziója", + "appIdMismatch": "A letöltött csomagazonosító nem egyezik a meglévő app azonosítóval", + "functionNotImplemented": "Ez az osztály nem valósította meg ezt a függvényt", + "placeholder": "Helykitöltő", + "someErrors": "Néhány hiba történt", + "unexpectedError": "Váratlan hiba", + "ok": "Oké", + "and": "és", + "githubPATLabel": "GitHub Personal Access Token (megnöveli a díjkorlátot)", + "includePrereleases": "Tartalmazza az előzetes kiadásokat", + "fallbackToOlderReleases": "Visszatérés a régebbi kiadásokhoz", + "filterReleaseTitlesByRegEx": "A kiadás címeinek szűrése reguláris kifejezéssel", + "invalidRegEx": "Érvénytelen reguláris kifejezés", + "noDescription": "Nincs leírás", + "cancel": "Mégse", + "continue": "Tovább", + "requiredInBrackets": "(Kötelező)", + "dropdownNoOptsError": "HIBA: A LEDOBÁST LEGALÁBB EGY OPCIÓHOZ KELL RENDELNI", + "colour": "Szín", + "githubStarredRepos": "GitHub Csillagos Repo-k", + "uname": "Felh.név", + "wrongArgNum": "Rossz számú argumentumot adott meg", + "xIsTrackOnly": "A(z) {} csak nyomonkövethető", + "source": "Forrás", + "app": "App", + "appsFromSourceAreTrackOnly": "Az ebből a forrásból származó alkalmazások 'Csak nyomon követhetőek'.", + "youPickedTrackOnly": "A 'Csak követés' opciót választotta.", + "trackOnlyAppDescription": "Az alkalmazás frissítéseit nyomon követi, de az Obtainium nem tudja letölteni vagy telepíteni.", + "cancelled": "Törölve", + "appAlreadyAdded": "Az app már hozzáadva", + "alreadyUpToDateQuestion": "Az app már naprakész?", + "addApp": "App hozzáadás", + "appSourceURL": "App forrás URL", + "error": "Hiba", + "add": "Hozzáadás", + "searchSomeSourcesLabel": "Keresés (csak egyes források)", + "search": "Keresés", + "additionalOptsFor": "További lehetőségek a következőhöz: {}", + "supportedSources": "Támogatott források", + "trackOnlyInBrackets": "(Csak nyomonkövetés)", + "searchableInBrackets": "(Kereshető)", + "appsString": "Appok", + "noApps": "Nincs App", + "noAppsForFilter": "Nincsenek appok a szűrőhöz", + "byX": "Fejlesztő: {}", + "percentProgress": "Folyamat: {}%", + "pleaseWait": "Kis türelmet", + "updateAvailable": "Frissítés érhető el", + "estimateInBracketsShort": "(Becsült)", + "notInstalled": "Nem telepített", + "estimateInBrackets": "(Becslés)", + "selectAll": "Mindet kiválaszt", + "deselectN": "Törölje {} kijelölését", + "xWillBeRemovedButRemainInstalled": "A(z) {} el lesz távolítva az Obtainiumból, de továbbra is telepítve marad az eszközön.", + "removeSelectedAppsQuestion": "Eltávolítja a kiválasztott appokat?", + "removeSelectedApps": "Távolítsa el a kiválasztott appokat", + "updateX": "Frissítés: {}", + "installX": "Telepítés: {}", + "markXTrackOnlyAsUpdated": "Jelölje meg: {}\n(Csak nyomon követhető)\nmint Frissített", + "changeX": "Változás {}", + "installUpdateApps": "Appok telepítése/frissítése", + "installUpdateSelectedApps": "Telepítse/frissítse a kiválasztott appokat", + "markXSelectedAppsAsUpdated": "Megjelöl {} kiválasztott alkalmazást frissítettként?", + "no": "Nem", + "yes": "Igen", + "markSelectedAppsUpdated": "Jelölje meg a kiválasztott appokat frissítettként", + "pinToTop": "Rögzítés felülre", + "unpinFromTop": "Eltávolít felülről", + "resetInstallStatusForSelectedAppsQuestion": "Visszaállítja a kiválasztott appok telepítési állapotát?", + "installStatusOfXWillBeResetExplanation": "A kiválasztott appok telepítési állapota visszaáll.\n\nEz akkor segíthet, ha az Obtainiumban megjelenített app verzió hibás, frissítések vagy egyéb problémák miatt.", + "shareSelectedAppURLs": "Ossza meg a kiválasztott app URL címeit", + "resetInstallStatus": "Telepítési állapot visszaállítása", + "more": "További", + "removeOutdatedFilter": "Távolítsa el az elavult app szűrőt", + "showOutdatedOnly": "Csak az elavult appok megjelenítése", + "filter": "Szűrő", + "filterActive": "Szűrő *", + "filterApps": "Appok szűrése", + "appName": "App név", + "author": "Szerző", + "upToDateApps": "Naprakész appok", + "nonInstalledApps": "Nem telepített appok", + "importExport": "Import/Export", + "settings": "Beállítások", + "exportedTo": "Exportálva ide {}", + "obtainiumExport": "Obtainium Adat Exportálás", + "invalidInput": "Hibás bemenet", + "importedX": "Importálva innen {}", + "obtainiumImport": "Obtainium Adat Importálás", + "importFromURLList": "Importálás URL listából", + "searchQuery": "Keresési lekérdezés", + "appURLList": "App URL lista", + "line": "Sor", + "searchX": "Keresés {}", + "noResults": "Nincs találat", + "importX": "Import {}", + "importedAppsIdDisclaimer": "Előfordulhat, hogy az importált appok helytelenül \"Nincs telepítve\" jelzéssel jelennek meg.\nA probléma megoldásához telepítse újra őket az Obtainiumon keresztül.\nEz nem érinti az alkalmazásadatokat.\n\nCsak az URL-ekre és a harmadik féltől származó importálási módszerekre vonatkozik..", + "importErrors": "Importálási hibák", + "importedXOfYApps": "{}/{} app importálva.", + "followingURLsHadErrors": "A következő URL-ek hibákat tartalmaztak:", + "okay": "Oké", + "selectURL": "Válassza ki az URL-t", + "selectURLs": "Kiválasztott URL-ek", + "pick": "Válasszon", + "theme": "Téma", + "dark": "Sötét", + "light": "Világos", + "followSystem": "Rendszer szerint", + "obtainium": "Obtainium", + "materialYou": "Material You", + "useBlackTheme": "Használjon tiszta fekete sötét témát", + "appSortBy": "App rendezés...", + "authorName": "Szerző/Név", + "nameAuthor": "Név/Szerző", + "asAdded": "Mint Hozzáadott", + "appSortOrder": "Appok rendezése", + "ascending": "Emelkedő", + "descending": "Csökkenő", + "bgUpdateCheckInterval": "Háttérfrissítés ellenőrzés időköze", + "neverManualOnly": "Soha – csak manuális", + "appearance": "Megjelenés", + "showWebInAppView": "Forrás megjelenítése az Appok nézetben", + "pinUpdates": "Frissítések kitűzése az App nézet tetejére", + "updates": "Frissítések", + "sourceSpecific": "Forrás-specifikus", + "appSource": "App forrás", + "noLogs": "Nincsenek naplók", + "appLogs": "App naplók", + "close": "Bezárás", + "share": "Megosztás", + "appNotFound": "App nem található", + "obtainiumExportHyphenatedLowercase": "obtainium-export", + "pickAnAPK": "Válasszon egy APK-t", + "appHasMoreThanOnePackage": "A(z) {} egynél több csomaggal rendelkezik:", + "deviceSupportsXArch": "Eszköze támogatja a {} CPU architektúrát.", + "deviceSupportsFollowingArchs": "Az eszköze a következő CPU architektúrákat támogatja:", + "warning": "Figyelem", + "sourceIsXButPackageFromYPrompt": "Az alkalmazás forrása „{}”, de a kiadási csomag innen származik: „{}”. Folytatja?", + "updatesAvailable": "Frissítések érhetők el", + "updatesAvailableNotifDescription": "Értesíti a felhasználót, hogy frissítések állnak rendelkezésre egy vagy több, az Obtainium által nyomon követett alkalmazáshoz", + "noNewUpdates": "Nincsenek új frissítések.", + "xHasAnUpdate": "A(z) {} frissítést kapott.", + "appsUpdated": "Alkalmazások frissítve", + "appsUpdatedNotifDescription": "Értesíti a felhasználót, hogy egy/több app frissítése megtörtént a háttérben", + "xWasUpdatedToY": "{} frissítve a következőre: {}.", + "errorCheckingUpdates": "Hiba a frissítések keresésekor", + "errorCheckingUpdatesNotifDescription": "Értesítés, amely akkor jelenik meg, ha a háttérbeli frissítések ellenőrzése sikertelen", + "appsRemoved": "Alkalmazások eltávolítva", + "appsRemovedNotifDescription": "Értesíti a felhasználót egy vagy több alkalmazás eltávolításáról a betöltésük során fellépő hibák miatt", + "xWasRemovedDueToErrorY": "A(z) {} a következő hiba miatt lett eltávolítva: {}", + "completeAppInstallation": "Teljes app telepítés", + "obtainiumMustBeOpenToInstallApps": "Az Obtainiumnak megnyitva kell lennie az alkalmazások telepítéséhez", + "completeAppInstallationNotifDescription": "Megkéri a felhasználót, hogy térjen vissza az Obtainiumhoz, hogy befejezze az alkalmazás telepítését", + "checkingForUpdates": "Frissítések keresése", + "checkingForUpdatesNotifDescription": "Átmeneti értesítés, amely a frissítések keresésekor jelenik meg", + "pleaseAllowInstallPerm": "Kérjük, engedélyezze az Obtainiumnak az alkalmazások telepítését", + "trackOnly": "Csak követés", + "errorWithHttpStatusCode": "Hiba {}", + "versionCorrectionDisabled": "Verzió korrekció letiltva (úgy tűnik, a beépülő modul nem működik)", + "unknown": "Ismeretlen", + "none": "Egyik sem", + "never": "Soha", + "latestVersionX": "Legújabb verzió: {}", + "installedVersionX": "Telepített verzió: {}", + "lastUpdateCheckX": "Frissítés ellenőrizve: {}", + "remove": "Eltávolítás", + "yesMarkUpdated": "Igen, megjelölés frissítettként", + "fdroid": "F-Droid Official", + "appIdOrName": "App ID vagy név", + "appId": "App ID", + "appWithIdOrNameNotFound": "Nem található app ezzel az azonosítóval vagy névvel", + "reposHaveMultipleApps": "A repók több alkalmazást is tartalmazhatnak", + "fdroidThirdPartyRepo": "F-Droid Harmadik-fél Repo", + "steam": "Steam", + "steamMobile": "Steam Mobile", + "steamChat": "Steam Chat", + "install": "Telepít", + "markInstalled": "Telepítettnek jelöl", + "update": "Frissít", + "markUpdated": "Frissítettnek jelöl", + "additionalOptions": "További lehetőségek", + "disableVersionDetection": "Verzió érzékelés letiltása", + "noVersionDetectionExplanation": "Ezt a beállítást csak olyan alkalmazásoknál szabad használni, ahol a verzióérzékelés nem működik megfelelően.", + "downloadingX": "{} letöltés", + "downloadNotifDescription": "Értesíti a felhasználót az app letöltésének előrehaladásáról", + "noAPKFound": "Nem található APK", + "noVersionDetection": "Nincs verzió érzékelés", + "categorize": "Kategorizálás", + "categories": "Kategóriák", + "category": "Kategória", + "noCategory": "Nincs kategória", + "noCategories": "No Categories", + "deleteCategoryQuestion": "Törli a kategóriát?", + "categoryDeleteWarning": "A(z) {} összes app kategorizálatlan állapotba kerül.", + "addCategory": "Új kategória", + "label": "Címke", + "language": "Nyelv", + "copiedToClipboard": "Másolva a vágólapra", + "storagePermissionDenied": "Tárhely engedély megtagadva", + "selectedCategorizeWarning": "Ez felváltja a kiválasztott alkalmazások meglévő kategória-beállításait.", + "filterAPKsByRegEx": "Az APK-k szűrése reguláris kifejezéssel", + "removeFromObtainium": "Eltávolítás az Obtainiumból", + "uninstallFromDevice": "Eltávolítás a készülékről", + "onlyWorksWithNonVersionDetectApps": "Csak azoknál az alkalmazásoknál működik, amelyeknél a verzióérzékelés le van tiltva.", + "releaseDateAsVersion": "Használja a Kiadás dátumát, mint verziót", + "releaseDateAsVersionExplanation": "Ezt a beállítást csak olyan alkalmazásoknál szabad használni, ahol a verzió érzékelése nem működik megfelelően, de elérhető a kiadás dátuma.", + "changes": "Változtatások", + "releaseDate": "Kiadás dátuma", + "importFromURLsInFile": "Importálás fájlban található URL-ből (mint pl. OPML)", + "versionDetection": "Verzió érzékelés", + "standardVersionDetection": "Alapért. verzió érzékelés", + "groupByCategory": "Csoportosítás Kategória alapján", + "autoApkFilterByArch": "Ha lehetséges, próbálja CPU architektúra szerint szűrni az APK-okat", + "overrideSource": "Forrás felülbírálása", + "dontShowAgain": "Ne mutassa ezt újra", + "dontShowTrackOnlyWarnings": "Ne jelenítsen meg 'Csak nyomon követés' figyelmeztetést", + "dontShowAPKOriginWarnings": "Ne jelenítsen meg az APK eredetére vonatkozó figyelmeztetéseket", + "moveNonInstalledAppsToBottom": "Helyezze át a nem telepített appokat az App nézet aljára", + "gitlabPATLabel": "GitLab Personal Access Token\n(Engedélyezi a Keresést és jobb APK felfedezés)", + "about": "Rólunk", + "requiresCredentialsInSettings": "Ehhez további hitelesítő adatokra van szükség (a Beállításokban)", + "checkOnStart": "Egyszer az alkalmazás indításakor is", + "tryInferAppIdFromCode": "Próbálja kikövetkeztetni az app azonosítót a forráskódból", + "removeOnExternalUninstall": "A külsőleg eltávolított appok auto. eltávolítása", + "pickHighestVersionCode": "A legmagasabb verziószámú APK auto. kiválasztása", + "checkUpdateOnDetailPage": "Frissítések keresése az app részleteit tartalmazó oldal megnyitásakor", + "disablePageTransitions": "Lap áttűnési animációk letiltása", + "reversePageTransitions": "Fordított lap áttűnési animációk", + "minStarCount": "Minimális csillag szám", + "addInfoBelow": "Adja hozzá ezt az infót alább.", + "addInfoInSettings": "Adja hozzá ezt az infót a Beállításokban.", + "githubSourceNote": "A GitHub sebességkorlátozás elkerülhető API-kulcs használatával.", + "gitlabSourceNote": "Előfordulhat, hogy a GitLab APK kibontása nem működik API-kulcs nélkül.", + "sortByFileNamesNotLinks": "Fájlnevek szerinti elrendezés teljes linkek helyett", + "filterReleaseNotesByRegEx": "Kiadási megjegyzések szűrése reguláris kifejezéssel", + "customLinkFilterRegex": "Egyéni APK hivatkozásszűrő reguláris kifejezéssel (Alapérték '.apk$')", + "appsPossiblyUpdated": "App frissítési kísérlet", + "appsPossiblyUpdatedNotifDescription": "Értesíti a felhasználót, hogy egy vagy több alkalmazás frissítése lehetséges a háttérben", + "xWasPossiblyUpdatedToY": "{} frissítve lehet erre {}.", + "backgroundUpdateReqsExplanation": "Előfordulhat, hogy nem minden appnál lehetséges a háttérbeli frissítés.", + "backgroundUpdateLimitsExplanation": "A háttérben történő telepítés sikeressége csak az Obtainium megnyitásakor állapítható meg.", + "verifyLatestTag": "Ellenőrizze a „legújabb” címkét", + "intermediateLinkRegex": "Szűrés egy 'közvetítő' linkre, amelyet először meg kell látogatni", + "intermediateLinkNotFound": "Közvetítő link nem található", + "exemptFromBackgroundUpdates": "Mentes a háttérben történő frissítések alól (ha engedélyezett)", + "bgUpdatesOnWiFiOnly": "Tiltsa le a háttérben frissítéseket, ha nincs Wi-Fi-n", + "autoSelectHighestVersionCode": "A legmagasabb verziószámú APK auto. kiválasztása", + "versionExtractionRegEx": "Verzió kibontása reguláris kifejezéssel", + "matchGroupToUse": "Párosítsa a csoportot a használathoz", + "highlightTouchTargets": "Emelje ki a kevésbé nyilvánvaló érintési célokat", + "pickExportDir": "Válassza az Exportálási könyvtárat", + "autoExportOnChanges": "Auto-exportálás a változások után", + "filterVersionsByRegEx": "Verziók szűrése reguláris kifejezéssel", + "trySelectingSuggestedVersionCode": "Próbálja ki a javasolt verziókódú APK-t", + "dontSortReleasesList": "Az API-ból származó kiadási sorrend megőrzése", + "reverseSort": "Fordított rendezés", + "debugMenu": "Hibakereső menü", + "bgTaskStarted": "A háttérfeladat elindult – ellenőrizze a naplókat.", + "enableBackgroundUpdates": "Frissítések a háttérben", + "runBgCheckNow": "Futtassa a Háttérben frissítés ellenőrzését most", + "versionExtractWholePage": "Alkalmazza a Version Extraction Regex-et az egész oldalra", + "installing": "Telepítés", + "skipUpdateNotifications": "A frissítési értesítések kihagyása", + "updatesAvailableNotifChannel": "Frissítések érhetők el", + "appsUpdatedNotifChannel": "Alkalmazások frissítve", + "appsPossiblyUpdatedNotifChannel": "App frissítési kísérlet", + "errorCheckingUpdatesNotifChannel": "Hiba a frissítések keresésekor", + "appsRemovedNotifChannel": "Alkalmazások eltávolítva", + "downloadingXNotifChannel": "{} letöltés", + "completeAppInstallationNotifChannel": "Teljes app telepítés", + "checkingForUpdatesNotifChannel": "Frissítések keresése", + "onlyCheckInstalledOrTrackOnlyApps": "Csak a telepített és a csak követhető appokat ellenőrizze frissítésekért", + "removeAppQuestion": { + "one": "Eltávolítja az alkalmazást?", + "other": "Eltávolítja az alkalmazást?" + }, + "tooManyRequestsTryAgainInMinutes": { + "one": "Túl sok kérés (korlátozott arány) – próbálja újra {} perc múlva", + "other": "Túl sok kérés (korlátozott arány) – próbálja újra {} perc múlva" + }, + "bgUpdateGotErrorRetryInMinutes": { + "one": "A háttérfrissítések ellenőrzése {}-t észlelt, {} perc múlva ütemezi az újrapróbálkozást", + "other": "A háttérfrissítések ellenőrzése {}-t észlelt, {} perc múlva ütemezi az újrapróbálkozást" + }, + "bgCheckFoundUpdatesWillNotifyIfNeeded": { + "one": "A háttérfrissítés ellenőrzése {} frissítést talált – szükség esetén értesíti a felhasználót", + "other": "A háttérfrissítés ellenőrzése {} frissítést talált – szükség esetén értesíti a felhasználót" + }, + "apps": { + "one": "{} app", + "other": "{} app" + }, + "url": { + "one": "{} URL", + "other": "{} URL" + }, + "minute": { + "one": "{} perc", + "other": "{} perc" + }, + "hour": { + "one": "{} óra", + "other": "{} óra" + }, + "day": { + "one": "{} nap", + "other": "{} nap" + }, + "clearedNLogsBeforeXAfterY": { + "one": "{n} napló törölve (előtte = {előtte}, utána = {utána})", + "other": "{n} napló törölve (előtte = {előtte}, utána = {utána})" + }, + "xAndNMoreUpdatesAvailable": { + "one": "A(z) {} és 1 további alkalmazás frissítéseket kapott.", + "other": "{} és {} további alkalmazás frissítéseket kapott." + }, + "xAndNMoreUpdatesInstalled": { + "one": "A(z) {} és 1 további alkalmazás frissítve.", + "other": "{} és {} további alkalmazás frissítve." + }, + "xAndNMoreUpdatesPossiblyInstalled": { + "one": "{} és 1 további alkalmazás is frissült.", + "other": "{} és {} további alkalmazás is frissült." + } +} diff --git a/assets/translations/it.json b/assets/translations/it.json index a0f01f0..945dfc0 100644 --- a/assets/translations/it.json +++ b/assets/translations/it.json @@ -1,26 +1,17 @@ { - "invalidURLForSource": "URL dell'App da {} non valido", + "invalidURLForSource": "URL dell'app {} non valido", "noReleaseFound": "Impossibile trovare una release adatta", "noVersionFound": "Impossibile determinare la versione della release", "urlMatchesNoSource": "L'URL non corrisponde ad alcuna fonte conosciuta", - "cantInstallOlderVersion": "Impossibile installare una versione precedente di un'App", - "appIdMismatch": "L'ID del pacchetto scaricato non corrisponde all'ID dell'App esistente", + "cantInstallOlderVersion": "Impossibile installare una versione precedente di un'app", + "appIdMismatch": "L'ID del pacchetto scaricato non corrisponde all'ID dell'app esistente", "functionNotImplemented": "Questa classe non ha implementato questa funzione", "placeholder": "Segnaposto", "someErrors": "Si sono verificati degli errori", "unexpectedError": "Errore imprevisto", "ok": "Va bene", "and": "e", - "startedBgUpdateTask": "Avviata l'attività di controllo degli aggiornamenti in background", - "bgUpdateIgnoreAfterIs": "Bg update ignoreAfter is {}", - "startedActualBGUpdateCheck": "Avviato il controllo effettivo degli aggiornamenti in background", - "bgUpdateTaskFinished": "Terminata l'attività di controllo degli aggiornamenti in background", - "firstRun": "Questo è il primo avvio di sempre di Obtainium", - "settingUpdateCheckIntervalTo": "Fissato intervallo di aggiornamento a {}", - "githubPATLabel": "GitHub Personal Access Token (diminuisce limite di traffico)", - "githubPATHint": "PAT deve seguire questo formato: username:token", - "githubPATFormat": "username:token", - "githubPATLinkText": "Informazioni su GitHub PAT", + "githubPATLabel": "GitHub Personal Access Token (aumenta limite di traffico)", "includePrereleases": "Includi prerelease", "fallbackToOlderReleases": "Ripiega su release precedenti", "filterReleaseTitlesByRegEx": "Filtra release con espressioni regolari", @@ -32,70 +23,69 @@ "dropdownNoOptsError": "ERRORE: LA TENDINA DEVE AVERE ALMENO UN'OPZIONE", "colour": "Colore", "githubStarredRepos": "repository stellati da GitHub", - "uname": "Username", + "uname": "Nome utente", "wrongArgNum": "Numero di argomenti forniti errato", "xIsTrackOnly": "{} è in modalità Solo-Monitoraggio", "source": "Fonte", "app": "App", - "appsFromSourceAreTrackOnly": "Le App da questa fonte sono in modalità 'Solo-Monitoraggio'.", + "appsFromSourceAreTrackOnly": "Le app da questa fonte sono in modalità 'Solo-Monitoraggio'.", "youPickedTrackOnly": "È stata selezionata l'opzione 'Solo-Monitoraggio'.", - "trackOnlyAppDescription": "L'App sarà monitorata per gli aggiornamenti, ma Obtainium non sarà in grado di scaricarli o di installarli.", + "trackOnlyAppDescription": "L'app sarà monitorata per gli aggiornamenti, ma Obtainium non sarà in grado di scaricarli o di installarli.", "cancelled": "Annullato", "appAlreadyAdded": "App già aggiunta", - "alreadyUpToDateQuestion": "L'App è già aggiornata?", - "addApp": "Aggiungi App", - "appSourceURL": "URL della fonte dell'App", + "alreadyUpToDateQuestion": "L'app è già aggiornata?", + "addApp": "Aggiungi app", + "appSourceURL": "URL della fonte dell'app", "error": "Errore", "add": "Aggiungi", "searchSomeSourcesLabel": "Cerca (solo per alcune fonti)", "search": "Cerca", "additionalOptsFor": "Opzioni aggiuntive per {}", - "supportedSourcesBelow": "Fonti supportate:", + "supportedSources": "Fonti supportate", "trackOnlyInBrackets": "(Solo-Monitoraggio)", "searchableInBrackets": "(ricercabile)", "appsString": "App", - "noApps": "Nessuna App", - "noAppsForFilter": "Nessuna App per i filtri selezionati", - "byX": "Da {}", - "percentProgress": "Progresso: {}%", - "pleaseWait": "Attendere prego", + "noApps": "Nessuna app", + "noAppsForFilter": "Nessuna app per i filtri selezionati", + "byX": "Di {}", + "percentProgress": "Avanzamento: {}%", + "pleaseWait": "In attesa", "updateAvailable": "Aggiornamento disponibile", - "estimateInBracketsShort": "(prev.)", + "estimateInBracketsShort": "(stim.)", "notInstalled": "Non installato", - "estimateInBrackets": "(previsto)", + "estimateInBrackets": "(stimato)", "selectAll": "Seleziona tutto", "deselectN": "Deseleziona {}", "xWillBeRemovedButRemainInstalled": "Verà effettuata la rimozione di {}, ma non la disinstallazione.", - "removeSelectedAppsQuestion": "Rimuovere le App selezionate?", - "removeSelectedApps": "Rimuovi le App selezionate", + "removeSelectedAppsQuestion": "Rimuovere le app selezionate?", + "removeSelectedApps": "Rimuovi le app selezionate", "updateX": "Aggiorna {}", "installX": "Installa {}", - "markXTrackOnlyAsUpdated": "Contrassegna {}\n(Solo-Monitoraggio)\ncome aggiornato", + "markXTrackOnlyAsUpdated": "Contrassegna {}\n(Solo-Monitoraggio)\ncome aggiornata", "changeX": "Modifica {}", - "installUpdateApps": "Installa/Aggiorna App", - "installUpdateSelectedApps": "Installa/Aggiorna le App selezionate", - "onlyWorksWithNonEVDApps": "Funziona solo per le App il cui stato d'installazione non può essere rilevato automaticamente (inconsueto).", - "markXSelectedAppsAsUpdated": "Contrassegnare le {} App selezionate come aggiornate?", + "installUpdateApps": "Installa/Aggiorna app", + "installUpdateSelectedApps": "Installa/Aggiorna le app selezionate", + "markXSelectedAppsAsUpdated": "Contrassegnare le {} app selezionate come aggiornate?", "no": "No", "yes": "Sì", - "markSelectedAppsUpdated": "Contrassegna le App selezionate come aggiornate", + "markSelectedAppsUpdated": "Contrassegna le app selezionate come aggiornate", "pinToTop": "Fissa in alto", "unpinFromTop": "Rimuovi dall'alto", - "resetInstallStatusForSelectedAppsQuestion": "Ripristinare lo stato d'installazione delle App selezionate?", - "installStatusOfXWillBeResetExplanation": "Lo stato d'installazione di ogni App selezionata sarà ripristinato.\n\nCiò può essere d'aiuto nel caso in cui la versione mostrata dell'App in Obtainium non è corretta a causa di un aggiornamento fallito o di altri problemi.", - "shareSelectedAppURLs": "Condividi gli URL delle App selezionate", + "resetInstallStatusForSelectedAppsQuestion": "Ripristinare lo stato d'installazione delle app selezionate?", + "installStatusOfXWillBeResetExplanation": "Lo stato d'installazione di ogni app selezionata sarà ripristinato.\n\nCiò può essere d'aiuto nel caso in cui la versione mostrata dell'app in Obtainium non sia corretta a causa di un aggiornamento fallito o di altri problemi.", + "shareSelectedAppURLs": "Condividi gli URL delle app selezionate", "resetInstallStatus": "Ripristina lo stato d'installazione", - "more": "Di più", - "removeOutdatedFilter": "Rimuovi il filtro per le App non aggiornate", - "showOutdatedOnly": "Mostra solo le App non aggiornate", + "more": "Altro", + "removeOutdatedFilter": "Rimuovi il filtro per le app non aggiornate", + "showOutdatedOnly": "Mostra solo le app non aggiornate", "filter": "Filtri", "filterActive": "Filtri *", - "filterApps": "Filtra App", - "appName": "Nome dell'App", + "filterApps": "Filtra app", + "appName": "Nome dell'app", "author": "Autore", "upToDateApps": "App aggiornate", "nonInstalledApps": "App non installate", - "importExport": "Importa - Esporta", + "importExport": "Importa/Esporta", "settings": "Impostazioni", "exportedTo": "Esportato in {}", "obtainiumExport": "Esporta da Obtainium", @@ -104,14 +94,14 @@ "obtainiumImport": "Importa in Obtainium", "importFromURLList": "Importa da lista di URL", "searchQuery": "Stringa di ricerca", - "appURLList": "Lista di URL delle App", + "appURLList": "Lista di URL delle app", "line": "Linea", "searchX": "Cerca su {}", "noResults": "Nessun risultato trovato", "importX": "Importa {}", - "importedAppsIdDisclaimer": "Le App importate potrebbero essere visualizzate erroneamente come \"Non installate\".\nPer risolvere il problema, reinstallale con Obtainium.\nQuesto non dovrebbe influire sui dati delle App.\n\nRiguarda solo l'URL e i metodi di importazione di terze parti.", - "importErrors": "Errori dell'importazione", - "importedXOfYApps": "{} App di {} importate.", + "importedAppsIdDisclaimer": "Le app importate potrebbero essere visualizzate erroneamente come \"Non installate\".\nPer risolvere il problema, reinstallale con Obtainium.\nCiò non dovrebbe influire sui dati delle app.\n\nRiguarda solo l'URL e i metodi di importazione di terze parti.", + "importErrors": "Errori di importazione", + "importedXOfYApps": "{} app di {} importate.", "followingURLsHadErrors": "I seguenti URL contengono errori:", "okay": "Va bene", "selectURL": "Seleziona l'URL", @@ -120,26 +110,27 @@ "theme": "Tema", "dark": "Scuro", "light": "Chiaro", - "followSystem": "Segui sistema", + "followSystem": "Segui il sistema", "obtainium": "Obtainium", "materialYou": "Material You", + "useBlackTheme": "Usa il tema Nero puro", "appSortBy": "App ordinate per", "authorName": "Autore/Nome", "nameAuthor": "Nome/Autore", "asAdded": "Data di aggiunta", - "appSortOrder": "Ordinamento", + "appSortOrder": "Ordine", "ascending": "Ascendente", "descending": "Discendente", - "bgUpdateCheckInterval": "Intervallo di controllo degli aggiornamenti in background", + "bgUpdateCheckInterval": "Intervallo di controllo degli aggiornamenti in secondo piano", "neverManualOnly": "Mai - Solo manuale", "appearance": "Aspetto", - "showWebInAppView": "Mostra pagina web dell'App se selezionata", + "showWebInAppView": "Mostra pagina web dell'app se selezionata", "pinUpdates": "Fissa aggiornamenti disponibili in alto", "updates": "Aggiornamenti", "sourceSpecific": "Specifiche per la fonte", - "appSource": "Sorgente dell'App", + "appSource": "Codice dell'app", "noLogs": "Nessun log", - "appLogs": "Log dell'App", + "appLogs": "Log dell'app", "close": "Chiudi", "share": "Condividi", "appNotFound": "App non trovata", @@ -149,28 +140,28 @@ "deviceSupportsXArch": "Il dispositivo in uso supporta l'architettura {} della CPU.", "deviceSupportsFollowingArchs": "Il dispositivo in uso supporta le seguenti architetture della CPU:", "warning": "Attenzione", - "sourceIsXButPackageFromYPrompt": "L'origine dell'App è '{}' ma il pacchetto della release proviene da '{}'. Continuare?", + "sourceIsXButPackageFromYPrompt": "L'origine dell'app è '{}' ma il pacchetto della release proviene da '{}'. Continuare?", "updatesAvailable": "Aggiornamenti disponibili", - "updatesAvailableNotifDescription": "Notifica all'utente che sono disponibili gli aggiornamenti di una o più App monitorate da Obtainium", + "updatesAvailableNotifDescription": "Notifica all'utente che sono disponibili gli aggiornamenti di una o più app monitorate da Obtainium", "noNewUpdates": "Nessun nuovo aggiornamento.", "xHasAnUpdate": "Aggiornamento disponibile per {}", "appsUpdated": "App aggiornate", - "appsUpdatedNotifDescription": "Notifica all'utente che una o più App sono state aggiornate in background", - "xWasUpdatedToY": "{} è stato aggiornato a {}.", + "appsUpdatedNotifDescription": "Notifica all'utente che una o più app sono state aggiornate in secondo piano", + "xWasUpdatedToY": "{} è stato aggiornato alla {}.", "errorCheckingUpdates": "Controllo degli errori per gli aggiornamenti", - "errorCheckingUpdatesNotifDescription": "Una notifica che mostra quando il controllo degli aggiornamenti in background fallisce", + "errorCheckingUpdatesNotifDescription": "Una notifica che mostra quando il controllo degli aggiornamenti in secondo piano fallisce", "appsRemoved": "App rimosse", - "appsRemovedNotifDescription": "Notifica all'utente che una o più App sono state rimosse a causa di errori durante il caricamento", + "appsRemovedNotifDescription": "Notifica all'utente che una o più app sono state rimosse a causa di errori durante il caricamento", "xWasRemovedDueToErrorY": "{} è stata rimosso a causa di questo errore: {}", - "completeAppInstallation": "Completa l'installazione dell'App", - "obtainiumMustBeOpenToInstallApps": "Obtainium deve essere aperto per poter installare le App", - "completeAppInstallationNotifDescription": "Chiede all'utente di riaprire Obtainium per terminare l'installazione di un App", + "completeAppInstallation": "Completa l'installazione dell'app", + "obtainiumMustBeOpenToInstallApps": "Obtainium deve essere aperto per poter installare le app", + "completeAppInstallationNotifDescription": "Chiede all'utente di riaprire Obtainium per terminare l'installazione di un'app", "checkingForUpdates": "Controllo degli aggiornamenti in corso", "checkingForUpdatesNotifDescription": "Notifica transitoria che appare durante la verifica degli aggiornamenti", - "pleaseAllowInstallPerm": "Per favore permetti a Obtainium di installare le App", + "pleaseAllowInstallPerm": "Per favore permetti a Obtainium di installare le app", "trackOnly": "Solo-Monitoraggio", "errorWithHttpStatusCode": "Errore {}", - "versionCorrectionDisabled": "Correzione della versione disabilitata (il plugin non pare funzionare)", + "versionCorrectionDisabled": "Correzione della versione disattivata (il plugin sembra non funzionare)", "unknown": "Sconosciuto", "none": "Nessuno", "never": "Mai", @@ -178,25 +169,25 @@ "installedVersionX": "Versione installata: {}", "lastUpdateCheckX": "Ultimo controllo degli aggiornamenti: {}", "remove": "Rimuovi", - "removeAppQuestion": "Rimuovere l'App?", - "yesMarkUpdated": "Sì, contrassegna come aggiornato", - "fdroid": "F-Droid", - "appIdOrName": "ID o nome dell'App", - "appWithIdOrNameNotFound": "Non è stata trovata alcuna App con quell'ID o nome", - "reposHaveMultipleApps": "I repository possono contenere più App", + "yesMarkUpdated": "Sì, contrassegna come aggiornata", + "fdroid": "F-Droid ufficiale", + "appIdOrName": "ID o nome dell'app", + "appId": "ID dell'app", + "appWithIdOrNameNotFound": "Non è stata trovata alcuna app con quell'ID o nome", + "reposHaveMultipleApps": "I repository possono contenere più app", "fdroidThirdPartyRepo": "Repository F-Droid di terze parti", "steam": "Steam", "steamMobile": "Steam Mobile", "steamChat": "Steam Chat", "install": "Installa", - "markInstalled": "Contrassegna come installato", + "markInstalled": "Contrassegna come installata", "update": "Aggiorna", - "markUpdated": "Contrassegna come aggiornato", + "markUpdated": "Contrassegna come aggiornata", "additionalOptions": "Opzioni aggiuntive", "disableVersionDetection": "Disattiva il rilevamento della versione", - "noVersionDetectionExplanation": "Questa opzione dovrebbe essere usata solo per le App la cui versione non viene rilevata correttamente.", + "noVersionDetectionExplanation": "Questa opzione dovrebbe essere usata solo per le app la cui versione non viene rilevata correttamente.", "downloadingX": "Scaricamento di {} in corso", - "downloadNotifDescription": "Notifica all'utente lo stato di avanzamento del download di un'App", + "downloadNotifDescription": "Notifica all'utente lo stato di avanzamento del download di un'app", "noAPKFound": "Nessun APK trovato", "noVersionDetection": "Disattiva rilevamento di versione", "categorize": "Aggiungi a categoria", @@ -205,27 +196,104 @@ "noCategory": "Nessuna categoria", "noCategories": "Nessuna categoria", "deleteCategoriesQuestion": "Eliminare le categorie?", - "categoryDeleteWarning": "Tutte le App nelle categorie eliminate saranno impostate come non categorizzate.", + "categoryDeleteWarning": "Tutte le app nelle categorie eliminate saranno impostate come non categorizzate.", "addCategory": "Aggiungi categoria", "label": "Etichetta", "language": "Lingua", - "storagePermissionDenied": "Storage permission denied", - "selectedCategorizeWarning": "This will replace any existing category settings for the selected Apps.", + "copiedToClipboard": "Copiato negli appunti", + "storagePermissionDenied": "Accesso ai file non autorizzato", + "selectedCategorizeWarning": "Ciò sostituirà le impostazioni di categoria esistenti per le app selezionate.", + "filterAPKsByRegEx": "Filtra file APK con espressioni regolari", + "removeFromObtainium": "Rimuovi da Obtainium", + "uninstallFromDevice": "Disinstalla dal dispositivo", + "onlyWorksWithNonVersionDetectApps": "Funziona solo per le app con il rilevamento della versione disattivato.", + "releaseDateAsVersion": "Usa data di rilascio come versione", + "releaseDateAsVersionExplanation": "Questa opzione dovrebbe essere usata solo per le app in cui il rilevamento della versione non funziona correttamente, ma è disponibile una data di rilascio.", + "changes": "Novità", + "releaseDate": "Data di rilascio", + "importFromURLsInFile": "Importa da URL in file (come OPML)", + "versionDetection": "Rilevamento di versione", + "standardVersionDetection": "Rilevamento di versione standard", + "groupByCategory": "Raggruppa per categoria", + "autoApkFilterByArch": "Tenta di filtrare gli APK in base all'architettura della CPU, se possibile", + "overrideSource": "Sovrascrivi fonte", + "dontShowAgain": "Non mostrarlo più", + "dontShowTrackOnlyWarnings": "Non mostrare gli avvisi 'Solo-Monitoraggio'", + "dontShowAPKOriginWarnings": "Non mostrare gli avvisi di origine dell'APK", + "moveNonInstalledAppsToBottom": "Sposta le app non installate in fondo alla lista", + "gitlabPATLabel": "GitLab Personal Access Token\n(attiva la ricerca e migliora la rilevazione di apk)", + "about": "Informazioni", + "requiresCredentialsInSettings": "Servono credenziali aggiuntive (in Impostazioni)", + "checkOnStart": "Controlla una volta all'avvio", + "tryInferAppIdFromCode": "Prova a dedurre l'ID dell'app dal codice sorgente", + "removeOnExternalUninstall": "Rimuovi automaticamente app disinstallate esternamente", + "pickHighestVersionCode": "Auto-seleziona APK con version code più alto", + "checkUpdateOnDetailPage": "Controlla aggiornamenti all'apertura dei dettagli dell'app", + "disablePageTransitions": "Disattiva animazioni di transizione pagina", + "reversePageTransitions": "Inverti animazioni di transizione pagina", + "minStarCount": "Numero minimo di stelle", + "addInfoBelow": "Aggiungi questa info sotto.", + "addInfoInSettings": "Aggiungi questa info nelle impostazioni.", + "githubSourceNote": "Il limite di ricerca GitHub può essere evitato usando una chiave API.", + "gitlabSourceNote": "L'estrazione di APK da GitLab potrebbe non funzionare senza chiave API.", + "sortByFileNamesNotLinks": "Ordina per nome del file invece dei link completi", + "filterReleaseNotesByRegEx": "Filtra le note di rilascio con espressione regolare", + "customLinkFilterRegex": "Filtra link APK personalizzato con espressione regolare (predefinito '.apk$')", + "appsPossiblyUpdated": "Aggiornamenti app tentati", + "appsPossiblyUpdatedNotifDescription": "Notifica all'utente che sono stati potenzialmente applicati in secondo piano aggiornamenti a una o più app", + "xWasPossiblyUpdatedToY": "{} potrebbe essere stata aggiornata alla {}.", + "enableBackgroundUpdates": "Attiva aggiornamenti in secondo piano", + "backgroundUpdateReqsExplanation": "Gli aggiornamenti in secondo piano potrebbero non essere possibili per tutte le app.", + "backgroundUpdateLimitsExplanation": "La riuscita di un'installazione in secondo piano può essere determinata solo quando viene aperto Obtainium.", + "verifyLatestTag": "Verifica l'etichetta 'Latest'", + "intermediateLinkRegex": "Filtra un link 'Intermedio' da visitare prima", + "intermediateLinkNotFound": "Link intermedio non trovato", + "exemptFromBackgroundUpdates": "Esente da aggiornamenti in secondo piano (se attivo)", + "bgUpdatesOnWiFiOnly": "Disattiva aggiornamenti in secondo piano quando non si usa il WiFi", + "autoSelectHighestVersionCode": "Auto-seleziona APK con versionCode più alto", + "versionExtractionRegEx": "RegEx di estrazione versione", + "matchGroupToUse": "Gruppo da usare", + "highlightTouchTargets": "Evidenzia elementi toccabili meno ovvi", + "pickExportDir": "Scegli cartella esp.", + "autoExportOnChanges": "Auto-esporta dopo modifiche", + "filterVersionsByRegEx": "Filtra versioni con espressione regolare", + "trySelectingSuggestedVersionCode": "Prova a selezionare APK con versionCode suggerito", + "dontSortReleasesList": "Conserva l'ordine di release da API", + "reverseSort": "Ordine inverso", + "debugMenu": "Menu di debug", + "bgTaskStarted": "Attività in secondo piano iniziata - controllo log.", + "runBgCheckNow": "Inizia aggiornamento in secondo piano ora", + "versionExtractWholePage": "Applica regex di estrazione versione a tutta la pagina", + "installing": "Installazione", + "skipUpdateNotifications": "Salta notifiche di aggiornamento", + "updatesAvailableNotifChannel": "Aggiornamenti disponibili", + "appsUpdatedNotifChannel": "App aggiornate", + "appsPossiblyUpdatedNotifChannel": "Aggiornamenti app tentati", + "errorCheckingUpdatesNotifChannel": "Controllo degli errori per gli aggiornamenti", + "appsRemovedNotifChannel": "App rimosse", + "downloadingXNotifChannel": "Scaricamento di {} in corso", + "completeAppInstallationNotifChannel": "Completa l'installazione dell'app", + "checkingForUpdatesNotifChannel": "Controllo degli aggiornamenti in corso", + "onlyCheckInstalledOrTrackOnlyApps": "Cerca aggiornamenti solo per app installate e app in Solo-Monitoraggio", + "removeAppQuestion": { + "one": "Rimuovere l'app?", + "other": "Rimuovere le app?" + }, "tooManyRequestsTryAgainInMinutes": { "one": "Troppe richieste (traffico limitato) - riprova tra {} minuto", "other": "Troppe richieste (traffico limitato) - riprova tra {} minuti" }, "bgUpdateGotErrorRetryInMinutes": { - "one": "Il controllo degli aggiornamenti in background ha incontrato un {}, nuovo tentativo tra {} minuto", - "other": "Il controllo degli aggiornamenti in background ha incontrato un {}, nuovo tentativo tra {} minuti" + "one": "Il controllo degli aggiornamenti in secondo piano ha riscontrato un {}, nuovo tentativo tra {} minuto", + "other": "Il controllo degli aggiornamenti in secondo piano ha riscontrato un {}, nuovo tentativo tra {} minuti" }, "bgCheckFoundUpdatesWillNotifyIfNeeded": { - "one": "Il controllo degli aggiornamenti in background ha trovato {} aggiornamento - notificherà l'utente se necessario", - "other": "Il controllo degli aggiornamenti in background ha trovato {} aggiornamenti - notificherà l'utente se necessario" + "one": "Il controllo degli aggiornamenti in secondo piano ha trovato {} aggiornamento - notificherà l'utente se necessario", + "other": "Il controllo degli aggiornamenti in secondo piano ha trovato {} aggiornamenti - notificherà l'utente se necessario" }, "apps": { - "one": "{} App", - "other": "{} App" + "one": "{} app", + "other": "{} app" }, "url": { "one": "{} URL", @@ -244,15 +312,19 @@ "other": "{} giorni" }, "clearedNLogsBeforeXAfterY": { - "one": "Pulito {n} log (prima = {before}, dopo = {after})", - "other": "Puliti {n} log (prima = {before}, dopo = {after})" + "one": "Rimosso {n} log (prima = {before}, dopo = {after})", + "other": "Rimossi {n} log (prima = {before}, dopo = {after})" }, "xAndNMoreUpdatesAvailable": { - "one": "{} e un'altra App hanno aggiornamenti disponibili.", - "other": "{} e altre {} App hanno aggiornamenti disponibili." + "one": "{} e un'altra app hanno aggiornamenti disponibili.", + "other": "{} e altre {} app hanno aggiornamenti disponibili." }, "xAndNMoreUpdatesInstalled": { - "one": "{} e un'altra App sono state aggiornate.", - "other": "{} e altre {} App sono state aggiornate." + "one": "{} e un'altra app sono state aggiornate.", + "other": "{} e altre {} app sono state aggiornate." + }, + "xAndNMoreUpdatesPossiblyInstalled": { + "one": "{} e un'altra app potrebbero essere state aggiornate.", + "other": "{} e altre {} app potrebbero essere state aggiornate." } } diff --git a/assets/translations/ja.json b/assets/translations/ja.json index 7aadadb..e176194 100644 --- a/assets/translations/ja.json +++ b/assets/translations/ja.json @@ -7,23 +7,14 @@ "appIdMismatch": "ダウンロードしたパッケージのIDが既存のApp IDと一致しません", "functionNotImplemented": "このクラスはこの機能を実装していません", "placeholder": "プレースホルダー", - "someErrors": "いくつかのエラーが発生しました", + "someErrors": "何らかのエラーが発生しました", "unexpectedError": "予期せぬエラーが発生しました", "ok": "OK", "and": "と", - "startedBgUpdateTask": "バックグラウンドのアップデート確認タスクを開始", - "bgUpdateIgnoreAfterIs": "Bg update ignoreAfter is {}", - "startedActualBGUpdateCheck": "実際のバックグラウンドのアップデート確認を開始", - "bgUpdateTaskFinished": "バックグラウンドのアップデート確認タスクを終了", - "firstRun": "これがObtainiumの最初の実行です", - "settingUpdateCheckIntervalTo": "確認間隔を{}に設定する", "githubPATLabel": "GitHub パーソナルアクセストークン (レート制限の引き上げ)", - "githubPATHint": "PATは次の形式でなければなりません: ユーザー名:トークン", - "githubPATFormat": "ユーザー名:トークン", - "githubPATLinkText": "GitHub PATsについて", "includePrereleases": "プレリリースを含む", "fallbackToOlderReleases": "旧リリースへのフォールバック", - "filterReleaseTitlesByRegEx": "正規表現でリリースタイトルを絞り込む", + "filterReleaseTitlesByRegEx": "正規表現でリリースタイトルをフィルタリングする", "invalidRegEx": "無効な正規表現", "noDescription": "説明はありません", "cancel": "キャンセル", @@ -50,7 +41,7 @@ "searchSomeSourcesLabel": "検索 (一部ソースのみ)", "search": "検索", "additionalOptsFor": "{}の追加オプション", - "supportedSourcesBelow": "対応するソース:", + "supportedSources": "対応するソース", "trackOnlyInBrackets": "(追跡のみ)", "searchableInBrackets": "(検索可能)", "appsString": "アプリ", @@ -74,7 +65,6 @@ "changeX": "{} を変更する", "installUpdateApps": "アプリのインストール/アップデート", "installUpdateSelectedApps": "選択したアプリのインストール/アップデート", - "onlyWorksWithNonEVDApps": "インストール状況を自動検出できないアプリ(一般的でないもの)のみ動作します。", "markXSelectedAppsAsUpdated": "{}個の選択したアプリをアップデート済みとしてマークしますか?", "no": "いいえ", "yes": "はい", @@ -82,7 +72,7 @@ "pinToTop": "トップに固定", "unpinFromTop": "トップから固定解除", "resetInstallStatusForSelectedAppsQuestion": "選択したアプリのインストール状態をリセットしますか?", - "installStatusOfXWillBeResetExplanation": "選択したアプリのインストール状態がリセットされます。\n\nアップデートに失敗するなどして、Obtainiumに表示されるアプリのバージョンが正しくない場合に役立ちます。", + "installStatusOfXWillBeResetExplanation": "選択したアプリのインストール状態がリセットされます。\n\nアップデートに失敗した場合など、Obtainiumに表示されるアプリのバージョンが正しくない場合に有効です。", "shareSelectedAppURLs": "選択したアプリのURLを共有する", "resetInstallStatus": "インストール状態をリセットする", "more": "もっと見る", @@ -90,7 +80,7 @@ "showOutdatedOnly": "アップデートが存在するアプリのみ表示する", "filter": "フィルター", "filterActive": "フィルター *", - "filterApps": "アプリを絞り込む", + "filterApps": "アプリをフィルタリングする", "appName": "アプリ名", "author": "作者", "upToDateApps": "最新のアプリ", @@ -108,8 +98,8 @@ "line": "行", "searchX": "{}で検索", "noResults": "結果は見つかりませんでした", - "importX": "{}をインポートする", - "importedAppsIdDisclaimer": "インポートしたアプリが「未インストール」と表示されることがあります。\nこれを解決するには、Obtainiumから再インストールしてください。\nアプリのデータには影響しません。\n\nURLとサードパーティーのインポートメソッドにのみ影響します。", + "importX": "{}をインポート", + "importedAppsIdDisclaimer": "インポートしたアプリが「未インストール」と表示されることがあります。\nこれを解決するには、Obtainiumから再インストールしてください。\nアプリのデータには影響しません。\n\nURLとサードパーティのインポートメソッドにのみ影響します。", "importErrors": "インポートエラー", "importedXOfYApps": "{} / {} アプリをインポートしました", "followingURLsHadErrors": "以下のURLでエラーが発生しました:", @@ -123,6 +113,7 @@ "followSystem": "システムに従う", "obtainium": "Obtainium", "materialYou": "Material You", + "useBlackTheme": "ピュアブラックダークテーマを使用する", "appSortBy": "アプリの並び方", "authorName": "作者名/アプリ名", "nameAuthor": "アプリ名/作者名", @@ -133,10 +124,10 @@ "bgUpdateCheckInterval": "バックグラウンドでのアップデート確認の間隔", "neverManualOnly": "手動", "appearance": "外観", - "showWebInAppView": "アプリビューにソースウェブページを表示する", + "showWebInAppView": "アプリページにソースのWebページを表示する", "pinUpdates": "アップデートがあるアプリをトップに固定する", "updates": "アップデート", - "sourceSpecific": "Github アクセストークン", + "sourceSpecific": "ソース別の設定", "appSource": "アプリのソース", "noLogs": "ログはありません", "appLogs": "アプリのログ", @@ -153,7 +144,7 @@ "updatesAvailable": "アップデートが利用可能", "updatesAvailableNotifDescription": "Obtainiumが追跡している1つまたは複数のアプリのアップデートが利用可能であることをユーザーに通知する", "noNewUpdates": "新しいアップデートはありません", - "xHasAnUpdate": "{} のアップデートが利用可能です", + "xHasAnUpdate": "{} のアップデートが利用可能です。", "appsUpdated": "アプリをアップデートしました", "appsUpdatedNotifDescription": "1つまたは複数のAppのアップデートがバックグラウンドで適用されたことをユーザーに通知する", "xWasUpdatedToY": "{} が {} にアップデートされました", @@ -178,13 +169,13 @@ "installedVersionX": "インストールされたバージョン: {}", "lastUpdateCheckX": "最終アップデート確認: {}", "remove": "削除", - "removeAppQuestion": "アプリを削除しますか?", "yesMarkUpdated": "はい、アップデート済みとしてマークします", - "fdroid": "F-Droid", + "fdroid": "F-Droid Official", "appIdOrName": "アプリのIDまたは名前", + "appId": "App ID", "appWithIdOrNameNotFound": "そのIDや名前を持つアプリは見つかりませんでした", "reposHaveMultipleApps": "リポジトリには複数のアプリが含まれることがあります", - "fdroidThirdPartyRepo": "F-Droid Third-Party Repo", + "fdroidThirdPartyRepo": "F-Droid サードパーティリポジトリ", "steam": "Steam", "steamMobile": "Steam Mobile", "steamChat": "Steam Chat", @@ -209,11 +200,88 @@ "addCategory": "カテゴリを追加", "label": "ラベル", "language": "言語", + "copiedToClipboard": "クリップボードにコピーしました", "storagePermissionDenied": "ストレージ権限が拒否されました", "selectedCategorizeWarning": "これにより、選択したアプリの既存のカテゴリ設定がすべて置き換えられます。", + "filterAPKsByRegEx": "正規表現でAPKをフィルタリングする", + "removeFromObtainium": "Obtainiumから削除する", + "uninstallFromDevice": "デバイスからアンインストールする", + "onlyWorksWithNonVersionDetectApps": "バージョン検出を無効にしているアプリにのみ動作します。", + "releaseDateAsVersion": "リリース日をバージョンとして使用する", + "releaseDateAsVersionExplanation": "このオプションは、バージョン検出が正しく機能しないアプリで、リリース日が利用可能な場合にのみ使用する必要があります。", + "changes": "変更点", + "releaseDate": "リリース日", + "importFromURLsInFile": "ファイル(OPMLなど)内のURLからインポート", + "versionDetection": "バージョン検出", + "standardVersionDetection": "標準のバージョン検出", + "groupByCategory": "カテゴリ別にグループ化する", + "autoApkFilterByArch": "可能であれば,CPUアーキテクチャによるAPKのフィルタリングを試みる", + "overrideSource": "ソースの上書き", + "dontShowAgain": "二度と表示しない", + "dontShowTrackOnlyWarnings": "「追跡のみ」の警告を表示しない", + "dontShowAPKOriginWarnings": "APKのダウンロード元の警告を表示しない", + "moveNonInstalledAppsToBottom": "未インストールのアプリをアプリ一覧の下部に移動させる", + "gitlabPATLabel": "GitLab パーソナルアクセストークン\n(検索とより良いAPK検出の有効化)", + "about": "概要", + "requiresCredentialsInSettings": "これには追加の認証が必要です (設定にて)", + "checkOnStart": "起動時にアップデートを確認する", + "tryInferAppIdFromCode": "ソースコードからApp IDを推測する", + "removeOnExternalUninstall": "外部でアンインストールされたアプリを自動的に削除する", + "pickHighestVersionCode": "最も高いバージョンコードのAPKを自動的に選択する", + "checkUpdateOnDetailPage": "アプリの詳細ページを開く際にアップデートを確認する", + "disablePageTransitions": "ページ遷移アニメーションを無効化する", + "reversePageTransitions": "ページ遷移アニメーションを反転する", + "minStarCount": "最小スター数", + "addInfoBelow": "下部でこの情報を追加してください。", + "addInfoInSettings": "設定でこの情報を追加してください。", + "githubSourceNote": "GitHubのレート制限はAPIキーを使うことで回避できます。", + "gitlabSourceNote": "GitLabのAPK抽出はAPIキーがないと動作しない場合があります。", + "sortByFileNamesNotLinks": "フルのリンクではなくファイル名でソートする", + "filterReleaseNotesByRegEx": "正規表現でリリースノートをフィルタリングする", + "customLinkFilterRegex": "正規表現によるカスタムリンクフィルター (デフォルト '.apk$')", + "appsPossiblyUpdated": "アプリのアップデートを試行", + "appsPossiblyUpdatedNotifDescription": "1つまたは複数のアプリのアップデートがバックグラウンドで適用された可能性があることをユーザーに通知する", + "xWasPossiblyUpdatedToY": "{} が {} にアップデートされた可能性があります。", + "enableBackgroundUpdates": "バックグラウンドアップデートを有効化する", + "backgroundUpdateReqsExplanation": "バックグラウンドアップデートは、すべてのアプリで可能とは限りません。", + "backgroundUpdateLimitsExplanation": "バックグラウンドアップデートが成功したかどうかは、Obtainiumを起動したときにしか判断できません。", + "verifyLatestTag": "'latest'タグを確認する", + "intermediateLinkRegex": "最初にアクセスする「中間」リンクをフィルタリングする", + "intermediateLinkNotFound": "中間リンクが見つかりませんでした", + "exemptFromBackgroundUpdates": "バックグラウンドアップデートを行わない (有効な場合)", + "bgUpdatesOnWiFiOnly": "WiFiを使用していない場合,バックグラウンドアップデートを無効にする", + "autoSelectHighestVersionCode": "最も高いバージョンコードのAPKを自動で選択する", + "versionExtractionRegEx": "バージョン抽出の正規表現", + "matchGroupToUse": "使用するマッチしたグループ", + "highlightTouchTargets": "目立たないタップ可能な対象をハイライトする", + "pickExportDir": "エクスポートディレクトリを選択", + "autoExportOnChanges": "変更があった際に自動でエクスポートする", + "filterVersionsByRegEx": "正規表現でバージョンをフィルタリングする", + "trySelectingSuggestedVersionCode": "提案されたバージョンコードのAPKを選択する", + "dontSortReleasesList": "APIからのリリース順を保持する", + "reverseSort": "逆順ソート", + "debugMenu": "デバッグメニュー", + "bgTaskStarted": "バックグラウンドタスクが開始されました - ログを確認してください。", + "runBgCheckNow": "今すぐバックグラウンドでのアップデート確認を開始する", + "versionExtractWholePage": "バージョン抽出の正規表現をページ全体に適用する", + "installing": "インストール中", + "skipUpdateNotifications": "アップデート通知を行わない", + "updatesAvailableNotifChannel": "アップデートが利用可能", + "appsUpdatedNotifChannel": "アプリをアップデートしました", + "appsPossiblyUpdatedNotifChannel": "アプリのアップデートを試行", + "errorCheckingUpdatesNotifChannel": "アップデート確認中のエラー", + "appsRemovedNotifChannel": "削除されたアプリ", + "downloadingXNotifChannel": "{} をダウンロード中", + "completeAppInstallationNotifChannel": "アプリのインストールを完了する", + "checkingForUpdatesNotifChannel": "アップデートを確認中", + "onlyCheckInstalledOrTrackOnlyApps": "インストール済みのアプリと「追跡のみ」のアプリのアップデートのみを確認する", + "removeAppQuestion": { + "one": "アプリを削除しますか?", + "other": "アプリを削除しますか?" + }, "tooManyRequestsTryAgainInMinutes": { - "one": "リクエストが多すぎます(レート制限)- {}分後に再試行してください", - "other": "リクエストが多すぎます(レート制限)- {}分後に再試行してください" + "one": "リクエストが多すぎます(レート制限)- {} 分後に再試行してください", + "other": "リクエストが多すぎます(レート制限)- {} 分後に再試行してください" }, "bgUpdateGotErrorRetryInMinutes": { "one": "バックグラウンドでのアップデート確認で {} の問題が発生, {} 分後に再試行します", @@ -224,35 +292,39 @@ "other": "バックグラウンドでのアップデート確認で {} 個のアップデートを発見 - 必要に応じてユーザーに通知します" }, "apps": { - "one": "{}個のアプリ", - "other": "{}個のアプリ" + "one": "{} 個のアプリ", + "other": "{} 個のアプリ" }, "url": { - "one": "{}個のURL", - "other": "{}個のURL" + "one": "{} 個のURL", + "other": "{} 個のURL" }, "minute": { - "one": "{}分", - "other": "{}分" + "one": "{} 分", + "other": "{} 分" }, "hour": { - "one": "{}時間", - "other": "{}時間" + "one": "{} 時間", + "other": "{} 時間" }, "day": { - "one": "{}日", - "other": "{}日" + "one": "{} 日", + "other": "{} 日" }, "clearedNLogsBeforeXAfterY": { - "one": "{n}個のログをクリアしました (前 = {before}, 後 = {after})", - "other": "{n}個のログをクリアしました (前 = {before}, 後 = {after})" + "one": "{n} 個のログをクリアしました (前 = {before}, 後 = {after})", + "other": "{n} 個のログをクリアしました (前 = {before}, 後 = {after})" }, "xAndNMoreUpdatesAvailable": { - "one": "{} とさらに {} 個のアプリのアップデートが利用可能です", - "other": "{} とさらに {} 個のアプリのアップデートが利用可能です" + "one": "{} とさらに {} 個のアプリのアップデートが利用可能です。", + "other": "{} とさらに {} 個のアプリのアップデートが利用可能です。" }, "xAndNMoreUpdatesInstalled": { - "one": "{} とさらに {} 個のアプリがアップデートされました", - "other": "{} とさらに {} 個のアプリがアップデートされました" + "one": "{} とさらに {} 個のアプリがアップデートされました。", + "other": "{} とさらに {} 個のアプリがアップデートされました。" + }, + "xAndNMoreUpdatesPossiblyInstalled": { + "one": "{} とさらに 1 個のアプリがアップデートされた可能性があります。", + "other": "{} とさらに {} 個のアプリがアップデートされた可能性があります。" } } \ No newline at end of file diff --git a/assets/translations/nl.json b/assets/translations/nl.json new file mode 100644 index 0000000..7c953da --- /dev/null +++ b/assets/translations/nl.json @@ -0,0 +1,330 @@ +{ + "invalidURLForSource": "Geen valide {} app URL", + "noReleaseFound": "Kan geen geschikte release vinden", + "noVersionFound": "Kan de versie niet bepalen", + "urlMatchesNoSource": "URL komt niet overeen met bekende bron", + "cantInstallOlderVersion": "Kan geen oudere versie van de app installeren", + "appIdMismatch": "Gedownloade pakket-ID komt niet overeen met de bestaande app-ID", + "functionNotImplemented": "Deze class heeft deze functie niet geïmplementeerd.", + "placeholder": "Plaatshouder", + "someErrors": "Er zijn enkele fouten opgetreden", + "unexpectedError": "Onverwachte fout", + "ok": "Ok", + "and": "en", + "githubPATLabel": "GitHub Personal Access Token\n(Verhoogt limiet aantal verzoeken)", + "includePrereleases": "Bevat prereleases", + "fallbackToOlderReleases": "Terugvallen op oudere releases", + "filterReleaseTitlesByRegEx": "Filter release-titels met reguliere expressies.", + "invalidRegEx": "Ongeldige reguliere expressie", + "noDescription": "Geen omschrijving", + "cancel": "Annuleer", + "continue": "Ga verder", + "requiredInBrackets": "(Verplicht)", + "dropdownNoOptsError": "FOUTMELDING: DROPDOWN MOET TENMINSTE ÉÉN OPT HEBBEN", + "colour": "Kleur", + "githubStarredRepos": "GitHub Starred Repos", + "uname": "Gebruikersnaam", + "wrongArgNum": "Onjuist aantal argumenten verstrekt.", + "xIsTrackOnly": "{} is Track-Only", + "source": "Bron", + "app": "App", + "appsFromSourceAreTrackOnly": "Apps van deze bron zijn 'Track-Only'.", + "youPickedTrackOnly": "Je hebt de 'Track-Only' optie geselecteerd.", + "trackOnlyAppDescription": "De app zal worden gevolgd voor updates, maar Obtainium zal niet in staat zijn om deze te downloaden of te installeren.", + "cancelled": "Geannuleerd", + "appAlreadyAdded": "App al toegevoegd", + "alreadyUpToDateQuestion": "Is de app al up-to-date?", + "addApp": "App toevoegen", + "appSourceURL": "App bron URL", + "error": "Foutmelding", + "add": "Toevoegen", + "searchSomeSourcesLabel": "Zoeken (Alleen sommige bronnen)", + "search": "Zoeken", + "additionalOptsFor": "Aanvullende opties voor {}", + "supportedSources": "Ondersteunde bronnen", + "trackOnlyInBrackets": "(Track-Only)", + "searchableInBrackets": "(Doorzoekbaar)", + "appsString": "Apps", + "noApps": "Geen Apps", + "noAppsForFilter": "Geen Apps voor filter", + "byX": "Door {}", + "percentProgress": "Vooruitgang: {}%", + "pleaseWait": "Even geduld", + "updateAvailable": "Update beschikbaar", + "estimateInBracketsShort": "(Ong.)", + "notInstalled": "Niet geinstalleerd", + "estimateInBrackets": "(Ongeveer)", + "selectAll": "Selecteer alles", + "deselectN": "Deselecteer {}", + "xWillBeRemovedButRemainInstalled": "{} zal worden verwijderd uit Obtainium, maar blijft geïnstalleerd op het apparaat.", + "removeSelectedAppsQuestion": "Geselecteerde apps verwijderen??", + "removeSelectedApps": "Geselecteerde apps verwijderen", + "updateX": "Update {}", + "installX": "Installeer {}", + "markXTrackOnlyAsUpdated": "Markeer {}\n(Track-Only)\nals up-to-date", + "changeX": "Verander {}", + "installUpdateApps": "Installeer/Update apps", + "installUpdateSelectedApps": "Installeer/Update geselecteerde apps", + "markXSelectedAppsAsUpdated": "{} geselecteerde apps markeren als up-to-date?", + "no": "Nee", + "yes": "Ja", + "markSelectedAppsUpdated": "Markeer geselecteerde aps als up-to-date", + "pinToTop": "Vastzetten aan de bovenkant", + "unpinFromTop": "Losmaken van de bovenkant", + "resetInstallStatusForSelectedAppsQuestion": "Installatiestatus resetten voor geselecteerde apps?", + "installStatusOfXWillBeResetExplanation": "De installatiestatus van alle geselecteerde apps zal worden gereset.\n\nDit kan helpen wanneer de versie van de app die in Obtainium wordt weergegeven onjuist is vanwege mislukte updates of andere problemen.", + "shareSelectedAppURLs": "Deel geselecteerde app URL's", + "resetInstallStatus": "Reset installatiestatus", + "more": "Meer", + "removeOutdatedFilter": "Verwijder out-of-date app filter", + "showOutdatedOnly": "Toon alleen out-of-date apps", + "filter": "Filter", + "filterActive": "Filter *", + "filterApps": "Filter apps", + "appName": "App naam", + "author": "Auteur", + "upToDateApps": "Up-to-date apps", + "nonInstalledApps": "Niet-geïnstalleerde apps", + "importExport": "Import/Export", + "settings": "Instellingen", + "exportedTo": "Geëxporteerd naar {}", + "obtainiumExport": "Obtainium export", + "invalidInput": "Ongeldige invoer", + "importedX": "Geïmporteerd {}", + "obtainiumImport": "Obtainium import", + "importFromURLList": "Importeer van URL-lijsten", + "searchQuery": "Zoekopdracht", + "appURLList": "App URL-lijst", + "line": "Lijn", + "searchX": "Zoek {}", + "noResults": "Geen resultaten gevonden", + "importX": "Import {}", + "importedAppsIdDisclaimer": "Geïmporteerde apps kunnen mogelijk onjuist worden weergegeven als \"Niet geïnstalleerd\".\nOm dit op te lossen, herinstalleer ze via Obtainium.\nDit zou geen invloed moeten hebben op app-gegevens.\n\nDit heeft alleen invloed op URL- en importmethoden van derden.", + "importErrors": "Import foutmeldingen", + "importedXOfYApps": "{} van {} apps geïmporteerd.", + "followingURLsHadErrors": "De volgende URL's bevatten fouten:", + "okay": "Ok", + "selectURL": "Selecteer URL", + "selectURLs": "Selecteer URL's", + "pick": "Kies", + "theme": "Thema", + "dark": "Donker", + "light": "Licht", + "followSystem": "Volg systeem", + "obtainium": "Obtainium", + "materialYou": "Material You", + "useBlackTheme": "Gebruik zwart thema", + "appSortBy": "App sorteren op", + "authorName": "Auteur/Naam", + "nameAuthor": "Naam/Auteur", + "asAdded": "Zoals toegevoegd", + "appSortOrder": "App sorteervolgorde", + "ascending": "Oplopend", + "descending": "Aflopend", + "bgUpdateCheckInterval": "Frequentie voor achtergrondupdatecontrole", + "neverManualOnly": "Nooit - Alleen handmatig", + "appearance": "Weergave", + "showWebInAppView": "Toon de bronwebpagina in app-weergave", + "pinUpdates": "Updates bovenaan in de apps-weergave vastpinnen", + "updates": "Updates", + "sourceSpecific": "Bron-specifiek", + "appSource": "App bron", + "noLogs": "Geen logs", + "appLogs": "App logs", + "close": "Sluiten", + "share": "Delen", + "appNotFound": "App niet gevonden", + "obtainiumExportHyphenatedLowercase": "obtainium-export", + "pickAnAPK": "Kies een APK", + "appHasMoreThanOnePackage": "{} heeft meer dan één package:", + "deviceSupportsXArch": "Jouw apparaat support de {} CPU-architectuur.", + "deviceSupportsFollowingArchs": "Je apparaat ondersteunt de volgende CPU-architecturen:", + "warning": "Waarschuwing", + "sourceIsXButPackageFromYPrompt": "De appbron is '{}' maar de release package komt van '{}'. Doorgaan?", + "updatesAvailable": "Updates beschikbaar", + "updatesAvailableNotifDescription": "Stelt de gebruiker op de hoogte dat er updates beschikbaar zijn voor één of meer apps die worden bijgehouden door Obtainium.", + "noNewUpdates": "Geen nieuwe updates.", + "xHasAnUpdate": "{} heeft een update.", + "appsUpdated": "Apps bijgewerkt", + "appsUpdatedNotifDescription": "Stelt de gebruiker op de hoogte dat updates voor één of meer apps in de achtergrond zijn toegepast.", + "xWasUpdatedToY": "{} is bijgewerkt naar {}.", + "errorCheckingUpdates": "Fout bij het controleren op updates", + "errorCheckingUpdatesNotifDescription": "Een melding die verschijnt wanneer het controleren op updates in de achtergrond mislukt", + "appsRemoved": "Apps verwijderd", + "appsRemovedNotifDescription": "Stelt de gebruiker op de hoogte dat één of meer apps zijn verwijderd vanwege fouten tijdens het laden ervan", + "xWasRemovedDueToErrorY": "{} is verwijderd vanwege deze foutmelding: {}", + "completeAppInstallation": "Complete app installatie", + "obtainiumMustBeOpenToInstallApps": "Obtainium moet geopend zijn om apps te installeren", + "completeAppInstallationNotifDescription": "Vraagt de gebruiker om terug te keren naar Obtainium om de installatie van een app af te ronden", + "checkingForUpdates": "Controleren op updates", + "checkingForUpdatesNotifDescription": "Tijdelijke melding die verschijnt tijdens het controleren op updates", + "pleaseAllowInstallPerm": "Sta Obtainium toe om apps te installeren", + "trackOnly": "Track-Only", + "errorWithHttpStatusCode": "Foutmelding {}", + "versionCorrectionDisabled": "Versiecorrectie uitgeschakeld (de plug-in lijkt niet te werken)", + "unknown": "Onbekend", + "none": "Geen", + "never": "Nooit", + "latestVersionX": "Laatste versie: {}", + "installedVersionX": "Geïnstalleerde versie: {}", + "lastUpdateCheckX": "Laatste updatecontrole: {}", + "remove": "Verwijderen", + "yesMarkUpdated": "Ja, markeer als bijgewerkt", + "fdroid": "F-Droid Official", + "appIdOrName": "App ID of naam", + "appId": "App ID", + "appWithIdOrNameNotFound": "Er werd geen app gevonden met dat ID of die naam", + "reposHaveMultipleApps": "Repositories kunnen meerdere apps bevatten", + "fdroidThirdPartyRepo": "F-Droid Third-Party Repo", + "steam": "Steam", + "steamMobile": "Steam Mobile", + "steamChat": "Steam Chat", + "install": "Installeren", + "markInstalled": "Als geïnstalleerd markere", + "update": "Update", + "markUpdated": "Markeren als bijgewerkt", + "additionalOptions": "Aanvullende opties", + "disableVersionDetection": "Versieherkenning uitschakelen", + "noVersionDetectionExplanation": "Deze optie moet alleen worden gebruikt voor apps waar versieherkenning niet correct werkt.", + "downloadingX": "Downloaden {}", + "downloadNotifDescription": "Stelt de gebruiker op de hoogte van de voortgang bij het downloaden van een app", + "noAPKFound": "Geen APK gevonden", + "noVersionDetection": "Geen versieherkenning", + "categorize": "Categoriseren", + "categories": "Categorieën", + "category": "Categorie", + "noCategory": "Geen categorie", + "noCategories": "Geen categorieën", + "deleteCategoriesQuestion": "Categorieën verwijderen?", + "categoryDeleteWarning": "Alle apps in verwijderde categorieën worden teruggezet naar 'ongecategoriseerd'.", + "addCategory": "Categorie toevoegen", + "label": "Label", + "language": "Taal", + "copiedToClipboard": "Gekopieerd naar klembord", + "storagePermissionDenied": "Toegang tot opslag geweigerd", + "selectedCategorizeWarning": "Dit zal eventuele bestaande categorie-instellingen voor de geselecteerde apps vervangen.", + "filterAPKsByRegEx": "Filter APK's op reguliere expressie", + "removeFromObtainium": "Verwijder van Obtainium", + "uninstallFromDevice": "Verwijder van apparaat", + "onlyWorksWithNonVersionDetectApps": "Werkt alleen voor apps waarbij versieherkenning is uitgeschakeld.", + "releaseDateAsVersion": "Gebruik de releasedatum als versie", + "releaseDateAsVersionExplanation": "Deze optie moet alleen worden gebruikt voor apps waar versieherkenning niet correct werkt, maar waar wel een releasedatum beschikbaar is.", + "changes": "Veranderingen", + "releaseDate": "Releasedatum", + "importFromURLsInFile": "Importeren vanaf URL's in een bestand (zoals OPML)", + "versionDetection": "Versieherkenning", + "standardVersionDetection": "Standaard versieherkenning", + "groupByCategory": "Groepeer op categorie", + "autoApkFilterByArch": "Poging om APK's te filteren op CPU-architectuur indien mogelijk", + "overrideSource": "Bron overschrijven", + "dontShowAgain": "Don't show this again", + "dontShowTrackOnlyWarnings": "Geen waarschuwingen voor 'Track-Only' weergeven", + "dontShowAPKOriginWarnings": "APK-herkomstwaarschuwingen niet weergeven", + "moveNonInstalledAppsToBottom": "Verplaats niet-geïnstalleerde apps naar de onderkant van de apps-weergave", + "gitlabPATLabel": "GitLab Personal Access Token\n(Maakt het mogelijk beter te zoeken naar APK's)", + "about": "Over", + "requiresCredentialsInSettings": "Dit vereist aanvullende referenties (in Instellingen)", + "checkOnStart": "Controleren op updates bij opstarten", + "tryInferAppIdFromCode": "Probeer de app-ID af te leiden uit de broncode", + "removeOnExternalUninstall": "Automatisch extern verwijderde apps verwijderen", + "pickHighestVersionCode": "Automatisch de APK met de hoogste versiecode selecteren", + "checkUpdateOnDetailPage": "Controleren op updates bij het openen van een app-detailpagina", + "disablePageTransitions": "Schakel overgangsanimaties tussen pagina's uit", + "reversePageTransitions": "Omgekeerde overgangsanimaties tussen pagina's", + "minStarCount": "Minimale Github Stars", + "addInfoBelow": "Voeg deze informatie hieronder toe.", + "addInfoInSettings": "Voeg deze informatie toe in de instellingen.", + "githubSourceNote": "Beperkingen van GitHub kunnen worden vermeden door het gebruik van een API-sleutel.", + "gitlabSourceNote": "GitLab APK-extractie werkt mogelijk niet zonder een API-sleutel.", + "sortByFileNamesNotLinks": "Sorteren op bestandsnamen in plaats van volledige links.", + "filterReleaseNotesByRegEx": "Filter release-opmerkingen met een reguliere expressie.", + "customLinkFilterRegex": "Aangepaste APK-linkfilter met een reguliere expressie (Standaard '.apk$').", + "appsPossiblyUpdated": "Poging tot app-updates", + "appsPossiblyUpdatedNotifDescription": "Stelt de gebruiker op de hoogte dat updates voor één of meer apps mogelijk in de achtergrond zijn toegepast", + "xWasPossiblyUpdatedToY": "{} mogelijk bijgewerkt naar {}.", + "enableBackgroundUpdates": "Achtergrondupdates inschakelen", + "backgroundUpdateReqsExplanation": "Achtergrondupdates zijn mogelijk niet voor alle apps mogelijk.", + "backgroundUpdateLimitsExplanation": "Het succes van een installatie in de achtergrond kan alleen worden bepaald wanneer Obtainium is geopend.", + "verifyLatestTag": "Verifieer de 'Laatste'-tag", + "intermediateLinkRegex": "Filter voor een 'tussenliggende' link om eerst te bezoeken", + "intermediateLinkNotFound": "Tussenliggende link niet gevonden", + "exemptFromBackgroundUpdates": "Vrijgesteld van achtergrondupdates (indien ingeschakeld)", + "bgUpdatesOnWiFiOnly": "Achtergrondupdates uitschakelen wanneer niet verbonden met WiFi", + "autoSelectHighestVersionCode": "Automatisch de APK met de hoogste versiecode selecteren", + "versionExtractionRegEx": "Reguliere expressie voor versie-extractie", + "matchGroupToUse": "Overeenkomende groep om te gebruiken voor de reguliere expressie voor versie-extractie", + "highlightTouchTargets": "Markeer minder voor de hand liggende aanraakdoelen.", + "pickExportDir": "Kies de exportmap", + "autoExportOnChanges": "Automatisch exporteren bij wijzigingen", + "filterVersionsByRegEx": "Filter versies met een reguliere expressie", + "trySelectingSuggestedVersionCode": "Probeer de voorgestelde versiecode APK te selecteren", + "dontSortReleasesList": "Volgorde van releases behouden vanuit de API", + "reverseSort": "Sortering omkeren", + "debugMenu": "Debug menu", + "bgTaskStarted": "Achtergrondtaak gestart - controleer de logs.", + "runBgCheckNow": "Voer nu een achtergrondupdatecontrole uit", + "versionExtractWholePage": "De reguliere expressie voor versie-extractie toepassen op de hele pagina", + "installing": "Installeren", + "skipUpdateNotifications": "Updatemeldingen overslaan", + "updatesAvailableNotifChannel": "Updates beschikbaar", + "appsUpdatedNotifChannel": "Apps bijgewerkt", + "appsPossiblyUpdatedNotifChannel": "Poging tot app-updates", + "errorCheckingUpdatesNotifChannel": "Foutcontrole bij het zoeken naar updates", + "appsRemovedNotifChannel": "Apps verwijderd", + "downloadingXNotifChannel": "{} downloaden", + "completeAppInstallationNotifChannel": "Voltooien van de app-installatie", + "checkingForUpdatesNotifChannel": "Controleren op updates", + "onlyCheckInstalledOrTrackOnlyApps": "Alleen geïnstalleerde en Track-Only apps controleren op updates", + "removeAppQuestion": { + "one": "App verwijderen?", + "other": "Apps verwijderen?" + }, + "tooManyRequestsTryAgainInMinutes": { + "one": "Te veel verzoeken (aantal beperkt) - probeer het opnieuw in {} minuut", + "other": "Te veel verzoeken (aantal beperkt) - probeer het opnieuw in {} minuten" + }, + "bgUpdateGotErrorRetryInMinutes": { + "one": "Achtergrondupdatecontrole heeft een {}, zal een hercontrole plannen over {} minuut", + "other": "Achtergrondupdatecontrole heeft een {}, zal een hercontrole plannen over {} minuten" + }, + "bgCheckFoundUpdatesWillNotifyIfNeeded": { + "one": "Achtergrondupdatecontrole heeft {} update gevonden - zal de gebruiker op de hoogte stellen indien nodig", + "other": "Achtergrondupdatecontrole heeft {} updates gevonden - zal de gebruiker op de hoogte stellen indien nodig" + }, + "apps": { + "one": "{} app", + "other": "{} apps" + }, + "url": { + "one": "{} URL", + "other": "{} URLs" + }, + "minute": { + "one": "{} minuut", + "other": "{} minuten" + }, + "hour": { + "one": "{} uur", + "other": "{} uur" + }, + "day": { + "one": "{} dag", + "other": "{} dagen" + }, + "clearedNLogsBeforeXAfterY": { + "one": "{n} logboekitem gewist (voor = {before}, na = {after})", + "other": "{n} logboekitems gewist (voor = {before}, na = {after})" + }, + "xAndNMoreUpdatesAvailable": { + "one": "{} en nog 1 app hebben updates.", + "other": "{} en {} meer apps hebben updates." + }, + "xAndNMoreUpdatesInstalled": { + "one": "{} en nog 1 app is bijgewerkt.", + "other": "{} en {} meer apps zijn bijgewerkt." + }, + "xAndNMoreUpdatesPossiblyInstalled": { + "one": "{} en nog 1 app zijn mogelijk bijgewerkt.", + "other": "{} en {} meer apps zijn mogelijk bijgwerkt." + } +} diff --git a/assets/translations/pl.json b/assets/translations/pl.json new file mode 100644 index 0000000..e36ef9c --- /dev/null +++ b/assets/translations/pl.json @@ -0,0 +1,356 @@ +{ + "invalidURLForSource": "Nieprawidłowy adres URL aplikacji {}", + "noReleaseFound": "Nie można znaleźć odpowiedniego wydania", + "noVersionFound": "Nie można określić wersji wydania", + "urlMatchesNoSource": "Adres URL nie pasuje do znanego źródła", + "cantInstallOlderVersion": "Nie można zainstalować starszej wersji aplikacji", + "appIdMismatch": "Pobrane ID pakietu nie pasuje do istniejącego ID aplikacji", + "functionNotImplemented": "Ta klasa nie zaimplementowała tej funkcji", + "placeholder": "Placeholder", + "someErrors": "Wystąpiły pewne błędy", + "unexpectedError": "Nieoczekiwany błąd", + "ok": "Okej", + "and": "i", + "githubPATLabel": "Osobisty token dostępu GitHub (zwiększa limit zapytań)", + "includePrereleases": "Uwzględnij wersje wstępne", + "fallbackToOlderReleases": "Powracaj do starszych wersji", + "filterReleaseTitlesByRegEx": "Filtruj tytuły wydań wg. wyrażeń regularnych", + "invalidRegEx": "Nieprawidłowe wyrażenie regularne", + "noDescription": "Brak opisu", + "cancel": "Anuluj", + "continue": "Kontynuuj", + "requiredInBrackets": "(Wymagane)", + "dropdownNoOptsError": "BŁĄD: LISTA ROZWIJANA MUSI MIEĆ CO NAJMNIEJ JEDNĄ OPCJĘ", + "colour": "Kolor", + "githubStarredRepos": "Repozytoria GitHub oznaczone gwiazdką", + "uname": "Nazwa użytkownika", + "wrongArgNum": "Nieprawidłowa liczba podanych argumentów", + "xIsTrackOnly": "{} jest tylko obserwowane", + "source": "Źródło", + "app": "Aplikacja", + "appsFromSourceAreTrackOnly": "Aplikacje z tego źródła są tylko obserwowane.", + "youPickedTrackOnly": "Wybrano opcję \"Tylko obserwuj\".", + "trackOnlyAppDescription": "Aplikacja będzie obserwowana pod kątem aktualizacji, ale Obtainium nie będzie w stanie jej pobrać ani zainstalować.", + "cancelled": "Anulowano", + "appAlreadyAdded": "Aplikacja już została dodana", + "alreadyUpToDateQuestion": "Aplikacja jest już aktualna?", + "addApp": "Dodaj apkę", + "appSourceURL": "Adres URL źródła aplikacji", + "error": "Błąd", + "add": "Dodaj", + "searchSomeSourcesLabel": "Szukaj (tylko niektóre źródła)", + "search": "Szukaj", + "additionalOptsFor": "Dodatkowe opcje dla {}", + "supportedSources": "Obsługiwane źródła", + "trackOnlyInBrackets": "(tylko obserwowane)", + "searchableInBrackets": "(wyszukiwalne)", + "appsString": "Aplikacje", + "noApps": "Brak aplikacji", + "noAppsForFilter": "Brak aplikacji dla filtra", + "byX": "Autorstwa {}", + "percentProgress": "Postęp: {}%", + "pleaseWait": "Proszę czekać", + "updateAvailable": "Dostępna aktualizacja", + "estimateInBracketsShort": "(Szac.)", + "notInstalled": "Nie zainstalowano", + "estimateInBrackets": "(Szacunkowo)", + "selectAll": "Zaznacz wszystkie", + "deselectN": "Odznacz {}", + "xWillBeRemovedButRemainInstalled": "{} zostanie usunięty z Obtainium, ale pozostanie zainstalowany na urządzeniu.", + "removeSelectedAppsQuestion": "Usunąć wybrane aplikacje?", + "removeSelectedApps": "Usuń wybrane aplikacje", + "updateX": "Zaktualizuj {}", + "installX": "Zainstaluj {}", + "markXTrackOnlyAsUpdated": "Oznacz {}\n(tylko obserwowana)\njako zaktualizowaną", + "changeX": "Zmień {}", + "installUpdateApps": "Instaluj/aktualizuj aplikacje", + "installUpdateSelectedApps": "Zainstaluj/zaktualizuj wybrane aplikacje", + "markXSelectedAppsAsUpdated": "Oznaczyć {} wybranych aplikacji jako zaktualizowane?", + "no": "Nie", + "yes": "Tak", + "markSelectedAppsUpdated": "Oznacz wybrane aplikacje jako zaktualizowane", + "pinToTop": "Przypnij", + "unpinFromTop": "Odepnij", + "resetInstallStatusForSelectedAppsQuestion": "Zresetować status instalacji dla wybranych aplikacji?", + "installStatusOfXWillBeResetExplanation": "Stan instalacji wybranych aplikacji zostanie zresetowany.\n\nMoże być to pomocne, gdy wersja aplikacji wyświetlana w Obtainium jest nieprawidłowa z powodu nieudanych aktualizacji lub innych problemów.", + "shareSelectedAppURLs": "Udostępnij wybrane adresy URL aplikacji", + "resetInstallStatus": "Zresetuj stan instalacji", + "more": "Więcej", + "removeOutdatedFilter": "Usuń filtr nieaktualnych aplikacji", + "showOutdatedOnly": "Pokaż tylko nieaktualne aplikacje", + "filter": "FIltr", + "filterActive": "Filtruj *", + "filterApps": "Filtruj aplikacje", + "appName": "Nazwa aplikacji", + "author": "Autor", + "upToDateApps": "Aktualne aplikacje", + "nonInstalledApps": "Niezainstalowane aplikacje", + "importExport": "Import/Eksport", + "settings": "Ustawienia", + "exportedTo": "Wyeksportowano do {}", + "obtainiumExport": "Eksportuj Obtainium", + "invalidInput": "Nieprawidłowe wprowadzenie", + "importedX": "Zaimportowano {}", + "obtainiumImport": "Import Obtainium", + "importFromURLList": "Importuj z listy adresów URL", + "searchQuery": "Wyszukiwane zapytanie", + "appURLList": "Lista adresów URL aplikacji", + "line": "Linia", + "searchX": "Przeszukaj {}", + "noResults": "Nie znaleziono wyników", + "importX": "Importuj {}", + "importedAppsIdDisclaimer": "Zaimportowane aplikacje mogą być wyświetlane jako niezainstalowane.\nAby to naprawić, przeinstaluj je za pomocą Obtainium.\nNie powinno to mieć wpływu na dane aplikacji.\n\nDotyczy tylko adresu URL i innych metod importu.", + "importErrors": "Błędy importowania", + "importedXOfYApps": "Zaimportowano {} z {} aplikacji.", + "followingURLsHadErrors": "Następujące adresy URL zawierały błędy:", + "okay": "Okej", + "selectURL": "Wybierz adres URL", + "selectURLs": "Wybierz adresy URL", + "pick": "Wybierz", + "theme": "Motyw", + "dark": "Ciemny", + "light": "Jasny", + "followSystem": "Zgodny z systemem", + "obtainium": "Obtainium", + "materialYou": "Material You", + "useBlackTheme": "Użyj czarnego motywu", + "appSortBy": "Sortuj aplikacje według", + "authorName": "Autor/Nazwa", + "nameAuthor": "Nazwa/Autor", + "asAdded": "Dodania", + "appSortOrder": "Kolejność sortowania aplikacji", + "ascending": "Rosnąco", + "descending": "Malejąco", + "bgUpdateCheckInterval": "Częstotliwość sprawdzania aktualizacji w tle", + "neverManualOnly": "Nigdy - tylko ręcznie", + "appearance": "Wygląd", + "showWebInAppView": "Pokaż stronę źródłową w widoku aplikacji", + "pinUpdates": "Przypnij aktualizacje na górze widoku aplikacji", + "updates": "Aktualizacje", + "sourceSpecific": "Zależnie od źródła", + "appSource": "Źródło aplikacji", + "noLogs": "Brak logów", + "appLogs": "Logi aplikacji", + "close": "Zamknij", + "share": "Udostępnij", + "appNotFound": "Nie znaleziono aplikacji", + "obtainiumExportHyphenatedLowercase": "obtainium-eksport", + "pickAnAPK": "Wybierz plik APK", + "appHasMoreThanOnePackage": "{} ma więcej niż jeden pakiet:", + "deviceSupportsXArch": "Urządzenie obsługuje architekturę procesora {}.", + "deviceSupportsFollowingArchs": "Urządzenie obsługuje następujące architektury procesora:", + "warning": "Uwaga", + "sourceIsXButPackageFromYPrompt": "Źródłem aplikacji jest '{}', ale pakiet wydania pochodzi z '{}'. Kontynuować?", + "updatesAvailable": "Dostępne aktualizacje", + "updatesAvailableNotifDescription": "Informuje o dostępności aktualizacji dla jednej lub więcej aplikacji obserwowanych przez Obtainium", + "noNewUpdates": "Brak nowych aktualizacji.", + "xHasAnUpdate": "{} ma aktualizację.", + "appsUpdated": "Zaktualizowano aplikacje", + "appsUpdatedNotifDescription": "Informuje, gdy co najmniej jedna aplikacja została zaktualizowana w tle", + "xWasUpdatedToY": "{} zaktualizowano do {}.", + "errorCheckingUpdates": "Błąd sprawdzania aktualizacji", + "errorCheckingUpdatesNotifDescription": "Jest wyświetlane, gdy sprawdzanie aktualizacji w tle nie powiedzie się", + "appsRemoved": "Usunięte aplikacje", + "appsRemovedNotifDescription": "Informuje, gdy co najmniej jedna aplikacja została usunięta z powodu błędów podczas wczytywania", + "xWasRemovedDueToErrorY": "Usunięto {} z powodu błędu: {}", + "completeAppInstallation": "Ukończenie instalacji aplikacji", + "obtainiumMustBeOpenToInstallApps": "Aby zainstalować aplikacje, Obtainium musi być otwarte", + "completeAppInstallationNotifDescription": "Informuje o możliwości powrotu do Obtainium w celu dokończenia instalacji aplikacji", + "checkingForUpdates": "Sprawdzanie aktualizacji", + "checkingForUpdatesNotifDescription": "Tymczasowe powiadomienie pojawiające się podczas sprawdzania aktualizacji", + "pleaseAllowInstallPerm": "Pozwól Obtainium instalować aplikacje", + "trackOnly": "Tylko obserwuj", + "errorWithHttpStatusCode": "Błąd {}", + "versionCorrectionDisabled": "Korekta wersji wyłączona (wtyczka wydaje się nie działać)", + "unknown": "Nieznane", + "none": "Brak", + "never": "Nigdy", + "latestVersionX": "Najnowsza wersja: {}", + "installedVersionX": "Zainstalowana wersja: {}", + "lastUpdateCheckX": "Ostatnio sprawdzono: {}", + "remove": "Usuń", + "yesMarkUpdated": "Tak, oznacz jako zaktualizowane", + "fdroid": "Oficjalny F-Droid", + "appIdOrName": "ID aplikacji lub nazwa", + "appId": "ID aplikacji", + "appWithIdOrNameNotFound": "Nie znaleziono aplikacji o tym identyfikatorze lub nazwie", + "reposHaveMultipleApps": "Repozytoria mogą zawierać wiele aplikacji", + "fdroidThirdPartyRepo": "Zewnętrzne repo F-Droid", + "steam": "Steam", + "steamMobile": "Mobilny Steam", + "steamChat": "Steam Chat", + "install": "Instaluj", + "markInstalled": "Oznacz jako zainstalowane", + "update": "Zaktualizuj", + "markUpdated": "Oznacz jako zaktualizowane", + "additionalOptions": "Dodatkowe opcje", + "disableVersionDetection": "Wyłącz wykrywanie wersji", + "noVersionDetectionExplanation": "Opcja ta powinna być używana tylko w przypadku aplikacji, w których wykrywanie wersji nie działa poprawnie.", + "downloadingX": "Pobieranie {}", + "downloadNotifDescription": "Informuje o postępach w pobieraniu aplikacji", + "noAPKFound": "Nie znaleziono pakietu APK", + "noVersionDetection": "Bez wykrywania wersji", + "categorize": "Kategoryzuj", + "categories": "Kategorie", + "category": "Kategoria", + "noCategory": "Bez kategorii", + "noCategories": "Brak kategorii", + "deleteCategoriesQuestion": "Usunąć kategorie?", + "categoryDeleteWarning": "Wszystkie aplikacje w usuniętych kategoriach zostaną ustawione jako nieskategoryzowane.", + "addCategory": "Dodaj kategorię", + "label": "Etykieta", + "language": "Język", + "copiedToClipboard": "Skopiowano do schowka", + "storagePermissionDenied": "Odmówiono zezwolenia dostępu do pamięci", + "selectedCategorizeWarning": "Spowoduje to zastąpienie wszystkich istniejących ustawień kategorii dla wybranych aplikacji.", + "filterAPKsByRegEx": "Filtruj pliki APK według wyrażeń regularnych", + "removeFromObtainium": "Usuń z Obtainium", + "uninstallFromDevice": "Odinstaluj z urządzenia", + "onlyWorksWithNonVersionDetectApps": "Działa tylko w przypadku aplikacji z wyłączonym wykrywaniem wersji.", + "releaseDateAsVersion": "Użyj daty wydania jako wersji", + "releaseDateAsVersionExplanation": "Opcja ta powinna być używana tylko w przypadku aplikacji, w których wykrywanie wersji nie działa poprawnie, ale dostępna jest data wydania.", + "changes": "Zmiany", + "releaseDate": "Data wydania", + "importFromURLsInFile": "Importuj z adresów URL w pliku (typu OPML)", + "versionDetection": "Wykrywanie wersji", + "standardVersionDetection": "Standardowe wykrywanie wersji", + "groupByCategory": "Grupuj według kategorii", + "autoApkFilterByArch": "Spróbuj filtrować pliki APK według architektury procesora, jeśli to możliwe", + "overrideSource": "Nadpisz źródło", + "dontShowAgain": "Nie pokazuj tego ponownie", + "dontShowTrackOnlyWarnings": "Nie pokazuj ostrzeżeń \"Tylko obserwowana\"", + "dontShowAPKOriginWarnings": "Nie pokazuj ostrzeżeń o pochodzeniu APK", + "moveNonInstalledAppsToBottom": "Przenieś niezainstalowane aplikacje na dół widoku aplikacji", + "gitlabPATLabel": "Osobisty token dostępu GitLab\n(Umożliwia wyszukiwanie i lepsze wykrywanie APK)", + "about": "Więcej informacji", + "requiresCredentialsInSettings": "Wymaga to dodatkowych poświadczeń (w Ustawieniach)", + "checkOnStart": "Sprawdź aktualizacje przy uruchomieniu", + "tryInferAppIdFromCode": "Spróbuj wywnioskować identyfikator aplikacji z kodu źródłowego", + "removeOnExternalUninstall": "Automatyczne usuń odinstalowane zewnętrznie aplikacje", + "pickHighestVersionCode": "Automatycznie wybierz najwyższy kod wersji APK", + "checkUpdateOnDetailPage": "Sprawdzaj aktualizacje podczas otwierania strony szczegółów aplikacji", + "disablePageTransitions": "Wyłącz animacje przejścia między stronami", + "reversePageTransitions": "Odwróć animacje przejścia pomiędzy stronami", + "minStarCount": "Minimalna ilość gwiazdek", + "addInfoBelow": "Dodaj tę informację poniżej.", + "addInfoInSettings": "Dodaj tę informację w Ustawieniach.", + "githubSourceNote": "Limit żądań GitHub można ominąć za pomocą klucza API.", + "gitlabSourceNote": "Pozyskiwanie pliku APK z GitLab może nie działać bez klucza API.", + "sortByFileNamesNotLinks": "Sortuj wg nazw plików zamiast pełnych linków", + "filterReleaseNotesByRegEx": "Filtruj informacje o wersji według wyrażenia regularnego", + "customLinkFilterRegex": "Filtruj linki APK według wyrażenia regularnego (domyślnie \".apk$\")", + "appsPossiblyUpdated": "Aplikacje mogły zostać zaktualizowane", + "appsPossiblyUpdatedNotifDescription": "Powiadamia, gdy co najmniej jedna aktualizacja aplikacji została potencjalnie zastosowana w tle", + "xWasPossiblyUpdatedToY": "{} być może zaktualizowano do {}.", + "enableBackgroundUpdates": "Włącz aktualizacje w tle", + "backgroundUpdateReqsExplanation": "Aktualizacje w tle mogą nie być możliwe dla wszystkich aplikacji.", + "backgroundUpdateLimitsExplanation": "Powodzenie instalacji w tle można określić dopiero po otwarciu Obtainium.", + "verifyLatestTag": "Zweryfikuj najnowszy tag", + "intermediateLinkRegex": "Filtr linków \"pośrednich\" do odwiedzenia w pierwszej kolejności", + "intermediateLinkNotFound": "Nie znaleziono linku pośredniego", + "exemptFromBackgroundUpdates": "Wyklucz z uaktualnień w tle (jeśli są włączone)", + "bgUpdatesOnWiFiOnly": "Wyłącz aktualizacje w tle, gdy nie ma połączenia z Wi-Fi", + "autoSelectHighestVersionCode": "Automatycznie wybierz najwyższy kod wersji APK", + "versionExtractionRegEx": "Wyrażenie regularne wyodrębniające wersję", + "matchGroupToUse": "Dopasuj grupę do użycia dla wyrażenia regularnego wyodrębniania wersji", + "highlightTouchTargets": "Wyróżnij mniej oczywiste elementy dotykowe", + "pickExportDir": "Wybierz katalog eksportu", + "autoExportOnChanges": "Automatyczny eksport po wprowadzeniu zmian", + "filterVersionsByRegEx": "Filtruj wersje według wyrażenia regularnego", + "trySelectingSuggestedVersionCode": "Spróbuj wybierać sugerowany kod wersji APK", + "dontSortReleasesList": "Utrzymaj kolejność wydań z interfejsu API", + "reverseSort": "Odwrotne sortowanie", + "debugMenu": "Menu debugowania", + "bgTaskStarted": "Uruchomiono zadanie w tle - sprawdź logi.", + "runBgCheckNow": "Wymuś sprawdzenie aktualizacji w tle", + "versionExtractWholePage": "Zastosuj wyrażenie regularne wyodrębniania wersji dla całej strony", + "installing": "Instalacja", + "skipUpdateNotifications": "Pomiń powiadomienia o aktualizacjach", + "updatesAvailableNotifChannel": "Dostępne aktualizacje aplikacji", + "appsUpdatedNotifChannel": "Zaktualizowane aplikacje", + "appsPossiblyUpdatedNotifChannel": "Informuj o próbach aktualizacji", + "errorCheckingUpdatesNotifChannel": "Błędy sprawdzania aktualizacji", + "appsRemovedNotifChannel": "Usunięte aplikacje", + "downloadingXNotifChannel": "Pobieranie aplikacji", + "completeAppInstallationNotifChannel": "Ukończenie instalacji aplikacji", + "checkingForUpdatesNotifChannel": "Sprawdzanie dostępności aktualizacji", + "onlyCheckInstalledOrTrackOnlyApps": "Sprawdzaj tylko zainstalowane i obserwowane aplikacje pod kątem aktualizacji", + "removeAppQuestion": { + "one": "Usunąć aplikację?", + "few": "Usunąć aplikacje?", + "many": "Usunąć aplikacje?", + "other": "Usunąć aplikacje?" + }, + "tooManyRequestsTryAgainInMinutes": { + "one": "Zbyt wiele żądań (ograniczona częstotliwość) - spróbuj ponownie za {} minutę", + "few": "Zbyt wiele żądań (ograniczona częstotliwość) - spróbuj ponownie za {} minuty", + "many": "Zbyt wiele żądań (ograniczona częstotliwość) - spróbuj ponownie za {} minut", + "other": "Zbyt wiele żądań (ograniczona częstotliwość) - spróbuj ponownie za {} minuty" + }, + "bgUpdateGotErrorRetryInMinutes": { + "one": "Sprawdzanie aktualizacji w tle napotkało {}, zaplanuje ponowne sprawdzenie za {} minutę", + "few": "Sprawdzanie aktualizacji w tle napotkało {}, zaplanuje ponowne sprawdzenie za {} minuty", + "many": "Sprawdzanie aktualizacji w tle napotkało {}, zaplanuje ponowne sprawdzenie za {} minut", + "other": "Sprawdzanie aktualizacji w tle napotkało {}, zaplanuje ponowne sprawdzenie za {} minuty" + }, + "bgCheckFoundUpdatesWillNotifyIfNeeded": { + "one": "W tle znaleziono {} aktualizację - w razie potrzeby użytkownik zostanie o tym powiadomiony", + "few": "W tle znaleziono {} aktualizacje - w razie potrzeby użytkownik zostanie o tym powiadomiony", + "many": "W tle znaleziono {} aktualizacji - w razie potrzeby użytkownik zostanie o tym powiadomiony", + "other": "W tle znaleziono {} aktualizacje - w razie potrzeby użytkownik zostanie o tym powiadomiony" + }, + "apps": { + "one": "{} apkę", + "few": "{} apki", + "many": "{} apek", + "other": "{} apki" + }, + "url": { + "one": "{} adres URL", + "few": "{} adresy URL", + "many": "{} adresów URL", + "other": "{} adresy URL" + }, + "minute": { + "one": "{} minuta", + "few": "{} minuty", + "many": "{} minut", + "other": "{} minuty" + }, + "hour": { + "one": "{} godzina", + "few": "{} godziny", + "many": "{} godzin", + "other": "{} godziny" + }, + "day": { + "one": "{} dzień", + "few": "{} dni", + "many": "{} dni", + "other": "{} dni" + }, + "clearedNLogsBeforeXAfterY": { + "one": "Wyczyszczono {n} log (przed = {before}, po = {after})", + "few": "Wyczyszczono {n} logi (przed = {before}, po = {after})", + "many": "Wyczyszczono {n} logów (przed = {before}, po = {after})", + "other": "Wyczyszczono {n} logi (przed = {before}, po = {after})" + }, + "xAndNMoreUpdatesAvailable": { + "one": "{} i 1 inna apka mają aktualizacje.", + "few": "{} i {} inne apki mają aktualizacje.", + "many": "{} i {} innych apek ma aktualizacje.", + "other": "{} i {} inne apki mają aktualizacje." + }, + "xAndNMoreUpdatesInstalled": { + "one": "Zaktualizowano {} i 1 inną apkę.", + "few": "{} i {} inne apki zostały zaktualizowane.", + "many": "{} i {} innych apek zostało zaktualizowanych.", + "other": "{} i {} inne apki zostały zaktualizowane." + }, + "xAndNMoreUpdatesPossiblyInstalled": { + "one": "{} i 1 inna apka mogły zostać zaktualizowane.", + "few": "{} i {} inne apki mogły zostać zaktualizowane.", + "many": "{} i {} innych apek mogło zostać zaktualizowanych.", + "other": "{} i {} inne apki mogły zostać zaktualizowane." + } +} \ No newline at end of file diff --git a/assets/translations/pt.json b/assets/translations/pt.json new file mode 100644 index 0000000..9611468 --- /dev/null +++ b/assets/translations/pt.json @@ -0,0 +1,330 @@ +{ + "invalidURLForSource": "URL {} inválida", + "noReleaseFound": "Não foi possivel encontrar uma versão adequada", + "noVersionFound": "Não foi possivel encontrar uma versão lançada", + "urlMatchesNoSource": "URL não corresponde a uma fonte conhecida", + "cantInstallOlderVersion": "Não pode instalar uma versão anterior de um App", + "appIdMismatch": "ID do pacote baixado não é igual ao ID do App instalado", + "functionNotImplemented": "Esta classe não implementou essa função", + "placeholder": "Espaço Reservado", + "someErrors": "Alguns Erros Ocorreram", + "unexpectedError": "Erro Inesperado", + "ok": "Ok", + "and": "e", + "githubPATLabel": "Token de Acceso Pessoal do GitHub (Reduz tempos de espera)", + "includePrereleases": "Incluir pré-lançamentos", + "fallbackToOlderReleases": "Retornar para versões anteriores", + "filterReleaseTitlesByRegEx": "Filtrar Titulos de Versões por Expressão Regular", + "invalidRegEx": "Expressão Regular Inválida", + "noDescription": "Sem descrição", + "cancel": "Cancelar", + "continue": "Continuar", + "requiredInBrackets": "(Necessário)", + "dropdownNoOptsError": "ERRO: O DROPDOWN DEVE TER PELO MENOS UMA OPÇÃO", + "colour": "Cor", + "githubStarredRepos": "Favoritados no GitHub", + "uname": "Nome de usuário", + "wrongArgNum": "Número de argumentos errado", + "xIsTrackOnly": "{} é 'Apenas Seguir'", + "source": "Fonte", + "app": "App", + "appsFromSourceAreTrackOnly": "Os apps desta fonte são 'Apenas Seguir'.", + "youPickedTrackOnly": "Você selecionou a opção 'Apenas Seguir'.", + "trackOnlyAppDescription": "Esse App vai ser seguido por atualizações, mais o Obtainium não poderá baixa-lo ou instala-lo.", + "cancelled": "Cancelado", + "appAlreadyAdded": "App já adicionado", + "alreadyUpToDateQuestion": "App já atualizado?", + "addApp": "Adicionar App", + "appSourceURL": "URL de origem do App", + "error": "Erro", + "add": "Adicionar", + "searchSomeSourcesLabel": "Procurar (Apenas Algumas Fontes)", + "search": "Procurar", + "additionalOptsFor": "Opções Adicionais para {}", + "supportedSources": "Fontes Compatíveis", + "trackOnlyInBrackets": "(Apenas Seguir)", + "searchableInBrackets": "(Pesquisável)", + "appsString": "Apps", + "noApps": "Sem Apps", + "noAppsForFilter": "Sem Apps para Filtrar", + "byX": "Por {}", + "percentProgress": "Progresso: {}%", + "pleaseWait": "Por Favor Espere", + "updateAvailable": "Atualização Disponível", + "estimateInBracketsShort": "(Aprox.)", + "notInstalled": "Não Instalado", + "estimateInBrackets": "(Aproximado)", + "selectAll": "Selecionar All", + "deselectN": "Deselecionar {}", + "xWillBeRemovedButRemainInstalled": "{} sera removido do Obtainium mais permanecerá instalado no dispositivo.", + "removeSelectedAppsQuestion": "Remover Apps Selecionados?", + "removeSelectedApps": "Remover Apps Selecionados", + "updateX": "Atualizar {}", + "installX": "Instalar {}", + "markXTrackOnlyAsUpdated": "Marcar {}\n(Apenas Seguir)\ncomo Atualizado", + "changeX": "Mudar {}", + "installUpdateApps": "Instalar/Atualizar Apps", + "installUpdateSelectedApps": "Instalar/Atualizar Apps Selecionados", + "markXSelectedAppsAsUpdated": "Marcar {} Apps Delecionados como Atualizados?", + "no": "Não", + "yes": "Sim", + "markSelectedAppsUpdated": "Marcar Apps Selecionados como Atualizados", + "pinToTop": "Fixar no topo", + "unpinFromTop": "Desafixar do topo", + "resetInstallStatusForSelectedAppsQuestion": "Reiniciar Status de Instalação para Apps Seleciondos?", + "installStatusOfXWillBeResetExplanation": "O status de instalação de qualquer app selecionado sera reiniciado.\n\nIsso pode ajudar quando uma versão de um App mostrada no Obtainium é incorreta devido a falhas ao atualizar ou outros problemas.", + "shareSelectedAppURLs": "Compartilhar URLs de Apps Selecionados", + "resetInstallStatus": "Reiniciar Status de Instalação", + "more": "Mais", + "removeOutdatedFilter": "Remover Filtro de Apps Desatualizados", + "showOutdatedOnly": "Mostrar Apenas Apps Desatualizados", + "filter": "Filtro", + "filterActive": "Filtro *", + "filterApps": "Filtrar Apps", + "appName": "Nome do App", + "author": "Autor", + "upToDateApps": "Apps Atualizados", + "nonInstalledApps": "Apps Não Instalados", + "importExport": "Importar/Exportar", + "settings": "Configurações", + "exportedTo": "Exportado para {}", + "obtainiumExport": "Exportar Obtainium", + "invalidInput": "Input Inválido", + "importedX": "Importado {}", + "obtainiumImport": "Importar Obtainium", + "importFromURLList": "Importar de Lista de URLs", + "searchQuery": "Pesquisa", + "appURLList": "Lista de URLs de Apps", + "line": "Linha", + "searchX": "Pesquisa {}", + "noResults": "Nenhum resultado encontrado", + "importX": "Importar {}", + "importedAppsIdDisclaimer": "Apps Importados podem ser mostrados incorretamente como \"Não Instalado\".\nPara consertar, reinstale-os usando o Obtainium.\nIsso não deve afetar dados do App.\n\nAfeta apenas métodos de importação de URL e de terceiros.", + "importErrors": "Erros de Importação", + "importedXOfYApps": "{} de {} Apps importados.", + "followingURLsHadErrors": "As seguintes URLs apresentaram erros:", + "okay": "Ok", + "selectURL": "Selecionar URL", + "selectURLs": "Selecionar URLs", + "pick": "Escolher", + "theme": "Tema", + "dark": "Escuro", + "light": "Claro", + "followSystem": "Seguir o Sistema", + "obtainium": "Obtainium", + "materialYou": "Material You", + "useBlackTheme": "Usar tema preto completamente escuro", + "appSortBy": "Classificar App por", + "authorName": "Autor/Nome", + "nameAuthor": "Nome/Autor", + "asAdded": "Como Adicionado", + "appSortOrder": "Ordem de classificação de Apps", + "ascending": "Ascendente", + "descending": "Descendente", + "bgUpdateCheckInterval": "Intervalo de verificação de atualizações em segundo plano", + "neverManualOnly": "Nunca - Apenas Manual", + "appearance": "Aparência", + "showWebInAppView": "Mostrar páginas da internet em App view", + "pinUpdates": "Fixar atualizações no topo da visão de Apps", + "updates": "Atualizações", + "sourceSpecific": "Específico a fonte", + "appSource": "Fonte do App", + "noLogs": "Sem Logs", + "appLogs": "Logs do App", + "close": "Fechar", + "share": "Compartilhar", + "appNotFound": "App não encontrado", + "obtainiumExportHyphenatedLowercase": "obtainium-export", + "pickAnAPK": "Selecionar um APK", + "appHasMoreThanOnePackage": "{} tem mais de um pacote:", + "deviceSupportsXArch": "Seu dispositivo suporta a arquitetura de CPU {}.", + "deviceSupportsFollowingArchs": "Seu dispositivo suporta as seguintes arquiteturas de CPU:", + "warning": "Aviso", + "sourceIsXButPackageFromYPrompt": "A Fonte do App é '{}' mais o pacote lançado vem de '{}'. Continuar?", + "updatesAvailable": "Atualizações Disponíveis", + "updatesAvailableNotifDescription": "Notifica o usuário quando atualizações estão disponíveis um ou mais Apps seguidos pelo Obtainium", + "noNewUpdates": "Sem novas atualizações.", + "xHasAnUpdate": "{} tem uma atualização.", + "appsUpdated": "Apps Atualizados", + "appsUpdatedNotifDescription": "Notifica o usuário quando atualizações para um ou mais Apps foram aplicadas em segundo plano", + "xWasUpdatedToY": "{} foi atualizado para {}.", + "errorCheckingUpdates": "Erro ao Procurar por Atualizações", + "errorCheckingUpdatesNotifDescription": "Uma notificação que mostra quando a checagem por atualizações em segundo plano falha", + "appsRemoved": "Apps Removidos", + "appsRemovedNotifDescription": "Notifica o usuário quando um ou mais Apps foram removidos devido a erros ao carregá-los", + "xWasRemovedDueToErrorY": "{} foi removido devido a este erro: {}", + "completeAppInstallation": "Instalação completa do App", + "obtainiumMustBeOpenToInstallApps": "Obtainium deve estar aberto para instalar Apps", + "completeAppInstallationNotifDescription": "Pede ao usuário que retorne ao Obtainium para finalizar a instalação de um App", + "checkingForUpdates": "Checando por Atualizações", + "checkingForUpdatesNotifDescription": "Notificação transiente que aparece quando checando por atualizações", + "pleaseAllowInstallPerm": "Por favor, permita o Obtainium instalar Apps", + "trackOnly": "Apenas Seguir", + "errorWithHttpStatusCode": "Erro {}", + "versionCorrectionDisabled": "Correção de versão desativada (plugin parece não funcionar)", + "unknown": "Desconhecido", + "none": "Nenhum", + "never": "Nunca", + "latestVersionX": "Última versão: {}", + "installedVersionX": "Versão Instalada: {}", + "lastUpdateCheckX": "Última Checagem por Atualização: {}", + "remove": "Remover", + "yesMarkUpdated": "Sim, Marcar como Atualizado", + "fdroid": "F-Droid Official", + "appIdOrName": "ID do App ou Nome", + "appId": "ID do App", + "appWithIdOrNameNotFound": "Nenhum App foi encontrado com esse ID ou nome", + "reposHaveMultipleApps": "Repositórios podem conter multiplos Apps", + "fdroidThirdPartyRepo": "Repositórios de terceiros F-Droid", + "steam": "Steam", + "steamMobile": "Steam Mobile", + "steamChat": "Steam Chat", + "install": "Instalar", + "markInstalled": "Marcar Instalado", + "update": "Atualizar", + "markUpdated": "Marcar Atualizado", + "additionalOptions": "Opções Adicionais", + "disableVersionDetection": "Desativar Detecção de Versão", + "noVersionDetectionExplanation": "Essa opção deve apenas ser usada por Apps onde detecção de versão não funciona corretamente.", + "downloadingX": "Baixando {}", + "downloadNotifDescription": "Notifica o usuário do progresso ao baixar um App", + "noAPKFound": "APK não encontrado", + "noVersionDetection": "Sem Detecção de versão", + "categorize": "Categorizar", + "categories": "Categorias", + "category": "Categoria", + "noCategory": "Sem Categoria", + "noCategories": "Sem Categoria", + "deleteCategoriesQuestion": "Deletar Categorias?", + "categoryDeleteWarning": "Todos os Apps em categorias removidas serão descategorizados.", + "addCategory": "Adicionar Categoria", + "label": "Etiqueta", + "language": "Linguagem", + "copiedToClipboard": "Copiado para a área de transferência", + "storagePermissionDenied": "Permição ao armazenamento negada", + "selectedCategorizeWarning": "Isso vai substituir qualquer confirução de categoria para os Apps selecionados.", + "filterAPKsByRegEx": "Filtrar APKs por Expressão Regular", + "removeFromObtainium": "Remover do Obtainium", + "uninstallFromDevice": "Desinstalar do dispositivo", + "onlyWorksWithNonVersionDetectApps": "Apenas funciona para Apps com detecção de versão desativada.", + "releaseDateAsVersion": "Usar Data de Lançamento como Versão", + "releaseDateAsVersionExplanation": "Esta opção só deve ser usada para aplicativos onde a detecção de versão não funciona corretamente, mas há uma data de lançamento disponível.", + "changes": "Mudanças", + "releaseDate": "Data de Lançamento", + "importFromURLsInFile": "Importar de URLs em Arquivo (como OPML)", + "versionDetection": "Detecção de Versão", + "standardVersionDetection": "Detecção de versão padrão", + "groupByCategory": "Agroupar por Categoria", + "autoApkFilterByArch": "Tente filtrar APKs por arquitetura de CPU, se possível", + "overrideSource": "Substituir Fonte", + "dontShowAgain": "Não mostrar isso novamente", + "dontShowTrackOnlyWarnings": "Não mostrar avisos 'Apenas Seguir'", + "dontShowAPKOriginWarnings": "Não mostrar avisos de origem da APK", + "moveNonInstalledAppsToBottom": "Mover Apps não instalados para o fundo da visão de Apps", + "gitlabPATLabel": "Token de Acceso Pessoal do Gitlab\n(Ativa Pesquisa e Melhor Descoberta de APKs)", + "about": "Sobre", + "requiresCredentialsInSettings": "Isso requer credenciais adicionais (em Configurações)", + "checkOnStart": "Checar por atualizações ao iniciar ", + "tryInferAppIdFromCode": "Tente inferir o ID do App pelo código fonte", + "removeOnExternalUninstall": "Remover automaticamente Apps desinstalados externamente", + "pickHighestVersionCode": "Auto-selecionar o maior numero de versão do APK", + "checkUpdateOnDetailPage": "Checar por atualizações ao abrir a pagina de detalhes de um App", + "disablePageTransitions": "Desativar animações de transição de pagina", + "reversePageTransitions": "Reverter animações de transição de pagina", + "minStarCount": "Contagem Minima de Estrelas", + "addInfoBelow": "Adicionar essa informação abaixo.", + "addInfoInSettings": "Adicionar essa informação nas configurações.", + "githubSourceNote": "A limitação de taxa do GitHub pode ser evitada usando uma chave de API.", + "gitlabSourceNote": "A extração de APK do GitLab pode não funcionar sem uma chave de API.", + "sortByFileNamesNotLinks": "Classifique por nomes de arquivos em vez de links completos", + "filterReleaseNotesByRegEx": "Filtrar Notas de Lançamento por Expressão Regular", + "customLinkFilterRegex": "Filtro de Link Personalizado por Expressão Regular (Padrão '.apk$')", + "appsPossiblyUpdated": "Tentativas de atualização de Apps", + "appsPossiblyUpdatedNotifDescription": "Notifica o usuário de que atualizações de um ou mais Apps foram potencialmente aplicadas em segundo plano", + "xWasPossiblyUpdatedToY": "{} pode ter sido atualizado para {}.", + "enableBackgroundUpdates": "Ativar atualizações em segundo plano", + "backgroundUpdateReqsExplanation": "Atualizações em segundo plano podem não ser possíveis para todos os Apps.", + "backgroundUpdateLimitsExplanation": "O sucesso de uma instalação em segundo plano só pode ser determinado quando o Obtainium é aberto.", + "verifyLatestTag": "Verifique a 'ultima' etiqueta", + "intermediateLinkRegex": "Filtre por um Link 'Intermediário' para Visitar Primeiro", + "intermediateLinkNotFound": "Link intermediário não encontrado", + "exemptFromBackgroundUpdates": "Isento de atualizações em segundo plano (se ativadas)", + "bgUpdatesOnWiFiOnly": "Desative atualizações em segundo plano quando não estiver em WiFi", + "autoSelectHighestVersionCode": "Auto-selecionar o maior codigo de versão", + "versionExtractionRegEx": "RegEx para Extração de Versão", + "matchGroupToUse": "Grupo de Seleção para Usar", + "highlightTouchTargets": "Destaque areas de toque menos óbvias", + "pickExportDir": "Escolher Diretorio de Exportação", + "autoExportOnChanges": "Auto-exportar em mudanças", + "filterVersionsByRegEx": "Filtrar Versões por Expressão Regular", + "trySelectingSuggestedVersionCode": "Tente selecionar a versão sugerida", + "dontSortReleasesList": "Reter a ordem de lançamento da API", + "reverseSort": "Ordenação reversa", + "debugMenu": "Menu Debug", + "bgTaskStarted": "Tarefa em segundo plano iniciada - verifique os logs.", + "runBgCheckNow": "Execute a verificação de atualização em segundo plano agora", + "versionExtractWholePage": "Aplicar Regex de Extração de Versão à Página Inteira", + "installing": "Instalando", + "skipUpdateNotifications": "Pular notificações de update", + "updatesAvailableNotifChannel": "Atualizações Disponíveis", + "appsUpdatedNotifChannel": "Apps Atualizados", + "appsPossiblyUpdatedNotifChannel": "Tentativas de atualização de Apps", + "errorCheckingUpdatesNotifChannel": "Erro ao Procurar por Atualizações", + "appsRemovedNotifChannel": "Apps Removidos", + "downloadingXNotifChannel": "Baixando {}", + "completeAppInstallationNotifChannel": "Instalação completa do App", + "checkingForUpdatesNotifChannel": "Checando por Atualizações", + "onlyCheckInstalledOrTrackOnlyApps": "Only check installed and Track-Only apps for updates", + "removeAppQuestion": { + "one": "Remover App?", + "other": "Remover Apps?" + }, + "tooManyRequestsTryAgainInMinutes": { + "one": "Muitas solicitações (taxa limitada) - tente novamente em {} minuto", + "other": "Muitas solicitações (taxa limitada) - tente novamente em {} minutos" + }, + "bgUpdateGotErrorRetryInMinutes": { + "one": "A verificação de atualizações em segundo plano encontrou um {}, agendada uma nova verificação em {} minuto", + "other": "A verificação de atualizações em segundo plano encontrou um {}, agendada uma nova verificação em {} minutos" + }, + "bgCheckFoundUpdatesWillNotifyIfNeeded": { + "one": "A verificação de atualizações em segundo plano encontrou {} atualização, o usuário sera notificado caso necessário", + "other": "A verificação de atualizações em segundo plano encontrou {} atualizações, o usuário sera notificado caso necessário" + }, + "apps": { + "one": "{} App", + "other": "{} Apps" + }, + "url": { + "one": "{} URL", + "other": "{} URLs" + }, + "minute": { + "one": "{} Minuto", + "other": "{} Minutos" + }, + "hour": { + "one": "{} Hora", + "other": "{} Horas" + }, + "day": { + "one": "{} Dia", + "other": "{} Dias" + }, + "clearedNLogsBeforeXAfterY": { + "one": "Limpo {n} log (antes = {antes}, depois = {depois})", + "other": "Limpados {n} logs (antes = {antes}, depois = {depois})" + }, + "xAndNMoreUpdatesAvailable": { + "one": "{} e 1 outro app tem atualizações.", + "other": "{} e {} outros apps tem atualizações." + }, + "xAndNMoreUpdatesInstalled": { + "one": "{} e 1 outro app foi atualizado.", + "other": "{} e {} outros apps foram atualizados." + }, + "xAndNMoreUpdatesPossiblyInstalled": { + "one": "{} e 1 outro app pode ter sido atualizado.", + "other": "{} e {} outros apps podem ter sido atualizados." + } +} \ No newline at end of file diff --git a/assets/translations/ru.json b/assets/translations/ru.json new file mode 100644 index 0000000..f02a7da --- /dev/null +++ b/assets/translations/ru.json @@ -0,0 +1,330 @@ +{ + "invalidURLForSource": "Неверный URL-адрес приложения: {}", + "noReleaseFound": "Не удалось найти подходящий релиз", + "noVersionFound": "Не удалось определить версию релиза", + "urlMatchesNoSource": "URL-адрес не соответствует известному источнику", + "cantInstallOlderVersion": "Невозможно установить более старую версию приложения", + "appIdMismatch": "ID загруженного пакета не совпадает с существующим ID приложения", + "functionNotImplemented": "Этот класс не реализовал эту функцию", + "placeholder": "Заполнитель", + "someErrors": "Возникли некоторые ошибки", + "unexpectedError": "Неожиданная ошибка", + "ok": "Ok", + "and": "и", + "githubPATLabel": "Персональный токен доступа GitHub\n(увеличивает лимит запросов)", + "includePrereleases": "Включить предварительные релизы", + "fallbackToOlderReleases": "Откатываться к предыдущей версии", + "filterReleaseTitlesByRegEx": "Фильтровать заголовки релизов\n(регулярное выражение)", + "invalidRegEx": "Неверное регулярное выражение", + "noDescription": "Нет описания", + "cancel": "Отмена", + "continue": "Продолжить", + "requiredInBrackets": "(обязательно)", + "dropdownNoOptsError": "Ошибка: в выпадающем списке должна быть выбрана хотя бы одна настройка", + "colour": "Цвет", + "githubStarredRepos": "Избранные репозитории GitHub", + "uname": "Имя пользователя", + "wrongArgNum": "Неправильное количество предоставленных аргументов", + "xIsTrackOnly": "{} только для отслеживания", + "source": "Источник", + "app": "Приложение", + "appsFromSourceAreTrackOnly": "Приложения из этого источника настроены только для отслеживания", + "youPickedTrackOnly": "Вы выбрали опцию 'Только для отслеживания'", + "trackOnlyAppDescription": "Приложение будет отслеживаться на предмет обновлений, но Obtainium не сможет загрузить или установить его", + "cancelled": "Отменено", + "appAlreadyAdded": "Приложение уже добавлено", + "alreadyUpToDateQuestion": "Приложение уже обновлено?", + "addApp": "Добавить", + "appSourceURL": "URL-источник приложения", + "error": "Ошибка", + "add": "Добавить", + "searchSomeSourcesLabel": "Поиск (в некоторых источниках)", + "search": "Поиск", + "additionalOptsFor": "Дополнительные настройки для {}", + "supportedSources": "Поддерживаемые источники", + "trackOnlyInBrackets": "(только отслеживание)", + "searchableInBrackets": "(поиск)", + "appsString": "Приложения", + "noApps": "Нет приложений", + "noAppsForFilter": "Нет приложений для фильтра", + "byX": "От {}", + "percentProgress": "Прогресс: {}%", + "pleaseWait": "Пожалуйста, подождите", + "updateAvailable": "Доступно обновление", + "estimateInBracketsShort": "(Оценка)", + "notInstalled": "Не установлено", + "estimateInBrackets": "(Оценка)", + "selectAll": "Выбрать всё", + "deselectN": "Отменить выбор {}", + "xWillBeRemovedButRemainInstalled": "{} будет удалено из Obtainium, но останется на устройстве", + "removeSelectedAppsQuestion": "Удалить выбранные приложения?", + "removeSelectedApps": "Удалить выбранные приложения", + "updateX": "Обновить {}", + "installX": "Установить {}", + "markXTrackOnlyAsUpdated": "Отметить {}\n(Только для отслеживания)\nкак обновленное", + "changeX": "Изменить {}", + "installUpdateApps": "Установить/Обновить приложения", + "installUpdateSelectedApps": "Установить/Обновить выбранные приложения", + "markXSelectedAppsAsUpdated": "Выбрано приложений: {}. Отметить как обновлённые?", + "no": "Нет", + "yes": "Да", + "markSelectedAppsUpdated": "Отметить выбранные приложения как обновлённые", + "pinToTop": "Закрепить сверху", + "unpinFromTop": "Открепить", + "resetInstallStatusForSelectedAppsQuestion": "Сбросить статус установки для выбранных приложений?", + "installStatusOfXWillBeResetExplanation": "Статус установки для выбранных приложений будет сброшен.\n\nЭто может помочь, если версия приложения, отображаемая в Obtainium, некорректная — из-за неудачных обновлений или других проблем", + "shareSelectedAppURLs": "Поделиться выбранными URL-адресами приложений", + "resetInstallStatus": "Сбросить статус установки", + "more": "Ещё", + "removeOutdatedFilter": "Удалить фильтр для устаревших приложений", + "showOutdatedOnly": "Показывать только устаревшие приложения", + "filter": "Фильтр", + "filterActive": "Фильтр *", + "filterApps": "Фильтровать приложения", + "appName": "Название приложения", + "author": "Автор", + "upToDateApps": "Приложения со свежими обновлениями", + "nonInstalledApps": "Неустановленные приложения", + "importExport": "Данные", + "settings": "Настройки", + "exportedTo": "Экспортировано в {}", + "obtainiumExport": "Экспорт из Obtainium", + "invalidInput": "Неверный ввод", + "importedX": "Импортировано {}", + "obtainiumImport": "Импорт в Obtainium", + "importFromURLList": "Импорт из списка URL-адресов", + "searchQuery": "Поисковый запрос", + "appURLList": "Список URL приложений", + "line": "Строка", + "searchX": "Поиск {}", + "noResults": "Результатов не найдено", + "importX": "Импорт {}", + "importedAppsIdDisclaimer": "Импортированные приложения могут неверно отображаться как неустановленные.\nДля исправления этой проблемы повторно установите их через Obtainium.\nЭто не должно повлиять на данные приложения.\n\nПроблемы возникают только при импорте из URL-адреса и сторонних источников", + "importErrors": "Ошибка импорта", + "importedXOfYApps": "Импортировано приложений: {} из {}", + "followingURLsHadErrors": "При импорте следующие URL-адреса содержали ошибки:", + "okay": "Ok", + "selectURL": "Выбрать URL-адрес", + "selectURLs": "Выбрать URL-адреса", + "pick": "Выбрать", + "theme": "Тема", + "dark": "Тёмная", + "light": "Светлая", + "followSystem": "Системная", + "obtainium": "Obtainium", + "materialYou": "Material You", + "useBlackTheme": "Использовать чёрную тему", + "appSortBy": "Сортировка приложений", + "authorName": "Автор/Название", + "nameAuthor": "Название/Автор", + "asAdded": "В порядке добавления", + "appSortOrder": "Порядок", + "ascending": "По возрастанию", + "descending": "По убыванию", + "bgUpdateCheckInterval": "Интервал проверки обновлений в фоновом режиме", + "neverManualOnly": "Никогда — только вручную", + "appearance": "Внешний вид", + "showWebInAppView": "Показывать исходную веб-страницу на странице приложения", + "pinUpdates": "Отображать обновления приложений сверху списка", + "updates": "Обновления", + "sourceSpecific": "Настройки источников", + "appSource": "Исходный код", + "noLogs": "Нет журналов", + "appLogs": "Логи", + "close": "Закрыть", + "share": "Поделиться", + "appNotFound": "Приложение не найдено", + "obtainiumExportHyphenatedLowercase": "obtainium-export", + "pickAnAPK": "Выберите APK-файл", + "appHasMoreThanOnePackage": "{} имеет более одного пакета:", + "deviceSupportsXArch": "Ваше устройство поддерживает архитектуру процессора {}", + "deviceSupportsFollowingArchs": "Ваше устройство поддерживает следующие архитектуры процессора:", + "warning": "Предупреждение", + "sourceIsXButPackageFromYPrompt": "Источник приложения — '{}', но пакет для установки получен из '{}'. Продолжить?", + "updatesAvailable": "Доступны обновления", + "updatesAvailableNotifDescription": "Уведомляет о наличии обновлений для одного или нескольких приложений в Obtainium", + "noNewUpdates": "Нет новых обновлений", + "xHasAnUpdate": "{} есть обновление", + "appsUpdated": "Приложения обновлены", + "appsUpdatedNotifDescription": "Уведомляет об обновлении одного или нескольких приложений в фоновом режиме", + "xWasUpdatedToY": "{} была обновлена до версии {}", + "errorCheckingUpdates": "Ошибка при проверке обновлений", + "errorCheckingUpdatesNotifDescription": "Уведомление о завершении проверки обновлений в фоновом режиме с ошибкой", + "appsRemoved": "Приложение удалено", + "appsRemovedNotifDescription": "Уведомление об удалении одного или несколько приложений из-за ошибок при их загрузке", + "xWasRemovedDueToErrorY": "{} был удален из-за ошибки: {}", + "completeAppInstallation": "Завершение установки приложения", + "obtainiumMustBeOpenToInstallApps": "Obtainium должен быть открыт для установки приложений", + "completeAppInstallationNotifDescription": "Уведомление о необходимости открыть Obtainium для завершения установки приложения", + "checkingForUpdates": "Проверка обновлений", + "checkingForUpdatesNotifDescription": "Временное уведомление, которое появляется при проверке обновлений", + "pleaseAllowInstallPerm": "Пожалуйста, разрешите Obtainium устанавливать приложения", + "trackOnly": "Только отслеживать", + "errorWithHttpStatusCode": "Ошибка {}", + "versionCorrectionDisabled": "Коррекция версий отключена (плагин, кажется, не работает)", + "unknown": "Неизвестно", + "none": "Отсутствует", + "never": "Никогда", + "latestVersionX": "Последняя версия: {}", + "installedVersionX": "Установленная версия: {}", + "lastUpdateCheckX": "Последняя проверка: {}", + "remove": "Удалить", + "yesMarkUpdated": "Да, отметить как обновленное", + "fdroid": "Официальные репозитории F-Droid", + "appIdOrName": "ID или название приложения", + "appId": "ID приложения", + "appWithIdOrNameNotFound": "Приложение с таким ID или названием не было найдено", + "reposHaveMultipleApps": "В хранилище несколько приложений", + "fdroidThirdPartyRepo": "Сторонние репозитории F-Droid", + "steam": "Steam", + "steamMobile": "Steam Mobile", + "steamChat": "Steam Chat", + "install": "Установить", + "markInstalled": "Пометить как установленное", + "update": "Обновить", + "markUpdated": "Отметить обновлённым", + "additionalOptions": "Дополнительные настройки", + "disableVersionDetection": "Отключить обнаружение версии", + "noVersionDetectionExplanation": "Эта настройка должна использоваться только для приложений, где обнаружение версии не работает корректно", + "downloadingX": "Загрузка {}", + "downloadNotifDescription": "Уведомляет пользователя о прогрессе загрузки приложения", + "noAPKFound": "APK не найден", + "noVersionDetection": "Обнаружение версий отключено", + "categorize": "Категоризировать", + "categories": "Категории", + "category": "Категория", + "noCategory": "Без категории", + "noCategories": "Без категорий", + "deleteCategoriesQuestion": "Удалить категории?", + "categoryDeleteWarning": "Все приложения в удаленных категориях будут помечены как без категории", + "addCategory": "Добавить категорию", + "label": "Метка", + "language": "Язык", + "copiedToClipboard": "Скопировано в буфер обмена", + "storagePermissionDenied": "Отказано в доступе к хранилищу", + "selectedCategorizeWarning": "Это заменит все текущие настройки категорий для выбранных приложений", + "filterAPKsByRegEx": "Отфильтровать APK-файлы\n(регулярное выражение)", + "removeFromObtainium": "Удалить из Obtainium", + "uninstallFromDevice": "Удалить с устройства", + "onlyWorksWithNonVersionDetectApps": "Работает только для приложений с отключенным определением версии", + "releaseDateAsVersion": "Дата выпуска вместо версии", + "releaseDateAsVersionExplanation": "Этот параметр следует использовать только для приложений, в которых определение версии не работает правильно, но имеется дата выпуска", + "changes": "Изменения", + "releaseDate": "Дата выпуска", + "importFromURLsInFile": "Импорт из файла URL-адресов (например: OPML)", + "versionDetection": "Определение версии", + "standardVersionDetection": "Стандартное", + "groupByCategory": "Группировать по категориям", + "autoApkFilterByArch": "Попытаться отфильтровать APK-файлы по архитектуре процессора", + "overrideSource": "Переопределить источник", + "dontShowAgain": "Не показывать снова", + "dontShowTrackOnlyWarnings": "Не показывать предупреждения о только отслеживаемых приложениях", + "dontShowAPKOriginWarnings": "Не показывать предупреждения об отличающемся источнике APK-файлов", + "moveNonInstalledAppsToBottom": "Отображать неустановленные приложения внизу списка", + "gitlabPATLabel": "Персональный токен доступа GitLab\n(включает поиск и улучшает обнаружение APK)", + "about": "Описание", + "requiresCredentialsInSettings": "Для этого требуются дополнительные учетные данные (в настройках)", + "checkOnStart": "Проверять наличие обновлений при запуске", + "tryInferAppIdFromCode": "Попытаться определить ID приложения из исходного кода", + "removeOnExternalUninstall": "Автоматически убирать из списка удаленные извне приложения", + "pickHighestVersionCode": "Автовыбор актуальной версии кода APK", + "checkUpdateOnDetailPage": "Проверять наличие обновлений при открытии страницы приложения", + "disablePageTransitions": "Отключить анимацию перехода между страницами", + "reversePageTransitions": "Реверс анимации перехода между страницами", + "minStarCount": "Минимальное количество звёзд", + "addInfoBelow": "Добавьте эту информацию ниже", + "addInfoInSettings": "Добавьте эту информацию в Настройки", + "githubSourceNote": "Используя ключ API можно обойти лимит запросов GitHub", + "gitlabSourceNote": "Без ключа API может не работать извлечение APK с GitLab", + "sortByFileNamesNotLinks": "Сортировать по именам файлов, а не ссылкам целиком", + "filterReleaseNotesByRegEx": "Фильтровать примечания к выпуску\n(регулярное выражение)", + "customLinkFilterRegex": "Пользовательский фильтр ссылок APK\n(регулярное выражение, по умолчанию: '.apk$')", + "appsPossiblyUpdated": "Попытки обновления приложений", + "appsPossiblyUpdatedNotifDescription": "Уведомление о возможных обновлениях одного или нескольких приложений в фоновом режиме", + "xWasPossiblyUpdatedToY": "{} возможно был обновлен до {}", + "enableBackgroundUpdates": "Включить обновления в фоне", + "backgroundUpdateReqsExplanation": "Фоновые обновления могут быть возможны не для всех приложений", + "backgroundUpdateLimitsExplanation": "Успешность фоновой установки можно определить только после открытия Obtainium", + "verifyLatestTag": "Проверять тег 'latest'", + "intermediateLinkRegex": "Фильтр промежуточных ссылок для первоочередного посещения\n(регулярное выражение)", + "intermediateLinkNotFound": "Промежуточная ссылка не найдена", + "exemptFromBackgroundUpdates": "Исключить из фоновых обновлений (если включено)", + "bgUpdatesOnWiFiOnly": "Отключить фоновые обновления, если нет соединения с Wi-Fi", + "autoSelectHighestVersionCode": "Автоматически выбирать APK с актуальной версией кода", + "versionExtractionRegEx": "Регулярное выражение для извлечения версии", + "matchGroupToUse": "Выберите группу для использования", + "highlightTouchTargets": "Выделить менее очевидные элементы управления касанием", + "pickExportDir": "Выбрать каталог для экспорта", + "autoExportOnChanges": "Автоэкспорт при изменениях", + "filterVersionsByRegEx": "Фильтровать версии по регулярному выражению", + "trySelectingSuggestedVersionCode": "Попробуйте выбрать предложенный код версии APK", + "dontSortReleasesList": "Сохранить порядок релизов от API", + "reverseSort": "Обратная сортировка", + "debugMenu": "Меню отладки", + "bgTaskStarted": "Фоновая задача начата — проверьте журналы", + "runBgCheckNow": "Запустить проверку фонового обновления сейчас", + "versionExtractWholePage": "Применить регулярное выражение версии ко всей странице", + "installing": "Устанавливается", + "skipUpdateNotifications": "Не оповещать об обновлениях", + "updatesAvailableNotifChannel": "Доступны обновления", + "appsUpdatedNotifChannel": "Приложения обновлены", + "appsPossiblyUpdatedNotifChannel": "Попытки обновления приложений", + "errorCheckingUpdatesNotifChannel": "Ошибка при проверке обновлений", + "appsRemovedNotifChannel": "Приложение удалено", + "downloadingXNotifChannel": "Загрузка {}", + "completeAppInstallationNotifChannel": "Завершение установки приложения", + "checkingForUpdatesNotifChannel": "Проверка обновлений", + "onlyCheckInstalledOrTrackOnlyApps": "Only check installed and Track-Only apps for updates", + "removeAppQuestion": { + "one": "Удалить приложение?", + "other": "Удалить приложения?" + }, + "tooManyRequestsTryAgainInMinutes": { + "one": "Слишком много запросов (ограничение скорости) — попробуйте снова через {} минуту", + "other": "Слишком много запросов (ограничение скорости) — попробуйте снова через {} минуты" + }, + "bgUpdateGotErrorRetryInMinutes": { + "one": "При проверке обновлений в фоновом режиме возникла ошибка {}, повторная проверка будет запланирована через {} минуту", + "other": "При проверке обновлений в фоновом режиме возникла ошибка {}, повторная проверка будет запланирована через {} минуты" + }, + "bgCheckFoundUpdatesWillNotifyIfNeeded": { + "one": "В ходе проверки обновления в фоновом режиме было обнаружено {} обновление — Пользователю будет отправлено уведомление, если это необходимо", + "other": "В ходе проверки обновления в фоновом режиме было обнаружено {} обновлений — Пользователю будет отправлено уведомление, если это необходимо" + }, + "apps": { + "one": "{} приложение", + "other": "{} приложений" + }, + "url": { + "one": "{} URL-адрес", + "other": "{} URL-адреса" + }, + "minute": { + "one": "{} минута", + "other": "{} минуты" + }, + "hour": { + "one": "{} час", + "other": "{} часов" + }, + "day": { + "one": "{} день", + "other": "{} дней" + }, + "clearedNLogsBeforeXAfterY": { + "one": "Очищен {n} журнал (до = {before}, после = {after})", + "other": "Очищено {n} журналов (до = {before}, после = {after})" + }, + "xAndNMoreUpdatesAvailable": { + "one": "У {} и еще 1 приложения есть обновление", + "other": "У {} и ещё {} приложений есть обновления" + }, + "xAndNMoreUpdatesInstalled": { + "one": "{} и ещё 1 приложение были обновлены", + "other": "{} и ещё {} приложений были обновлены" + }, + "xAndNMoreUpdatesPossiblyInstalled": { + "one": "{} и ещё 1 приложение могли быть обновлены", + "other": "{} и ещё {} приложений могли быть обновлены" + } +} diff --git a/assets/translations/sv.json b/assets/translations/sv.json new file mode 100644 index 0000000..06aeabb --- /dev/null +++ b/assets/translations/sv.json @@ -0,0 +1,318 @@ +{ + "invalidURLForSource": "Inte giltig {} App-URL", + "noReleaseFound": "Kunde inte hitta en lämplig releaseversion", + "noVersionFound": "Kunde inte bestämma releaseversion", + "urlMatchesNoSource": "URL matchar inte känd källa", + "cantInstallOlderVersion": "Kan inte installera en äldre version av en app", + "appIdMismatch": "Nerladdat paket-ID matchar inte nuvarande App-ID", + "functionNotImplemented": "This class has not implemented this function", + "placeholder": "Platshållare", + "someErrors": "Några fel uppstod", + "unexpectedError": "Oväntat fel", + "ok": "Okej", + "and": "och", + "githubPATLabel": "GitHub Personal Access Token (Increases Rate Limit)", + "includePrereleases": "Inkludera förreleaser", + "fallbackToOlderReleases": "Fall tillbaka till äldre releaser", + "filterReleaseTitlesByRegEx": "Filter Release Titles by Regular Expression", + "invalidRegEx": "Invalid regular expression", + "noDescription": "Ingen beskrivning", + "cancel": "Avbryt", + "continue": "Fortsätt", + "requiredInBrackets": "(Kräver)", + "dropdownNoOptsError": "ERROR: DROPDOWN MUST HAVE AT LEAST ONE OPT", + "colour": "Färg", + "githubStarredRepos": "GitHub Stjärnmärkta Förråd", + "uname": "Användarnamn", + "wrongArgNum": "Wrong number of arguments provided", + "xIsTrackOnly": "{} är 'Följ-Endast'", + "source": "Källa", + "app": "App", + "appsFromSourceAreTrackOnly": "Apparna från denna källa är 'Följ-Endast'.", + "youPickedTrackOnly": "Du har markerat 'Följ-Endast'-alternativet", + "trackOnlyAppDescription": "Appen kommer följas för uppdateringar men Obtainium kommer inte ladda ner eller installera den.", + "cancelled": "Avbruten", + "appAlreadyAdded": "App redan tillagd", + "alreadyUpToDateQuestion": "App redan uppdaterad?", + "addApp": "Lägg till App", + "appSourceURL": "URL till Appkälla", + "error": "Fel", + "add": "Lägg till", + "searchSomeSourcesLabel": "Sök (Bara några källor)", + "search": "Sök", + "additionalOptsFor": "Ytterligare Alternativ för {}", + "supportedSources": "Stödda Källor", + "trackOnlyInBrackets": "(Följ-Endast)", + "searchableInBrackets": "(Sökbar)", + "appsString": "Appar", + "noApps": "Inga Appar", + "noAppsForFilter": "Inga Appar för Filter", + "byX": "Av {}", + "percentProgress": "Progress: {}%", + "pleaseWait": "Vänta", + "updateAvailable": "Uppdatering Tillgänglig", + "estimateInBracketsShort": "(Est.)", + "notInstalled": "Inte Installerad", + "estimateInBrackets": "(Uppskattning)", + "selectAll": "Välj Alla", + "deselectN": "Avmarkera {}", + "xWillBeRemovedButRemainInstalled": "{} kommer tas bort från Obtainium men kommer vara fortsatt installerad på enheten.", + "removeSelectedAppsQuestion": "Ta bort markerade Appar?", + "removeSelectedApps": "Ta bort markerade Appar", + "updateX": "Uppdatera {}", + "installX": "Installera {}", + "markXTrackOnlyAsUpdated": "Märk {}\n(Följ-Endast)\nsom Uppdaterad", + "changeX": "Byt {}", + "installUpdateApps": "Installera/Uppdatera Appar", + "installUpdateSelectedApps": "Installera/Uppdatera Markerade Appar", + "markXSelectedAppsAsUpdated": "Märk {} markerade Appar som Uppdaterade?", + "no": "Nej", + "yes": "Ja", + "markSelectedAppsUpdated": "Märk Valda Appar som Uppdaterade", + "pinToTop": "Nåla fast högst upp", + "unpinFromTop": "Avnåla", + "resetInstallStatusForSelectedAppsQuestion": "Återställ Installationsstatus för valda Appar?", + "installStatusOfXWillBeResetExplanation": "Installationsstatusen för de markerade apparna kommer återställas.\n\n Detta kan hjälpa när appversionen visad i Obtanium är fel på grund av misslyckade uppdateringar eller andra orsaker.", + "shareSelectedAppURLs": "Dela Valda Appars URL:er", + "resetInstallStatus": "Återställ Installationstatus", + "more": "Mer", + "removeOutdatedFilter": "Ta bort Utgånga App-filtret", + "showOutdatedOnly": "Visa Endast Utgånga Appar", + "filter": "Filter", + "filterActive": "Filter *", + "filterApps": "Filtrera Appar", + "appName": "Appnamn", + "author": "Utvecklare", + "upToDateApps": "Uppdaterade Appar", + "nonInstalledApps": "Icke-Installerade Appar", + "importExport": "Importera/Exportera", + "settings": "Inställningar", + "exportedTo": "Exporterad till {}", + "obtainiumExport": "Obtainiumexport", + "invalidInput": "Ogiltig inmatning", + "importedX": "Importerad {}", + "obtainiumImport": "Obtainium Import", + "importFromURLList": "Importera från URL-lista", + "searchQuery": "Sökförfrågan", + "appURLList": "App URL List", + "line": "Linje", + "searchX": "Sök {}", + "noResults": "Inga resultat", + "importX": "Importera {}", + "importedAppsIdDisclaimer": "Importerade Appar kan felaktigt visas som \"Inte Installerad\".\nFör att fixa detta återinstallera dem genom Obtainium.\nDetta skall inte påverka appdata.\n\n Påverkar endast URL:en och tredjepartsimportermetoder.", + "importErrors": "Importfel", + "importedXOfYApps": "{} av {} Appar importerade.", + "followingURLsHadErrors": "Följande URL:er hade fel:", + "okay": "Okej", + "selectURL": "Välj URL", + "selectURLs": "Välj URL:er", + "pick": "Välj", + "theme": "Tema", + "dark": "Mörkt", + "light": "Ljust", + "followSystem": "Följ System", + "obtainium": "Obtainium", + "materialYou": "Material You", + "useBlackTheme": "Använd svart tema", + "appSortBy": "Sortera Appar via", + "authorName": "Utvecklare/Namn", + "nameAuthor": "Namn/Utvecklare", + "asAdded": "As Added", + "appSortOrder": "App Sort Order", + "ascending": "Stigande", + "descending": "Fallande", + "bgUpdateCheckInterval": "Bakgrundsuppdateringskollfrekvens", + "neverManualOnly": "Never - Manual Only", + "appearance": "Utseende", + "showWebInAppView": "Visa källans hemsida i appvyn", + "pinUpdates": "Fäst uppdateringar högst upp i appvyn", + "updates": "Uppdateringar", + "sourceSpecific": "Källspecifik", + "appSource": "Appkälla", + "noLogs": "Inga Loggar", + "appLogs": "Apploggar", + "close": "Stäng", + "share": "Dela", + "appNotFound": "App ej funnen", + "obtainiumExportHyphenatedLowercase": "obtainium-export", + "pickAnAPK": "Välj en APK", + "appHasMoreThanOnePackage": "{} har fler än ett paket:", + "deviceSupportsXArch": "Din enhet stödjer {} CPU-arkiktektur.", + "deviceSupportsFollowingArchs": "YDin enhet stödjer följande CPU-arkitekturer:", + "warning": "Varning", + "sourceIsXButPackageFromYPrompt": "Appens källa är '{}' men releasepaketet kommer från '{}'. Vill du fortsätta?", + "updatesAvailable": "Uppdateringar Tillgängliga", + "updatesAvailableNotifDescription": "Aviserar användaren att det finns uppdateringar tillgängaliga för en eller fler Appar som följs av Obtainium", + "noNewUpdates": "Inga nya uppdateringar.", + "xHasAnUpdate": "{} har en uppdatering.", + "appsUpdated": "Appar Uppdaterade", + "appsUpdatedNotifDescription": "Notifies the user that updates to one or more Apps were applied in the background", + "xWasUpdatedToY": "{} uppdaterades till {}.", + "errorCheckingUpdates": "Fel vid uppdateringskoll", + "errorCheckingUpdatesNotifDescription": "En aviserings som visar när bakgrundsuppdateringarkollar misslyckas", + "appsRemoved": "Appar borttagna", + "appsRemovedNotifDescription": "Aviserar användaren när en eller fler Appar togs bort på grund av fel när de laddades", + "xWasRemovedDueToErrorY": "{} togs bort på grund av detta felet: {}", + "completeAppInstallation": "Gör klar appinstallation", + "obtainiumMustBeOpenToInstallApps": "Obtainium måste vara öppet för att installera Appar", + "completeAppInstallationNotifDescription": "Frågar användaren att återvända till Obtaiunium när appinstallation är klar", + "checkingForUpdates": "Kollar efter Uppdateringar", + "checkingForUpdatesNotifDescription": "Transient notification that appears when checking for updates", + "pleaseAllowInstallPerm": "Tillåt Obtanium att installera Appar", + "trackOnly": "Följ-Endast", + "errorWithHttpStatusCode": "Fel {}", + "versionCorrectionDisabled": "Versionskorrigering inaktiverat (plugin verkar inte fungera)", + "unknown": "Okänd", + "none": "None", + "never": "Aldrig", + "latestVersionX": "Senaste Version: {}", + "installedVersionX": "Installerad Version: {}", + "lastUpdateCheckX": "Senaste uppdateringskoll: {}", + "remove": "Ta bort", + "yesMarkUpdated": "Ja, Märk som Uppdaterad", + "fdroid": "F-Droid Officiell", + "appIdOrName": "App-ID eller Namn", + "appId": "App-ID", + "appWithIdOrNameNotFound": "Ingen App funnen med det namnet eller ID", + "reposHaveMultipleApps": "Förråd kan innehålla flera ApparR", + "fdroidThirdPartyRepo": "F-Droid Tredjeparts Förråd", + "steam": "Steam", + "steamMobile": "Steam Mobile", + "steamChat": "Steam Chat", + "install": "Installera", + "markInstalled": "Märk Installerad", + "update": "Uppdatera", + "markUpdated": "Märk Uppdaterad", + "additionalOptions": "Ytterligare Alternativ", + "disableVersionDetection": "Inaktivera versionsdetektering", + "noVersionDetectionExplanation": "This option should only be used for Apps where version detection does not work correctly.", + "downloadingX": "Laddar ner {}", + "downloadNotifDescription": "Notifies the user of the progress in downloading an App", + "noAPKFound": "Ingen APK funnen", + "noVersionDetection": "Ingen versiondetektering", + "categorize": "Kategorisera", + "categories": "Kategorier", + "category": "Kategori", + "noCategory": "Ingen Kategori", + "noCategories": "Inga Kategorier", + "deleteCategoriesQuestion": "Ta Bort Kategorier?", + "categoryDeleteWarning": "Alla Appar i de borttagna kategorierna kommer att märkas som okategoriserade.", + "addCategory": "Lägg till Kategori", + "label": "Label", + "language": "Språk", + "copiedToClipboard": "Kopierat till Urklipp", + "storagePermissionDenied": "Lagringsbehörighet nekad", + "selectedCategorizeWarning": "This will replace any existing category settings for the selected Apps.", + "filterAPKsByRegEx": "Filter APKs by Regular Expression", + "removeFromObtainium": "Ta bort från Obtainium", + "uninstallFromDevice": "Avinstallera från Enheten", + "onlyWorksWithNonVersionDetectApps": "Fungerar bara för Appar med versionsdetektering inaktiverat..", + "releaseDateAsVersion": "Använd releasedatum som version", + "releaseDateAsVersionExplanation": "This option should only be used for Apps where version detection does not work correctly, but a release date is available.", + "changes": "Ändringar", + "releaseDate": "Releasedatum", + "importFromURLsInFile": "Importera från URL:er i fil (som OPML)", + "versionDetection": "Versionsdetektering", + "standardVersionDetection": "Standardversionsdetektering", + "groupByCategory": "Gruppera via Kategori", + "autoApkFilterByArch": "Attempt to filter APKs by CPU architecture if possible", + "overrideSource": "Överskrid Källa", + "dontShowAgain": "Visa inte detta igen", + "dontShowTrackOnlyWarnings": "Visa inte 'Följ-Endast' varningar", + "dontShowAPKOriginWarnings": "Visa inte APK-ursprung varningar", + "moveNonInstalledAppsToBottom": "Move non-installed Apps to bottom of Apps view", + "gitlabPATLabel": "GitLab Personal Access Token\n(Enables Search and Better APK Discovery)", + "about": "Om", + "requiresCredentialsInSettings": "This needs additional credentials (in Settings)", + "checkOnStart": "Kolla efter uppdateringar vid start", + "tryInferAppIdFromCode": "Try inferring App ID from source code", + "removeOnExternalUninstall": "Automatically remove externally uninstalled Apps", + "pickHighestVersionCode": "Auto-select highest version code APK", + "checkUpdateOnDetailPage": "Check for updates on opening an App detail page", + "disablePageTransitions": "Disable page transition animations", + "reversePageTransitions": "Reverse page transition animations", + "minStarCount": "Minsta antal stjärnmarkeringar", + "addInfoBelow": "Lägg till denna information nedanför.", + "addInfoInSettings": "Lägg till denna information i Inställningar.", + "githubSourceNote": "GitHub rate limiting can be avoided using an API key.", + "gitlabSourceNote": "GitLab APK extraction may not work without an API key.", + "sortByFileNamesNotLinks": "Sort by file names instead of full links", + "filterReleaseNotesByRegEx": "Filter Release Notes by Regular Expression", + "customLinkFilterRegex": "Custom APK Link Filter by Regular Expression (Default '.apk$')", + "appsPossiblyUpdated": "App Updates Attempted", + "appsPossiblyUpdatedNotifDescription": "Notifies the user that updates to one or more Apps were potentially applied in the background", + "xWasPossiblyUpdatedToY": "{} may have been updated to {}.", + "enableBackgroundUpdates": "Aktivera Bakgrundsuppdateringar", + "backgroundUpdateReqsExplanation": "Bakgrundsuppdateringar är inte möjligt för alla appar.", + "backgroundUpdateLimitsExplanation": "The success of a background install can only be determined when Obtainium is opened.", + "verifyLatestTag": "Verifiera 'senaste'-taggen", + "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit First", + "intermediateLinkNotFound": "Intermediate link not found", + "exemptFromBackgroundUpdates": "Undta från bakgrundsuppdateringar (om aktiverad)", + "bgUpdatesOnWiFiOnly": "Inaktivera Bakgrundsuppdateringar utan WiFi", + "autoSelectHighestVersionCode": "Auto-select highest versionCode APK", + "versionExtractionRegEx": "Version Extraction RegEx", + "matchGroupToUse": "Match Group to Use", + "highlightTouchTargets": "Highlight less obvious touch targets", + "pickExportDir": "Välj Exportsökväg", + "autoExportOnChanges": "Automatisk export vid ändringar", + "filterVersionsByRegEx": "Filter Versions by Regular Expression", + "trySelectingSuggestedVersionCode": "Try selecting suggested versionCode APK", + "dontSortReleasesList": "Retain release order from API", + "reverseSort": "Omvänd sortering", + "debugMenu": "Felsökningsmeny", + "bgTaskStarted": "Background task started - check logs.", + "runBgCheckNow": "Kör Bakgrundsuppdateringskoll Nu", + "removeAppQuestion": { + "one": "Ta Bort App?", + "other": "Ta Bort Appar?" + }, + "tooManyRequestsTryAgainInMinutes": { + "one": "Too many requests (rate limited) - try again in {} minute", + "other": "Too many requests (rate limited) - try again in {} minutes" + }, + "bgUpdateGotErrorRetryInMinutes": { + "one": "BG update checking encountered a {}, will schedule a retry check in {} minute", + "other": "BG update checking encountered a {}, will schedule a retry check in {} minutes" + }, + "bgCheckFoundUpdatesWillNotifyIfNeeded": { + "one": "BG update checking found {} update - will notify user if needed", + "other": "BG update checking found {} updates - will notify user if needed" + }, + "apps": { + "one": "{} App", + "other": "{} Appar" + }, + "url": { + "one": "{} URL", + "other": "{} URL:er" + }, + "minute": { + "one": "{} Minut", + "other": "{} Minuter" + }, + "hour": { + "one": "{} Timme", + "other": "{} Timmar" + }, + "day": { + "one": "{} Dag", + "other": "{} Dagar" + }, + "clearedNLogsBeforeXAfterY": { + "one": "Rensade {n} logg (före = {before}, efter = {after})", + "other": "Rensade {n} loggar (före = {before}, efter = {after})" + }, + "xAndNMoreUpdatesAvailable": { + "one": "{} och 1 app till har tillgängliga uppdateringar.", + "other": "{} och {} appar till har tillgängliga uppdateringar." + }, + "xAndNMoreUpdatesInstalled": { + "one": "{} och 1 till app uppdaterades.", + "other": "{} och {} appar till uppdaterades." + }, + "xAndNMoreUpdatesPossiblyInstalled": { + "one": "{} och 1 till app kan ha uppdaterats.", + "other": "{} och {} appar till kan ha uppdaterats." + } +} diff --git a/assets/translations/tr.json b/assets/translations/tr.json new file mode 100644 index 0000000..3071bc9 --- /dev/null +++ b/assets/translations/tr.json @@ -0,0 +1,330 @@ +{ + "invalidURLForSource": "Geçerli bir {} Uygulama URL'si değil", + "noReleaseFound": "Uygun bir sürüm bulunamadı", + "noVersionFound": "Sürüm bulunamadı", + "urlMatchesNoSource": "URL, bilinen bir kaynağa uymuyor", + "cantInstallOlderVersion": "Eski bir sürümü yükleyemem", + "appIdMismatch": "İndirilen paket kimliği mevcut Uygulama kimliği ile eşleşmiyor", + "functionNotImplemented": "Bu sınıf bu işlevi uygulamamıştır", + "placeholder": "Yer Tutucu", + "someErrors": "Bazı Hatalar Oluştu", + "unexpectedError": "Beklenmeyen Hata", + "ok": "Tamam", + "and": "ve", + "githubPATLabel": "GitHub Kişisel Erişim Anahtarı (Sınırlamayı Artırır)", + "includePrereleases": "Ön sürümleri dahil et", + "fallbackToOlderReleases": "Daha eski sürümlere geri dön", + "filterReleaseTitlesByRegEx": "Düzenli İfadelerle Sürüm Başlıklarını Filtrele", + "invalidRegEx": "Geçersiz düzenli ifade", + "noDescription": "Açıklama yok", + "cancel": "İptal", + "continue": "Devam Et", + "requiredInBrackets": "(Gerekli)", + "dropdownNoOptsError": "HATA: DİPLOMADA EN AZ BİR SEÇENEK OLMALI", + "colour": "Renk", + "githubStarredRepos": "GitHub'a Yıldızlı Depolar", + "uname": "Kullanıcı Adı", + "wrongArgNum": "Hatalı argüman sayısı sağlandı", + "xIsTrackOnly": "{} yalnızca Takip Edilen", + "source": "Kaynak", + "app": "Uygulama", + "appsFromSourceAreTrackOnly": "Bu kaynaktan gelen uygulamalar 'Yalnızca Takip Edilen'dir.", + "youPickedTrackOnly": "'Yalnızca Takip Edilen' seçeneğini seçtiniz.", + "trackOnlyAppDescription": "Uygulama güncellemeleri için takip edilecek, ancak Obtainium onu indiremeyecek veya kuramayacaktır.", + "cancelled": "İptal Edildi", + "appAlreadyAdded": "Uygulama zaten eklenmiş", + "alreadyUpToDateQuestion": "Uygulama Zaten Güncel mi?", + "addApp": "Uygulama Ekle", + "appSourceURL": "Uygulama Kaynak URL'si", + "error": "Hata", + "add": "Ekle" + "searchSomeSourcesLabel": "Ara (Bazı Kaynaklar Yalnızca)", + "search": "Ara", + "additionalOptsFor": "{} İçin Ek Seçenekler", + "supportedSources": "Desteklenen Kaynaklar", + "trackOnlyInBrackets": "(Yalnızca Takip)", + "searchableInBrackets": "(Aranabilir)", + "appsString": "Uygulamalar", + "noApps": "Uygulama Yok", + "noAppsForFilter": "Filtre İçin Uygulama Yok", + "byX": "{} Tarafından", + "percentProgress": "İlerleme: {}%", + "pleaseWait": "Lütfen Bekleyin", + "updateAvailable": "Güncelleme Var", + "estimateInBracketsShort": "(Est.)", + "notInstalled": "Yüklenmedi", + "estimateInBrackets": "(Tahmini)", + "selectAll": "Hepsini Seç", + "deselectN": "{}'yi Seçimden Kaldır", + "xWillBeRemovedButRemainInstalled": "{} Obtainium'dan kaldırılacak ancak cihazınızda yüklü kalacaktır.", + "removeSelectedAppsQuestion": "Seçilen Uygulamaları Kaldırmak İstiyor musunuz?", + "removeSelectedApps": "Seçilen Uygulamaları Kaldır", + "updateX": "{}'yi Güncelle", + "installX": "{}'yi Yükle", + "markXTrackOnlyAsUpdated": "{}(Takip Edilen) olarak Güncellendi olarak İşaretle", + "changeX": "{}'yi Değiştir", + "installUpdateApps": "Uygulamaları Yükle/Güncelle", + "installUpdateSelectedApps": "Seçilen Uygulamaları Yükle/Güncelle", + "markXSelectedAppsAsUpdated": "Seçilen Uygulamaları {} olarak Güncellendi olarak İşaretle?", + "no": "Hayır", + "yes": "Evet", + "markSelectedAppsUpdated": "Seçilen Uygulamaları Güncellendi olarak İşaretle", + "pinToTop": "Üstte Tut", + "unpinFromTop": "Üstten Kaldır", + "resetInstallStatusForSelectedAppsQuestion": "Seçilen Uygulamaların Yükleme Durumunu Sıfırlamak İstiyor musunuz?", + "installStatusOfXWillBeResetExplanation": "Seçilen Uygulamaların yükleme durumu sıfırlanacak.\n\nBu, Obtainium'da gösterilen uygulama sürümünün başarısız güncellemeler veya diğer sorunlar nedeniyle yanlış olması durumunda yardımcı olabilir.", + "shareSelectedAppURLs": "Seçilen Uygulama URL'larını Paylaş", + "resetInstallStatus": "Yükleme Durumunu Sıfırla", + "more": "Daha Fazla", + "removeOutdatedFilter": "Güncel Olmayan Uygulama Filtresini Kaldır", + "showOutdatedOnly": "Yalnızca Güncel Olmayan Uygulamaları Göster", + "filter": "Filtre", + "filterActive": "Filtre *", + "filterApps": "Uygulamaları Filtrele", + "appName": "Uygulama Adı", + "author": "Yazar", + "upToDateApps": "Güncel Uygulamalar", + "nonInstalledApps": "Yüklenmemiş Uygulamalar", + "importExport": "İçe/Dışa Aktar", + "settings": "Ayarlar", + "exportedTo": "{}'e Dışa Aktarıldı", + "obtainiumExport": "Obtainium Dışa Aktar", + "invalidInput": "Geçersiz Giriş", + "importedX": "{} İçe Aktarıldı", + "obtainiumImport": "Obtainium İçe Aktar", + "importFromURLList": "URL Listesinden İçe Aktar (Örneğin OPML)", + "searchQuery": "Arama Sorgusu", + "appURLList": "Uygulama URL Listesi", + "line": "Satır", + "searchX": "{}'yi Ara", + "noResults": "Sonuç Bulunamadı", + "importX": "{} İçe Aktar", + "importedAppsIdDisclaimer": "İçe Aktarılan Uygulamalar yanlışlıkla \"Yüklenmedi\" olarak gösterilebilir.\nBunu düzeltmek için bunları Obtainium üzerinden yeniden yükleyin.\nBu, yalnızca URL ve üçüncü taraf içe aktarma yöntemlerini etkiler.\n\nYalnızca URL ve üçüncü taraf içe aktarma yöntemlerini etkiler.", + "importErrors": "İçe Aktarma Hataları", + "importedXOfYApps": "{}'den {} Uygulama İçe Aktarıldı.", + "followingURLsHadErrors": "Aşağıdaki URL'lerde hatalar oluştu:", + "okay": "Tamam", + "selectURL": "URL Seç", + "selectURLs": "URL'leri Seç", + "pick": "Seç", + "theme": "Tema", + "dark": "Koyu", + "light": "Aydınlık", + "followSystem": "Sistemi Takip Et", + "obtainium": "Obtainium", + "materialYou": "Material You", + "useBlackTheme": "Saf siyah koyu temasını kullan", + "appSortBy": "Uygulama Sıralama Ölçütü", + "authorName": "Yazar/Ad", + "nameAuthor": "Ad/Yazar", + "asAdded": "Eklendiği Gibi", + "appSortOrder": "Uygulama Sıralama Sırası", + "ascending": "Artan", + "descending": "Azalan", + "bgUpdateCheckInterval": "Arka Planda Güncelleme Kontrol Aralığı", + "neverManualOnly": "Asla - Yalnızca El ile", + "appearance": "Görünüm", + "showWebInAppView": "Kaynağı Uygulama Görünümünde Göster", + "pinUpdates": "Güncellemeleri Uygulamalar Görünümünün Üstüne Sabitle", + "updates": "Güncellemeler", + "sourceSpecific": "Kaynak Özel", + "appSource": "Uygulama Kaynağı", + "noLogs": "Günlük Yok", + "appLogs": "Uygulama Günlükleri", + "close": "Kapat", + "share": "Paylaş", + "appNotFound": "Uygulama Bulunamadı", + "obtainiumExportHyphenatedLowercase": "obtainium-ihracat", + "pickAnAPK": "APK Seç", + "appHasMoreThanOnePackage": "{}'nin birden fazla paketi var:", + "deviceSupportsXArch": "Cihazınız {} CPU mimarisini destekliyor.", + "deviceSupportsFollowingArchs": "Cihazınız aşağıdaki CPU mimarilerini destekliyor:", + "warning": "Uyarı", + "sourceIsXButPackageFromYPrompt": "Uygulama kaynağı '{}', ancak dağıtım paketi '{}'. Devam etmek istiyor musunuz?", + "updatesAvailable": "Güncellemeler Var", + "updatesAvailableNotifDescription": "Kullanıcıya Obtainium tarafından takip edilen bir veya daha fazla uygulama için güncelleme bulunduğuna dair bilgi verir", + "noNewUpdates": "Yeni güncelleme yok.", + "xHasAnUpdate": "{} güncelleme alıyor.", + "appsUpdated": "Uygulamalar Güncellendi", + "appsUpdatedNotifDescription": "Kullanıcıya bir veya daha fazla uygulamanın arka planda güncellendiğine dair bilgi verir", + "xWasUpdatedToY": "{} şu sürüme güncellendi: {}.", + "errorCheckingUpdates": "Güncellemeler Kontrol Edilirken Hata Oluştu", + "errorCheckingUpdatesNotifDescription": "Arka planda güncelleme kontrolü sırasında hata oluştuğunda görünen bir bildirim", + "appsRemoved": "Uygulamalar Kaldırıldı", + "appsRemovedNotifDescription": "Bir veya daha fazla uygulamanın yüklenirken hata nedeniyle kaldırıldığını bildiren bir bildirim", + "xWasRemovedDueToErrorY": "{} şu hatadan dolayı kaldırıldı: {}", + "completeAppInstallation": "Uygulama Yüklemeyi Tamamla", + "obtainiumMustBeOpenToInstallApps": "Uygulamaları yüklemek için Obtainium'un açık olması gerekiyor", + "completeAppInstallationNotifDescription": "Kullanıcıdan Obtainium'a geri dönüp bir uygulama yüklemeyi tamamlamasını isteyen bir bildirim", + "checkingForUpdates": "Güncellemeler Kontrol Ediliyor", + "checkingForUpdatesNotifDescription": "Güncellemeler kontrol edildiğinde görünen geçici bir bildirim", + "pleaseAllowInstallPerm": "Lütfen Obtainium'un uygulama yüklemesine izin verin", + "trackOnly": "Sadece Takip Et", + "errorWithHttpStatusCode": "Hata {}", + "versionCorrectionDisabled": "Sürüm düzeltme devre dışı bırakıldı (eklenti çalışmıyor gibi görünüyor)", + "unknown": "Bilinmiyor", + "none": "Hiçbiri", + "never": "Asla", + "latestVersionX": "En Son Sürüm: {}", + "installedVersionX": "Yüklenen Sürüm: {}", + "lastUpdateCheckX": "Son Güncelleme Kontrolü: {}", + "remove": "Kaldır", + "yesMarkUpdated": "Evet, Güncellendi olarak İşaretle", + "fdroid": "F-Droid Resmi", + "appIdOrName": "Uygulama Kimliği veya Adı", + "appId": "Uygulama Kimliği", + "appWithIdOrNameNotFound": "Bu kimlik veya ada sahip bir uygulama bulunamadı", + "reposHaveMultipleApps": "Depolar birden fazla uygulama içerebilir", + "fdroidThirdPartyRepo": "F-Droid Üçüncü Taraf Depo", + "steam": "Steam", + "steamMobile": "Steam Mobile", + "steamChat": "Steam Sohbet", + "install": "Yükle", + "markInstalled": "Yüklendi olarak İşaretle", + "update": "Güncelle", + "markUpdated": "Güncellendi olarak İşaretle", + "additionalOptions": "Ek Seçenekler", + "disableVersionDetection": "Sürüm Algılama Devre Dışı", + "noVersionDetectionExplanation": "Bu seçenek, sürüm algılamanın doğru çalışmadığı uygulamalar için kullanılmalıdır.", + "downloadingX": "{} İndiriliyor", + "downloadNotifDescription": "Bir uygulamanın indirme sürecinde ilerlemeyi bildiren bir bildirim", + "noAPKFound": "APK bulunamadı", + "noVersionDetection": "Sürüm Algılanamıyor", + "categorize": "Kategorize Et", + "categories": "Kategoriler", + "category": "Kategori", + "noCategory": "Kategori Yok", + "noCategories": "Kategoriler Yok", + "deleteCategoriesQuestion": "Kategorileri Silmek İstiyor musunuz?", + "categoryDeleteWarning": "Silinen kategorilerdeki tüm uygulamalar kategorisiz olarak ayarlanacaktır.", + "addCategory": "Kategori Ekle", + "label": "Etiket", + "language": "Dil", + "copiedToClipboard": "Panoya Kopyalandı", + "storagePermissionDenied": "Depolama izni reddedildi", + "selectedCategorizeWarning": "Bu, seçilen uygulamalar için mevcut kategori ayarlarını değiştirecektir.", + "filterAPKsByRegEx": "APK'leri Düzenli İfade ile Filtrele", + "removeFromObtainium": "Obtainium'dan Kaldır", + "uninstallFromDevice": "Cihazdan Kaldır", + "onlyWorksWithNonVersionDetectApps": "Yalnızca Sürüm Algılaması Devre Dışı Uygulamalar İçin Çalışır.", + "releaseDateAsVersion": "Sürüm Olarak Yayın Tarihi Kullan", + "releaseDateAsVersionExplanation": "Bu seçenek, sürüm algılamanın doğru çalışmadığı ancak bir sürüm tarihinin mevcut olduğu uygulamalar için kullanılmalıdır.", + "changes": "Değişiklikler", + "releaseDate": "Yayın Tarihi", + "importFromURLsInFile": "Dosyadaki URL'lerden İçe Aktar", + "versionDetection": "Sürüm Tespiti", + "standardVersionDetection": "Standart sürüm tespiti", + "groupByCategory": "Kategoriye Göre Grupla", + "autoApkFilterByArch": "Mümkünse APK'leri CPU mimarisi ile filtreleme girişimi", + "overrideSource": "Kaynağı Geçersiz Kıl", + "dontShowAgain": "Bunu tekrar gösterme", + "dontShowTrackOnlyWarnings": "'Yalnızca Takip Edilen' uyarılarını gösterme", + "dontShowAPKOriginWarnings": "APK kaynağı uyarılarını gösterme", + "moveNonInstalledAppsToBottom": "Yüklenmemiş Uygulamaları Uygulamalar Görünümünün Altına Taşı", + "gitlabPATLabel": "GitLab Kişisel Erişim Belirteci\n(Arama ve Daha İyi APK Keşfi İçin)", + "about": "Hakkında", + "requiresCredentialsInSettings": "Bu, ek kimlik bilgilerine ihtiyaç duyar (Ayarlar'da)", + "checkOnStart": "Başlangıçta güncellemeleri kontrol et", + "tryInferAppIdFromCode": "Uygulama kimliğini kaynak kodundan çıkarma girişimi", + "removeOnExternalUninstall": "Harici kaldırmada otomatik olarak kaldırılan uygulamalar" + "pickHighestVersionCode": "Otomatik olarak en yüksek sürüm kodlu APK'yi seç", + "checkUpdateOnDetailPage": "Uygulama detay sayfasını açarken güncellemeleri kontrol et", + "disablePageTransitions": "Sayfa geçiş animasyonlarını devre dışı bırak", + "reversePageTransitions": "Sayfa geçiş animasyonlarını tersine çevir", + "minStarCount": "Minimum Yıldız Sayısı", + "addInfoBelow": "Bu bilgiyi aşağıya ekle.", + "addInfoInSettings": "Bu bilgiyi Ayarlar'da ekleyin.", + "githubSourceNote": "GitHub hız sınırlaması bir API anahtarı kullanılarak atlanabilir.", + "gitlabSourceNote": "GitLab APK çıkarma işlemi bir API anahtarı olmadan çalışmayabilir.", + "sortByFileNamesNotLinks": "Bağlantılar yerine dosya adlarına göre sırala", + "filterReleaseNotesByRegEx": "Sürüm Notlarını Düzenli İfade ile Filtrele", + "customLinkFilterRegex": "Özel APK Bağlantı Filtresi Düzenli İfade ile (Varsayılan '.apk$')", + "appsPossiblyUpdated": "Uygulama Güncellemeleri Denendi", + "appsPossiblyUpdatedNotifDescription": "Kullanıcıya bir veya daha fazla uygulamanın arka planda potansiyel olarak güncellendiğini bildiren bildirim", + "xWasPossiblyUpdatedToY": "{} muhtemelen {} sürümüne güncellendi." + "enableBackgroundUpdates": "Arka plan güncellemelerini etkinleştir", + "backgroundUpdateReqsExplanation": "Arka plan güncellemeleri tüm uygulamalar için mümkün olmayabilir.", + "backgroundUpdateLimitsExplanation": "Arka plan kurulumunun başarısı, Obtainium'un açıldığında ancak belirlenebilir.", + "verifyLatestTag": "'latest' etiketini doğrula", + "intermediateLinkRegex": "İlk Ziyaret Edilecek 'Ara' Bağlantısını Filtrele", + "intermediateLinkNotFound": "Ara bağlantı bulunamadı", + "exemptFromBackgroundUpdates": "Arka plan güncellemelerinden muaf tut (etkinse)", + "bgUpdatesOnWiFiOnly": "WiFi olmadığında arka plan güncellemelerini devre dışı bırak", + "autoSelectHighestVersionCode": "Otomatik olarak en yüksek sürüm kodunu seç", + "versionExtractionRegEx": "Sürüm Çıkarma Düzenli İfade", + "matchGroupToUse": "Sürüm Çıkarma Regex için Kullanılacak Eşleşme Grubu", + "highlightTouchTargets": "Daha az belirgin dokunma hedeflerini vurgula", + "pickExportDir": "Dışa Aktarılacak Klasörü Seç", + "autoExportOnChanges": "Değişikliklerde otomatik olarak dışa aktar", + "filterVersionsByRegEx": "Sürümleri Düzenli İfade ile Filtrele", + "trySelectingSuggestedVersionCode": "Önerilen sürüm kodunu seçmeyi dene", + "dontSortReleasesList": "API'den sıralama düzenini koru" + "reverseSort": "Ters sıralama", + "debugMenu": "Hata Ayıklama Menüsü", + "bgTaskStarted": "Arka plan görevi başladı - günlükleri kontrol et.", + "runBgCheckNow": "Arka Plan Güncelleme Kontrolünü Şimdi Çalıştır", + "versionExtractWholePage": "Tüm Sayfaya Sürüm Çıkarma Regex'i Uygula", + "installing": "Yükleniyor", + "skipUpdateNotifications": "Güncelleme bildirimlerini atla", + "updatesAvailableNotifChannel": "Güncellemeler Mevcut", + "appsUpdatedNotifChannel": "Güncellenen Uygulamalar", + "appsPossiblyUpdatedNotifChannel": "Uygulama Güncellemeleri Denendi", + "errorCheckingUpdatesNotifChannel": "Güncellemeler Kontrol Edilirken Hata", + "appsRemovedNotifChannel": "Kaldırılan Uygulamalar", + "downloadingXNotifChannel": "{} İndiriliyor", + "completeAppInstallationNotifChannel": "Uygulama Kurulumu Tamamlandı", + "checkingForUpdatesNotifChannel": "Güncellemeler Kontrol Ediliyor", + "onlyCheckInstalledOrTrackOnlyApps": "Yalnızca yüklü ve Yalnızca İzleme Uygulamalarını güncelleme" +"removeAppQuestion": { + "one": "Uygulamayı Kaldır?", + "other": "Uygulamaları Kaldır?" + }, + "tooManyRequestsTryAgainInMinutes": { + "one": "Çok fazla istek (hız sınırlı) - {} dakika sonra tekrar deneyin", + "other": "Çok fazla istek (hız sınırlı) - {} dakika sonra tekrar deneyin" + }, + "bgUpdateGotErrorRetryInMinutes": { + "one": "Arka plan güncelleme kontrolü bir hatayla karşılaştı, {} dakika sonra tekrar kontrol edilecek", + "other": "Arka plan güncelleme kontrolü bir hatayla karşılaştı, {} dakika sonra tekrar kontrol edilecek" + }, + "bgCheckFoundUpdatesWillNotifyIfNeeded": { + "one": "Arka plan güncelleme kontrolü {} güncelleme buldu - gerektiğinde kullanıcıyı bilgilendirecek", + "other": "Arka plan güncelleme kontrolü {} güncelleme buldu - gerektiğinde kullanıcıyı bilgilendirecek" + } + "apps": { + "one": "{} Uygulama", + "other": "{} Uygulamalar" + }, + "url": { + "one": "{} URL", + "other": "{} URL'ler" + }, + "minute": { + "one": "{} Dakika", + "other": "{} Dakika" + }, + "hour": { + "one": "{} Saat", + "other": "{} Saat" + }, + "day": { + "one": "{} Gün", + "other": "{} Gün" + }, + "clearedNLogsBeforeXAfterY": { + "one": "{n} log temizlendi (önce = {before}, sonra = {after})", + "other": "{n} log temizlendi (önce = {before}, sonra = {after})" + }, + "xAndNMoreUpdatesAvailable": { + "one": "{} ve 1 diğer uygulama güncellemeye sahip.", + "other": "{} ve {} daha fazla uygulama güncellemeye sahip." + }, + "xAndNMoreUpdatesInstalled": { + "one": "{} ve 1 diğer uygulama güncellendi.", + "other": "{} ve {} daha fazla uygulama güncellendi." + }, + "xAndNMoreUpdatesPossiblyInstalled": { + "one": "{} ve 1 diğer uygulama muhtemelen güncellendi.", + "other": "{} ve {} daha fazla uygulama muhtemelen güncellendi." + } +} diff --git a/assets/translations/vi.json b/assets/translations/vi.json new file mode 100644 index 0000000..4b66a9e --- /dev/null +++ b/assets/translations/vi.json @@ -0,0 +1,330 @@ +{ + "invalidURLForSource": "URL ứng dụng {} không hợp lệ", + "noReleaseFound": "Không thể tìm thấy bản phát hành phù hợp", + "noVersionFound": "Không thể xác định phiên bản phát hành", + "urlMatchesNoSource": "URL không khớp với nguồn đã biết", + "cantInstallOlderVersion": "Không thể cài đặt phiên bản cũ hơn của Ứng dụng", + "appIdMismatch": "ID gói đã tải xuống không khớp với ID ứng dụng hiện tại", + "functionNotImplemented": "Lớp này chưa triển khai chức năng này", + "placeholder": "Giữ chỗ", + "someErrors": "Đã xảy ra một số lỗi", + "unexpectedError": "Lỗi không mong đợi", + "ok": "Ôkê", + "and": "và", + "githubPATLabel": "Mã thông báo truy cập cá nhân GitHub (Tăng tốc độ giới hạn)", + "includePrereleases": "Bao gồm các bản phát hành trước", + "fallbackToOlderReleases": "Dự phòng về bản phát hành cũ hơn", + "filterReleaseTitlesByRegEx": "Lọc tiêu đề bản phát hành theo biểu thức chính quy", + "invalidRegEx": "Biểu thức chính quy không hợp lệ", + "noDescription": "Không có mô tả", + "cancel": "Hủy bỏ", + "continue": "Tiếp tục", + "requiredInBrackets": "(Yêu cầu)", + "dropdownNoOptsError": "LỖI: TẢI XUỐNG PHẢI CÓ ÍT NHẤT MỘT LỰA CHỌN", + "colour": "Màu sắc", + "githubStarredRepos": "Kho lưu trữ có gắn dấu sao GitHub", + "uname": "Tên người dùng", + "wrongArgNum": "Số lượng đối số được cung cấp sai", + "xIsTrackOnly": "{}là Chỉ-Theo dõi", + "source": "Nguồn", + "app": "Ứng dụng", + "appsFromSourceAreTrackOnly": "Các ứng dụng từ nguồn này là 'Chỉ-Theo dõi'.", + "youPickedTrackOnly": "Bạn đã chọn tùy chọn 'Chỉ-Theo dõi'.", + "trackOnlyAppDescription": "Ứng dụng sẽ được theo dõi để cập nhật, nhưng Obtainium sẽ không thể tải xuống hoặc cài đặt nó.", + "cancelled": "Đã hủy", + "appAlreadyAdded": "Ứng dụng được thêm rồi", + "alreadyUpToDateQuestion": "Ứng dụng đã được cập nhật?", + "addApp": "Thêm ứng dụng", + "appSourceURL": "URL nguồn ứng dụng", + "error": "Lỗi", + "add": "Thêm", + "searchSomeSourcesLabel": "Tìm kiếm (Chỉ một số nguồn)", + "search": "Tìm kiếm", + "additionalOptsFor": "Tùy chọn bổ sung cho {}", + "supportedSources": "Nguồn được hỗ trợ", + "trackOnlyInBrackets": "(Chỉ-Theo dõi)", + "searchableInBrackets": "(Có thể tìm kiếm)", + "appsString": "Ứng dụng", + "noApps": "Không có ứng dụng", + "noAppsForFilter": "Không có ứng dụng cho bộ lọc", + "byX": "Bởi {}", + "percentProgress": "Tiến triển: {}%", + "pleaseWait": "Vui lòng chờ", + "updateAvailable": "Có sẵn bản cập nhật", + "estimateInBracketsShort": "(Ước lượng.)", + "notInstalled": "Chưa cài đặt", + "estimateInBrackets": "(Ước lượng)", + "selectAll": "Chọn tất cả", + "deselectN": "Bỏ chọn {}", + "xWillBeRemovedButRemainInstalled": "{} sẽ bị xóa khỏi Obtainium nhưng vẫn còn cài đặt trên thiết bị.", + "removeSelectedAppsQuestion": "Xóa ứng dụng đã chọn?", + "removeSelectedApps": "Xóa ứng dụng đã chọn", + "updateX": "Cập nhật {}", + "installX": "Cài đặt {}", + "markXTrackOnlyAsUpdated": "Đánh dấu {}\n(Chỉ-Theo dõi)\nnhư là đã cập nhật", + "changeX": "Thay đổi {}", + "installUpdateApps": "Cài đặt/Cập nhật ứng dụng", + "installUpdateSelectedApps": "Cài đặt/Cập nhật ứng dụng đã chọn", + "markXSelectedAppsAsUpdated": "Đánh dấu {} ứng dụng đã chọn là đã cập nhật?", + "no": "Không", + "yes": "Đúng", + "markSelectedAppsUpdated": "Đánh dấu các ứng dụng đã chọn là đã cập nhật", + "pinToTop": "Ghim đầu trang", + "unpinFromTop": "Bỏ ghim khỏi đầu trang", + "resetInstallStatusForSelectedAppsQuestion": "Đặt lại trạng thái cài đặt cho ứng dụng đã chọn?", + "installStatusOfXWillBeResetExplanation": "Trạng thái cài đặt của mọi Ứng dụng đã chọn sẽ được đặt lại.\n\nĐiều này có thể hữu ích khi phiên bản Ứng dụng hiển thị trong Obtainium không chính xác do cập nhật không thành công hoặc các sự cố khác.", + "shareSelectedAppURLs": "Chia sẻ URL ứng dụng đã chọn", + "resetInstallStatus": "Đặt lại trạng thái cài đặt", + "more": "Nhiều hơn", + "removeOutdatedFilter": "Xóa bộ lọc ứng dụng lỗi thời", + "showOutdatedOnly": "Chỉ hiển thị các ứng dụng lỗi thời", + "filter": "Lọc", + "filterActive": "Lọc *", + "filterApps": "Lọc ứng dụng", + "appName": "Tên ứng dụng", + "author": "Tác giả", + "upToDateApps": "Ứng dụng cập nhật", + "nonInstalledApps": "Ứng dụng chưa được cài đặt", + "importExport": "Nhập/Xuất", + "settings": "Cài đặt", + "exportedTo": "Đã xuất sang {}", + "obtainiumExport": "Xuất Obtainium", + "invalidInput": "Đầu vào không hợp lệ", + "importedX": "Đã nhập {}", + "obtainiumImport": "Nhập Obtainium", + "importFromURLList": "Nhập từ danh sách URL", + "searchQuery": "Truy vấn tìm kiếm", + "appURLList": "Danh sách URL ứng dụng", + "line": "Hàng", + "searchX": "Tìm kiếm {}", + "noResults": "Không có kết quả nào được tìm thấy", + "importX": "Nhập {}", + "importedAppsIdDisclaimer": "Ứng dụng đã nhập có thể hiển thị không chính xác là \"Chưa được cài đặt\".\nĐể khắc phục sự cố này, hãy cài đặt lại chúng thông qua Obtainium.\nĐiều này sẽ không ảnh hưởng đến dữ liệu Ứng dụng.\n\nChỉ ảnh hưởng đến URL và phương thức nhập của bên thứ ba.", + "importErrors": "Lỗi nhập", + "importedXOfYApps": "{} trong số {} Ứng dụng đã được nhập.", + "followingURLsHadErrors": "Các URL sau có lỗi:", + "okay": "Ôkê", + "selectURL": "Chọn URL", + "selectURLs": "Chọn URL", + "pick": "Chọn", + "theme": "Chủ đề", + "dark": "Tối", + "light": "Sáng", + "followSystem": "Theo hệ thống", + "obtainium": "Obtainium", + "materialYou": "Material You", + "useBlackTheme": "Sử dụng chủ đề tối màu đen thuần túy", + "appSortBy": "Sắp xếp ứng dụng theo", + "authorName": "Tác giả/Tên", + "nameAuthor": "Tên/Tác giả", + "asAdded": "Như đã thêm", + "appSortOrder": "Thứ tự sắp xếp ứng dụng", + "ascending": "Tăng dần", + "descending": "Giảm dần", + "bgUpdateCheckInterval": "Khoảng thời gian kiểm tra cập nhật nền", + "neverManualOnly": "Không bao giờ - Chỉ thủ công", + "appearance": "Vẻ ngoài", + "showWebInAppView": "Hiển thị trang web Nguồn trong chế độ xem Ứng dụng", + "pinUpdates": "Ghim nội dung cập nhật lên đầu chế độ xem Ứng dụng", + "updates": "Cập nhật", + "sourceSpecific": "Nguồn cụ thể", + "appSource": "Nguồn ứng dụng", + "noLogs": "Không có nhật ký", + "appLogs": "Nhật ký ứng dụng", + "close": "Đóng", + "share": "Chia sẻ", + "appNotFound": "Không tìm thấy ứng dụng", + "obtainiumExportHyphenatedLowercase": "xuất khẩu-obtainium", + "pickAnAPK": "Chọn một APK", + "appHasMoreThanOnePackage": "{} có nhiều gói:", + "deviceSupportsXArch": "Thiết bị của bạn hỗ trợ kiến trúc CPU {}.", + "deviceSupportsFollowingArchs": "Thiết bị của bạn hỗ trợ các kiến trúc CPU sau:", + "warning": "Cảnh báo", + "sourceIsXButPackageFromYPrompt": "Nguồn ứng dụng là '{}' nhưng gói phát hành đến từ '{}'. Tiếp tục?", + "updatesAvailable": "Cập nhật có sẵn", + "updatesAvailableNotifDescription": "Thông báo cho người dùng rằng có bản cập nhật cho một hoặc nhiều Ứng dụng được theo dõi bởi Obtainium", + "noNewUpdates": "Không có bản cập nhật mới.", + "xHasAnUpdate": "{} có bản cập nhật.", + "appsUpdated": "Ứng dụng đã cập nhật ", + "appsUpdatedNotifDescription": "Thông báo cho người dùng rằng các bản cập nhật cho một hoặc nhiều Ứng dụng đã được áp dụng trong nền", + "xWasUpdatedToY": "{} đã được cập nhật thành {}.", + "errorCheckingUpdates": "Lỗi kiểm tra bản cập nhật", + "errorCheckingUpdatesNotifDescription": "Thông báo hiển thị khi kiểm tra cập nhật nền không thành công", + "appsRemoved": "Ứng dụng đã loại bỏ", + "appsRemovedNotifDescription": "Thông báo cho người dùng rằng một hoặc nhiều Ứng dụng đã bị loại bỏ do lỗi khi tải chúng", + "xWasRemovedDueToErrorY": "{} đã bị loại bỏ do lỗi này: {}", + "completeAppInstallation": "Hoàn tất cài đặt ứng dụng", + "obtainiumMustBeOpenToInstallApps": "Obtainium phải được mở để cài đặt Ứng dụng", + "completeAppInstallationNotifDescription": "Yêu cầu người dùng quay lại Obtainium để hoàn tất cài đặt Ứng dụng", + "checkingForUpdates": "Đang kiểm tra cập nhật", + "checkingForUpdatesNotifDescription": "Thông báo tạm thời xuất hiện khi kiểm tra bản cập nhật", + "pleaseAllowInstallPerm": "Vui lòng cho phép Obtainium cài đặt Ứng dụng", + "trackOnly": "Chỉ-Theo dõi", + "errorWithHttpStatusCode": "Lỗi {}", + "versionCorrectionDisabled": "Tính năng sửa phiên bản bị vô hiệu hóa (plugin dường như không hoạt động)", + "unknown": "Không xác định", + "none": "Không", + "never": "Không bao giờ", + "latestVersionX": "Phiên bản mới nhất: {}", + "installedVersionX": "Phiên bản đã cài đặt: {}", + "lastUpdateCheckX": "Kiểm tra cập nhật lần cuối: {}", + "remove": "Loại bỏ", + "yesMarkUpdated": "Có, Đánh dấu là đã cập nhật", + "fdroid": "Chính thức của F-Droid", + "appIdOrName": "ID hoặc tên ứng dụng", + "appId": "ID ứng dụng", + "appWithIdOrNameNotFound": "Không tìm thấy ứng dụng nào có ID hoặc tên đó", + "reposHaveMultipleApps": "Kho có thể chứa nhiều Ứng dụng", + "fdroidThirdPartyRepo": "Kho lưu trữ bên thứ ba F-Droid", + "steam": "Steam", + "steamMobile": "Steam Mobile", + "steamChat": "Steam Chat", + "install": "Cài đặt", + "markInstalled": "Đánh dấu là đã cài đặt", + "update": "Cập nhật", + "markUpdated": "Đánh dấu đã cập nhật", + "additionalOptions": "Tùy chọn bổ sung", + "disableVersionDetection": "Tắt tính năng phát hiện phiên bản", + "noVersionDetectionExplanation": "Chỉ nên sử dụng tùy chọn này cho Ứng dụng mà tính năng phát hiện phiên bản không hoạt động chính xác.", + "downloadingX": "Đang tải xuống {}", + "downloadNotifDescription": "Thông báo cho người dùng về tiến trình tải xuống Ứng dụng", + "noAPKFound": "Không tìm thấy APK", + "noVersionDetection": "Không phát hiện phiên bản", + "categorize": "Phân loại", + "categories": "Thể loại", + "category": "Thể loại", + "noCategory": "Không thể loại", + "noCategories": "Không thể loại", + "deleteCategoriesQuestion": "Xóa thể loại?", + "categoryDeleteWarning": "Tất cả ứng dụng trong thể loại đã xóa sẽ được đặt thành chưa được phân loại.", + "addCategory": "Thêm thể loại", + "label": "Nhãn", + "language": "Ngôn ngữ", + "copiedToClipboard": "Sao chép vào clipboard", + "storagePermissionDenied": "Quyền lưu trữ bị từ chối", + "selectedCategorizeWarning": "Điều này sẽ thay thế mọi cài đặt thể loại hiện có cho Ứng dụng đã chọn.", + "filterAPKsByRegEx": "Lọc APK theo biểu thức chính quy", + "removeFromObtainium": "Loại khỏi Obtainium", + "uninstallFromDevice": "Gỡ cài đặt khỏi thiết bị", + "onlyWorksWithNonVersionDetectApps": "Chỉ hoạt động với Ứng dụng đã tắt tính năng phát hiện phiên bản.", + "releaseDateAsVersion": "Sử dụng ngày phát hành làm phiên bản", + "releaseDateAsVersionExplanation": "Chỉ nên sử dụng tùy chọn này cho Ứng dụng trong đó tính năng phát hiện phiên bản không hoạt động chính xác nhưng đã có ngày phát hành.", + "changes": "Thay đổi", + "releaseDate": "Ngày phát hành", + "importFromURLsInFile": "Nhập từ URL trong Tệp (như OPML)", + "versionDetection": "Phát hiện phiên bản", + "standardVersionDetection": "Phát hiện phiên bản tiêu chuẩn", + "groupByCategory": "Nhóm theo thể loại", + "autoApkFilterByArch": "Cố gắng lọc APK theo kiến trúc CPU nếu có thể", + "overrideSource": "Ghi đè nguồn", + "dontShowAgain": "Đừng hiển thị thông tin này nữa", + "dontShowTrackOnlyWarnings": "Không hiển thị cảnh báo 'Chỉ-Theo dõi'", + "dontShowAPKOriginWarnings": "Không hiển thị cảnh báo nguồn gốc APK", + "moveNonInstalledAppsToBottom": "Di chuyển Ứng dụng chưa được cài đặt xuống cuối chế độ xem Ứng dụng", + "gitlabPATLabel": "Mã thông báo truy cập cá nhân GitLab\n(Cho phép tìm kiếm và khám phá APK tốt hơn)", + "about": "Giới thiệu", + "requiresCredentialsInSettings": "Điều này cần thông tin xác thực bổ sung (trong Cài đặt)", + "checkOnStart": "Kiểm tra các bản cập nhật khi khởi động", + "tryInferAppIdFromCode": "Thử suy ra ID ứng dụng từ mã nguồn", + "removeOnExternalUninstall": "Tự động xóa ứng dụng đã gỡ cài đặt bên ngoài", + "pickHighestVersionCode": "Tự động chọn APK mã phiên bản cao nhất", + "checkUpdateOnDetailPage": "Kiểm tra các bản cập nhật khi mở trang chi tiết Ứng dụng", + "disablePageTransitions": "Tắt hoạt ảnh chuyển trang", + "reversePageTransitions": "Hoạt ảnh chuyển đổi trang đảo ngược", + "minStarCount": "Số lượng sao tối thiểu", + "addInfoBelow": "Thêm thông tin này vào bên dưới.", + "addInfoInSettings": "Thêm thông tin này vào Cài đặt.", + "githubSourceNote": "Có thể tránh được việc giới hạn tốc độ GitHub bằng cách sử dụng khóa API.", + "gitlabSourceNote": "Trích xuất APK GitLab có thể không hoạt động nếu không có khóa API.", + "sortByFileNamesNotLinks": "Sắp xếp theo tên tệp thay vì liên kết đầy đủ", + "filterReleaseNotesByRegEx": "Lọc ghi chú phát hành theo biểu thức chính quy", + "customLinkFilterRegex": "Bộ lọc liên kết APK tùy chỉnh theo biểu thức chính quy (Mặc định '.apk$')", + "appsPossiblyUpdated": "Đã cố gắng cập nhật ứng dụng", + "appsPossiblyUpdatedNotifDescription": "Thông báo cho người dùng rằng các bản cập nhật cho một hoặc nhiều Ứng dụng có khả năng được áp dụng trong nền", + "xWasPossiblyUpdatedToY": "{} có thể đã được cập nhật thành {}.", + "enableBackgroundUpdates": "Bật cập nhật nền", + "backgroundUpdateReqsExplanation": "Có thể không thực hiện được cập nhật nền cho tất cả ứng dụng.", + "backgroundUpdateLimitsExplanation": "Sự thành công của cài đặt nền chỉ có thể được xác định khi mở Obtainium.", + "verifyLatestTag": "Xác minh thẻ 'mới nhất'", + "intermediateLinkRegex": "Lọc tìm liên kết 'Trung gian' để truy cập trước", + "intermediateLinkNotFound": "Không tìm thấy liên kết trung gian", + "exemptFromBackgroundUpdates": "Miễn cập nhật nền (nếu được bật)", + "bgUpdatesOnWiFiOnly": "Tắt cập nhật nền khi không có WiFi", + "autoSelectHighestVersionCode": "Tự động chọn APK mã phiên bản cao nhất", + "versionExtractionRegEx": "Trích xuất phiên bản RegEx", + "matchGroupToUse": "Nhóm đối sánh để sử dụng cho Regex trích xuất phiên bản", + "highlightTouchTargets": "Đánh dấu các mục tiêu cảm ứng ít rõ ràng hơn", + "pickExportDir": "Chọn thư mục xuất", + "autoExportOnChanges": "Tự động xuất khi thay đổi", + "filterVersionsByRegEx": "Lọc phiên bản theo biểu thức chính quy", + "trySelectingSuggestedVersionCode": "Thử chọn APK Mã phiên bản được đề xuất", + "dontSortReleasesList": "Giữ lại thứ tự phát hành từ API", + "reverseSort": "Sắp xếp ngược", + "debugMenu": "Danh sách gỡ lỗi", + "bgTaskStarted": "Tác vụ nền đã bắt đầu - kiểm tra nhật ký.", + "runBgCheckNow": "Chạy kiểm tra cập nhật nền ngay bây giờ", + "versionExtractWholePage": "Áp dụng Regex trích xuất phiên bản cho toàn bộ trang", + "installing": "Đang cài đặt", + "skipUpdateNotifications": "Bỏ qua thông báo cập nhật", + "updatesAvailableNotifChannel": "Cập nhật có sẵn", + "appsUpdatedNotifChannel": "Đã cập nhật ứng dụng", + "appsPossiblyUpdatedNotifChannel": "Đã cố gắng cập nhật ứng dụng", + "errorCheckingUpdatesNotifChannel": "Lỗi kiểm tra bản cập nhật", + "appsRemovedNotifChannel": "Ứng dụng đã bị loại bỏ", + "downloadingXNotifChannel": "Đang tải xuống {}", + "completeAppInstallationNotifChannel": "Hoàn tất cài đặt ứng dụng", + "checkingForUpdatesNotifChannel": "Đang kiểm tra cập nhật", + "onlyCheckInstalledOrTrackOnlyApps": "Chỉ kiểm tra các ứng dụng đã cài đặt và Chỉ-Theo dõi để biết các bản cập nhật", + "removeAppQuestion":{ + "one": "Gỡ ứng dụng?", + "other": "Gỡ ứng dụng?" + }, + "tooManyRequestsTryAgainInMinutes":{ + "one": "Quá nhiều yêu cầu (tốc độ giới hạn) - hãy thử lại sau {} phút", + "other": "Quá nhiều yêu cầu (tốc độ giới hạn) - hãy thử lại sau {} phút" + }, + "bgUpdateGotErrorRetryInMinutes":{ + "one": "Việc kiểm tra bản cập nhật BG gặp phải {}, sẽ lên lịch kiểm tra lại sau {} phút", + "other": "Việc kiểm tra bản cập nhật BG gặp phải {}, sẽ lên lịch kiểm tra lại sau {} phút" + }, + "bgCheckFoundUpdatesWillNotifyIfNeeded":{ + "one": "Đang kiểm tra bản cập nhật BG tìm thấy {} bản cập nhật - sẽ thông báo cho người dùng nếu cần", + "other": "Đang kiểm tra bản cập nhật BG tìm thấy {} bản cập nhật - sẽ thông báo cho người dùng nếu cần" + }, + "apps":{ + "one": "{} Ứng dụng", + "other": "{} Ứng dụng" + }, + "url":{ + "one": "{} URL", + "other": "{} URL" + }, + "minute":{ + "one": "{} Phút", + "other": "{} Phút" + }, + "hour":{ + "one": "{} Giờ", + "other": "{} Giờ" + }, + "day":{ + "one": "{} Ngày", + "other": "{} ngày" + }, + "clearedNLogsBeforeXAfterY":{ + "one": "Đã xóa {n} nhật ký (trước = {trước}, sau = {sau})", + "other": "Đã xóa {n} nhật ký (trước = {trước}, sau = {sau})" + }, + "xAndNMoreUpdatesAvailable":{ + "one": "{} và 1 ứng dụng khác có bản cập nhật.", + "other": "{} và {} ứng dụng khác có bản cập nhật." + }, + "xAndNMoreUpdatesInstalled":{ + "one": "{} và 1 ứng dụng khác đã được cập nhật.", + "other": "{} và {} ứng dụng khác đã được cập nhật." + }, + "xAndNMoreUpdatesPossiblyInstalled":{ + "one": "{} và 1 ứng dụng khác có thể đã được cập nhật.", + "other": "{} và {} ứng dụng khác có thể đã được cập nhật." + } +} diff --git a/assets/translations/zh.json b/assets/translations/zh.json index 0d271b1..314d9fe 100644 --- a/assets/translations/zh.json +++ b/assets/translations/zh.json @@ -1,106 +1,96 @@ { - "invalidURLForSource": "不是一个有效的 {} URL", - "noReleaseFound": "找不到合适的更新", - "noVersionFound": "无法确定更新版本", - "urlMatchesNoSource": "URL 与已知来源不符", - "cantInstallOlderVersion": "无法安装旧版应用程序", - "appIdMismatch": "下载的软件包名与现有的应用程序包名不一致", - "functionNotImplemented": "该类没有实现此功能", + "invalidURLForSource": "无效的 {} URL", + "noReleaseFound": "找不到合适的发行版", + "noVersionFound": "无法确定发行版本号", + "urlMatchesNoSource": "URL 与已知的来源不符", + "cantInstallOlderVersion": "无法安装旧版本的应用", + "appIdMismatch": "所下载 APK 的应用 ID 与现有应用不一致", + "functionNotImplemented": "该类未实现此功能", "placeholder": "占位符", "someErrors": "出现了一些错误", "unexpectedError": "意外错误", "ok": "好的", "and": "和", - "startedBgUpdateTask": "开始后台检查更新任务", - "bgUpdateIgnoreAfterIs": "下次后台更新检查 {}", - "startedActualBGUpdateCheck": "后台检查更新已开始", - "bgUpdateTaskFinished": "后台检查更新已完成", - "firstRun": "这是你第一次运行 Obtainium", - "settingUpdateCheckIntervalTo": "设置检查更新间隔为 {}", - "githubPATLabel": "GitHub 个人访问令牌 (提高 API 限制)", - "githubPATHint": "个人访问令牌必须为: username:token 形式", - "githubPATFormat": "username:token", - "githubPATLinkText": "关于 GitHub 个人访问令牌", - "includePrereleases": "包含预发布版", - "fallbackToOlderReleases": "回退到旧版", - "filterReleaseTitlesByRegEx": "使用正则以过滤发布标题", - "invalidRegEx": "表达式无效", + "githubPATLabel": "GitHub 个人访问令牌(提升 API 请求限额)", + "includePrereleases": "包含预发行版", + "fallbackToOlderReleases": "将旧发行版作为备选", + "filterReleaseTitlesByRegEx": "筛选发行标题(正则表达式)", + "invalidRegEx": "无效的正则表达式", "noDescription": "无描述", "cancel": "取消", "continue": "继续", - "requiredInBrackets": "(必须)", - "dropdownNoOptsError": "错误:下拉菜单必须至少有一个选项", - "colour": "颜色", + "requiredInBrackets": "(必填)", + "dropdownNoOptsError": "错误:下拉菜单必须包含至少一个选项", + "colour": "配色", "githubStarredRepos": "GitHub 已星标仓库", "uname": "用户名", - "wrongArgNum": "提供了错误的参数数量", - "xIsTrackOnly": "{} 仅追踪", - "source": "源码", - "app": "应用程序", - "appsFromSourceAreTrackOnly": "来自此来源的应用为仅追踪", - "youPickedTrackOnly": "你已选择仅追踪选项", - "trackOnlyAppDescription": "该应用程序将被跟踪更新,但 Obtainium 无法下载或安装它", + "wrongArgNum": "参数数量错误", + "xIsTrackOnly": "{}为“仅追踪”模式", + "source": "源代码", + "app": "应用", + "appsFromSourceAreTrackOnly": "此来源的应用为“仅追踪”模式。", + "youPickedTrackOnly": "您选择了“仅追踪”。", + "trackOnlyAppDescription": "该应用的更新会被追踪,但 Obtainium 无法下载或安装它。", "cancelled": "已取消", - "appAlreadyAdded": "此应用程序已被添加", - "alreadyUpToDateQuestion": "应用已是最新?", + "appAlreadyAdded": "此应用已经添加", + "alreadyUpToDateQuestion": "应用是否已经为最新版本?", "addApp": "添加应用", - "appSourceURL": "应用来源 URL", + "appSourceURL": "来源 URL", "error": "错误", "add": "添加", - "searchSomeSourcesLabel": "搜索 (仅部分来源)", + "searchSomeSourcesLabel": "搜索(仅支持部分来源)", "search": "搜索", "additionalOptsFor": "{} 的更多选项", - "supportedSourcesBelow": "受支持的来源:", + "supportedSources": "支持的来源", "trackOnlyInBrackets": "(仅追踪)", - "searchableInBrackets": "(可被搜索)", - "appsString": "应用程序", - "noApps": "无应用程序", - "noAppsForFilter": "没有应用可被过滤", - "byX": "来自 {}", - "percentProgress": "进度: {}%", - "pleaseWait": "请等待...", + "searchableInBrackets": "(可搜索)", + "appsString": "应用列表", + "noApps": "无应用", + "noAppsForFilter": "没有符合条件的应用", + "byX": "作者:{}", + "percentProgress": "进度:{}%", + "pleaseWait": "请稍候", "updateAvailable": "更新可用", - "estimateInBracketsShort": "(预计.)", + "estimateInBracketsShort": "(推测)", "notInstalled": "未安装", - "estimateInBrackets": "(预计)", + "estimateInBrackets": "(推测)", "selectAll": "全选", "deselectN": "取消选择 {}", - "xWillBeRemovedButRemainInstalled": "{} 将被从 Obtainium 中删除,但仍安装在设备上。", - "removeSelectedAppsQuestion": "删除已选择的应用程序吗?", - "removeSelectedApps": "删除已选择的应用程序", + "xWillBeRemovedButRemainInstalled": "{} 将从 Obtainium 中删除,但仍安装在您的设备中。", + "removeSelectedAppsQuestion": "是否删除选中的应用?", + "removeSelectedApps": "删除选中的应用", "updateX": "更新 {}", "installX": "安装 {}", - "markXTrackOnlyAsUpdated": "将仅追踪编辑为已更新", + "markXTrackOnlyAsUpdated": "将 {}\n(仅追踪)\n标记为已更新", "changeX": "更改 {}", - "installUpdateApps": "安装/更新应用程序", - "installUpdateSelectedApps": "安装/更新已选择的应用程序", - "onlyAppliesToInstalledAndOutdatedApps": "'只适用于已安装但已过时的应用程序", - "markXSelectedAppsAsUpdated": "将已选择的 {} 个应用程序标记为已更新?", - "no": "不要", - "yes": "好的", - "markSelectedAppsUpdated": "标记已选择的应用程序为已更新", + "installUpdateApps": "安装/更新应用", + "installUpdateSelectedApps": "安装/更新选中的应用", + "markXSelectedAppsAsUpdated": "是否将选中的 {} 个应用标记为已更新?", + "no": "否", + "yes": "是", + "markSelectedAppsUpdated": "将选中的应用标记为已更新", "pinToTop": "置顶", "unpinFromTop": "取消置顶", - "resetInstallStatusForSelectedAppsQuestion": "为已选择的应用程序重置安装状态吗?", - "installStatusOfXWillBeResetExplanation": "当 Obtainium 中显示的应用程序版本由于更新失败或其他问题而不正确时,这将有助于重置任何选定应用程序的安装状态。", - "shareSelectedAppURLs": "分享已选择的应用程序 URL", + "resetInstallStatusForSelectedAppsQuestion": "是否重置选中应用的安装状态?", + "installStatusOfXWillBeResetExplanation": "选中应用的安装状态将会被重置。\n\n当更新安装失败或其他问题导致 Obtainium 中的应用版本显示错误时,可以尝试通过此方法解决。", + "shareSelectedAppURLs": "分享选中应用的 URL", "resetInstallStatus": "重置安装状态", "more": "更多", - "removeOutdatedFilter": "删除过时的应用程序过滤器", - "showOutdatedOnly": "只显示过时的应用程序", - "filter": "过滤器", - "filterActive": "过滤器 *", - "filterApps": "过滤应用", + "removeOutdatedFilter": "删除失效的应用筛选", + "showOutdatedOnly": "只显示待更新应用", + "filter": "筛选", + "filterActive": "筛选 *", + "filterApps": "筛选应用", "appName": "应用名称", "author": "作者", - "upToDateApps": "已更新的应用程序", - "nonInstalledApps": "未安装的应用程序", + "upToDateApps": "无需更新的应用", + "nonInstalledApps": "未安装的应用", "importExport": "导入/导出", "settings": "设置", - "exportedTo": "导出到 {}", + "exportedTo": "已导出至 {}", "obtainiumExport": "Obtainium 导出", - "invalidInput": "无效输入", - "importedX": "已导出到 {}", + "invalidInput": "无效的输入", + "importedX": "已导入 {}", "obtainiumImport": "Obtainium 导入", "importFromURLList": "从 URL 列表导入", "searchQuery": "搜索查询", @@ -109,13 +99,13 @@ "searchX": "搜索 {}", "noResults": "无结果", "importX": "导入 {}", - "importedAppsIdDisclaimer": "导入的应用程序可能显示为未安装。要解决这个问题,请通过 Obtainium 重新安装它们。", + "importedAppsIdDisclaimer": "导入的应用可能会错误地显示为“未安装”状态。\n请通过 Obtainium 重新安装这些应用来解决此问题。", "importErrors": "导入错误", - "importedXOfYApps": "{} 中的 {} 个应用已导入", - "followingURLsHadErrors": "以下 URL 有错误:", + "importedXOfYApps": "已导入 {} 中的 {} 个应用。", + "followingURLsHadErrors": "下列 URL 存在错误:", "okay": "好的", - "selectURL": "已选择的 URL", - "selectURLs": "已选择的 URL", + "selectURL": "选择 URL", + "selectURLs": "选择 URL", "pick": "选择", "theme": "主题", "dark": "深色", @@ -123,68 +113,69 @@ "followSystem": "跟随系统", "obtainium": "Obtainium", "materialYou": "Material You", - "appSortBy": "排列方式", - "authorName": "作者 / 名字", - "nameAuthor": "名字 / 作者", - "asAdded": "添加顺序", - "appSortOrder": "排列顺序", + "useBlackTheme": "使用纯黑深色主题", + "appSortBy": "排序依据", + "authorName": "作者 / 应用名称", + "nameAuthor": "应用名称 / 作者", + "asAdded": "添加次序", + "appSortOrder": "顺序", "ascending": "升序", "descending": "降序", "bgUpdateCheckInterval": "后台更新检查间隔", "neverManualOnly": "手动", "appearance": "外观", - "showWebInAppView": "在应用来源页显示网页", - "pinUpdates": "需更新的应用置顶", - "updates": "检查间隔", - "sourceSpecific": "Github 访问令牌", + "showWebInAppView": "在应用详情页显示来源网页", + "pinUpdates": "将待更新应用置顶", + "updates": "更新", + "sourceSpecific": "来源", "appSource": "源代码", "noLogs": "无日志", - "appLogs": "应用日志", + "appLogs": "日志", "close": "关闭", "share": "分享", "appNotFound": "未找到应用", - "obtainiumExportHyphenatedLowercase": "obtainium-导出", - "pickAnAPK": "选择一个安装包", + "obtainiumExportHyphenatedLowercase": "obtainium-export", + "pickAnAPK": "选择一个 APK 文件", "appHasMoreThanOnePackage": "{} 有多个架构可用:", - "deviceSupportsXArch": "你的设备支持 {} 架构", - "deviceSupportsFollowingArchs": "你的设备支持以下架构:", + "deviceSupportsXArch": "您的设备支持 {} 架构。", + "deviceSupportsFollowingArchs": "您的设备支持下列架构:", "warning": "警告", - "sourceIsXButPackageFromYPrompt": "此应用来源是 '{}' 但更新包来自 '{}'。 继续吗?", + "sourceIsXButPackageFromYPrompt": "此应用的来源是“{}”,但 APK 文件来自“{}”。是否继续?", "updatesAvailable": "更新可用", - "updatesAvailableNotifDescription": "通知 Obtainium 所跟踪应用程序的更新", - "noNewUpdates": "你的应用已是最新。", - "xHasAnUpdate": "{} 有更新啦", + "updatesAvailableNotifDescription": "Obtainium 追踪的应用有更新时发送通知", + "noNewUpdates": "全部应用已是最新。", + "xHasAnUpdate": "{} 可以更新了。", "appsUpdated": "应用已更新", - "appsUpdatedNotifDescription": "通知在后台安装应用程序的更新", - "xWasUpdatedToY": "{} 已更新到 {}.", + "appsUpdatedNotifDescription": "当应用在后台安装更新时发送通知", + "xWasUpdatedToY": "{} 已更新至 {}。", "errorCheckingUpdates": "检查更新出错", - "errorCheckingUpdatesNotifDescription": "当后台更新检查失败时显示的通知", + "errorCheckingUpdatesNotifDescription": "当后台检查更新失败时显示的通知", "appsRemoved": "应用已删除", - "appsRemovedNotifDescription": "通知由于加载应用程序时出错而被删除", - "xWasRemovedDueToErrorY": "{} 已因以下错误被删除: {}", + "appsRemovedNotifDescription": "当应用因加载出错而被删除时发送通知", + "xWasRemovedDueToErrorY": "{} 由于以下错误被删除:{}", "completeAppInstallation": "完成应用安装", - "obtainiumMustBeOpenToInstallApps": "Obtainium 需要被启动以安装更新", - "completeAppInstallationNotifDescription": "需要返回 Obtainium,以完成应用程序的安装。", - "checkingForUpdates": "检查更新中", - "checkingForUpdatesNotifDescription": "检查更新时出现的瞬时通知", - "pleaseAllowInstallPerm": "请允许 Obtainium 安装应用程序", + "obtainiumMustBeOpenToInstallApps": "必须启动 Obtainium 才能安装应用", + "completeAppInstallationNotifDescription": "提示返回 Obtainium 以完成应用的安装", + "checkingForUpdates": "正在检查更新", + "checkingForUpdatesNotifDescription": "检查更新时短暂显示的通知", + "pleaseAllowInstallPerm": "请授予 Obtainium 安装应用的权限", "trackOnly": "仅追踪", "errorWithHttpStatusCode": "错误 {}", - "versionCorrectionDisabled": "禁用版本更正(插件似乎未起作用)", + "versionCorrectionDisabled": "禁用版本号更正(插件似乎未起作用)", "unknown": "未知", "none": "无", - "never": "从不", - "latestVersionX": "最新: {}", - "installedVersionX": "已安装: {}", - "lastUpdateCheckX": "最后检查: {}", + "never": "从未", + "latestVersionX": "最新版本:{}", + "installedVersionX": "当前版本:{}", + "lastUpdateCheckX": "上次更新检查:{}", "remove": "删除", - "removeAppQuestion": "删除应用?", - "yesMarkUpdated": "'是的,标为已更新", - "fdroid": "F-Droid", + "yesMarkUpdated": "是,标记为已更新", + "fdroid": "F-Droid 官方存储库", "appIdOrName": "应用 ID 或名称", - "appWithIdOrNameNotFound": "没有发现具有此 ID 或名称的应用", - "reposHaveMultipleApps": "来源可能包含多个应用", - "fdroidThirdPartyRepo": "F-Droid 第三方源", + "appId": "应用 ID", + "appWithIdOrNameNotFound": "未找到符合此 ID 或名称的应用", + "reposHaveMultipleApps": "存储库中可能包含多个应用", + "fdroidThirdPartyRepo": "F-Droid 第三方存储库", "steam": "Steam", "steamMobile": "Steam Mobile", "steamChat": "Steam Chat", @@ -193,35 +184,112 @@ "update": "更新", "markUpdated": "标记为已更新", "additionalOptions": "附加选项", - "disableVersionDetection": "关闭版本检测", - "noVersionDetectionExplanation": "此选项应只用于版本检测不能工作的应用程序", - "downloadingX": "下载中 {}", - "downloadNotifDescription": "通知用户下载进度", - "noAPKFound": "未找到安装包", - "noVersionDetection": "无版本检测", - "categorize": "归档", - "categories": "归档", + "disableVersionDetection": "禁用版本检测", + "noVersionDetectionExplanation": "此选项应该仅用于无法进行版本检测的应用。", + "downloadingX": "正在下载{}", + "downloadNotifDescription": "提示应用的下载进度", + "noAPKFound": "未找到 APK 文件", + "noVersionDetection": "禁用版本检测", + "categorize": "分类", + "categories": "类别", "category": "类别", "noCategory": "无类别", "noCategories": "无类别", - "deleteCategoriesQuestion": "删除所有类别?", - "categoryDeleteWarning": "所有被删除类别的应用程序将被设置为无类别", + "deleteCategoriesQuestion": "是否删除选中的类别?", + "categoryDeleteWarning": "被删除类别下的应用将恢复为未分类状态。", "addCategory": "添加类别", "label": "标签", "language": "语言", - "storagePermissionDenied": "存储权限已被拒绝", - "selectedCategorizeWarning": "这将取代所选应用程序的任何现有类别", + "copiedToClipboard": "已复制至剪贴板", + "storagePermissionDenied": "已拒绝授予存储权限", + "selectedCategorizeWarning": "这将覆盖选中应用当前的类别设置。", + "filterAPKsByRegEx": "筛选 APK 文件(正则表达式)", + "removeFromObtainium": "从 Obtainium 中删除", + "uninstallFromDevice": "从设备中卸载", + "onlyWorksWithNonVersionDetectApps": "仅适用于禁用版本检测的应用。", + "releaseDateAsVersion": "将发行日期作为版本号", + "releaseDateAsVersionExplanation": "此选项应该仅用于无法进行版本检测但能够获取发行日期的应用。", + "changes": "更新日志", + "releaseDate": "发行日期", + "importFromURLsInFile": "从文件中的 URL 导入(如 OPML)", + "versionDetection": "版本检测", + "standardVersionDetection": "常规版本检测", + "groupByCategory": "按类别分组显示", + "autoApkFilterByArch": "如果可能,尝试按设备支持的 CPU 架构筛选 APK 文件", + "overrideSource": "覆盖来源", + "dontShowAgain": "不再显示", + "dontShowTrackOnlyWarnings": "不显示“仅追踪”模式警告", + "dontShowAPKOriginWarnings": "不显示 APK 文件来源警告", + "moveNonInstalledAppsToBottom": "将未安装应用置底", + "gitlabPATLabel": "GitLab 个人访问令牌(启用搜索功能并增强 APK 发现)", + "about": "相关文档", + "requiresCredentialsInSettings": "此功能需要额外的凭据(在“设置”中添加)", + "checkOnStart": "启动时进行一次检查", + "tryInferAppIdFromCode": "尝试从源代码推断应用 ID", + "removeOnExternalUninstall": "自动删除已卸载的外部应用", + "pickHighestVersionCode": "自动选择版本号最高的 APK 文件", + "checkUpdateOnDetailPage": "打开应用详情页时检查更新", + "disablePageTransitions": "禁用页面过渡动画效果", + "reversePageTransitions": "反转页面过渡动画效果", + "minStarCount": "最小星标数", + "addInfoBelow": "在下方添加此凭据。", + "addInfoInSettings": "在“设置”中添加此凭据。", + "githubSourceNote": "使用访问令牌可避免触发 GitHub 的 API 请求限制。", + "gitlabSourceNote": "未使用访问令牌时可能无法从 GitLab 获取 APK 文件。", + "sortByFileNamesNotLinks": "使用文件名代替链接进行排序", + "filterReleaseNotesByRegEx": "筛选发行说明(正则表达式)", + "customLinkFilterRegex": "筛选自定义来源 APK 文件链接\n(正则表达式,默认匹配模式为“.apk$”)", + "appsPossiblyUpdated": "已尝试更新应用", + "appsPossiblyUpdatedNotifDescription": "当应用已尝试在后台更新时发送通知", + "xWasPossiblyUpdatedToY": "已尝试将“{}”更新至 {}。", + "enableBackgroundUpdates": "启用后台更新", + "backgroundUpdateReqsExplanation": "后台更新未必适用于所有的应用。", + "backgroundUpdateLimitsExplanation": "只有在启动 Obtainium 时才能确认安装是否成功。", + "verifyLatestTag": "验证“Latest”标签", + "intermediateLinkRegex": "筛选首先访问的“中转”链接(正则表达式)", + "intermediateLinkNotFound": "未找到“中转”链接", + "exemptFromBackgroundUpdates": "禁用后台更新\n(如果已经全局启用)", + "bgUpdatesOnWiFiOnly": "未连接 Wi-Fi 时禁用后台更新", + "autoSelectHighestVersionCode": "自动选择版本号最高的 APK 文件", + "versionExtractionRegEx": "提取版本号(正则表达式)", + "matchGroupToUse": "引用的捕获组", + "highlightTouchTargets": "突出展示不明显的触摸区域", + "pickExportDir": "选择导出文件夹", + "autoExportOnChanges": "数据变更时自动导出", + "filterVersionsByRegEx": "筛选版本号(正则表达式)", + "trySelectingSuggestedVersionCode": "尝试选择推荐版本的 APK 文件", + "dontSortReleasesList": "保持来自 API 的发行顺序", + "reverseSort": "反转排序", + "debugMenu": "调试选项", + "bgTaskStarted": "后台任务已启动 - 详见日志", + "runBgCheckNow": "立即进行后台更新检查", + "versionExtractWholePage": "将提取版本号的正则表达式应用于整个页面", + "installing": "正在安装", + "skipUpdateNotifications": "忽略更新通知", + "updatesAvailableNotifChannel": "更新可用", + "appsUpdatedNotifChannel": "应用已更新", + "appsPossiblyUpdatedNotifChannel": "已尝试更新应用", + "errorCheckingUpdatesNotifChannel": "检查更新出错", + "appsRemovedNotifChannel": "应用已删除", + "downloadingXNotifChannel": "正在下载{}", + "completeAppInstallationNotifChannel": "完成应用安装", + "checkingForUpdatesNotifChannel": "正在检查更新", + "onlyCheckInstalledOrTrackOnlyApps": "只对已安装和“仅追踪”的应用进行更新检查", + "removeAppQuestion": { + "one": "是否删除应用?", + "other": "是否删除应用?" + }, "tooManyRequestsTryAgainInMinutes": { - "one": "请求过多 (API 限制) - 在 {} 分钟后重试", - "other": "请求过多 (API 限制) - 在 {} 分钟后重试" + "one": "API 请求过于频繁(速率限制)- 在 {} 分钟后重试", + "other": "API 请求过于频繁(速率限制)- 在 {} 分钟后重试" }, "bgUpdateGotErrorRetryInMinutes": { - "one": "后台更新检查遇到了 {} 问题, 将在 {} 分钟后重试", - "other": "后台更新检查遇到了 {} 问题, 将在 {} 分钟后重试" + "one": "后台更新检查遇到了“{}”问题,预定于 {} 分钟后重试", + "other": "后台更新检查遇到了“{}”问题,预定于 {} 分钟后重试" }, "bgCheckFoundUpdatesWillNotifyIfNeeded": { - "one": "后台更新检查找到了 {} 个更新 - 将通知用户", - "other": "后台更新检查找到了 {} 个更新 - 将通知用户" + "one": "后台检查发现 {} 个应用更新 - 如有需要将发送通知", + "other": "后台检查发现 {} 个应用更新 - 如有需要将发送通知" }, "apps": { "one": "{} 个应用", @@ -244,15 +312,19 @@ "other": "{} 天" }, "clearedNLogsBeforeXAfterY": { - "one": "清除了 {n} 个日志 (清除前 = {before}, 清除后 = {after})", - "other": "清除了 {n} 个日志 (清除前 = {before}, 清除后 = {after})" + "one": "清除了 {n} 个日志({before} 之前,{after} 之后)", + "other": "清除了 {n} 个日志({before} 之前,{after} 之后)" }, "xAndNMoreUpdatesAvailable": { - "one": "{} 和 {} 更多应用已被更新", - "other": "{} 和 {} 更多应用已被更新" + "one": "{} 和另外 1 个应用可以更新了。", + "other": "{} 和另外 {} 个应用可以更新了。" }, "xAndNMoreUpdatesInstalled": { - "one": "{} 和 {} 更多应用已被安装", - "other": "{} 和 {} 更多应用已被安装" + "one": "{} 和另外 1 个应用已更新。", + "other": "{} 和另外 {} 个应用已更新。" + }, + "xAndNMoreUpdatesPossiblyInstalled": { + "one": "{} 和另外 1 个应用已尝试更新。", + "other": "{} 和另外 {} 个应用已尝试更新。" } } \ No newline at end of file diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..76fcdde --- /dev/null +++ b/build.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Convenience script + +CURR_DIR="$(pwd)" +trap "cd "$CURR_DIR"" EXIT + +if [ -z "$1" ]; then + git fetch && git merge origin/main && git push # Typically run after a PR to main, so bring dev up to date +fi +rm ./build/app/outputs/flutter-apk/* 2>/dev/null # Get rid of older builds if any +flutter build apk && flutter build apk --split-per-abi # Build (both split and combined APKs) +for file in ./build/app/outputs/flutter-apk/*.sha1; do gpg --sign --detach-sig "$file"; done # Generate PGP signatures +rsync -r ./build/app/outputs/flutter-apk/ ~/Downloads/Obtainium-build/ # Dropoff in Downloads to allow for drag-drop into Flatpak Firefox +cd ~/Downloads/Obtainium-build/ # Make zips just in case (for in-comment uploads) +for apk in *.apk; do + PREFIX="$(echo "$apk" | head -c -5)" + zip "$PREFIX" "$PREFIX"* +done +mkdir -p zips +mv *.zip zips/ diff --git a/lib/app_sources/apkcombo.dart b/lib/app_sources/apkcombo.dart new file mode 100644 index 0000000..73c144b --- /dev/null +++ b/lib/app_sources/apkcombo.dart @@ -0,0 +1,118 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:html/parser.dart'; +import 'package:obtainium/custom_errors.dart'; +import 'package:obtainium/providers/source_provider.dart'; + +class APKCombo extends AppSource { + APKCombo() { + host = 'apkcombo.com'; + } + + @override + String sourceSpecificStandardizeURL(String url) { + RegExp standardUrlRegEx = RegExp('^https?://$host/+[^/]+/+[^/]+'); + var match = standardUrlRegEx.firstMatch(url.toLowerCase()); + if (match == null) { + throw InvalidURLError(name); + } + return url.substring(0, match.end); + } + + @override + Future tryInferringAppId(String standardUrl, + {Map additionalSettings = const {}}) async { + return Uri.parse(standardUrl).pathSegments.last; + } + + @override + Future?> getRequestHeaders( + {Map additionalSettings = const {}, + bool forAPKDownload = false}) async { + return { + "User-Agent": "curl/8.0.1", + "Accept": "*/*", + "Connection": "keep-alive", + "Host": "$host" + }; + } + + Future>> getApkUrls(String standardUrl) async { + var res = await sourceRequest('$standardUrl/download/apk'); + if (res.statusCode != 200) { + throw getObtainiumHttpError(res); + } + var html = parse(res.body); + return html + .querySelectorAll('#variants-tab > div > ul > li') + .map((e) { + String? arch = e + .querySelector('code') + ?.text + .trim() + .replaceAll(',', '') + .replaceAll(':', '-') + .replaceAll(' ', '-'); + return e.querySelectorAll('a').map((e) { + String? url = e.attributes['href']; + if (url != null && + !Uri.parse(url).path.toLowerCase().endsWith('.apk')) { + url = null; + } + String verCode = + e.querySelector('.info .header .vercode')?.text.trim() ?? ''; + return MapEntry( + arch != null ? '$arch-$verCode.apk' : '', url ?? ''); + }).toList(); + }) + .reduce((value, element) => [...value, ...element]) + .where((element) => element.value.isNotEmpty) + .toList(); + } + + @override + Future apkUrlPrefetchModifier( + String apkUrl, String standardUrl) async { + var freshURLs = await getApkUrls(standardUrl); + var path2Match = Uri.parse(apkUrl).path; + for (var url in freshURLs) { + if (Uri.parse(url.value).path == path2Match) { + return url.value; + } + } + throw NoAPKError(); + } + + @override + Future getLatestAPKDetails( + String standardUrl, + Map additionalSettings, + ) async { + String appId = (await tryInferringAppId(standardUrl))!; + var preres = await sourceRequest(standardUrl); + if (preres.statusCode != 200) { + throw getObtainiumHttpError(preres); + } + var res = parse(preres.body); + String? version = res.querySelector('div.version')?.text.trim(); + if (version == null) { + throw NoVersionError(); + } + String appName = res.querySelector('div.app_name')?.text.trim() ?? appId; + String author = res.querySelector('div.author')?.text.trim() ?? appName; + List infoArray = res + .querySelectorAll('div.information-table > .item > div.value') + .map((e) => e.text.trim()) + .toList(); + DateTime? releaseDate; + if (infoArray.length >= 2) { + try { + releaseDate = DateFormat('MMMM d, yyyy').parse(infoArray[1]); + } catch (e) { + // ignore + } + } + return APKDetails( + version, await getApkUrls(standardUrl), AppNames(author, appName), + releaseDate: releaseDate); + } +} diff --git a/lib/app_sources/apkmirror.dart b/lib/app_sources/apkmirror.dart index d583bbb..2265cc7 100644 --- a/lib/app_sources/apkmirror.dart +++ b/lib/app_sources/apkmirror.dart @@ -1,5 +1,9 @@ +import 'dart:io'; + +import 'package:easy_localization/easy_localization.dart'; import 'package:html/parser.dart'; import 'package:http/http.dart'; +import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/providers/source_provider.dart'; @@ -7,10 +11,27 @@ class APKMirror extends AppSource { APKMirror() { host = 'apkmirror.com'; enforceTrackOnly = true; + + additionalSourceAppSpecificSettingFormItems = [ + [ + GeneratedFormSwitch('fallbackToOlderReleases', + label: tr('fallbackToOlderReleases'), defaultValue: true) + ], + [ + GeneratedFormTextField('filterReleaseTitlesByRegEx', + label: tr('filterReleaseTitlesByRegEx'), + required: false, + additionalValidators: [ + (value) { + return regExValidator(value); + } + ]) + ] + ]; } @override - String standardizeURL(String url) { + String sourceSpecificStandardizeURL(String url) { RegExp standardUrlRegEx = RegExp('^https?://$host/apk/[^/]+/[^/]+'); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); if (match == null) { @@ -28,12 +49,38 @@ class APKMirror extends AppSource { String standardUrl, Map additionalSettings, ) async { - Response res = await get(Uri.parse('$standardUrl/feed')); + bool fallbackToOlderReleases = + additionalSettings['fallbackToOlderReleases'] == true; + String? regexFilter = + (additionalSettings['filterReleaseTitlesByRegEx'] as String?) + ?.isNotEmpty == + true + ? additionalSettings['filterReleaseTitlesByRegEx'] + : null; + Response res = await sourceRequest('$standardUrl/feed'); if (res.statusCode == 200) { - String? titleString = parse(res.body) - .querySelector('item') - ?.querySelector('title') - ?.innerHtml; + var items = parse(res.body).querySelectorAll('item'); + dynamic targetRelease; + for (int i = 0; i < items.length; i++) { + if (!fallbackToOlderReleases && i > 0) break; + String? nameToFilter = items[i].querySelector('title')?.innerHtml; + if (regexFilter != null && + nameToFilter != null && + !RegExp(regexFilter).hasMatch(nameToFilter.trim())) { + continue; + } + targetRelease = items[i]; + break; + } + String? titleString = targetRelease?.querySelector('title')?.innerHtml; + String? dateString = targetRelease + ?.querySelector('pubDate') + ?.innerHtml + .split(' ') + .sublist(0, 5) + .join(' '); + DateTime? releaseDate = + dateString != null ? HttpDate.parse('$dateString GMT') : null; String? version = titleString ?.substring(RegExp('[0-9]').firstMatch(titleString)?.start ?? 0, RegExp(' by ').firstMatch(titleString)?.start ?? 0) @@ -44,7 +91,8 @@ class APKMirror extends AppSource { if (version == null || version.isEmpty) { throw NoVersionError(); } - return APKDetails(version, [], getAppNames(standardUrl)); + return APKDetails(version, [], getAppNames(standardUrl), + releaseDate: releaseDate); } else { throw getObtainiumHttpError(res); } diff --git a/lib/app_sources/apkpure.dart b/lib/app_sources/apkpure.dart new file mode 100644 index 0000000..a86fa0d --- /dev/null +++ b/lib/app_sources/apkpure.dart @@ -0,0 +1,93 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:html/parser.dart'; +import 'package:obtainium/custom_errors.dart'; +import 'package:obtainium/providers/source_provider.dart'; + +parseDateTimeMMMddCommayyyy(String? dateString) { + DateTime? releaseDate; + try { + releaseDate = dateString != null + ? DateFormat('MMM dd, yyyy').parse(dateString) + : null; + releaseDate = dateString != null && releaseDate == null + ? DateFormat('MMMM dd, yyyy').parse(dateString) + : releaseDate; + } catch (err) { + // ignore + } + return releaseDate; +} + +class APKPure extends AppSource { + APKPure() { + host = 'apkpure.com'; + allowSubDomains = true; + naiveStandardVersionDetection = true; + } + + @override + String sourceSpecificStandardizeURL(String url) { + RegExp standardUrlRegExB = + RegExp('^https?://m.$host/+[^/]+/+[^/]+(/+[^/]+)?'); + RegExpMatch? match = standardUrlRegExB.firstMatch(url.toLowerCase()); + if (match != null) { + url = 'https://$host${Uri.parse(url).path}'; + } + RegExp standardUrlRegExA = + RegExp('^https?://$host/+[^/]+/+[^/]+(/+[^/]+)?'); + match = standardUrlRegExA.firstMatch(url.toLowerCase()); + if (match == null) { + throw InvalidURLError(name); + } + return url.substring(0, match.end); + } + + @override + Future tryInferringAppId(String standardUrl, + {Map additionalSettings = const {}}) async { + return Uri.parse(standardUrl).pathSegments.last; + } + + @override + Future getLatestAPKDetails( + String standardUrl, + Map additionalSettings, + ) async { + String appId = (await tryInferringAppId(standardUrl))!; + String host = Uri.parse(standardUrl).host; + var res = await sourceRequest('$standardUrl/download'); + var resChangelog = await sourceRequest(standardUrl); + if (res.statusCode == 200 && resChangelog.statusCode == 200) { + var html = parse(res.body); + var htmlChangelog = parse(resChangelog.body); + String? version = html.querySelector('span.info-sdk span')?.text.trim(); + if (version == null) { + throw NoVersionError(); + } + String? dateString = + html.querySelector('span.info-other span.date')?.text.trim(); + DateTime? releaseDate = parseDateTimeMMMddCommayyyy(dateString); + String type = html.querySelector('a.info-tag')?.text.trim() ?? 'APK'; + List> apkUrls = [ + MapEntry('$appId.apk', 'https://d.$host/b/$type/$appId?version=latest') + ]; + String author = html + .querySelector('span.info-sdk') + ?.text + .trim() + .substring(version.length + 4) ?? + Uri.parse(standardUrl).pathSegments.reversed.last; + String appName = + html.querySelector('h1.info-title')?.text.trim() ?? appId; + String? changeLog = htmlChangelog + .querySelector("div.whats-new-info p:not(.date)") + ?.innerHtml + .trim() + .replaceAll("
", " \n"); + return APKDetails(version, apkUrls, AppNames(author, appName), + releaseDate: releaseDate, changeLog: changeLog); + } else { + throw getObtainiumHttpError(res); + } + } +} diff --git a/lib/app_sources/aptoide.dart b/lib/app_sources/aptoide.dart new file mode 100644 index 0000000..5b92b33 --- /dev/null +++ b/lib/app_sources/aptoide.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:obtainium/custom_errors.dart'; +import 'package:obtainium/providers/source_provider.dart'; + +class Aptoide extends AppSource { + Aptoide() { + host = 'aptoide.com'; + name = tr('Aptoide'); + allowSubDomains = true; + naiveStandardVersionDetection = true; + } + + @override + String sourceSpecificStandardizeURL(String url) { + RegExp standardUrlRegEx = RegExp('^https?://([^\\.]+\\.){2,}$host'); + RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); + if (match == null) { + throw InvalidURLError(name); + } + return url.substring(0, match.end); + } + + @override + Future tryInferringAppId(String standardUrl, + {Map additionalSettings = const {}}) async { + return (await getAppDetailsJSON(standardUrl))['package']; + } + + Future> getAppDetailsJSON(String standardUrl) async { + var res = await sourceRequest(standardUrl); + if (res.statusCode != 200) { + throw getObtainiumHttpError(res); + } + var idMatch = RegExp('"app":{"id":[0-9]+').firstMatch(res.body); + String? id; + if (idMatch != null) { + id = res.body.substring(idMatch.start + 12, idMatch.end); + } else { + throw NoReleasesError(); + } + var res2 = + await sourceRequest('https://ws2.aptoide.com/api/7/getApp/app_id/$id'); + if (res2.statusCode != 200) { + throw getObtainiumHttpError(res); + } + return jsonDecode(res2.body)?['nodes']?['meta']?['data']; + } + + @override + Future getLatestAPKDetails( + String standardUrl, + Map additionalSettings, + ) async { + var appDetails = await getAppDetailsJSON(standardUrl); + String appName = appDetails['name'] ?? tr('app'); + String author = appDetails['developer']?['name'] ?? name; + String? dateStr = appDetails['updated']; + String? version = appDetails['file']?['vername']; + String? apkUrl = appDetails['file']?['path']; + if (version == null) { + throw NoVersionError(); + } + if (apkUrl == null) { + throw NoAPKError(); + } + DateTime? relDate; + if (dateStr != null) { + relDate = DateTime.parse(dateStr); + } + + return APKDetails( + version, getApkUrlsFromUrls([apkUrl]), AppNames(author, appName), + releaseDate: relDate); + } +} diff --git a/lib/app_sources/codeberg.dart b/lib/app_sources/codeberg.dart index 1e38a4e..df06b9f 100644 --- a/lib/app_sources/codeberg.dart +++ b/lib/app_sources/codeberg.dart @@ -1,50 +1,21 @@ -import 'dart:convert'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:http/http.dart'; -import 'package:obtainium/components/generated_form.dart'; +import 'package:obtainium/app_sources/github.dart'; import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/providers/source_provider.dart'; class Codeberg extends AppSource { + GitHub gh = GitHub(); Codeberg() { host = 'codeberg.org'; - additionalSourceSpecificSettingFormItems = []; - - additionalSourceAppSpecificSettingFormItems = [ - [ - GeneratedFormSwitch('includePrereleases', - label: tr('includePrereleases'), defaultValue: false) - ], - [ - GeneratedFormSwitch('fallbackToOlderReleases', - label: tr('fallbackToOlderReleases'), defaultValue: true) - ], - [ - GeneratedFormTextField('filterReleaseTitlesByRegEx', - label: tr('filterReleaseTitlesByRegEx'), - required: false, - additionalValidators: [ - (value) { - if (value == null || value.isEmpty) { - return null; - } - try { - RegExp(value); - } catch (e) { - return tr('invalidRegEx'); - } - return null; - } - ]) - ] - ]; + additionalSourceAppSpecificSettingFormItems = + gh.additionalSourceAppSpecificSettingFormItems; canSearch = true; + searchQuerySettingFormItems = gh.searchQuerySettingFormItems; } @override - String standardizeURL(String url) { + String sourceSpecificStandardizeURL(String url) { RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+'); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); if (match == null) { @@ -62,72 +33,10 @@ class Codeberg extends AppSource { String standardUrl, Map additionalSettings, ) async { - bool includePrereleases = additionalSettings['includePrereleases']; - bool fallbackToOlderReleases = - additionalSettings['fallbackToOlderReleases']; - String? regexFilter = - (additionalSettings['filterReleaseTitlesByRegEx'] as String?) - ?.isNotEmpty == - true - ? additionalSettings['filterReleaseTitlesByRegEx'] - : null; - Response res = await get(Uri.parse( - 'https://$host/api/v1/repos${standardUrl.substring('https://$host'.length)}/releases')); - if (res.statusCode == 200) { - var releases = jsonDecode(res.body) as List; - - List getReleaseAPKUrls(dynamic release) => - (release['assets'] as List?) - ?.map((e) { - return e['name'] != null && e['browser_download_url'] != null - ? MapEntry(e['name'] as String, - e['browser_download_url'] as String) - : const MapEntry('', ''); - }) - .where((element) => element.key.toLowerCase().endsWith('.apk')) - .map((e) => e.value) - .toList() ?? - []; - - dynamic targetRelease; - - for (int i = 0; i < releases.length; i++) { - if (!fallbackToOlderReleases && i > 0) break; - if (!includePrereleases && releases[i]['prerelease'] == true) { - continue; - } - if (releases[i]['draft'] == true) { - // Draft releases not supported - } - var nameToFilter = releases[i]['name'] as String?; - if (nameToFilter == null || nameToFilter.trim().isEmpty) { - // Some leave titles empty so tag is used - nameToFilter = releases[i]['tag_name'] as String; - } - if (regexFilter != null && - !RegExp(regexFilter).hasMatch(nameToFilter.trim())) { - continue; - } - var apkUrls = getReleaseAPKUrls(releases[i]); - if (apkUrls.isEmpty && additionalSettings['trackOnly'] != true) { - continue; - } - targetRelease = releases[i]; - targetRelease['apkUrls'] = apkUrls; - break; - } - if (targetRelease == null) { - throw NoReleasesError(); - } - String? version = targetRelease['tag_name']; - if (version == null) { - throw NoVersionError(); - } - return APKDetails(version, targetRelease['apkUrls'] as List, - getAppNames(standardUrl)); - } else { - throw getObtainiumHttpError(res); - } + return await gh.getLatestAPKDetailsCommon2(standardUrl, additionalSettings, + (bool useTagUrl) async { + return 'https://$host/api/v1/repos${standardUrl.substring('https://$host'.length)}/${useTagUrl ? 'tags' : 'releases'}?per_page=100'; + }, null); } AppNames getAppNames(String standardUrl) { @@ -137,21 +46,12 @@ class Codeberg extends AppSource { } @override - Future> search(String query) async { - Response res = await get(Uri.parse( - 'https://$host/api/v1/repos/search?q=${Uri.encodeQueryComponent(query)}&limit=100')); - if (res.statusCode == 200) { - Map urlsWithDescriptions = {}; - for (var e in (jsonDecode(res.body)['data'] as List)) { - urlsWithDescriptions.addAll({ - e['html_url'] as String: e['description'] != null - ? e['description'] as String - : tr('noDescription') - }); - } - return urlsWithDescriptions; - } else { - throw getObtainiumHttpError(res); - } + Future>> search(String query, + {Map querySettings = const {}}) async { + return gh.searchCommon( + query, + 'https://$host/api/v1/repos/search?q=${Uri.encodeQueryComponent(query)}&limit=100', + 'data', + querySettings: querySettings); } } diff --git a/lib/app_sources/fdroid.dart b/lib/app_sources/fdroid.dart index 01f9293..c071c02 100644 --- a/lib/app_sources/fdroid.dart +++ b/lib/app_sources/fdroid.dart @@ -1,7 +1,9 @@ import 'dart:convert'; import 'package:easy_localization/easy_localization.dart'; +import 'package:html/parser.dart'; import 'package:http/http.dart'; +import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/providers/source_provider.dart'; @@ -9,15 +11,38 @@ class FDroid extends AppSource { FDroid() { host = 'f-droid.org'; name = tr('fdroid'); + naiveStandardVersionDetection = true; + canSearch = true; + additionalSourceAppSpecificSettingFormItems = [ + [ + GeneratedFormTextField('filterVersionsByRegEx', + label: tr('filterVersionsByRegEx'), + required: false, + additionalValidators: [ + (value) { + return regExValidator(value); + } + ]) + ], + [ + GeneratedFormSwitch('trySelectingSuggestedVersionCode', + label: tr('trySelectingSuggestedVersionCode')) + ], + [ + GeneratedFormSwitch('autoSelectHighestVersionCode', + label: tr('autoSelectHighestVersionCode')) + ], + ]; } @override - String standardizeURL(String url) { + String sourceSpecificStandardizeURL(String url) { RegExp standardUrlRegExB = RegExp('^https?://$host/+[^/]+/+packages/+[^/]+'); RegExpMatch? match = standardUrlRegExB.firstMatch(url.toLowerCase()); if (match != null) { - url = 'https://$host/packages/${Uri.parse(url).pathSegments.last}'; + url = + 'https://${Uri.parse(url.substring(0, match.end)).host}/packages/${Uri.parse(url).pathSegments.last}'; } RegExp standardUrlRegExA = RegExp('^https?://$host/+packages/+[^/]+'); match = standardUrlRegExA.firstMatch(url.toLowerCase()); @@ -28,45 +53,137 @@ class FDroid extends AppSource { } @override - String? changeLogPageFromStandardUrl(String standardUrl) => null; - - @override - String? tryInferringAppId(String standardUrl, - {Map additionalSettings = const {}}) { + Future tryInferringAppId(String standardUrl, + {Map additionalSettings = const {}}) async { return Uri.parse(standardUrl).pathSegments.last; } - APKDetails getAPKUrlsFromFDroidPackagesAPIResponse( - Response res, String apkUrlPrefix, String standardUrl) { - if (res.statusCode == 200) { - List releases = jsonDecode(res.body)['packages'] ?? []; - if (releases.isEmpty) { - throw NoReleasesError(); - } - String? latestVersion = releases[0]['versionName']; - if (latestVersion == null) { - throw NoVersionError(); - } - List apkUrls = releases - .where((element) => element['versionName'] == latestVersion) - .map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk') - .toList(); - return APKDetails(latestVersion, apkUrls, - AppNames(name, Uri.parse(standardUrl).pathSegments.last)); - } else { - throw getObtainiumHttpError(res); - } - } - @override Future getLatestAPKDetails( String standardUrl, Map additionalSettings, ) async { - String? appId = tryInferringAppId(standardUrl); + String? appId = await tryInferringAppId(standardUrl); + String host = Uri.parse(standardUrl).host; return getAPKUrlsFromFDroidPackagesAPIResponse( - await get(Uri.parse('https://f-droid.org/api/v1/packages/$appId')), - 'https://f-droid.org/repo/$appId', - standardUrl); + await sourceRequest('https://$host/api/v1/packages/$appId'), + 'https://$host/repo/$appId', + standardUrl, + name, + autoSelectHighestVersionCode: + additionalSettings['autoSelectHighestVersionCode'] == true, + trySelectingSuggestedVersionCode: + additionalSettings['trySelectingSuggestedVersionCode'] == true, + filterVersionsByRegEx: + (additionalSettings['filterVersionsByRegEx'] as String?) + ?.isNotEmpty == + true + ? additionalSettings['filterVersionsByRegEx'] + : null); + } + + @override + Future>> search(String query, + {Map querySettings = const {}}) async { + Response res = await sourceRequest( + 'https://search.$host/?q=${Uri.encodeQueryComponent(query)}'); + if (res.statusCode == 200) { + Map> urlsWithDescriptions = {}; + parse(res.body).querySelectorAll('.package-header').forEach((e) { + String? url = e.attributes['href']; + if (url != null) { + try { + standardizeUrl(url); + } catch (e) { + url = null; + } + } + if (url != null) { + urlsWithDescriptions[url] = [ + e.querySelector('.package-name')?.text.trim() ?? '', + e.querySelector('.package-summary')?.text.trim() ?? + tr('noDescription') + ]; + } + }); + return urlsWithDescriptions; + } else { + throw getObtainiumHttpError(res); + } + } +} + +APKDetails getAPKUrlsFromFDroidPackagesAPIResponse( + Response res, String apkUrlPrefix, String standardUrl, String sourceName, + {bool autoSelectHighestVersionCode = false, + bool trySelectingSuggestedVersionCode = false, + String? filterVersionsByRegEx}) { + if (res.statusCode == 200) { + var response = jsonDecode(res.body); + List releases = response['packages'] ?? []; + if (releases.isEmpty) { + throw NoReleasesError(); + } + String? version; + Iterable releaseChoices = []; + // Grab the versionCode suggested if the user chose to do that + // Only do so at this stage if the user has no release filter + if (trySelectingSuggestedVersionCode && + response['suggestedVersionCode'] != null && + filterVersionsByRegEx == null) { + var suggestedReleases = releases.where((element) => + element['versionCode'] == response['suggestedVersionCode']); + if (suggestedReleases.isNotEmpty) { + releaseChoices = suggestedReleases; + version = suggestedReleases.first['versionName']; + } + } + // Apply the release filter if any + if (filterVersionsByRegEx != null) { + version = null; + releaseChoices = []; + for (var i = 0; i < releases.length; i++) { + if (RegExp(filterVersionsByRegEx) + .hasMatch(releases[i]['versionName'])) { + version = releases[i]['versionName']; + } + } + if (version == null) { + throw NoVersionError(); + } + } + // Default to the highest version + version ??= releases[0]['versionName']; + if (version == null) { + throw NoVersionError(); + } + // If a suggested release was not already picked, pick all those with the selected version + if (releaseChoices.isEmpty) { + releaseChoices = + releases.where((element) => element['versionName'] == version); + } + // For the remaining releases, use the toggles to auto-select one if possible + if (releaseChoices.length > 1) { + if (autoSelectHighestVersionCode) { + releaseChoices = [releaseChoices.first]; + } else if (trySelectingSuggestedVersionCode && + response['suggestedVersionCode'] != null) { + var suggestedReleases = releaseChoices.where((element) => + element['versionCode'] == response['suggestedVersionCode']); + if (suggestedReleases.isNotEmpty) { + releaseChoices = suggestedReleases; + } + } + } + if (releaseChoices.isEmpty) { + throw NoReleasesError(); + } + List apkUrls = releaseChoices + .map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk') + .toList(); + return APKDetails(version, getApkUrlsFromUrls(apkUrls.toSet().toList()), + AppNames(sourceName, Uri.parse(standardUrl).pathSegments.last)); + } else { + throw getObtainiumHttpError(res); } } diff --git a/lib/app_sources/fdroidrepo.dart b/lib/app_sources/fdroidrepo.dart index cffe307..b0d74d8 100644 --- a/lib/app_sources/fdroidrepo.dart +++ b/lib/app_sources/fdroidrepo.dart @@ -1,6 +1,5 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:html/parser.dart'; -import 'package:http/http.dart'; import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/providers/source_provider.dart'; @@ -8,6 +7,9 @@ import 'package:obtainium/providers/source_provider.dart'; class FDroidRepo extends AppSource { FDroidRepo() { name = tr('fdroidThirdPartyRepo'); + canSearch = true; + excludeFromMassSearch = true; + neverAutoSelect = true; additionalSourceAppSpecificSettingFormItems = [ [ @@ -15,19 +17,80 @@ class FDroidRepo extends AppSource { label: tr('appIdOrName'), hint: tr('reposHaveMultipleApps'), required: true) + ], + [ + GeneratedFormSwitch('pickHighestVersionCode', + label: tr('pickHighestVersionCode'), defaultValue: false) ] ]; } - @override - String standardizeURL(String url) { - RegExp standardUrlRegExp = - RegExp('^https?://.+/fdroid/([^/]+(/|\\?)|[^/]+\$)'); - RegExpMatch? match = standardUrlRegExp.firstMatch(url.toLowerCase()); - if (match == null) { - throw InvalidURLError(name); + String removeQueryParamsFromUrl(String url, {List keep = const []}) { + var uri = Uri.parse(url); + Map resultParams = {}; + uri.queryParameters.forEach((key, value) { + if (keep.contains(key)) { + resultParams[key] = value; + } + }); + url = uri.replace(queryParameters: resultParams).toString(); + if (url.endsWith('?')) { + url = url.substring(0, url.length - 1); } - return url.substring(0, match.end); + return url; + } + + @override + String sourceSpecificStandardizeURL(String url) { + var standardUri = Uri.parse(url); + var pathSegments = standardUri.pathSegments; + if (pathSegments.last == 'index.xml') { + pathSegments.removeLast(); + standardUri = standardUri.replace(path: pathSegments.join('/')); + } + return removeQueryParamsFromUrl(standardUri.toString(), keep: ['appId']); + } + + @override + Future>> search(String query, + {Map querySettings = const {}}) async { + query = removeQueryParamsFromUrl(standardizeUrl(query)); + var res = await sourceRequest('$query/index.xml'); + if (res.statusCode == 200) { + var body = parse(res.body); + Map> results = {}; + body.querySelectorAll('application').toList().forEach((app) { + String appId = app.attributes['id']!; + results['$query?appId=$appId'] = [ + app.querySelector('name')?.innerHtml ?? appId, + app.querySelector('desc')?.innerHtml ?? '' + ]; + }); + return results; + } else { + throw getObtainiumHttpError(res); + } + } + + @override + App endOfGetAppChanges(App app) { + var uri = Uri.parse(app.url); + String? appId; + if (!isTempId(app)) { + appId = app.id; + } else if (uri.queryParameters['appId'] != null) { + appId = uri.queryParameters['appId']; + } + if (appId != null) { + app.url = uri + .replace( + queryParameters: Map.fromEntries( + [...uri.queryParameters.entries, MapEntry('appId', appId)])) + .toString(); + app.additionalSettings['appIdOrName'] = appId; + app.id = appId; + } + return app; } @override @@ -36,10 +99,17 @@ class FDroidRepo extends AppSource { Map additionalSettings, ) async { String? appIdOrName = additionalSettings['appIdOrName']; + var standardUri = Uri.parse(standardUrl); + if (standardUri.queryParameters['appId'] != null) { + appIdOrName = standardUri.queryParameters['appId']; + } + standardUrl = removeQueryParamsFromUrl(standardUrl); + bool pickHighestVersionCode = additionalSettings['pickHighestVersionCode']; if (appIdOrName == null) { throw NoReleasesError(); } - var res = await get(Uri.parse('$standardUrl/index.xml')); + var res = await sourceRequest( + '$standardUrl${standardUrl.endsWith('/index.xml') ? '' : '/index.xml'}'); if (res.statusCode == 200) { var body = parse(res.body); var foundApps = body.querySelectorAll('application').where((element) { @@ -48,7 +118,7 @@ class FDroidRepo extends AppSource { if (foundApps.isEmpty) { foundApps = body.querySelectorAll('application').where((element) { return element.querySelector('name')?.innerHtml.toLowerCase() == - appIdOrName.toLowerCase(); + appIdOrName!.toLowerCase(); }).toList(); } if (foundApps.isEmpty) { @@ -57,7 +127,7 @@ class FDroidRepo extends AppSource { .querySelector('name') ?.innerHtml .toLowerCase() - .contains(appIdOrName.toLowerCase()) ?? + .contains(appIdOrName!.toLowerCase()) ?? false; }).toList(); } @@ -65,20 +135,34 @@ class FDroidRepo extends AppSource { throw ObtainiumError(tr('appWithIdOrNameNotFound')); } var authorName = body.querySelector('repo')?.attributes['name'] ?? name; - var appName = - foundApps[0].querySelector('name')?.innerHtml ?? appIdOrName; + String appId = foundApps[0].attributes['id']!; + foundApps[0].querySelector('name')?.innerHtml ?? appId; + var appName = foundApps[0].querySelector('name')?.innerHtml ?? appId; var releases = foundApps[0].querySelectorAll('package'); String? latestVersion = releases[0].querySelector('version')?.innerHtml; + String? added = releases[0].querySelector('added')?.innerHtml; + DateTime? releaseDate = added != null ? DateTime.parse(added) : null; if (latestVersion == null) { throw NoVersionError(); } - List apkUrls = releases + var latestVersionReleases = releases .where((element) => element.querySelector('version')?.innerHtml == latestVersion && element.querySelector('apkname') != null) + .toList(); + if (latestVersionReleases.length > 1 && pickHighestVersionCode) { + latestVersionReleases.sort((e1, e2) { + return int.parse(e2.querySelector('versioncode')!.innerHtml) + .compareTo(int.parse(e1.querySelector('versioncode')!.innerHtml)); + }); + latestVersionReleases = [latestVersionReleases[0]]; + } + List apkUrls = latestVersionReleases .map((e) => '$standardUrl/${e.querySelector('apkname')!.innerHtml}') .toList(); - return APKDetails(latestVersion, apkUrls, AppNames(authorName, appName)); + return APKDetails(latestVersion, getApkUrlsFromUrls(apkUrls), + AppNames(authorName, appName), + releaseDate: releaseDate); } else { throw getObtainiumHttpError(res); } diff --git a/lib/app_sources/github.dart b/lib/app_sources/github.dart index f17adf9..700fa06 100644 --- a/lib/app_sources/github.dart +++ b/lib/app_sources/github.dart @@ -1,9 +1,13 @@ import 'dart:convert'; +import 'dart:io'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart'; +import 'package:obtainium/app_sources/html.dart'; import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/custom_errors.dart'; +import 'package:obtainium/providers/apps_provider.dart'; +import 'package:obtainium/providers/logs_provider.dart'; import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/source_provider.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -11,30 +15,16 @@ import 'package:url_launcher/url_launcher_string.dart'; class GitHub extends AppSource { GitHub() { host = 'github.com'; + appIdInferIsOptional = true; - additionalSourceSpecificSettingFormItems = [ + sourceConfigSettingFormItems = [ GeneratedFormTextField('github-creds', label: tr('githubPATLabel'), password: true, required: false, - additionalValidators: [ - (value) { - if (value != null && value.trim().isNotEmpty) { - if (value - .split(':') - .where((element) => element.trim().isNotEmpty) - .length != - 2) { - return tr('githubPATHint'); - } - } - return null; - } - ], - hint: tr('githubPATFormat'), belowWidgets: [ const SizedBox( - height: 8, + height: 4, ), GestureDetector( onTap: () { @@ -43,10 +33,13 @@ class GitHub extends AppSource { mode: LaunchMode.externalApplication); }, child: Text( - tr('githubPATLinkText'), + tr('about'), style: const TextStyle( decoration: TextDecoration.underline, fontSize: 12), - )) + )), + const SizedBox( + height: 4, + ), ]) ]; @@ -65,25 +58,96 @@ class GitHub extends AppSource { required: false, additionalValidators: [ (value) { - if (value == null || value.isEmpty) { - return null; - } - try { - RegExp(value); - } catch (e) { - return tr('invalidRegEx'); - } - return null; + return regExValidator(value); } ]) + ], + [ + GeneratedFormTextField('filterReleaseNotesByRegEx', + label: tr('filterReleaseNotesByRegEx'), + required: false, + additionalValidators: [ + (value) { + return regExValidator(value); + } + ]) + ], + [GeneratedFormSwitch('verifyLatestTag', label: tr('verifyLatestTag'))], + [ + GeneratedFormSwitch('dontSortReleasesList', + label: tr('dontSortReleasesList')) ] ]; canSearch = true; + searchQuerySettingFormItems = [ + GeneratedFormTextField('minStarCount', + label: tr('minStarCount'), + defaultValue: '0', + additionalValidators: [ + (value) { + try { + int.parse(value ?? '0'); + } catch (e) { + return tr('invalidInput'); + } + return null; + } + ]) + ]; } @override - String standardizeURL(String url) { + Future tryInferringAppId(String standardUrl, + {Map additionalSettings = const {}}) async { + const possibleBuildGradleLocations = [ + '/app/build.gradle', + 'android/app/build.gradle', + 'src/app/build.gradle' + ]; + for (var path in possibleBuildGradleLocations) { + try { + var res = await sourceRequest( + '${await convertStandardUrlToAPIUrl(standardUrl, additionalSettings)}/contents/$path'); + if (res.statusCode == 200) { + try { + var body = jsonDecode(res.body); + var trimmedLines = utf8 + .decode(base64 + .decode(body['content'].toString().split('\n').join(''))) + .split('\n') + .map((e) => e.trim()); + var appId = trimmedLines + .where((l) => + l.startsWith('applicationId "') || + l.startsWith('applicationId \'')) + .first; + appId = appId + .split(appId.startsWith('applicationId "') ? '"' : '\'')[1]; + if (appId.startsWith('\${') && appId.endsWith('}')) { + appId = trimmedLines + .where((l) => l.startsWith( + 'def ${appId.substring(2, appId.length - 1)}')) + .first; + appId = appId.split(appId.contains('"') ? '"' : '\'')[1]; + } + if (appId.isNotEmpty) { + return appId; + } + } catch (err) { + LogsProvider().add( + 'Error parsing build.gradle from ${res.request!.url.toString()}: ${err.toString()}'); + } + } + } catch (err) { + // Ignore - ID will be extracted from the APK + } + } + return null; + } + + @override + String sourceSpecificStandardizeURL(String url) { RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+'); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); if (match == null) { @@ -92,53 +156,170 @@ class GitHub extends AppSource { return url.substring(0, match.end); } - Future getCredentialPrefixIfAny() async { + @override + Future?> getRequestHeaders( + {Map additionalSettings = const {}, + bool forAPKDownload = false}) async { + var token = await getTokenIfAny(additionalSettings); + var headers = {}; + if (token != null) { + headers[HttpHeaders.authorizationHeader] = 'Token $token'; + } + if (forAPKDownload == true) { + headers[HttpHeaders.acceptHeader] = 'application/octet-stream'; + } + if (headers.isNotEmpty) { + return headers; + } else { + return null; + } + } + + Future getTokenIfAny(Map additionalSettings) async { SettingsProvider settingsProvider = SettingsProvider(); await settingsProvider.initializeSettings(); - String? creds = settingsProvider - .getSettingString(additionalSourceSpecificSettingFormItems[0].key); - return creds != null && creds.isNotEmpty ? '$creds@' : ''; + var sourceConfig = + await getSourceConfigValues(additionalSettings, settingsProvider); + String? creds = sourceConfig['github-creds']; + if (creds != null) { + var userNameEndIndex = creds.indexOf(':'); + if (userNameEndIndex > 0) { + creds = creds.substring( + userNameEndIndex + 1); // For old username-included token inputs + } + return creds; + } else { + return null; + } } + @override + Future getSourceNote() async { + if (!hostChanged && (await getTokenIfAny({})) == null) { + return '${tr('githubSourceNote')} ${hostChanged ? tr('addInfoBelow') : tr('addInfoInSettings')}'; + } + return null; + } + + Future getAPIHost(Map additionalSettings) async => + 'https://api.$host'; + + Future convertStandardUrlToAPIUrl( + String standardUrl, Map additionalSettings) async => + '${await getAPIHost(additionalSettings)}/repos${standardUrl.substring('https://$host'.length)}'; + @override String? changeLogPageFromStandardUrl(String standardUrl) => '$standardUrl/releases'; - @override - Future getLatestAPKDetails( - String standardUrl, - Map additionalSettings, - ) async { - bool includePrereleases = additionalSettings['includePrereleases']; + Future getLatestAPKDetailsCommon(String requestUrl, + String standardUrl, Map additionalSettings, + {Function(Response)? onHttpErrorCode}) async { + bool includePrereleases = additionalSettings['includePrereleases'] == true; bool fallbackToOlderReleases = - additionalSettings['fallbackToOlderReleases']; + additionalSettings['fallbackToOlderReleases'] == true; String? regexFilter = (additionalSettings['filterReleaseTitlesByRegEx'] as String?) ?.isNotEmpty == true ? additionalSettings['filterReleaseTitlesByRegEx'] : null; - Response res = await get(Uri.parse( - 'https://${await getCredentialPrefixIfAny()}api.$host/repos${standardUrl.substring('https://$host'.length)}/releases')); + String? regexNotesFilter = + (additionalSettings['filterReleaseNotesByRegEx'] as String?) + ?.isNotEmpty == + true + ? additionalSettings['filterReleaseNotesByRegEx'] + : null; + bool verifyLatestTag = additionalSettings['verifyLatestTag'] == true; + bool dontSortReleasesList = + additionalSettings['dontSortReleasesList'] == true; + String? latestTag; + if (verifyLatestTag) { + var temp = requestUrl.split('?'); + Response res = await sourceRequest( + '${temp[0]}/latest${temp.length > 1 ? '?${temp.sublist(1).join('?')}' : ''}'); + if (res.statusCode != 200) { + if (onHttpErrorCode != null) { + onHttpErrorCode(res); + } + throw getObtainiumHttpError(res); + } + var jsres = jsonDecode(res.body); + latestTag = jsres['tag_name'] ?? jsres['name']; + } + Response res = await sourceRequest(requestUrl); if (res.statusCode == 200) { var releases = jsonDecode(res.body) as List; - List getReleaseAPKUrls(dynamic release) => + List> getReleaseAPKUrls(dynamic release) => (release['assets'] as List?) ?.map((e) { - return e['browser_download_url'] != null - ? e['browser_download_url'] as String - : ''; + return (e['name'] != null) && + ((e['url'] ?? e['browser_download_url']) != null) + ? MapEntry(e['name'] as String, + (e['url'] ?? e['browser_download_url']) as String) + : const MapEntry('', ''); }) - .where((element) => element.toLowerCase().endsWith('.apk')) + .where((element) => element.key.toLowerCase().endsWith('.apk')) .toList() ?? []; + DateTime? getReleaseDateFromRelease(dynamic rel) => + rel?['published_at'] != null + ? DateTime.parse(rel['published_at']) + : null; + if (dontSortReleasesList) { + releases = releases.reversed.toList(); + } else { + releases.sort((a, b) { + // See #478 and #534 + if (a == b) { + return 0; + } else if (a == null) { + return -1; + } else if (b == null) { + return 1; + } else { + var nameA = a['tag_name'] ?? a['name']; + var nameB = b['tag_name'] ?? b['name']; + var stdFormats = findStandardFormatsForVersion(nameA, true) + .intersection(findStandardFormatsForVersion(nameB, true)); + if (stdFormats.isNotEmpty) { + var reg = RegExp(stdFormats.first); + var matchA = reg.firstMatch(nameA); + var matchB = reg.firstMatch(nameB); + return compareAlphaNumeric( + (nameA as String).substring(matchA!.start, matchA.end), + (nameB as String).substring(matchB!.start, matchB.end)); + } else { + return (getReleaseDateFromRelease(a) ?? DateTime(1)) + .compareTo(getReleaseDateFromRelease(b) ?? DateTime(0)); + } + } + }); + } + if (latestTag != null && + releases.isNotEmpty && + latestTag != + (releases[releases.length - 1]['tag_name'] ?? + releases[0]['name'])) { + var ind = releases.indexWhere( + (element) => latestTag == (element['tag_name'] ?? element['name'])); + if (ind >= 0) { + releases.add(releases.removeAt(ind)); + } + } + releases = releases.reversed.toList(); dynamic targetRelease; - + var prerrelsSkipped = 0; for (int i = 0; i < releases.length; i++) { - if (!fallbackToOlderReleases && i > 0) break; + if (!fallbackToOlderReleases && i > prerrelsSkipped) break; if (!includePrereleases && releases[i]['prerelease'] == true) { + prerrelsSkipped++; + continue; + } + if (releases[i]['draft'] == true) { + // Draft releases not supported continue; } var nameToFilter = releases[i]['name'] as String?; @@ -150,6 +331,11 @@ class GitHub extends AppSource { !RegExp(regexFilter).hasMatch(nameToFilter.trim())) { continue; } + if (regexNotesFilter != null && + !RegExp(regexNotesFilter) + .hasMatch(((releases[i]['body'] as String?) ?? '').trim())) { + continue; + } var apkUrls = getReleaseAPKUrls(releases[i]); if (apkUrls.isEmpty && additionalSettings['trackOnly'] != true) { continue; @@ -161,44 +347,108 @@ class GitHub extends AppSource { if (targetRelease == null) { throw NoReleasesError(); } - String? version = targetRelease['tag_name']; + String? version = targetRelease['tag_name'] ?? targetRelease['name']; + DateTime? releaseDate = getReleaseDateFromRelease(targetRelease); if (version == null) { throw NoVersionError(); } - return APKDetails(version, targetRelease['apkUrls'] as List, - getAppNames(standardUrl)); + var changeLog = targetRelease['body'].toString(); + return APKDetails( + version, + targetRelease['apkUrls'] as List>, + getAppNames(standardUrl), + releaseDate: releaseDate, + changeLog: changeLog.isEmpty ? null : changeLog); } else { - rateLimitErrorCheck(res); + if (onHttpErrorCode != null) { + onHttpErrorCode(res); + } throw getObtainiumHttpError(res); } } + getLatestAPKDetailsCommon2( + String standardUrl, + Map additionalSettings, + Future Function(bool) reqUrlGenerator, + dynamic Function(Response)? onHttpErrorCode) async { + try { + return await getLatestAPKDetailsCommon( + await reqUrlGenerator(false), standardUrl, additionalSettings, + onHttpErrorCode: onHttpErrorCode); + } catch (err) { + if (err is NoReleasesError && additionalSettings['trackOnly'] == true) { + return await getLatestAPKDetailsCommon( + await reqUrlGenerator(true), standardUrl, additionalSettings, + onHttpErrorCode: onHttpErrorCode); + } else { + rethrow; + } + } + } + + @override + Future getLatestAPKDetails( + String standardUrl, + Map additionalSettings, + ) async { + return await getLatestAPKDetailsCommon2(standardUrl, additionalSettings, + (bool useTagUrl) async { + return '${await convertStandardUrlToAPIUrl(standardUrl, additionalSettings)}/${useTagUrl ? 'tags' : 'releases'}?per_page=100'; + }, (Response res) { + rateLimitErrorCheck(res); + }); + } + AppNames getAppNames(String standardUrl) { String temp = standardUrl.substring(standardUrl.indexOf('://') + 3); List names = temp.substring(temp.indexOf('/') + 1).split('/'); return AppNames(names[0], names[1]); } - @override - Future> search(String query) async { - Response res = await get(Uri.parse( - 'https://${await getCredentialPrefixIfAny()}api.$host/search/repositories?q=${Uri.encodeQueryComponent(query)}&per_page=100')); + Future>> searchCommon( + String query, String requestUrl, String rootProp, + {Function(Response)? onHttpErrorCode, + Map querySettings = const {}}) async { + Response res = await sourceRequest(requestUrl); if (res.statusCode == 200) { - Map urlsWithDescriptions = {}; - for (var e in (jsonDecode(res.body)['items'] as List)) { - urlsWithDescriptions.addAll({ - e['html_url'] as String: e['description'] != null - ? e['description'] as String - : tr('noDescription') - }); + int minStarCount = querySettings['minStarCount'] != null + ? int.parse(querySettings['minStarCount']) + : 0; + Map> urlsWithDescriptions = {}; + for (var e in (jsonDecode(res.body)[rootProp] as List)) { + if ((e['stargazers_count'] ?? e['stars_count'] ?? 0) >= minStarCount) { + urlsWithDescriptions.addAll({ + e['html_url'] as String: [ + e['full_name'] as String, + ((e['archived'] == true ? '[ARCHIVED] ' : '') + + (e['description'] != null + ? e['description'] as String + : tr('noDescription'))) + ] + }); + } } return urlsWithDescriptions; } else { - rateLimitErrorCheck(res); + if (onHttpErrorCode != null) { + onHttpErrorCode(res); + } throw getObtainiumHttpError(res); } } + @override + Future>> search(String query, + {Map querySettings = const {}}) async { + return searchCommon( + query, + '${await getAPIHost({})}/search/repositories?q=${Uri.encodeQueryComponent(query)}&per_page=100', + 'items', onHttpErrorCode: (Response res) { + rateLimitErrorCheck(res); + }, querySettings: querySettings); + } + rateLimitErrorCheck(Response res) { if (res.headers['x-ratelimit-remaining'] == '0') { throw RateLimitError( diff --git a/lib/app_sources/gitlab.dart b/lib/app_sources/gitlab.dart index b9bef9b..bea24eb 100644 --- a/lib/app_sources/gitlab.dart +++ b/lib/app_sources/gitlab.dart @@ -1,16 +1,57 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; import 'package:html/parser.dart'; import 'package:http/http.dart'; import 'package:obtainium/app_sources/github.dart'; import 'package:obtainium/custom_errors.dart'; +import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/source_provider.dart'; +import 'package:obtainium/components/generated_form.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:url_launcher/url_launcher_string.dart'; class GitLab extends AppSource { GitLab() { host = 'gitlab.com'; + canSearch = true; + + sourceConfigSettingFormItems = [ + GeneratedFormTextField('gitlab-creds', + label: tr('gitlabPATLabel'), + password: true, + required: false, + belowWidgets: [ + const SizedBox( + height: 4, + ), + GestureDetector( + onTap: () { + launchUrlString( + 'https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#create-a-personal-access-token', + mode: LaunchMode.externalApplication); + }, + child: Text( + tr('about'), + style: const TextStyle( + decoration: TextDecoration.underline, fontSize: 12), + )), + const SizedBox( + height: 4, + ) + ]) + ]; + + additionalSourceAppSpecificSettingFormItems = [ + [ + GeneratedFormSwitch('fallbackToOlderReleases', + label: tr('fallbackToOlderReleases'), defaultValue: true) + ] + ]; } @override - String standardizeURL(String url) { + String sourceSpecificStandardizeURL(String url) { RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+'); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); if (match == null) { @@ -19,6 +60,47 @@ class GitLab extends AppSource { return url.substring(0, match.end); } + Future getPATIfAny(Map additionalSettings) async { + SettingsProvider settingsProvider = SettingsProvider(); + await settingsProvider.initializeSettings(); + var sourceConfig = + await getSourceConfigValues(additionalSettings, settingsProvider); + String? creds = sourceConfig['gitlab-creds']; + return creds != null && creds.isNotEmpty ? creds : null; + } + + @override + Future getSourceNote() async { + if ((await getPATIfAny({})) == null) { + return '${tr('gitlabSourceNote')} ${hostChanged ? tr('addInfoBelow') : tr('addInfoInSettings')}'; + } + return null; + } + + @override + Future>> search(String query, + {Map querySettings = const {}}) async { + String? PAT = await getPATIfAny({}); + if (PAT == null) { + throw CredsNeededError(name); + } + var url = + 'https://$host/api/v4/search?private_token=$PAT&scope=projects&search=${Uri.encodeQueryComponent(query)}'; + var res = await sourceRequest(url); + if (res.statusCode != 200) { + throw getObtainiumHttpError(res); + } + var json = jsonDecode(res.body) as List; + Map> results = {}; + for (var element in json) { + results['https://$host/${element['path_with_namespace']}'] = [ + element['name_with_namespace'], + element['description'] ?? tr('noDescription') + ]; + } + return results; + } + @override String? changeLogPageFromStandardUrl(String standardUrl) => '$standardUrl/-/releases'; @@ -28,38 +110,99 @@ class GitLab extends AppSource { String standardUrl, Map additionalSettings, ) async { - Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom')); - if (res.statusCode == 200) { + bool fallbackToOlderReleases = + additionalSettings['fallbackToOlderReleases'] == true; + String? PAT = await getPATIfAny(hostChanged ? additionalSettings : {}); + Iterable apkDetailsList = []; + if (PAT != null) { + var names = GitHub().getAppNames(standardUrl); + Response res = await sourceRequest( + 'https://$host/api/v4/projects/${names.author}%2F${names.name}/releases?private_token=$PAT'); + if (res.statusCode != 200) { + throw getObtainiumHttpError(res); + } + var json = jsonDecode(res.body) as List; + apkDetailsList = json.map((e) { + var apkUrlsFromAssets = (e['assets']?['links'] as List? ?? []) + .map((e) { + return (e['direct_asset_url'] ?? e['url'] ?? '') as String; + }) + .where((s) => s.isNotEmpty) + .toList(); + List uploadedAPKsFromDescription = + ((e['description'] ?? '') as String) + .split('](') + .join('\n') + .split('.apk)') + .join('.apk\n') + .split('\n') + .where((s) => s.startsWith('/uploads/') && s.endsWith('apk')) + .map((s) => '$standardUrl$s') + .toList(); + var apkUrlsSet = apkUrlsFromAssets.toSet(); + apkUrlsSet.addAll(uploadedAPKsFromDescription); + var releaseDateString = e['released_at'] ?? e['created_at']; + DateTime? releaseDate = releaseDateString != null + ? DateTime.parse(releaseDateString) + : null; + return APKDetails( + e['tag_name'] ?? e['name'], + getApkUrlsFromUrls(apkUrlsSet.toList()), + GitHub().getAppNames(standardUrl), + releaseDate: releaseDate); + }); + } else { + Response res = await sourceRequest('$standardUrl/-/tags?format=atom'); + if (res.statusCode != 200) { + throw getObtainiumHttpError(res); + } var standardUri = Uri.parse(standardUrl); var parsedHtml = parse(res.body); - var entry = parsedHtml.querySelector('entry'); - var entryContent = - parse(parseFragment(entry?.querySelector('content')!.innerHtml).text); - var apkUrls = [ - ...getLinksFromParsedHTML( - entryContent, - RegExp( - '^${standardUri.path.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) { - return '\\${x[0]}'; - })}/uploads/[^/]+/[^/]+\\.apk\$', - caseSensitive: false), - standardUri.origin), - // GitLab releases may contain links to externally hosted APKs - ...getLinksFromParsedHTML(entryContent, - RegExp('/[^/]+\\.apk\$', caseSensitive: false), '') - .where((element) => Uri.parse(element).host != '') - .toList() - ]; - - var entryId = entry?.querySelector('id')?.innerHtml; - var version = - entryId == null ? null : Uri.parse(entryId).pathSegments.last; - if (version == null) { - throw NoVersionError(); - } - return APKDetails(version, apkUrls, GitHub().getAppNames(standardUrl)); - } else { - throw getObtainiumHttpError(res); + apkDetailsList = parsedHtml.querySelectorAll('entry').map((entry) { + var entryContent = parse( + parseFragment(entry.querySelector('content')!.innerHtml).text); + var apkUrls = [ + ...getLinksFromParsedHTML( + entryContent, + RegExp( + '^${standardUri.path.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) { + return '\\${x[0]}'; + })}/uploads/[^/]+/[^/]+\\.apk\$', + caseSensitive: false), + standardUri.origin), + // GitLab releases may contain links to externally hosted APKs + ...getLinksFromParsedHTML(entryContent, + RegExp('/[^/]+\\.apk\$', caseSensitive: false), '') + .where((element) => Uri.parse(element).host != '') + .toList() + ]; + var entryId = entry.querySelector('id')?.innerHtml; + var version = + entryId == null ? null : Uri.parse(entryId).pathSegments.last; + var releaseDateString = entry.querySelector('updated')?.innerHtml; + DateTime? releaseDate = releaseDateString != null + ? DateTime.parse(releaseDateString) + : null; + if (version == null) { + throw NoVersionError(); + } + return APKDetails(version, getApkUrlsFromUrls(apkUrls), + GitHub().getAppNames(standardUrl), + releaseDate: releaseDate); + }); } + if (apkDetailsList.isEmpty) { + throw NoReleasesError(); + } + if (fallbackToOlderReleases) { + if (additionalSettings['trackOnly'] != true) { + apkDetailsList = + apkDetailsList.where((e) => e.apkUrls.isNotEmpty).toList(); + } + if (apkDetailsList.isEmpty) { + throw NoReleasesError(); + } + } + return apkDetailsList.first; } } diff --git a/lib/app_sources/html.dart b/lib/app_sources/html.dart index 984c20e..13dcdd8 100644 --- a/lib/app_sources/html.dart +++ b/lib/app_sources/html.dart @@ -1,17 +1,162 @@ import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; import 'package:html/parser.dart'; import 'package:http/http.dart'; +import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/providers/source_provider.dart'; +String ensureAbsoluteUrl(String ambiguousUrl, Uri referenceAbsoluteUrl) { + try { + Uri.parse(ambiguousUrl).origin; + return ambiguousUrl; + } catch (err) { + // is relative + } + var currPathSegments = referenceAbsoluteUrl.path + .split('/') + .where((element) => element.trim().isNotEmpty) + .toList(); + if (ambiguousUrl.startsWith('/') || currPathSegments.isEmpty) { + return '${referenceAbsoluteUrl.origin}/$ambiguousUrl'; + } else if (ambiguousUrl.split('/').where((e) => e.isNotEmpty).length == 1) { + return '${referenceAbsoluteUrl.origin}/${currPathSegments.join('/')}/$ambiguousUrl'; + } else { + return '${referenceAbsoluteUrl.origin}/${currPathSegments.sublist(0, currPathSegments.length - (currPathSegments.last.contains('.') ? 1 : 0)).join('/')}/$ambiguousUrl'; + } +} + +int compareAlphaNumeric(String a, String b) { + List aParts = _splitAlphaNumeric(a); + List bParts = _splitAlphaNumeric(b); + + for (int i = 0; i < aParts.length && i < bParts.length; i++) { + String aPart = aParts[i]; + String bPart = bParts[i]; + + bool aIsNumber = _isNumeric(aPart); + bool bIsNumber = _isNumeric(bPart); + + if (aIsNumber && bIsNumber) { + int aNumber = int.parse(aPart); + int bNumber = int.parse(bPart); + int cmp = aNumber.compareTo(bNumber); + if (cmp != 0) { + return cmp; + } + } else if (!aIsNumber && !bIsNumber) { + int cmp = aPart.compareTo(bPart); + if (cmp != 0) { + return cmp; + } + } else { + // Alphanumeric strings come before numeric strings + return aIsNumber ? 1 : -1; + } + } + + return aParts.length.compareTo(bParts.length); +} + +List _splitAlphaNumeric(String s) { + List parts = []; + StringBuffer sb = StringBuffer(); + + bool isNumeric = _isNumeric(s[0]); + sb.write(s[0]); + + for (int i = 1; i < s.length; i++) { + bool currentIsNumeric = _isNumeric(s[i]); + if (currentIsNumeric == isNumeric) { + sb.write(s[i]); + } else { + parts.add(sb.toString()); + sb.clear(); + sb.write(s[i]); + isNumeric = currentIsNumeric; + } + } + + parts.add(sb.toString()); + + return parts; +} + +bool _isNumeric(String s) { + return s.codeUnitAt(0) >= 48 && s.codeUnitAt(0) <= 57; +} + class HTML extends AppSource { - @override - String standardizeURL(String url) { - return url; + HTML() { + additionalSourceAppSpecificSettingFormItems = [ + [ + GeneratedFormSwitch('sortByFileNamesNotLinks', + label: tr('sortByFileNamesNotLinks')) + ], + [GeneratedFormSwitch('reverseSort', label: tr('reverseSort'))], + [ + GeneratedFormTextField('customLinkFilterRegex', + label: tr('customLinkFilterRegex'), + hint: 'download/(.*/)?(android|apk|mobile)', + required: false, + additionalValidators: [ + (value) { + return regExValidator(value); + } + ]) + ], + [ + GeneratedFormTextField('intermediateLinkRegex', + label: tr('intermediateLinkRegex'), + hint: '([0-9]+.)*[0-9]+/\$', + required: false, + additionalValidators: [(value) => regExValidator(value)]) + ], + [ + GeneratedFormTextField('versionExtractionRegEx', + label: tr('versionExtractionRegEx'), + required: false, + additionalValidators: [(value) => regExValidator(value)]), + ], + [ + GeneratedFormTextField('matchGroupToUse', + label: tr('matchGroupToUse'), + required: false, + hint: '0', + textInputType: const TextInputType.numberWithOptions(), + additionalValidators: [ + (value) { + if (value?.isEmpty == true) { + value = null; + } + value ??= '0'; + return intValidator(value); + } + ]) + ], + [ + GeneratedFormSwitch('versionExtractWholePage', + label: tr('versionExtractWholePage')) + ] + ]; + overrideVersionDetectionFormDefault('noVersionDetection', + disableStandard: true, disableRelDate: true); } @override - String? changeLogPageFromStandardUrl(String standardUrl) => null; + Future?> getRequestHeaders( + {Map additionalSettings = const {}, + bool forAPKDownload = false}) async { + return { + "User-Agent": + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36" + }; + } + + @override + String sourceSpecificStandardizeURL(String url) { + return url; + } @override Future getLatestAPKDetails( @@ -19,27 +164,89 @@ class HTML extends AppSource { Map additionalSettings, ) async { var uri = Uri.parse(standardUrl); - Response res = await get(uri); + Response res = await sourceRequest(standardUrl); if (res.statusCode == 200) { - List links = parse(res.body) + var html = parse(res.body); + List allLinks = html .querySelectorAll('a') .map((element) => element.attributes['href'] ?? '') - .where((element) => element.toLowerCase().endsWith('.apk')) + .where((element) => element.isNotEmpty) .toList(); - links.sort((a, b) => a.split('/').last.compareTo(b.split('/').last)); + if (allLinks.isEmpty) { + allLinks = RegExp( + r'(http|ftp|https)://([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-])?') + .allMatches(res.body) + .map((match) => match.group(0)!) + .toList(); + } + List links = []; + if ((additionalSettings['intermediateLinkRegex'] as String?) + ?.isNotEmpty == + true) { + var reg = RegExp(additionalSettings['intermediateLinkRegex']); + links = allLinks.where((element) => reg.hasMatch(element)).toList(); + links.sort((a, b) => compareAlphaNumeric(a, b)); + if (links.isEmpty) { + throw ObtainiumError(tr('intermediateLinkNotFound')); + } + Map additionalSettingsTemp = + Map.from(additionalSettings); + additionalSettingsTemp['intermediateLinkRegex'] = null; + return getLatestAPKDetails( + ensureAbsoluteUrl(links.last, uri), additionalSettingsTemp); + } + if ((additionalSettings['customLinkFilterRegex'] as String?) + ?.isNotEmpty == + true) { + var reg = RegExp(additionalSettings['customLinkFilterRegex']); + links = allLinks.where((element) => reg.hasMatch(element)).toList(); + } else { + links = allLinks + .where((element) => + Uri.parse(element).path.toLowerCase().endsWith('.apk')) + .toList(); + } + links.sort((a, b) => additionalSettings['sortByFileNamesNotLinks'] == true + ? compareAlphaNumeric(a.split('/').where((e) => e.isNotEmpty).last, + b.split('/').where((e) => e.isNotEmpty).last) + : compareAlphaNumeric(a, b)); + if (additionalSettings['reverseSort'] == true) { + links = links.reversed.toList(); + } + if ((additionalSettings['apkFilterRegEx'] as String?)?.isNotEmpty == + true) { + var reg = RegExp(additionalSettings['apkFilterRegEx']); + links = links.where((element) => reg.hasMatch(element)).toList(); + } if (links.isEmpty) { throw NoReleasesError(); } var rel = links.last; - var apkName = rel.split('/').last; - var version = apkName.substring(0, apkName.length - 4); - List apkUrls = [rel] - .map((e) => e.toLowerCase().startsWith('http://') || - e.toLowerCase().startsWith('https://') - ? e - : '${uri.origin}/$e') - .toList(); - return APKDetails(version, apkUrls, AppNames(uri.host, tr('app'))); + String? version = rel.hashCode.toString(); + var versionExtractionRegEx = + additionalSettings['versionExtractionRegEx'] as String?; + if (versionExtractionRegEx?.isNotEmpty == true) { + var match = RegExp(versionExtractionRegEx!).allMatches( + additionalSettings['versionExtractWholePage'] == true + ? res.body.split('\r\n').join('\n').split('\n').join('\\n') + : rel); + if (match.isEmpty) { + throw NoVersionError(); + } + String matchGroupString = + (additionalSettings['matchGroupToUse'] as String).trim(); + if (matchGroupString.isEmpty) { + matchGroupString = "0"; + } + version = match.last.group(int.parse(matchGroupString)); + if (version?.isEmpty == true) { + throw NoVersionError(); + } + } + List apkUrls = + [rel].map((e) => ensureAbsoluteUrl(e, uri)).toList(); + return APKDetails(version!, apkUrls.map((e) => MapEntry(e, e)).toList(), + AppNames(uri.host, tr('app'))); } else { throw getObtainiumHttpError(res); } diff --git a/lib/app_sources/huaweiappgallery.dart b/lib/app_sources/huaweiappgallery.dart new file mode 100644 index 0000000..76388e9 --- /dev/null +++ b/lib/app_sources/huaweiappgallery.dart @@ -0,0 +1,91 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:http/http.dart'; +import 'package:obtainium/custom_errors.dart'; +import 'package:obtainium/providers/source_provider.dart'; + +class HuaweiAppGallery extends AppSource { + HuaweiAppGallery() { + name = 'Huawei AppGallery'; + host = 'appgallery.huawei.com'; + overrideVersionDetectionFormDefault('releaseDateAsVersion', + disableStandard: true); + } + + @override + String sourceSpecificStandardizeURL(String url) { + RegExp standardUrlRegEx = RegExp('^https?://$host/app/[^/]+'); + RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); + if (match == null) { + throw InvalidURLError(name); + } + return url.substring(0, match.end); + } + + getDlUrl(String standardUrl) => + 'https://${host!.replaceAll('appgallery.', 'appgallery.cloud.')}/appdl/${standardUrl.split('/').last}'; + + requestAppdlRedirect(String dlUrl) async { + Response res = await sourceRequest(dlUrl, followRedirects: false); + if (res.statusCode == 200 || + res.statusCode == 302 || + res.statusCode == 304) { + return res; + } else { + throw getObtainiumHttpError(res); + } + } + + appIdFromRedirectDlUrl(String redirectDlUrl) { + var parts = redirectDlUrl + .split('?')[0] + .split('/') + .last + .split('.') + .reversed + .toList(); + parts.removeAt(0); + parts.removeAt(0); + return parts.reversed.join('.'); + } + + @override + Future tryInferringAppId(String standardUrl, + {Map additionalSettings = const {}}) async { + String dlUrl = getDlUrl(standardUrl); + Response res = await requestAppdlRedirect(dlUrl); + return res.headers['location'] != null + ? appIdFromRedirectDlUrl(res.headers['location']!) + : null; + } + + @override + Future getLatestAPKDetails( + String standardUrl, + Map additionalSettings, + ) async { + String dlUrl = getDlUrl(standardUrl); + Response res = await requestAppdlRedirect(dlUrl); + if (res.headers['location'] == null) { + throw NoReleasesError(); + } + String appId = appIdFromRedirectDlUrl(res.headers['location']!); + var relDateStr = + res.headers['location']?.split('?')[0].split('.').reversed.toList()[1]; + var relDateStrAdj = relDateStr?.split(''); + var tempLen = relDateStrAdj?.length ?? 0; + var i = 2; + while (i < tempLen) { + relDateStrAdj?.insert((i + i ~/ 2 - 1), '-'); + i += 2; + } + var relDate = relDateStrAdj == null + ? null + : DateFormat('yy-MM-dd-HH-mm').parse(relDateStrAdj.join('')); + if (relDateStr == null) { + throw NoVersionError(); + } + return APKDetails( + relDateStr, [MapEntry('$appId.apk', dlUrl)], AppNames(name, appId), + releaseDate: relDate); + } +} diff --git a/lib/app_sources/izzyondroid.dart b/lib/app_sources/izzyondroid.dart index 8d61693..66624bc 100644 --- a/lib/app_sources/izzyondroid.dart +++ b/lib/app_sources/izzyondroid.dart @@ -1,17 +1,27 @@ -import 'package:http/http.dart'; import 'package:obtainium/app_sources/fdroid.dart'; import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/providers/source_provider.dart'; class IzzyOnDroid extends AppSource { + late FDroid fd; + IzzyOnDroid() { - host = 'android.izzysoft.de'; + host = 'izzysoft.de'; + fd = FDroid(); + additionalSourceAppSpecificSettingFormItems = + fd.additionalSourceAppSpecificSettingFormItems; + allowSubDomains = true; } @override - String standardizeURL(String url) { - RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+'); - RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); + String sourceSpecificStandardizeURL(String url) { + RegExp standardUrlRegExA = RegExp('^https?://android.$host/repo/apk/[^/]+'); + RegExpMatch? match = standardUrlRegExA.firstMatch(url.toLowerCase()); + if (match == null) { + RegExp standardUrlRegExB = + RegExp('^https?://apt.$host/fdroid/index/apk/[^/]+'); + match = standardUrlRegExB.firstMatch(url.toLowerCase()); + } if (match == null) { throw InvalidURLError(name); } @@ -19,12 +29,9 @@ class IzzyOnDroid extends AppSource { } @override - String? changeLogPageFromStandardUrl(String standardUrl) => null; - - @override - String? tryInferringAppId(String standardUrl, - {Map additionalSettings = const {}}) { - return FDroid().tryInferringAppId(standardUrl); + Future tryInferringAppId(String standardUrl, + {Map additionalSettings = const {}}) async { + return fd.tryInferringAppId(standardUrl); } @override @@ -32,11 +39,17 @@ class IzzyOnDroid extends AppSource { String standardUrl, Map additionalSettings, ) async { - String? appId = tryInferringAppId(standardUrl); - return FDroid().getAPKUrlsFromFDroidPackagesAPIResponse( - await get( - Uri.parse('https://apt.izzysoft.de/fdroid/api/v1/packages/$appId')), + String? appId = await tryInferringAppId(standardUrl); + return getAPKUrlsFromFDroidPackagesAPIResponse( + await sourceRequest( + 'https://apt.izzysoft.de/fdroid/api/v1/packages/$appId'), 'https://android.izzysoft.de/frepo/$appId', - standardUrl); + standardUrl, + name, + autoSelectHighestVersionCode: + additionalSettings['autoSelectHighestVersionCode'] == true, + trySelectingSuggestedVersionCode: + additionalSettings['trySelectingSuggestedVersionCode'] == true, + filterVersionsByRegEx: additionalSettings['filterVersionsByRegEx']); } } diff --git a/lib/app_sources/jenkins.dart b/lib/app_sources/jenkins.dart new file mode 100644 index 0000000..9e5af8d --- /dev/null +++ b/lib/app_sources/jenkins.dart @@ -0,0 +1,67 @@ +import 'dart:convert'; + +import 'package:http/http.dart'; +import 'package:obtainium/custom_errors.dart'; +import 'package:obtainium/providers/source_provider.dart'; + +class Jenkins extends AppSource { + Jenkins() { + overrideVersionDetectionFormDefault('releaseDateAsVersion', + disableStandard: true); + } + + String trimJobUrl(String url) { + RegExp standardUrlRegEx = RegExp('.*/job/[^/]+'); + RegExpMatch? match = standardUrlRegEx.firstMatch(url); + if (match == null) { + throw InvalidURLError(name); + } + return url.substring(0, match.end); + } + + @override + String? changeLogPageFromStandardUrl(String standardUrl) => + '$standardUrl/-/releases'; + + @override + Future getLatestAPKDetails( + String standardUrl, + Map additionalSettings, + ) async { + standardUrl = trimJobUrl(standardUrl); + Response res = + await sourceRequest('$standardUrl/lastSuccessfulBuild/api/json'); + if (res.statusCode == 200) { + var json = jsonDecode(res.body); + var releaseDate = json['timestamp'] == null + ? null + : DateTime.fromMillisecondsSinceEpoch(json['timestamp'] as int); + var version = + json['number'] == null ? null : (json['number'] as int).toString(); + if (version == null) { + throw NoVersionError(); + } + var apkUrls = (json['artifacts'] as List) + .map((e) { + var path = (e['relativePath'] as String?); + if (path != null && path.isNotEmpty) { + path = '$standardUrl/lastSuccessfulBuild/artifact/$path'; + } + return path == null + ? const MapEntry('', '') + : MapEntry( + (e['fileName'] ?? e['relativePath']) as String, path); + }) + .where((url) => + url.value.isNotEmpty && url.key.toLowerCase().endsWith('.apk')) + .toList(); + return APKDetails( + version, + apkUrls, + releaseDate: releaseDate, + AppNames(Uri.parse(standardUrl).host, standardUrl.split('/').last)); + } else { + throw getObtainiumHttpError(res); + } + } +} diff --git a/lib/app_sources/mullvad.dart b/lib/app_sources/mullvad.dart index 0d8fed9..4e15d2f 100644 --- a/lib/app_sources/mullvad.dart +++ b/lib/app_sources/mullvad.dart @@ -1,5 +1,6 @@ import 'package:html/parser.dart'; import 'package:http/http.dart'; +import 'package:obtainium/app_sources/github.dart'; import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/providers/source_provider.dart'; @@ -9,7 +10,7 @@ class Mullvad extends AppSource { } @override - String standardizeURL(String url) { + String sourceSpecificStandardizeURL(String url) { RegExp standardUrlRegEx = RegExp('^https?://$host'); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); if (match == null) { @@ -27,21 +28,39 @@ class Mullvad extends AppSource { String standardUrl, Map additionalSettings, ) async { - Response res = await get(Uri.parse('$standardUrl/en/download/android')); + Response res = await sourceRequest('$standardUrl/en/download/android'); if (res.statusCode == 200) { - var version = parse(res.body) - .querySelector('p.subtitle.is-6') - ?.querySelector('a') - ?.attributes['href'] - ?.split('/') - .last; - if (version == null) { + var versions = parse(res.body) + .querySelectorAll('p') + .map((e) => e.innerHtml) + .where((p) => p.contains('Latest version: ')) + .map((e) { + var match = RegExp('[0-9]+(\\.[0-9]+)*').firstMatch(e); + if (match == null) { + return ''; + } else { + return e.substring(match.start, match.end); + } + }) + .where((element) => element.isNotEmpty) + .toList(); + if (versions.isEmpty) { throw NoVersionError(); } + String? changeLog; + try { + changeLog = (await GitHub().getLatestAPKDetails( + 'https://github.com/mullvad/mullvadvpn-app', + {'fallbackToOlderReleases': true})) + .changeLog; + } catch (e) { + // Ignore + } return APKDetails( - version, - ['https://mullvad.net/download/app/apk/latest'], - AppNames(name, 'Mullvad-VPN')); + versions[0], + getApkUrlsFromUrls(['https://mullvad.net/download/app/apk/latest']), + AppNames(name, 'Mullvad-VPN'), + changeLog: changeLog); } else { throw getObtainiumHttpError(res); } diff --git a/lib/app_sources/neutroncode.dart b/lib/app_sources/neutroncode.dart new file mode 100644 index 0000000..4fbec3c --- /dev/null +++ b/lib/app_sources/neutroncode.dart @@ -0,0 +1,111 @@ +import 'package:html/parser.dart'; +import 'package:http/http.dart'; +import 'package:obtainium/custom_errors.dart'; +import 'package:obtainium/providers/source_provider.dart'; + +class NeutronCode extends AppSource { + NeutronCode() { + host = 'neutroncode.com'; + } + + @override + String sourceSpecificStandardizeURL(String url) { + RegExp standardUrlRegEx = RegExp('^https?://$host/downloads/file/[^/]+'); + RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); + if (match == null) { + throw InvalidURLError(name); + } + return url.substring(0, match.end); + } + + @override + String? changeLogPageFromStandardUrl(String standardUrl) => standardUrl; + + String monthNameToNumberString(String s) { + switch (s.toLowerCase()) { + case 'january': + return '01'; + case 'february': + return '02'; + case 'march': + return '03'; + case 'april': + return '04'; + case 'may': + return '05'; + case 'june': + return '06'; + case 'july': + return '07'; + case 'august': + return '08'; + case 'september': + return '09'; + case 'october': + return '10'; + case 'november': + return '11'; + case 'december': + return '12'; + default: + throw ArgumentError('Invalid month name: $s'); + } + } + + customDateParse(String dateString) { + List parts = dateString.split(' '); + if (parts.length != 3) { + return null; + } + String result = ''; + for (var s in parts.reversed) { + try { + try { + int.parse(s); + result += '$s-'; + } catch (e) { + result += '${monthNameToNumberString(s)}-'; + } + } catch (e) { + return null; + } + } + return result.substring(0, result.length - 1); + } + + @override + Future getLatestAPKDetails( + String standardUrl, + Map additionalSettings, + ) async { + Response res = await sourceRequest(standardUrl); + if (res.statusCode == 200) { + var http = parse(res.body); + var name = http.querySelector('.pd-title')?.innerHtml; + var filename = http.querySelector('.pd-filename .pd-float')?.innerHtml; + if (filename == null) { + throw NoReleasesError(); + } + var version = + http.querySelector('.pd-version-txt')?.nextElementSibling?.innerHtml; + if (version == null) { + throw NoVersionError(); + } + String? apkUrl = 'https://$host/download/$filename'; + var dateStringOriginal = + http.querySelector('.pd-date-txt')?.nextElementSibling?.innerHtml; + var dateString = dateStringOriginal != null + ? (customDateParse(dateStringOriginal)) + : null; + var changeLogElements = http.querySelectorAll('.pd-fdesc p'); + return APKDetails(version, getApkUrlsFromUrls([apkUrl]), + AppNames(runtimeType.toString(), name ?? standardUrl.split('/').last), + releaseDate: dateString != null ? DateTime.parse(dateString) : null, + changeLog: changeLogElements.isNotEmpty + ? changeLogElements.last.innerHtml + : null); + } else { + throw getObtainiumHttpError(res); + } + } +} diff --git a/lib/app_sources/signal.dart b/lib/app_sources/signal.dart index ad3c7b9..f7bda25 100644 --- a/lib/app_sources/signal.dart +++ b/lib/app_sources/signal.dart @@ -9,20 +9,17 @@ class Signal extends AppSource { } @override - String standardizeURL(String url) { + String sourceSpecificStandardizeURL(String url) { return 'https://$host'; } - @override - String? changeLogPageFromStandardUrl(String standardUrl) => null; - @override Future getLatestAPKDetails( String standardUrl, Map additionalSettings, ) async { Response res = - await get(Uri.parse('https://updates.$host/android/latest.json')); + await sourceRequest('https://updates.$host/android/latest.json'); if (res.statusCode == 200) { var json = jsonDecode(res.body); String? apkUrl = json['url']; @@ -31,7 +28,8 @@ class Signal extends AppSource { if (version == null) { throw NoVersionError(); } - return APKDetails(version, apkUrls, AppNames(name, 'Signal')); + return APKDetails( + version, getApkUrlsFromUrls(apkUrls), AppNames(name, 'Signal')); } else { throw getObtainiumHttpError(res); } diff --git a/lib/app_sources/sourceforge.dart b/lib/app_sources/sourceforge.dart index 2c80838..3253926 100644 --- a/lib/app_sources/sourceforge.dart +++ b/lib/app_sources/sourceforge.dart @@ -9,24 +9,27 @@ class SourceForge extends AppSource { } @override - String standardizeURL(String url) { - RegExp standardUrlRegEx = RegExp('^https?://$host/projects/[^/]+'); - RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); + String sourceSpecificStandardizeURL(String url) { + RegExp standardUrlRegExB = RegExp('^https?://$host/p/[^/]+'); + RegExpMatch? match = standardUrlRegExB.firstMatch(url.toLowerCase()); + if (match != null) { + url = + 'https://${Uri.parse(url.substring(0, match.end)).host}/projects/${url.substring(Uri.parse(url.substring(0, match.end)).host.length + '/projects/'.length + 1)}'; + } + RegExp standardUrlRegExA = RegExp('^https?://$host/projects/[^/]+'); + match = standardUrlRegExA.firstMatch(url.toLowerCase()); if (match == null) { throw InvalidURLError(name); } return url.substring(0, match.end); } - @override - String? changeLogPageFromStandardUrl(String standardUrl) => null; - @override Future getLatestAPKDetails( String standardUrl, Map additionalSettings, ) async { - Response res = await get(Uri.parse('$standardUrl/rss?path=/')); + Response res = await sourceRequest('$standardUrl/rss?path=/'); if (res.statusCode == 200) { var parsedHtml = parse(res.body); var allDownloadLinks = @@ -34,7 +37,8 @@ class SourceForge extends AppSource { getVersion(String url) { try { var tokens = url.split('/'); - return tokens[tokens.length - 3]; + var fi = tokens.indexOf('files'); + return tokens[tokens[fi + 2] == 'download' ? fi - 1 : fi + 1]; } catch (e) { return null; } @@ -53,7 +57,7 @@ class SourceForge extends AppSource { .toList(); return APKDetails( version, - apkUrlList, + getApkUrlsFromUrls(apkUrlList), AppNames( name, standardUrl.substring(standardUrl.lastIndexOf('/') + 1))); } else { diff --git a/lib/app_sources/sourcehut.dart b/lib/app_sources/sourcehut.dart new file mode 100644 index 0000000..d74fd7c --- /dev/null +++ b/lib/app_sources/sourcehut.dart @@ -0,0 +1,107 @@ +import 'package:html/parser.dart'; +import 'package:http/http.dart'; +import 'package:obtainium/app_sources/html.dart'; +import 'package:obtainium/custom_errors.dart'; +import 'package:obtainium/providers/source_provider.dart'; +import 'package:obtainium/components/generated_form.dart'; +import 'package:easy_localization/easy_localization.dart'; + +class SourceHut extends AppSource { + SourceHut() { + host = 'git.sr.ht'; + + additionalSourceAppSpecificSettingFormItems = [ + [ + GeneratedFormSwitch('fallbackToOlderReleases', + label: tr('fallbackToOlderReleases'), defaultValue: true) + ] + ]; + } + + @override + String sourceSpecificStandardizeURL(String url) { + RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+'); + RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); + if (match == null) { + throw InvalidURLError(name); + } + return url.substring(0, match.end); + } + + @override + String? changeLogPageFromStandardUrl(String standardUrl) => standardUrl; + + @override + Future getLatestAPKDetails( + String standardUrl, + Map additionalSettings, + ) async { + Uri standardUri = Uri.parse(standardUrl); + String appName = standardUri.pathSegments.last; + bool fallbackToOlderReleases = + additionalSettings['fallbackToOlderReleases'] == true; + Response res = await sourceRequest('$standardUrl/refs/rss.xml'); + if (res.statusCode == 200) { + var parsedHtml = parse(res.body); + List apkDetailsList = []; + int ind = 0; + + for (var entry in parsedHtml.querySelectorAll('item').sublist(0, 6)) { + // Limit 5 for speed + if (!fallbackToOlderReleases && ind > 0) { + break; + } + String? version = entry.querySelector('title')?.text.trim(); + if (version == null) { + throw NoVersionError(); + } + String? releaseDateString = entry.querySelector('pubDate')?.innerHtml; + String releasePage = '$standardUrl/refs/$version'; + DateTime? releaseDate; + try { + releaseDate = releaseDateString != null + ? DateFormat('E, dd MMM yyyy HH:mm:ss Z').parse(releaseDateString) + : null; + releaseDate = releaseDateString != null + ? DateFormat('EEE, dd MMM yyyy HH:mm:ss Z') + .parse(releaseDateString) + : null; + } catch (e) { + // ignore + } + var res2 = await sourceRequest(releasePage); + List> apkUrls = []; + if (res2.statusCode == 200) { + apkUrls = getApkUrlsFromUrls(parse(res2.body) + .querySelectorAll('a') + .map((e) => e.attributes['href'] ?? '') + .where((e) => e.toLowerCase().endsWith('.apk')) + .map((e) => ensureAbsoluteUrl(e, standardUri)) + .toList()); + } + apkDetailsList.add(APKDetails( + version, + apkUrls, + AppNames(entry.querySelector('author')?.innerHtml.trim() ?? appName, + appName), + releaseDate: releaseDate)); + ind++; + } + if (apkDetailsList.isEmpty) { + throw NoReleasesError(); + } + if (fallbackToOlderReleases) { + if (additionalSettings['trackOnly'] != true) { + apkDetailsList = + apkDetailsList.where((e) => e.apkUrls.isNotEmpty).toList(); + } + if (apkDetailsList.isEmpty) { + throw NoReleasesError(); + } + } + return apkDetailsList.first; + } else { + throw getObtainiumHttpError(res); + } + } +} diff --git a/lib/app_sources/steammobile.dart b/lib/app_sources/steammobile.dart index dacc088..65c518b 100644 --- a/lib/app_sources/steammobile.dart +++ b/lib/app_sources/steammobile.dart @@ -10,32 +10,33 @@ class SteamMobile extends AppSource { host = 'store.steampowered.com'; name = tr('steam'); additionalSourceAppSpecificSettingFormItems = [ - [GeneratedFormDropdown('app', apks.entries.toList(), label: tr('app'))] + [ + GeneratedFormDropdown('app', apks.entries.toList(), + label: tr('app'), defaultValue: apks.entries.toList()[0].key) + ] ]; } final apks = {'steam': tr('steamMobile'), 'steam-chat-app': tr('steamChat')}; @override - String standardizeURL(String url) { + String sourceSpecificStandardizeURL(String url) { return 'https://$host'; } - @override - String? changeLogPageFromStandardUrl(String standardUrl) => null; - @override Future getLatestAPKDetails( String standardUrl, Map additionalSettings, ) async { - Response res = await get(Uri.parse('https://$host/mobile')); + Response res = await sourceRequest('https://$host/mobile'); if (res.statusCode == 200) { var apkNamePrefix = additionalSettings['app'] as String?; if (apkNamePrefix == null) { throw NoReleasesError(); } - String apkInURLRegexPattern = '/$apkNamePrefix-[^/]+\\.apk\$'; + String apkInURLRegexPattern = + '/$apkNamePrefix-([0-9]+\\.)*[0-9]+\\.apk\$'; var links = parse(res.body) .querySelectorAll('a') .map((e) => e.attributes['href'] ?? '') @@ -52,7 +53,8 @@ class SteamMobile extends AppSource { var version = links[0].substring( versionMatch.start + apkNamePrefix.length + 2, versionMatch.end - 4); var apkUrls = [links[0]]; - return APKDetails(version, apkUrls, AppNames(name, apks[apkNamePrefix]!)); + return APKDetails(version, getApkUrlsFromUrls(apkUrls), + AppNames(name, apks[apkNamePrefix]!)); } else { throw getObtainiumHttpError(res); } diff --git a/lib/app_sources/telegramapp.dart b/lib/app_sources/telegramapp.dart new file mode 100644 index 0000000..44042e0 --- /dev/null +++ b/lib/app_sources/telegramapp.dart @@ -0,0 +1,41 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:html/parser.dart'; +import 'package:http/http.dart'; +import 'package:obtainium/custom_errors.dart'; +import 'package:obtainium/providers/source_provider.dart'; + +class TelegramApp extends AppSource { + TelegramApp() { + host = 'telegram.org'; + name = 'Telegram ${tr('app')}'; + } + + @override + String sourceSpecificStandardizeURL(String url) { + return 'https://$host'; + } + + @override + Future getLatestAPKDetails( + String standardUrl, + Map additionalSettings, + ) async { + Response res = await sourceRequest('https://t.me/s/TAndroidAPK'); + if (res.statusCode == 200) { + var http = parse(res.body); + var messages = + http.querySelectorAll('.tgme_widget_message_text.js-message_text'); + var version = messages.isNotEmpty + ? messages.last.innerHtml.split('\n').first.trim().split(' ').first + : null; + if (version == null) { + throw NoVersionError(); + } + String? apkUrl = 'https://telegram.org/dl/android/apk'; + return APKDetails(version, getApkUrlsFromUrls([apkUrl]), + AppNames('Telegram', 'Telegram')); + } else { + throw getObtainiumHttpError(res); + } + } +} diff --git a/lib/app_sources/uptodown.dart b/lib/app_sources/uptodown.dart new file mode 100644 index 0000000..7a0b41d --- /dev/null +++ b/lib/app_sources/uptodown.dart @@ -0,0 +1,99 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:html/parser.dart'; +import 'package:obtainium/app_sources/apkpure.dart'; +import 'package:obtainium/custom_errors.dart'; +import 'package:obtainium/providers/source_provider.dart'; + +class Uptodown extends AppSource { + Uptodown() { + host = 'uptodown.com'; + allowSubDomains = true; + naiveStandardVersionDetection = true; + } + + @override + String sourceSpecificStandardizeURL(String url) { + RegExp standardUrlRegEx = RegExp('^https?://([^\\.]+\\.){2,}$host'); + RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); + if (match == null) { + throw InvalidURLError(name); + } + return '${url.substring(0, match.end)}/android/download'; + } + + @override + Future tryInferringAppId(String standardUrl, + {Map additionalSettings = const {}}) async { + return (await getAppDetailsFromPage(standardUrl))['appId']; + } + + Future> getAppDetailsFromPage(String standardUrl) async { + var res = await sourceRequest(standardUrl); + if (res.statusCode != 200) { + throw getObtainiumHttpError(res); + } + var html = parse(res.body); + String? version = html.querySelector('div.version')?.innerHtml; + String? apkUrl = + '${standardUrl.split('/').reversed.toList().sublist(1).reversed.join('/')}/post-download'; + String? name = html.querySelector('#detail-app-name')?.innerHtml.trim(); + String? author = html.querySelector('#author-link')?.innerHtml.trim(); + var detailElements = html.querySelectorAll('#technical-information td'); + String? appId = (detailElements.elementAtOrNull(2))?.innerHtml.trim(); + String? dateStr = (detailElements.elementAtOrNull(29))?.innerHtml.trim(); + return Map.fromEntries([ + MapEntry('version', version), + MapEntry('apkUrl', apkUrl), + MapEntry('appId', appId), + MapEntry('name', name), + MapEntry('author', author), + MapEntry('dateStr', dateStr) + ]); + } + + @override + Future getLatestAPKDetails( + String standardUrl, + Map additionalSettings, + ) async { + var appDetails = await getAppDetailsFromPage(standardUrl); + var version = appDetails['version']; + var apkUrl = appDetails['apkUrl']; + var appId = appDetails['appId']; + if (version == null) { + throw NoVersionError(); + } + if (apkUrl == null) { + throw NoAPKError(); + } + if (appId == null) { + throw NoReleasesError(); + } + String appName = appDetails['name'] ?? tr('app'); + String author = appDetails['author'] ?? name; + String? dateStr = appDetails['dateStr']; + DateTime? relDate; + if (dateStr != null) { + relDate = parseDateTimeMMMddCommayyyy(dateStr); + } + return APKDetails( + version, getApkUrlsFromUrls([apkUrl]), AppNames(author, appName), + releaseDate: relDate); + } + + @override + Future apkUrlPrefetchModifier( + String apkUrl, String standardUrl) async { + var res = await sourceRequest(apkUrl); + if (res.statusCode != 200) { + throw getObtainiumHttpError(res); + } + var html = parse(res.body); + var finalUrlKey = + html.querySelector('.post-download')?.attributes['data-url']; + if (finalUrlKey == null) { + throw NoAPKError(); + } + return 'https://dw.$host/dwn/$finalUrlKey'; + } +} diff --git a/lib/app_sources/vlc.dart b/lib/app_sources/vlc.dart new file mode 100644 index 0000000..091411a --- /dev/null +++ b/lib/app_sources/vlc.dart @@ -0,0 +1,108 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:html/parser.dart'; +import 'package:http/http.dart'; +import 'package:obtainium/app_sources/html.dart'; +import 'package:obtainium/custom_errors.dart'; +import 'package:obtainium/providers/source_provider.dart'; + +class VLC extends AppSource { + VLC() { + host = 'videolan.org'; + } + get dwUrlBase => 'https://get.$host/vlc-android/'; + + @override + Future?> getRequestHeaders( + {Map additionalSettings = const {}, + bool forAPKDownload = false}) => + HTML().getRequestHeaders( + additionalSettings: additionalSettings, + forAPKDownload: forAPKDownload); + + @override + String sourceSpecificStandardizeURL(String url) { + return 'https://$host'; + } + + Future getLatestVersion(String standardUrl) async { + Response res = await sourceRequest(dwUrlBase); + if (res.statusCode == 200) { + var dwLinks = parse(res.body) + .querySelectorAll('a') + .where((element) => element.attributes['href'] != 'last/') + .map((e) => e.attributes['href']?.split('/')[0]) + .toList(); + String? version = dwLinks.isNotEmpty ? dwLinks.last : null; + if (version == null) { + throw NoVersionError(); + } + return version; + } else { + throw getObtainiumHttpError(res); + } + } + + @override + Future getLatestAPKDetails( + String standardUrl, + Map additionalSettings, + ) async { + Response res = await get( + Uri.parse('https://www.videolan.org/vlc/download-android.html')); + if (res.statusCode == 200) { + var dwUrlBase = 'get.videolan.org/vlc-android'; + var dwLinks = parse(res.body) + .querySelectorAll('a') + .where((element) => + element.attributes['href']?.contains(dwUrlBase) ?? false) + .toList(); + String? version = dwLinks.isNotEmpty + ? dwLinks.first.attributes['href'] + ?.split('/') + .where((s) => s.isNotEmpty) + .last + : null; + if (version == null) { + throw NoVersionError(); + } + String? targetUrl = 'https://$dwUrlBase/$version/'; + var apkUrls = ['arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64'] + .map((e) => '${targetUrl}VLC-Android-$version-$e.apk') + .toList(); + return APKDetails( + version, getApkUrlsFromUrls(apkUrls), AppNames('VideoLAN', 'VLC')); + } else { + throw getObtainiumHttpError(res); + } + } + + @override + Future apkUrlPrefetchModifier( + String apkUrl, String standardUrl) async { + Response res = await sourceRequest(apkUrl); + if (res.statusCode == 200) { + String? apkUrl = + parse(res.body).querySelector('#alt_link')?.attributes['href']; + if (apkUrl == null) { + throw NoAPKError(); + } + return apkUrl; + } else if (res.statusCode == 500 && + res.body.toLowerCase().indexOf('mirror') > 0) { + var html = parse(res.body); + var err = ''; + html.body?.nodes.forEach((element) { + if (element.text != null) { + err += '${element.text}\n'; + } + }); + err = err.trim(); + if (err.isEmpty) { + err = tr('err'); + } + throw ObtainiumError(err); + } else { + throw getObtainiumHttpError(res); + } + } +} diff --git a/lib/app_sources/whatsapp.dart b/lib/app_sources/whatsapp.dart new file mode 100644 index 0000000..ff295b4 --- /dev/null +++ b/lib/app_sources/whatsapp.dart @@ -0,0 +1,53 @@ +import 'package:html/parser.dart'; +import 'package:http/http.dart'; +import 'package:obtainium/custom_errors.dart'; +import 'package:obtainium/providers/source_provider.dart'; + +class WhatsApp extends AppSource { + WhatsApp() { + host = 'whatsapp.com'; + } + + @override + String sourceSpecificStandardizeURL(String url) { + return 'https://$host'; + } + + @override + Future apkUrlPrefetchModifier( + String apkUrl, String standardUrl) async { + Response res = await sourceRequest('$standardUrl/android'); + if (res.statusCode == 200) { + var targetLinks = parse(res.body) + .querySelectorAll('a') + .map((e) => e.attributes['href'] ?? '') + .where((e) => e.isNotEmpty) + .where((e) => e.contains('WhatsApp.apk')) + .toList(); + if (targetLinks.isEmpty) { + throw NoAPKError(); + } + return targetLinks[0]; + } else { + throw getObtainiumHttpError(res); + } + } + + @override + Future getLatestAPKDetails( + String standardUrl, + Map additionalSettings, + ) async { + // This is a CDN link that is consistent per version + // But it has query params that change constantly + Uri apkUri = + Uri.parse(await apkUrlPrefetchModifier(standardUrl, standardUrl)); + var unusableApkUrl = '${apkUri.origin}/${apkUri.path}'; + // So we use the param-less URL is a pseudo-version to add the app and check for updates + // See #357 for why we can't scrape the version number directly + // But we re-fetch the URL again with its latest query params at the actual download time + String version = unusableApkUrl.hashCode.toString(); + return APKDetails(version, getApkUrlsFromUrls([unusableApkUrl]), + AppNames('Meta', 'WhatsApp')); + } +} diff --git a/lib/components/generated_form.dart b/lib/components/generated_form.dart index 1a7e45e..41e742f 100644 --- a/lib/components/generated_form.dart +++ b/lib/components/generated_form.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:hsluv/hsluv.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:obtainium/components/generated_form_modal.dart'; @@ -24,6 +25,7 @@ class GeneratedFormTextField extends GeneratedFormItem { late int max; late String? hint; late bool password; + late TextInputType? textInputType; GeneratedFormTextField(String key, {String label = 'Input', @@ -33,7 +35,8 @@ class GeneratedFormTextField extends GeneratedFormItem { this.required = true, this.max = 1, this.hint, - this.password = false}) + this.password = false, + this.textInputType}) : super(key, label: label, belowWidgets: belowWidgets, @@ -48,6 +51,7 @@ class GeneratedFormTextField extends GeneratedFormItem { class GeneratedFormDropdown extends GeneratedFormItem { late List>? opts; + List? disabledOptKeys; GeneratedFormDropdown( String key, @@ -55,6 +59,7 @@ class GeneratedFormDropdown extends GeneratedFormItem { String label = 'Input', List belowWidgets = const [], String defaultValue = '', + this.disabledOptKeys, List additionalValidators = const [], }) : super(key, label: label, @@ -130,19 +135,20 @@ class GeneratedForm extends StatefulWidget { State createState() => _GeneratedFormState(); } -// Generates a random light color -// Courtesy of ChatGPT 😭 (with a bugfix 🥳) +// Generates a color in the HSLuv (Pastel) color space +// https://pub.dev/documentation/hsluv/latest/hsluv/Hsluv/hpluvToRgb.html Color generateRandomLightColor() { - // Create a random number generator - final Random random = Random(); - - // Generate random hue, saturation, and value values - final double hue = random.nextDouble() * 360; - final double saturation = 0.5 + random.nextDouble() * 0.5; - final double value = 0.9 + random.nextDouble() * 0.1; - - // Create a HSV color with the random values - return HSVColor.fromAHSV(1.0, hue, saturation, value).toColor(); + final randomSeed = Random().nextInt(120); + // https://en.wikipedia.org/wiki/Golden_angle + final goldenAngle = 180 * (3 - sqrt(5)); + // Generate next golden angle hue + final double hue = randomSeed * goldenAngle; + // Map from HPLuv color space to RGB, use constant saturation=100, lightness=70 + final List rgbValuesDbl = Hsluv.hpluvToRgb([hue, 100, 70]); + // Map RBG values from 0-1 to 0-255: + final List rgbValues = + rgbValuesDbl.map((rgb) => (rgb * 255).toInt()).toList(); + return Color.fromARGB(255, rgbValues[0], rgbValues[1], rgbValues[2]); } class _GeneratedFormState extends State { @@ -150,6 +156,7 @@ class _GeneratedFormState extends State { Map values = {}; late List> formInputs; List> rows = []; + String? initKey; // If any value changes, call this to update the parent with value and validity void someValueChanged({bool isBuilding = false}) { @@ -169,13 +176,10 @@ class _GeneratedFormState extends State { widget.onValueChanges(returnValues, valid, isBuilding); } - @override - void initState() { - super.initState(); - + initForm() { + initKey = widget.key.toString(); // Initialize form values as all empty values.clear(); - int j = 0; for (var row in widget.items) { for (var e in row) { values[e.key] = e.defaultValue; @@ -189,6 +193,7 @@ class _GeneratedFormState extends State { if (formItem is GeneratedFormTextField) { final formFieldKey = GlobalKey(); return TextFormField( + keyboardType: formItem.textInputType, obscureText: formItem.password, autocorrect: !formItem.password, enableSuggestions: !formItem.password, @@ -227,10 +232,15 @@ class _GeneratedFormState extends State { return DropdownButtonFormField( decoration: InputDecoration(labelText: formItem.label), value: values[formItem.key], - items: formItem.opts! - .map((e2) => - DropdownMenuItem(value: e2.key, child: Text(e2.value))) - .toList(), + items: formItem.opts!.map((e2) { + var enabled = + formItem.disabledOptKeys?.contains(e2.key) != true; + return DropdownMenuItem( + value: e2.key, + enabled: enabled, + child: Opacity( + opacity: enabled ? 1 : 0.5, child: Text(e2.value))); + }).toList(), onChanged: (value) { setState(() { values[formItem.key] = value ?? formItem.opts!.first.key; @@ -245,15 +255,27 @@ class _GeneratedFormState extends State { someValueChanged(isBuilding: true); } + @override + void initState() { + super.initState(); + initForm(); + } + @override Widget build(BuildContext context) { + if (widget.key.toString() != initKey) { + initForm(); + } for (var r = 0; r < formInputs.length; r++) { for (var e = 0; e < formInputs[r].length; e++) { if (widget.items[r][e] is GeneratedFormSwitch) { formInputs[r][e] = Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(widget.items[r][e].label), + Flexible(child: Text(widget.items[r][e].label)), + const SizedBox( + width: 8, + ), Switch( value: values[widget.items[r][e].key], onChanged: (value) { @@ -351,6 +373,39 @@ class _GeneratedFormState extends State { )); }) ?? [const SizedBox.shrink()], + (values[widget.items[r][e].key] + as Map>?) + ?.values + .where((e) => e.value) + .length == + 1 + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: IconButton( + onPressed: () { + setState(() { + var temp = values[widget.items[r][e].key] + as Map>; + // get selected category str where bool is true + final oldEntry = temp.entries + .firstWhere((entry) => entry.value.value); + // generate new color, ensure it is not the same + int newColor = oldEntry.value.key; + while (oldEntry.value.key == newColor) { + newColor = generateRandomLightColor().value; + } + // Update entry with new color, remain selected + temp.update(oldEntry.key, + (old) => MapEntry(newColor, old.value)); + values[widget.items[r][e].key] = temp; + someValueChanged(); + }); + }, + icon: const Icon(Icons.format_color_fill_rounded), + visualDensity: VisualDensity.compact, + tooltip: tr('colour'), + )) + : const SizedBox.shrink(), (values[widget.items[r][e].key] as Map>?) ?.values @@ -453,10 +508,9 @@ class _GeneratedFormState extends State { if (rowInputs.key > 0) { rows.add([ SizedBox( - height: widget.items[rowInputs.key][0] is GeneratedFormSwitch && - widget.items[rowInputs.key - 1][0] is! GeneratedFormSwitch - ? 25 - : 8, + height: widget.items[rowInputs.key - 1][0] is GeneratedFormSwitch + ? 8 + : 25, ) ]); } @@ -470,6 +524,7 @@ class _GeneratedFormState extends State { rowItems.add(Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, children: [ rowInput.value, ...widget.items[rowInputs.key][rowInput.key].belowWidgets diff --git a/lib/components/generated_form_modal.dart b/lib/components/generated_form_modal.dart index f44acc4..838b187 100644 --- a/lib/components/generated_form_modal.dart +++ b/lib/components/generated_form_modal.dart @@ -11,7 +11,8 @@ class GeneratedFormModal extends StatefulWidget { this.initValid = false, this.message = '', this.additionalWidgets = const [], - this.singleNullReturnButton}); + this.singleNullReturnButton, + this.primaryActionColour}); final String title; final String message; @@ -19,6 +20,7 @@ class GeneratedFormModal extends StatefulWidget { final bool initValid; final List additionalWidgets; final String? singleNullReturnButton; + final Color? primaryActionColour; @override State createState() => _GeneratedFormModalState(); @@ -71,6 +73,10 @@ class _GeneratedFormModalState extends State { : widget.singleNullReturnButton!)), widget.singleNullReturnButton == null ? TextButton( + style: widget.primaryActionColour == null + ? null + : TextButton.styleFrom( + foregroundColor: widget.primaryActionColour), onPressed: !valid ? null : () { diff --git a/lib/custom_errors.dart b/lib/custom_errors.dart index 8ae0fa5..eed8484 100644 --- a/lib/custom_errors.dart +++ b/lib/custom_errors.dart @@ -1,5 +1,9 @@ +import 'dart:io'; + +import 'package:android_package_installer/android_package_installer.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:obtainium/providers/logs_provider.dart'; import 'package:provider/provider.dart'; @@ -24,12 +28,17 @@ class InvalidURLError extends ObtainiumError { : super(tr('invalidURLForSource', args: [sourceName])); } +class CredsNeededError extends ObtainiumError { + CredsNeededError(String sourceName) + : super(tr('requiresCredentialsInSettings', args: [sourceName])); +} + class NoReleasesError extends ObtainiumError { NoReleasesError() : super(tr('noReleaseFound')); } class NoAPKError extends ObtainiumError { - NoAPKError() : super(tr('noReleaseFound')); + NoAPKError() : super(tr('noAPKFound')); } class NoVersionError extends ObtainiumError { @@ -44,8 +53,13 @@ class DowngradeError extends ObtainiumError { DowngradeError() : super(tr('cantInstallOlderVersion')); } +class InstallError extends ObtainiumError { + InstallError(int code) + : super(PackageInstallerStatus.byCode(code).name.substring(7)); +} + class IDChangedError extends ObtainiumError { - IDChangedError() : super(tr('appIdMismatch')); + IDChangedError(String newId) : super('${tr('appIdMismatch')} - $newId'); } class NotImplementedError extends ObtainiumError { @@ -53,30 +67,43 @@ class NotImplementedError extends ObtainiumError { } class MultiAppMultiError extends ObtainiumError { - Map> content = {}; + Map rawErrors = {}; + Map> idsByErrorString = {}; + Map appIdNames = {}; MultiAppMultiError() : super(tr('placeholder'), unexpected: true); - add(String appId, String string) { - var tempIds = content.remove(string); + add(String appId, dynamic error, {String? appName}) { + if (error is SocketException) { + error = error.message; + } + rawErrors[appId] = error; + var string = error.toString(); + var tempIds = idsByErrorString.remove(string); tempIds ??= []; tempIds.add(appId); - content.putIfAbsent(string, () => tempIds!); + idsByErrorString.putIfAbsent(string, () => tempIds!); + if (appName != null) { + appIdNames[appId] = appName; + } } + String errorString(String appId, {bool includeIdsWithNames = false}) => + '${appIdNames.containsKey(appId) ? '${appIdNames[appId]}${includeIdsWithNames ? ' ($appId)' : ''}' : appId}: ${rawErrors[appId].toString()}'; + + String errorsAppsString(String errString, List appIds, + {bool includeIdsWithNames = false}) => + '$errString [${list2FriendlyString(appIds.map((id) => appIdNames.containsKey(id) == true ? '${appIdNames[id]}${includeIdsWithNames ? ' ($id)' : ''}' : id).toList())}]'; + @override - String toString() { - String finalString = ''; - for (var e in content.keys) { - finalString += '$e: ${content[e].toString()}\n\n'; - } - return finalString; - } + String toString() => idsByErrorString.entries + .map((e) => errorsAppsString(e.key, e.value)) + .join('\n\n'); } -showError(dynamic e, BuildContext context) { +showMessage(dynamic e, BuildContext context, {bool isError = false}) { Provider.of(context, listen: false) - .add(e.toString(), level: LogLevels.error); + .add(e.toString(), level: isError ? LogLevels.error : LogLevels.info); if (e is String || (e is ObtainiumError && !e.unexpected)) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(e.toString())), @@ -88,9 +115,16 @@ showError(dynamic e, BuildContext context) { return AlertDialog( scrollable: true, title: Text(e is MultiAppMultiError - ? tr('someErrors') - : tr('unexpectedError')), - content: Text(e.toString()), + ? tr(isError ? 'someErrors' : 'updates') + : tr(isError ? 'unexpectedError' : 'unknown')), + content: GestureDetector( + onLongPress: () { + Clipboard.setData(ClipboardData(text: e.toString())); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(tr('copiedToClipboard')), + )); + }, + child: Text(e.toString())), actions: [ TextButton( onPressed: () { @@ -103,6 +137,10 @@ showError(dynamic e, BuildContext context) { } } +showError(dynamic e, BuildContext context) { + showMessage(e, context, isError: true); +} + String list2FriendlyString(List list) { return list.length == 2 ? '${list[0]} ${tr('and')} ${list[1]}' diff --git a/lib/main.dart b/lib/main.dart index c041806..631eade 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,7 @@ import 'dart:io'; -import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/pages/home.dart'; import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/logs_provider.dart'; @@ -21,19 +19,29 @@ import 'package:easy_localization/src/easy_localization_controller.dart'; // ignore: implementation_imports import 'package:easy_localization/src/localization.dart'; -const String currentVersion = '0.10.4'; +const String currentVersion = '0.14.32'; const String currentReleaseTag = 'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES const int bgUpdateCheckAlarmId = 666; -const supportedLocales = [ - Locale('en'), - Locale('zh'), - Locale('it'), - Locale('ja'), - Locale('hu'), - Locale('de') +List> supportedLocales = const [ + MapEntry(Locale('en'), 'English'), + MapEntry(Locale('zh'), '简体中文'), + MapEntry(Locale('it'), 'Italiano'), + MapEntry(Locale('ja'), '日本語'), + MapEntry(Locale('hu'), 'Magyar'), + MapEntry(Locale('de'), 'Deutsch'), + MapEntry(Locale('fa'), 'فارسی'), + MapEntry(Locale('fr'), 'Français'), + MapEntry(Locale('es'), 'Español'), + MapEntry(Locale('pl'), 'Polski'), + MapEntry(Locale('ru'), 'Русский язык'), + MapEntry(Locale('bs'), 'Bosanski'), + MapEntry(Locale('pt'), 'Brasileiro'), + MapEntry(Locale('cs'), 'Česky'), + MapEntry(Locale('sv'), 'Svenska'), + MapEntry(Locale('nl'), 'Nederlands'), ]; const fallbackLocale = Locale('en'); const localeDir = 'assets/translations'; @@ -51,7 +59,7 @@ Future loadTranslations() async { saveLocale: true, forceLocale: forceLocale != null ? Locale(forceLocale) : null, fallbackLocale: fallbackLocale, - supportedLocales: supportedLocales, + supportedLocales: supportedLocales.map((e) => e.key).toList(), assetLoader: const RootBundleAssetLoader(), useOnlyLangCode: true, useFallbackTranslations: true, @@ -66,86 +74,16 @@ Future loadTranslations() async { fallbackTranslations: controller.fallbackTranslations); } -@pragma('vm:entry-point') -Future bgUpdateCheck(int taskId, Map? params) async { - WidgetsFlutterBinding.ensureInitialized(); - await EasyLocalization.ensureInitialized(); - - await loadTranslations(); - - LogsProvider logs = LogsProvider(); - logs.add(tr('startedBgUpdateTask')); - int? ignoreAfterMicroseconds = params?['ignoreAfterMicroseconds']; - await AndroidAlarmManager.initialize(); - DateTime? ignoreAfter = ignoreAfterMicroseconds != null - ? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds) - : null; - logs.add(tr('bgUpdateIgnoreAfterIs', args: [ignoreAfter.toString()])); - var notificationsProvider = NotificationsProvider(); - await notificationsProvider.notify(checkingUpdatesNotification); - try { - var appsProvider = AppsProvider(); - await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id); - await appsProvider.loadApps(); - List existingUpdateIds = - appsProvider.findExistingUpdates(installedOnly: true); - DateTime nextIgnoreAfter = DateTime.now(); - String? err; - try { - logs.add(tr('startedActualBGUpdateCheck')); - await appsProvider.checkUpdates( - ignoreAppsCheckedAfter: ignoreAfter, throwErrorsForRetry: true); - } catch (e) { - if (e is RateLimitError || e is SocketException) { - var remainingMinutes = e is RateLimitError ? e.remainingMinutes : 15; - logs.add(plural('bgUpdateGotErrorRetryInMinutes', remainingMinutes, - args: [e.toString(), remainingMinutes.toString()])); - AndroidAlarmManager.oneShot(Duration(minutes: remainingMinutes), - Random().nextInt(pow(2, 31) as int), bgUpdateCheck, params: { - 'ignoreAfterMicroseconds': nextIgnoreAfter.microsecondsSinceEpoch - }); - } else { - err = e.toString(); - } - } - List newUpdates = appsProvider - .findExistingUpdates(installedOnly: true) - .where((id) => !existingUpdateIds.contains(id)) - .map((e) => appsProvider.apps[e]!.app) - .toList(); - - // TODO: This silent update code doesn't work yet - // List silentlyUpdated = await appsProvider - // .downloadAndInstallLatestApp( - // [...newUpdates.map((e) => e.id), ...existingUpdateIds], null); - // if (silentlyUpdated.isNotEmpty) { - // newUpdates = newUpdates - // .where((element) => !silentlyUpdated.contains(element.id)) - // .toList(); - // notificationsProvider.notify( - // SilentUpdateNotification( - // silentlyUpdated.map((e) => appsProvider.apps[e]!.app).toList()), - // cancelExisting: true); - // } - logs.add( - plural('bgCheckFoundUpdatesWillNotifyIfNeeded', newUpdates.length)); - if (newUpdates.isNotEmpty) { - notificationsProvider.notify(UpdateNotification(newUpdates)); - } - if (err != null) { - throw err; - } - } catch (e) { - notificationsProvider - .notify(ErrorCheckingUpdatesNotification(e.toString())); - } finally { - logs.add(tr('bgUpdateTaskFinished')); - await notificationsProvider.cancel(checkingUpdatesNotification.id); - } -} - void main() async { WidgetsFlutterBinding.ensureInitialized(); + try { + ByteData data = + await PlatformAssetBundle().load('assets/ca/lets-encrypt-r3.pem'); + SecurityContext.defaultContext + .setTrustedCertificatesBytes(data.buffer.asUint8List()); + } catch (e) { + // Already added, do nothing (see #375) + } await EasyLocalization.ensureInitialized(); if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) { SystemChrome.setSystemUIOverlayStyle( @@ -162,7 +100,7 @@ void main() async { Provider(create: (context) => LogsProvider()) ], child: EasyLocalization( - supportedLocales: supportedLocales, + supportedLocales: supportedLocales.map((e) => e.key).toList(), path: localeDir, fallbackLocale: fallbackLocale, useOnlyLangCode: true, @@ -193,7 +131,7 @@ class _ObtainiumState extends State { } else { bool isFirstRun = settingsProvider.checkAndFlipFirstRun(); if (isFirstRun) { - logs.add(tr('firstRun')); + logs.add('This is the first ever run of Obtainium.'); // If this is the first run, ask for notification permissions and add Obtainium to the Apps list Permission.notification.request(); if (!fdroid) { @@ -210,26 +148,40 @@ class _ObtainiumState extends State { {'includePrereleases': true}, null, false) - ]); + ], onlyIfExists: false); } } + if (!supportedLocales + .map((e) => e.key.languageCode) + .contains(context.locale.languageCode) || + (settingsProvider.forcedLocale == null && + context.deviceLocale.languageCode != + context.locale.languageCode)) { + settingsProvider.resetLocaleSafe(context); + } // Register the background update task according to the user's setting - if (existingUpdateInterval != settingsProvider.updateInterval) { - if (existingUpdateInterval != -1) { - logs.add(tr('settingUpdateCheckIntervalTo', - args: [settingsProvider.updateInterval.toString()])); - } - existingUpdateInterval = settingsProvider.updateInterval; - if (existingUpdateInterval == 0) { + var actualUpdateInterval = settingsProvider.updateInterval; + if (existingUpdateInterval != actualUpdateInterval) { + if (actualUpdateInterval == 0) { AndroidAlarmManager.cancel(bgUpdateCheckAlarmId); } else { - AndroidAlarmManager.periodic( - Duration(minutes: existingUpdateInterval), - bgUpdateCheckAlarmId, - bgUpdateCheck, - rescheduleOnReboot: true, - wakeup: true); + var settingChanged = existingUpdateInterval != -1; + var lastCheckWasTooLongAgo = actualUpdateInterval != 0 && + settingsProvider.lastBGCheckTime + .add(Duration(minutes: actualUpdateInterval + 60)) + .isBefore(DateTime.now()); + if (settingChanged || lastCheckWasTooLongAgo) { + logs.add( + 'Update interval was set to ${actualUpdateInterval.toString()} (reason: ${settingChanged ? 'setting changed' : 'last check was ${settingsProvider.lastBGCheckTime.toLocal().toString()}'}).'); + AndroidAlarmManager.periodic( + Duration(minutes: actualUpdateInterval), + bgUpdateCheckAlarmId, + bgUpdateCheck, + rescheduleOnReboot: true, + wakeup: true); + } } + existingUpdateInterval = actualUpdateInterval; } } @@ -248,6 +200,14 @@ class _ObtainiumState extends State { darkColorScheme = ColorScheme.fromSeed( seedColor: defaultThemeColour, brightness: Brightness.dark); } + + // set the background and surface colors to pure black in the amoled theme + if (settingsProvider.useBlackTheme) { + darkColorScheme = darkColorScheme + .copyWith(background: Colors.black, surface: Colors.black) + .harmonized(); + } + return MaterialApp( title: 'Obtainium', localizationsDelegates: context.localizationDelegates, @@ -266,7 +226,9 @@ class _ObtainiumState extends State { ? lightColorScheme : darkColorScheme, fontFamily: 'Metropolis'), - home: const HomePage()); + home: Shortcuts(shortcuts: { + LogicalKeySet(LogicalKeyboardKey.select): const ActivateIntent(), + }, child: const HomePage())); }); } } diff --git a/lib/mass_app_sources/githubstars.dart b/lib/mass_app_sources/githubstars.dart index d8ca40e..b32cc77 100644 --- a/lib/mass_app_sources/githubstars.dart +++ b/lib/mass_app_sources/githubstars.dart @@ -13,17 +13,22 @@ class GitHubStars implements MassAppUrlSource { @override late List requiredArgs = [tr('uname')]; - Future> getOnePageOfUserStarredUrlsWithDescriptions( + Future>> getOnePageOfUserStarredUrlsWithDescriptions( String username, int page) async { - Response res = await get(Uri.parse( - 'https://${await GitHub().getCredentialPrefixIfAny()}api.github.com/users/$username/starred?per_page=100&page=$page')); + Response res = await get( + Uri.parse( + 'https://api.github.com/users/$username/starred?per_page=100&page=$page'), + headers: await GitHub().getRequestHeaders()); if (res.statusCode == 200) { - Map urlsWithDescriptions = {}; + Map> urlsWithDescriptions = {}; for (var e in (jsonDecode(res.body) as List)) { urlsWithDescriptions.addAll({ - e['html_url'] as String: e['description'] != null - ? e['description'] as String - : tr('noDescription') + e['html_url'] as String: [ + e['full_name'] as String, + e['description'] != null + ? e['description'] as String + : tr('noDescription') + ] }); } return urlsWithDescriptions; @@ -35,11 +40,12 @@ class GitHubStars implements MassAppUrlSource { } @override - Future> getUrlsWithDescriptions(List args) async { + Future>> getUrlsWithDescriptions( + List args) async { if (args.length != requiredArgs.length) { throw ObtainiumError(tr('wrongArgNum')); } - Map urlsWithDescriptions = {}; + Map> urlsWithDescriptions = {}; var page = 1; while (true) { var pageUrls = diff --git a/lib/pages/add_app.dart b/lib/pages/add_app.dart index ff3f75e..41d047c 100644 --- a/lib/pages/add_app.dart +++ b/lib/pages/add_app.dart @@ -1,6 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:obtainium/app_sources/html.dart'; import 'package:obtainium/components/custom_app_bar.dart'; import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form_modal.dart'; @@ -10,6 +11,7 @@ import 'package:obtainium/pages/app.dart'; import 'package:obtainium/pages/import_export.dart'; import 'package:obtainium/pages/settings.dart'; import 'package:obtainium/providers/apps_provider.dart'; +import 'package:obtainium/providers/notifications_provider.dart'; import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/source_provider.dart'; import 'package:provider/provider.dart'; @@ -28,24 +30,51 @@ class _AddAppPageState extends State { String userInput = ''; String searchQuery = ''; + String? pickedSourceOverride; AppSource? pickedSource; Map additionalSettings = {}; bool additionalSettingsValid = true; + bool inferAppIdIfOptional = true; List pickedCategories = []; + int searchnum = 0; + SourceProvider sourceProvider = SourceProvider(); @override Widget build(BuildContext context) { - SourceProvider sourceProvider = SourceProvider(); AppsProvider appsProvider = context.read(); + SettingsProvider settingsProvider = context.watch(); + NotificationsProvider notificationsProvider = + context.read(); bool doingSomething = gettingAppInfo || searching; - changeUserInput(String input, bool valid, bool isBuilding) { + changeUserInput(String input, bool valid, bool isBuilding, + {bool isSearch = false}) { userInput = input; if (!isBuilding) { setState(() { - var source = valid ? sourceProvider.getSource(userInput) : null; - if (pickedSource.runtimeType != source.runtimeType) { + if (isSearch) { + searchnum++; + } + var prevHost = pickedSource?.host; + try { + var naturalSource = + valid ? sourceProvider.getSource(userInput) : null; + if (naturalSource != null && + naturalSource.runtimeType.toString() != + HTML().runtimeType.toString()) { + // If input has changed to match a regular source, reset the override + pickedSourceOverride = null; + } + } catch (e) { + // ignore + } + var source = valid + ? sourceProvider.getSource(userInput, + overrideSource: pickedSourceOverride) + : null; + if (pickedSource.runtimeType != source.runtimeType || + (prevHost != null && prevHost != source?.host)) { pickedSource = source; additionalSettings = source != null ? getDefaultValuesFromFormItems( @@ -54,338 +83,456 @@ class _AddAppPageState extends State { additionalSettingsValid = source != null ? !sourceProvider.ifRequiredAppSpecificSettingsExist(source) : true; + inferAppIdIfOptional = true; } }); } } + Future getTrackOnlyConfirmationIfNeeded(bool userPickedTrackOnly, + {bool ignoreHideSetting = false}) async { + var useTrackOnly = userPickedTrackOnly || pickedSource!.enforceTrackOnly; + if (useTrackOnly && + (!settingsProvider.hideTrackOnlyWarning || ignoreHideSetting)) { + // ignore: use_build_context_synchronously + var values = await showDialog( + context: context, + builder: (BuildContext ctx) { + return GeneratedFormModal( + initValid: true, + title: tr('xIsTrackOnly', args: [ + pickedSource!.enforceTrackOnly ? tr('source') : tr('app') + ]), + items: [ + [GeneratedFormSwitch('hide', label: tr('dontShowAgain'))] + ], + message: + '${pickedSource!.enforceTrackOnly ? tr('appsFromSourceAreTrackOnly') : tr('youPickedTrackOnly')}\n\n${tr('trackOnlyAppDescription')}', + ); + }); + if (values != null) { + settingsProvider.hideTrackOnlyWarning = values['hide'] == true; + } + return useTrackOnly && values != null; + } else { + return true; + } + } + + getReleaseDateAsVersionConfirmationIfNeeded( + bool userPickedTrackOnly) async { + return (!(additionalSettings['versionDetection'] == + 'releaseDateAsVersion' && + // ignore: use_build_context_synchronously + await showDialog( + context: context, + builder: (BuildContext ctx) { + return GeneratedFormModal( + title: tr('releaseDateAsVersion'), + items: const [], + message: tr('releaseDateAsVersionExplanation'), + ); + }) == + null)); + } + addApp({bool resetUserInputAfter = false}) async { setState(() { gettingAppInfo = true; }); - var settingsProvider = context.read(); - () async { + try { var userPickedTrackOnly = additionalSettings['trackOnly'] == true; - var userPickedNoVersionDetection = - additionalSettings['noVersionDetection'] == true; - var cont = true; - if ((userPickedTrackOnly || pickedSource!.enforceTrackOnly) && - await showDialog( - context: context, - builder: (BuildContext ctx) { - return GeneratedFormModal( - title: tr('xIsTrackOnly', args: [ - pickedSource!.enforceTrackOnly - ? tr('source') - : tr('app') - ]), - items: const [], - message: - '${pickedSource!.enforceTrackOnly ? tr('appsFromSourceAreTrackOnly') : tr('youPickedTrackOnly')}\n\n${tr('trackOnlyAppDescription')}', - ); - }) == - null) { - cont = false; - } - if (userPickedNoVersionDetection && - await showDialog( - context: context, - builder: (BuildContext ctx) { - return GeneratedFormModal( - title: tr('disableVersionDetection'), - items: const [], - message: tr('noVersionDetectionExplanation'), - ); - }) == - null) { - cont = false; - } - if (cont) { - HapticFeedback.selectionClick(); + App? app; + if ((await getTrackOnlyConfirmationIfNeeded(userPickedTrackOnly)) && + (await getReleaseDateAsVersionConfirmationIfNeeded( + userPickedTrackOnly))) { var trackOnly = pickedSource!.enforceTrackOnly || userPickedTrackOnly; - App app = await sourceProvider.getApp( - pickedSource!, userInput, additionalSettings, + app = await sourceProvider.getApp( + pickedSource!, userInput.trim(), additionalSettings, trackOnlyOverride: trackOnly, - noVersionDetectionOverride: userPickedNoVersionDetection); - if (!trackOnly) { - await settingsProvider.getInstallPermission(); - } + overrideSource: pickedSourceOverride, + inferAppIdIfOptional: inferAppIdIfOptional); // Only download the APK here if you need to for the package ID - if (sourceProvider.isTempId(app.id) && - app.additionalSettings['trackOnly'] != true) { + if (isTempId(app) && app.additionalSettings['trackOnly'] != true) { // ignore: use_build_context_synchronously var apkUrl = await appsProvider.confirmApkUrl(app, context); if (apkUrl == null) { throw ObtainiumError(tr('cancelled')); } - app.preferredApkIndex = app.apkUrls.indexOf(apkUrl); + app.preferredApkIndex = + app.apkUrls.map((e) => e.value).toList().indexOf(apkUrl.value); // ignore: use_build_context_synchronously - var downloadedApk = await appsProvider.downloadApp( - app, globalNavigatorKey.currentContext); - app.id = downloadedApk.appId; + var downloadedArtifact = await appsProvider.downloadApp( + app, globalNavigatorKey.currentContext, + notificationsProvider: notificationsProvider); + DownloadedApk? downloadedFile; + DownloadedXApkDir? downloadedDir; + if (downloadedArtifact is DownloadedApk) { + downloadedFile = downloadedArtifact; + } else { + downloadedDir = downloadedArtifact as DownloadedXApkDir; + } + app.id = downloadedFile?.appId ?? downloadedDir!.appId; } if (appsProvider.apps.containsKey(app.id)) { throw ObtainiumError(tr('appAlreadyAdded')); } - if (app.additionalSettings['trackOnly'] == true) { + if (app.additionalSettings['trackOnly'] == true || + app.additionalSettings['versionDetection'] != + 'standardVersionDetection') { app.installedVersion = app.latestVersion; } app.categories = pickedCategories; - await appsProvider.saveApps([app]); - - return app; + await appsProvider.saveApps([app], onlyIfExists: false); } - }() - .then((app) { if (app != null) { - Navigator.push(context, - MaterialPageRoute(builder: (context) => AppPage(appId: app.id))); + Navigator.push(globalNavigatorKey.currentContext ?? context, + MaterialPageRoute(builder: (context) => AppPage(appId: app!.id))); } - }).catchError((e) { + } catch (e) { showError(e, context); - }).whenComplete(() { + } finally { setState(() { gettingAppInfo = false; if (resetUserInputAfter) { changeUserInput('', false, true); } }); - }); + } } + Widget getUrlInputRow() => Row( + children: [ + Expanded( + child: GeneratedForm( + key: Key(searchnum.toString()), + items: [ + [ + GeneratedFormTextField('appSourceURL', + label: tr('appSourceURL'), + defaultValue: userInput, + additionalValidators: [ + (value) { + try { + sourceProvider + .getSource(value ?? '', + overrideSource: pickedSourceOverride) + .standardizeUrl(value ?? ''); + } catch (e) { + return e is String + ? e + : e is ObtainiumError + ? e.toString() + : tr('error'); + } + return null; + } + ]) + ] + ], + onValueChanges: (values, valid, isBuilding) { + changeUserInput( + values['appSourceURL']!, valid, isBuilding); + })), + const SizedBox( + width: 16, + ), + gettingAppInfo + ? const CircularProgressIndicator() + : ElevatedButton( + onPressed: doingSomething || + pickedSource == null || + (pickedSource!.combinedAppSpecificSettingFormItems + .isNotEmpty && + !additionalSettingsValid) + ? null + : () { + HapticFeedback.selectionClick(); + addApp(); + }, + child: Text(tr('add'))) + ], + ); + + runSearch() async { + setState(() { + searching = true; + }); + try { + var results = await Future.wait(sourceProvider.sources + .where((e) => e.canSearch && !e.excludeFromMassSearch) + .map((e) async { + try { + return await e.search(searchQuery); + } catch (err) { + if (err is! CredsNeededError) { + rethrow; + } else { + return >{}; + } + } + })); + + // .then((results) async { + // Interleave results instead of simple reduce + Map> res = {}; + var si = 0; + var done = false; + while (!done) { + done = true; + for (var r in results) { + if (r.length > si) { + done = false; + res.addEntries([r.entries.elementAt(si)]); + } + } + si++; + } + if (res.isEmpty) { + throw ObtainiumError(tr('noResults')); + } + List? selectedUrls = res.isEmpty + ? [] + // ignore: use_build_context_synchronously + : await showDialog?>( + context: context, + builder: (BuildContext ctx) { + return UrlSelectionModal( + urlsWithDescriptions: res, + selectedByDefault: false, + onlyOneSelectionAllowed: true, + ); + }); + if (selectedUrls != null && selectedUrls.isNotEmpty) { + changeUserInput(selectedUrls[0], true, false, isSearch: true); + } + } catch (e) { + showError(e, context); + } finally { + setState(() { + searching = false; + }); + } + } + + Widget getHTMLSourceOverrideDropdown() => Column(children: [ + Row( + children: [ + Expanded( + child: GeneratedForm( + items: [ + [ + GeneratedFormDropdown( + 'overrideSource', + defaultValue: HTML().runtimeType.toString(), + [ + ...sourceProvider.sources.map( + (s) => MapEntry(s.runtimeType.toString(), s.name)) + ], + label: tr('overrideSource')) + ] + ], + onValueChanges: (values, valid, isBuilding) { + fn() { + pickedSourceOverride = (values['overrideSource'] == null || + values['overrideSource'] == '') + ? null + : values['overrideSource']; + } + + if (!isBuilding) { + setState(() { + fn(); + }); + } else { + fn(); + } + changeUserInput(userInput, valid, isBuilding); + }, + )) + ], + ), + const SizedBox( + height: 16, + ) + ]); + + bool shouldShowSearchBar() => + sourceProvider.sources.where((e) => e.canSearch).isNotEmpty && + pickedSource == null && + userInput.isEmpty; + + Widget getSearchBarRow() => Row( + children: [ + Expanded( + child: GeneratedForm( + items: [ + [ + GeneratedFormTextField('searchSomeSources', + label: tr('searchSomeSourcesLabel'), required: false), + ] + ], + onValueChanges: (values, valid, isBuilding) { + if (values.isNotEmpty && valid && !isBuilding) { + setState(() { + searchQuery = values['searchSomeSources']!.trim(); + }); + } + }), + ), + const SizedBox( + width: 16, + ), + searching + ? const CircularProgressIndicator() + : ElevatedButton( + onPressed: searchQuery.isEmpty || doingSomething + ? null + : () { + runSearch(); + }, + child: Text(tr('search'))) + ], + ); + + Widget getAdditionalOptsCol() => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox( + height: 16, + ), + Text( + tr('additionalOptsFor', + args: [pickedSource?.name ?? tr('source')]), + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold)), + const SizedBox( + height: 16, + ), + GeneratedForm( + key: Key(pickedSource.runtimeType.toString()), + items: [ + ...pickedSource!.combinedAppSpecificSettingFormItems, + ...(pickedSourceOverride != null + ? pickedSource!.sourceConfigSettingFormItems + .map((e) => [e]) + : []) + ], + onValueChanges: (values, valid, isBuilding) { + if (!isBuilding) { + setState(() { + additionalSettings = values; + additionalSettingsValid = valid; + }); + } + }), + Column( + children: [ + const SizedBox( + height: 16, + ), + CategoryEditorSelector( + alignment: WrapAlignment.start, + onSelected: (categories) { + pickedCategories = categories; + }), + ], + ), + if (pickedSource != null && pickedSource!.appIdInferIsOptional) + GeneratedForm( + key: const Key('inferAppIdIfOptional'), + items: [ + [ + GeneratedFormSwitch('inferAppIdIfOptional', + label: tr('tryInferAppIdFromCode'), + defaultValue: inferAppIdIfOptional) + ] + ], + onValueChanges: (values, valid, isBuilding) { + if (!isBuilding) { + setState(() { + inferAppIdIfOptional = values['inferAppIdIfOptional']; + }); + } + }), + ], + ); + + Widget getSourcesListWidget() => Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + tr('supportedSources'), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox( + height: 16, + ), + ...sourceProvider.sources + .map((e) => GestureDetector( + onTap: e.host != null + ? () { + launchUrlString('https://${e.host}', + mode: LaunchMode.externalApplication); + } + : null, + child: Text( + '${e.name}${e.enforceTrackOnly ? ' ${tr('trackOnlyInBrackets')}' : ''}${e.canSearch ? ' ${tr('searchableInBrackets')}' : ''}', + style: TextStyle( + decoration: e.host != null + ? TextDecoration.underline + : TextDecoration.none, + fontStyle: FontStyle.italic), + ))) + .toList() + ]); + return Scaffold( backgroundColor: Theme.of(context).colorScheme.surface, - body: CustomScrollView(slivers: [ + body: CustomScrollView(shrinkWrap: true, slivers: [ CustomAppBar(title: tr('addApp')), - SliverFillRemaining( + SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(16), child: Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Row( - children: [ - Expanded( - child: GeneratedForm( - items: [ - [ - GeneratedFormTextField('appSourceURL', - label: tr('appSourceURL'), - additionalValidators: [ - (value) { - try { - sourceProvider - .getSource(value ?? '') - .standardizeURL( - preStandardizeUrl( - value ?? '')); - } catch (e) { - return e is String - ? e - : e is ObtainiumError - ? e.toString() - : tr('error'); - } - return null; - } - ]) - ] - ], - onValueChanges: (values, valid, isBuilding) { - changeUserInput(values['appSourceURL']!, - valid, isBuilding); - })), - const SizedBox( - width: 16, - ), - gettingAppInfo - ? const CircularProgressIndicator() - : ElevatedButton( - onPressed: doingSomething || - pickedSource == null || - (pickedSource! - .combinedAppSpecificSettingFormItems - .isNotEmpty && - !additionalSettingsValid) - ? null - : addApp, - child: Text(tr('add'))) - ], - ), - if (sourceProvider.sources - .where((e) => e.canSearch) - .isNotEmpty && - pickedSource == null && - userInput.isEmpty) - const SizedBox( - height: 16, - ), - if (sourceProvider.sources - .where((e) => e.canSearch) - .isNotEmpty && - pickedSource == null && - userInput.isEmpty) - Row( - children: [ - Expanded( - child: GeneratedForm( - items: [ - [ - GeneratedFormTextField( - 'searchSomeSources', - label: tr('searchSomeSourcesLabel'), - required: false), - ] - ], - onValueChanges: (values, valid, isBuilding) { - if (values.isNotEmpty && - valid && - !isBuilding) { - setState(() { - searchQuery = - values['searchSomeSources']!.trim(); - }); - } - }), - ), - const SizedBox( - width: 16, - ), - ElevatedButton( - onPressed: searchQuery.isEmpty || doingSomething - ? null - : () { - setState(() { - searching = true; - }); - Future.wait(sourceProvider.sources - .where((e) => e.canSearch) - .map((e) => - e.search(searchQuery))) - .then((results) async { - // Interleave results instead of simple reduce - Map res = {}; - var si = 0; - var done = false; - while (!done) { - done = true; - for (var r in results) { - if (r.length > si) { - done = false; - res.addEntries( - [r.entries.elementAt(si)]); - } - } - si++; - } - List? selectedUrls = res - .isEmpty - ? [] - : await showDialog?>( - context: context, - builder: (BuildContext ctx) { - return UrlSelectionModal( - urlsWithDescriptions: res, - selectedByDefault: false, - onlyOneSelectionAllowed: - true, - ); - }); - if (selectedUrls != null && - selectedUrls.isNotEmpty) { - changeUserInput( - selectedUrls[0], true, false); - addApp(resetUserInputAfter: true); - } - }).catchError((e) { - showError(e, context); - }).whenComplete(() { - setState(() { - searching = false; - }); - }); - }, - child: Text(tr('search'))) - ], - ), - if (pickedSource != null) - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Divider( - height: 64, - ), - Text( - tr('additionalOptsFor', - args: [pickedSource?.name ?? tr('source')]), - style: TextStyle( - color: - Theme.of(context).colorScheme.primary)), - const SizedBox( - height: 16, - ), - GeneratedForm( - items: pickedSource! - .combinedAppSpecificSettingFormItems, - onValueChanges: (values, valid, isBuilding) { - if (!isBuilding) { - setState(() { - additionalSettings = values; - additionalSettingsValid = valid; - }); - } - }), - Column( - children: [ - const SizedBox( - height: 16, - ), - CategoryEditorSelector( - alignment: WrapAlignment.start, - onSelected: (categories) { - pickedCategories = categories; - }), - ], - ), - ], - ) - else - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox( - height: 48, - ), - Text( - tr('supportedSourcesBelow'), - ), - const SizedBox( - height: 8, - ), - ...sourceProvider.sources - .map((e) => GestureDetector( - onTap: e.host != null - ? () { - launchUrlString( - 'https://${e.host}', - mode: LaunchMode - .externalApplication); - } - : null, - child: Text( - '${e.name}${e.enforceTrackOnly ? ' ${tr('trackOnlyInBrackets')}' : ''}${e.canSearch ? ' ${tr('searchableInBrackets')}' : ''}', - style: TextStyle( - decoration: e.host != null - ? TextDecoration.underline - : TextDecoration.none, - fontStyle: FontStyle.italic), - ))) - .toList() - ])), + getUrlInputRow(), const SizedBox( - height: 8, + height: 16, + ), + if (pickedSourceOverride != null || + (pickedSource != null && + pickedSource.runtimeType.toString() == + HTML().runtimeType.toString())) + getHTMLSourceOverrideDropdown(), + if (shouldShowSearchBar()) getSearchBarRow(), + if (pickedSource != null) + FutureBuilder( + builder: (ctx, val) { + return val.data != null && val.data!.isNotEmpty + ? Text( + val.data!, + style: + Theme.of(context).textTheme.bodySmall, + ) + : const SizedBox(); + }, + future: pickedSource?.getSourceNote()), + SizedBox( + height: pickedSource != null ? 16 : 96, + ), + if (pickedSource != null) getAdditionalOptsCol(), + if (pickedSource == null) + const Divider( + height: 48, + ), + if (pickedSource == null) getSourcesListWidget(), + SizedBox( + height: pickedSource != null ? 8 : 2, ), ])), ) diff --git a/lib/pages/app.dart b/lib/pages/app.dart index 4a6a24c..14e33d4 100644 --- a/lib/pages/app.dart +++ b/lib/pages/app.dart @@ -1,6 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form_modal.dart'; import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/main.dart'; @@ -31,385 +32,467 @@ class _AppPageState extends State { getUpdate(String id) { appsProvider.checkUpdate(id).catchError((e) { showError(e, context); + return null; }); } + bool areDownloadsRunning = appsProvider.areDownloadsRunning(); + var sourceProvider = SourceProvider(); - AppInMemory? app = appsProvider.apps[widget.appId]; - var source = app != null ? sourceProvider.getSource(app.app.url) : null; - if (!appsProvider.areDownloadsRunning() && prevApp == null && app != null) { + AppInMemory? app = appsProvider.apps[widget.appId]?.deepCopy(); + var source = app != null + ? sourceProvider.getSource(app.app.url, + overrideSource: app.app.overrideSource) + : null; + if (!areDownloadsRunning && + prevApp == null && + app != null && + settingsProvider.checkUpdateOnDetailPage) { prevApp = app; getUpdate(app.app.id); } var trackOnly = app?.app.additionalSettings['trackOnly'] == true; - var infoColumn = Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - GestureDetector( - onTap: () { - if (app?.app.url != null) { - launchUrlString(app?.app.url ?? '', - mode: LaunchMode.externalApplication); - } - }, - child: Text( - app?.app.url ?? '', - textAlign: TextAlign.center, - style: const TextStyle( - decoration: TextDecoration.underline, - fontStyle: FontStyle.italic, - fontSize: 12), - )), - const SizedBox( - height: 32, - ), - Text( - tr('latestVersionX', args: [app?.app.latestVersion ?? tr('unknown')]), - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyLarge, - ), - Text( - '${tr('installedVersionX', args: [ - app?.app.installedVersion ?? tr('none') - ])}${trackOnly ? ' ${tr('estimateInBrackets')}\n\n${tr('xIsTrackOnly', args: [ - tr('app') - ])}' : ''}', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyLarge, - ), - const SizedBox( - height: 32, - ), - Text( - tr('lastUpdateCheckX', args: [ - app?.app.lastUpdateCheck == null - ? tr('never') - : '\n${app?.app.lastUpdateCheck?.toLocal()}' - ]), - textAlign: TextAlign.center, - style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12), - ), - const SizedBox( - height: 48, - ), - CategoryEditorSelector( - alignment: WrapAlignment.center, - preselected: - app?.app.categories != null ? app!.app.categories.toSet() : {}, - onSelected: (categories) { - if (app != null) { - app.app.categories = categories; - appsProvider.saveApps([app.app]); - } - }), - ], - ); + bool isVersionDetectionStandard = + app?.app.additionalSettings['versionDetection'] == + 'standardVersionDetection'; - var fullInfoColumn = Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 150), - app?.installedInfo != null - ? Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - Image.memory( - app!.installedInfo!.icon!, - height: 150, - gaplessPlayback: true, - ) - ]) - : Container(), - const SizedBox( - height: 25, - ), - Text( - app?.installedInfo?.name ?? app?.app.name ?? tr('app'), + bool installedVersionIsEstimate = trackOnly || + (app?.app.installedVersion != null && + app?.app.additionalSettings['versionDetection'] == + 'noVersionDetection'); + + getInfoColumn() => Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + GestureDetector( + onTap: () { + if (app?.app.url != null) { + launchUrlString(app?.app.url ?? '', + mode: LaunchMode.externalApplication); + } + }, + onLongPress: () { + Clipboard.setData(ClipboardData(text: app?.app.url ?? '')); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(tr('copiedToClipboard')), + )); + }, + child: Text( + app?.app.url ?? '', + textAlign: TextAlign.center, + style: const TextStyle( + decoration: TextDecoration.underline, + fontStyle: FontStyle.italic, + fontSize: 12), + )), + const SizedBox( + height: 32, + ), + Column( + children: [ + Text( + '${tr('latestVersionX', args: [ + app?.app.latestVersion ?? tr('unknown') + ])}\n${tr('installedVersionX', args: [ + app?.app.installedVersion ?? tr('none') + ])}${installedVersionIsEstimate ? '\n${tr('estimateInBrackets')}' : ''}', + textAlign: TextAlign.end, + style: Theme.of(context).textTheme.bodyLarge!, + ), + ], + ), + if (app?.app.installedVersion != null && + !isVersionDetectionStandard) + Column( + children: [ + const SizedBox( + height: 16, + ), + Text( + '${trackOnly ? '${tr('xIsTrackOnly', args: [ + tr('app') + ])}\n' : ''}${tr('noVersionDetection')}', + style: Theme.of(context).textTheme.labelSmall, + textAlign: TextAlign.center, + ) + ], + ), + const SizedBox( + height: 32, + ), + Text( + tr('lastUpdateCheckX', args: [ + app?.app.lastUpdateCheck == null + ? tr('never') + : '\n${app?.app.lastUpdateCheck?.toLocal()}' + ]), + textAlign: TextAlign.center, + style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12), + ), + const SizedBox( + height: 48, + ), + CategoryEditorSelector( + alignment: WrapAlignment.center, + preselected: app?.app.categories != null + ? app!.app.categories.toSet() + : {}, + onSelected: (categories) { + if (app != null) { + app.app.categories = categories; + appsProvider.saveApps([app.app]); + } + }), + ], + ); + + getFullInfoColumn() => Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 20), + app?.icon != null + ? Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + GestureDetector( + child: Image.memory( + app!.icon!, + height: 150, + gaplessPlayback: true, + ), + onTap: () => pm.openApp(app.app.id), + ) + ]) + : Container(), + const SizedBox( + height: 25, + ), + Text( + app?.name ?? tr('app'), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.displayLarge, + ), + Text( + tr('byX', args: [app?.app.author ?? tr('unknown')]), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox( + height: 8, + ), + Text( + app?.app.id ?? '', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelSmall, + ), + app?.app.releaseDate == null + ? const SizedBox.shrink() + : Text( + app!.app.releaseDate.toString(), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelSmall, + ), + const SizedBox( + height: 32, + ), + getInfoColumn(), + const SizedBox(height: 150) + ], + ); + + getAppWebView() => app != null + ? WebViewWidget( + controller: WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setBackgroundColor(Theme.of(context).colorScheme.background) + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setNavigationDelegate( + NavigationDelegate( + onWebResourceError: (WebResourceError error) { + if (error.isForMainFrame == true) { + showError( + ObtainiumError(error.description, unexpected: true), + context); + } + }, + ), + ) + ..loadRequest(Uri.parse(app.app.url))) + : Container(); + + showMarkUpdatedDialog() { + return showDialog( + context: context, + builder: (BuildContext ctx) { + return AlertDialog( + title: Text(tr('alreadyUpToDateQuestion')), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text(tr('no'))), + TextButton( + onPressed: () { + HapticFeedback.selectionClick(); + var updatedApp = app?.app; + if (updatedApp != null) { + updatedApp.installedVersion = updatedApp.latestVersion; + appsProvider.saveApps([updatedApp]); + } + Navigator.of(context).pop(); + }, + child: Text(tr('yesMarkUpdated'))) + ], + ); + }); + } + + showAdditionalOptionsDialog() async { + return await showDialog?>( + context: context, + builder: (BuildContext ctx) { + var items = + (source?.combinedAppSpecificSettingFormItems ?? []).map((row) { + row = row.map((e) { + if (app?.app.additionalSettings[e.key] != null) { + e.defaultValue = app?.app.additionalSettings[e.key]; + } + return e; + }).toList(); + return row; + }).toList(); + + items = items.map((row) { + row = row.map((e) { + if (e.key == 'versionDetection' && e is GeneratedFormDropdown) { + e.disabledOptKeys ??= []; + if (app?.app.installedVersion != null && + app?.app.additionalSettings['versionDetection'] != + 'releaseDateAsVersion' && + !appsProvider.isVersionDetectionPossible(app)) { + e.disabledOptKeys!.add('standardVersionDetection'); + } + if (app?.app.releaseDate == null) { + e.disabledOptKeys!.add('releaseDateAsVersion'); + } + } + return e; + }).toList(); + return row; + }).toList(); + + return GeneratedFormModal( + title: tr('additionalOptions'), items: items); + }); + } + + handleAdditionalOptionChanges(Map? values) { + if (app != null && values != null) { + Map originalSettings = app.app.additionalSettings; + app.app.additionalSettings = values; + if (source?.enforceTrackOnly == true) { + app.app.additionalSettings['trackOnly'] = true; + // ignore: use_build_context_synchronously + showMessage(tr('appsFromSourceAreTrackOnly'), context); + } + if (app.app.additionalSettings['versionDetection'] == + 'releaseDateAsVersion') { + if (originalSettings['versionDetection'] != 'releaseDateAsVersion') { + if (app.app.releaseDate != null) { + bool isUpdated = + app.app.installedVersion == app.app.latestVersion; + app.app.latestVersion = + app.app.releaseDate!.microsecondsSinceEpoch.toString(); + if (isUpdated) { + app.app.installedVersion = app.app.latestVersion; + } + } + } + } else if (originalSettings['versionDetection'] == + 'releaseDateAsVersion') { + app.app.installedVersion = + app.installedInfo?.versionName ?? app.app.installedVersion; + } + appsProvider.saveApps([app.app]).then((value) { + getUpdate(app.app.id); + }); + } + } + + getResetInstallStatusButton() => TextButton( + onPressed: app?.app == null + ? null + : () { + app!.app.installedVersion = null; + appsProvider.saveApps([app.app]); + }, + child: Text( + tr('resetInstallStatus'), textAlign: TextAlign.center, - style: Theme.of(context).textTheme.displayLarge, - ), - Text( - tr('byX', args: [app?.app.author ?? tr('unknown')]), - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.headlineMedium, - ), - const SizedBox( - height: 32, - ), - infoColumn, - const SizedBox(height: 150) - ], - ); + )); + + getInstallOrUpdateButton() => TextButton( + onPressed: (app?.app.installedVersion == null || + app?.app.installedVersion != app?.app.latestVersion) && + !areDownloadsRunning + ? () async { + try { + HapticFeedback.heavyImpact(); + var res = await appsProvider.downloadAndInstallLatestApps( + app?.app.id != null ? [app!.app.id] : [], + globalNavigatorKey.currentContext, + ); + if (app?.app.installedVersion != null && !trackOnly) { + // ignore: use_build_context_synchronously + showMessage(tr('appsUpdated'), context); + } + if (res.isNotEmpty && mounted) { + Navigator.of(context).pop(); + } + } catch (e) { + // ignore: use_build_context_synchronously + showError(e, context); + } + } + : null, + child: Text(app?.app.installedVersion == null + ? !trackOnly + ? tr('install') + : tr('markInstalled') + : !trackOnly + ? tr('update') + : tr('markUpdated'))); + + getBottomSheetMenu() => Padding( + padding: + EdgeInsets.fromLTRB(0, 0, 0, MediaQuery.of(context).padding.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + if (app?.app.installedVersion != null && + app?.app.installedVersion != app?.app.latestVersion && + !isVersionDetectionStandard && + !trackOnly) + IconButton( + onPressed: app?.downloadProgress != null + ? null + : showMarkUpdatedDialog, + tooltip: tr('markUpdated'), + icon: const Icon(Icons.done)), + if (source != null && + source.combinedAppSpecificSettingFormItems.isNotEmpty) + IconButton( + onPressed: app?.downloadProgress != null + ? null + : () async { + var values = + await showAdditionalOptionsDialog(); + handleAdditionalOptionChanges(values); + }, + tooltip: tr('additionalOptions'), + icon: const Icon(Icons.edit)), + if (app != null && app.installedInfo != null) + IconButton( + onPressed: () { + appsProvider.openAppSettings(app.app.id); + }, + icon: const Icon(Icons.settings), + tooltip: tr('settings'), + ), + if (app != null && settingsProvider.showAppWebpage) + IconButton( + onPressed: () { + showDialog( + context: context, + builder: (BuildContext ctx) { + return AlertDialog( + scrollable: true, + content: getInfoColumn(), + title: Text( + '${app.name} ${tr('byX', args: [ + app.app.author + ])}'), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text(tr('continue'))) + ], + ); + }); + }, + icon: const Icon(Icons.more_horiz), + tooltip: tr('more')), + const SizedBox(width: 16.0), + Expanded( + child: (!isVersionDetectionStandard || trackOnly) && + app?.app.installedVersion != null && + app?.app.installedVersion == + app?.app.latestVersion + ? getResetInstallStatusButton() + : getInstallOrUpdateButton()), + const SizedBox(width: 16.0), + IconButton( + onPressed: app?.downloadProgress != null + ? null + : () { + appsProvider + .removeAppsWithModal( + context, app != null ? [app.app] : []) + .then((value) { + if (value == true) { + Navigator.of(context).pop(); + } + }); + }, + tooltip: tr('remove'), + icon: const Icon(Icons.delete_outline), + ), + ])), + if (app?.downloadProgress != null) + Padding( + padding: const EdgeInsets.fromLTRB(0, 8, 0, 0), + child: LinearProgressIndicator( + value: app!.downloadProgress! >= 0 + ? app.downloadProgress! / 100 + : null)) + ], + )); + + appScreenAppBar() => AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + Navigator.pop(context); + }, + ), + ); return Scaffold( - appBar: settingsProvider.showAppWebpage ? AppBar() : null, - backgroundColor: Theme.of(context).colorScheme.surface, - body: RefreshIndicator( - child: settingsProvider.showAppWebpage - ? app != null - ? WebViewWidget( - controller: WebViewController() - ..setJavaScriptMode(JavaScriptMode.unrestricted) - ..setBackgroundColor( - Theme.of(context).colorScheme.background) - ..setJavaScriptMode(JavaScriptMode.unrestricted) - ..setNavigationDelegate( - NavigationDelegate( - onWebResourceError: (WebResourceError error) { - if (error.isForMainFrame == true) { - showError( - ObtainiumError(error.description, - unexpected: true), - context); - } - }, - ), - ) - ..loadRequest(Uri.parse(app.app.url))) - : Container() - : CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: Column(children: [fullInfoColumn])), - ], - ), - onRefresh: () async { - if (app != null) { - getUpdate(app.app.id); - } - }), - bottomSheet: Padding( - padding: EdgeInsets.fromLTRB( - 0, 0, 0, MediaQuery.of(context).padding.bottom), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - if (app?.app.installedVersion != null && - !trackOnly && - app?.app.installedVersion != app?.app.latestVersion) - IconButton( - onPressed: app?.downloadProgress != null - ? null - : () { - showDialog( - context: context, - builder: (BuildContext ctx) { - return AlertDialog( - title: Text(tr( - 'alreadyUpToDateQuestion')), - content: Text( - tr('onlyWorksWithNonEVDApps'), - style: const TextStyle( - fontWeight: - FontWeight.bold, - fontStyle: - FontStyle.italic)), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context) - .pop(); - }, - child: Text(tr('no'))), - TextButton( - onPressed: () { - HapticFeedback - .selectionClick(); - var updatedApp = app?.app; - if (updatedApp != null) { - updatedApp - .installedVersion = - updatedApp - .latestVersion; - appsProvider.saveApps( - [updatedApp]); - } - Navigator.of(context) - .pop(); - }, - child: Text( - tr('yesMarkUpdated'))) - ], - ); - }); - }, - tooltip: tr('markUpdated'), - icon: const Icon(Icons.done)), - if (source != null && - source - .combinedAppSpecificSettingFormItems.isNotEmpty) - IconButton( - onPressed: app?.downloadProgress != null - ? null - : () { - showDialog?>( - context: context, - builder: (BuildContext ctx) { - var items = source - .combinedAppSpecificSettingFormItems - .map((row) { - row.map((e) { - if (app?.app.additionalSettings[ - e.key] != - null) { - e.defaultValue = app?.app - .additionalSettings[ - e.key]; - } - return e; - }).toList(); - return row; - }).toList(); - return GeneratedFormModal( - title: tr('additionalOptions'), - items: items); - }).then((values) { - if (app != null && values != null) { - var changedApp = app.app; - changedApp.additionalSettings = - values; - if (source.enforceTrackOnly) { - changedApp.additionalSettings[ - 'trackOnly'] = true; - showError( - tr('appsFromSourceAreTrackOnly'), - context); - } - appsProvider.saveApps( - [changedApp]).then((value) { - getUpdate(changedApp.id); - }); - } - }); - }, - tooltip: tr('additionalOptions'), - icon: const Icon(Icons.settings)), - if (app != null && settingsProvider.showAppWebpage) - IconButton( - onPressed: () { - showDialog( - context: context, - builder: (BuildContext ctx) { - return AlertDialog( - scrollable: true, - content: infoColumn, - title: Text( - '${app.app.name} ${tr('byX', args: [ - app.app.author - ])}'), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text(tr('continue'))) - ], - ); - }); - }, - icon: const Icon(Icons.more_horiz), - tooltip: tr('more')), - const SizedBox(width: 16.0), - Expanded( - child: ElevatedButton( - onPressed: (app?.app.installedVersion == null || - app?.app.installedVersion != - app?.app.latestVersion) && - !appsProvider.areDownloadsRunning() - ? () { - HapticFeedback.heavyImpact(); - () async { - if (app?.app.additionalSettings[ - 'trackOnly'] != - true) { - await settingsProvider - .getInstallPermission(); - } - }() - .then((value) { - appsProvider - .downloadAndInstallLatestApps( - [app!.app.id], - globalNavigatorKey - .currentContext).then( - (res) { - if (res.isNotEmpty && mounted) { - Navigator.of(context).pop(); - } - }); - }).catchError((e) { - showError(e, context); - }); - } - : null, - child: Text(app?.app.installedVersion == null - ? !trackOnly - ? tr('install') - : tr('markInstalled') - : !trackOnly - ? tr('update') - : tr('markUpdated')))), - const SizedBox(width: 16.0), - ElevatedButton( - onPressed: app?.downloadProgress != null - ? null - : () { - showDialog( - context: context, - builder: (BuildContext ctx) { - return AlertDialog( - title: Text(tr('removeAppQuestion')), - content: Text(tr( - 'xWillBeRemovedButRemainInstalled', - args: [ - app?.installedInfo?.name ?? - app?.app.name ?? - tr('app') - ])), - actions: [ - TextButton( - onPressed: () { - HapticFeedback - .selectionClick(); - appsProvider.removeApps( - [app!.app.id]).then((_) { - int count = 0; - Navigator.of(context) - .popUntil((_) => - count++ >= 2); - }); - }, - child: Text(tr('remove'))), - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text(tr('cancel'))) - ], - ); - }); - }, - style: TextButton.styleFrom( - foregroundColor: - Theme.of(context).colorScheme.error, - surfaceTintColor: - Theme.of(context).colorScheme.error), - child: Text(tr('remove')), - ), - ])), - if (app?.downloadProgress != null) - Padding( - padding: const EdgeInsets.fromLTRB(0, 8, 0, 0), - child: LinearProgressIndicator( - value: app!.downloadProgress! / 100)) - ], - )), - ); + appBar: settingsProvider.showAppWebpage ? AppBar() : appScreenAppBar(), + backgroundColor: Theme.of(context).colorScheme.surface, + body: RefreshIndicator( + child: settingsProvider.showAppWebpage + ? getAppWebView() + : CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: Column(children: [getFullInfoColumn()])), + ], + ), + onRefresh: () async { + if (app != null) { + getUpdate(app.app.id); + } + }), + bottomSheet: getBottomSheetMenu()); } } diff --git a/lib/pages/apps.dart b/lib/pages/apps.dart index 1b422f8..0d3c2c8 100644 --- a/lib/pages/apps.dart +++ b/lib/pages/apps.dart @@ -1,6 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:obtainium/components/custom_app_bar.dart'; import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form_modal.dart'; @@ -14,6 +15,7 @@ import 'package:obtainium/providers/source_provider.dart'; import 'package:provider/provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher_string.dart'; +import 'package:markdown/markdown.dart' as md; class AppsPage extends StatefulWidget { const AppsPage({super.key}); @@ -27,13 +29,13 @@ class AppsPageState extends State { final AppsFilter neutralFilter = AppsFilter(); var updatesOnlyFilter = AppsFilter(includeUptodate: false, includeNonInstalled: false); - Set selectedApps = {}; + Set selectedAppIds = {}; DateTime? refreshingSince; clearSelected() { - if (selectedApps.isNotEmpty) { + if (selectedAppIds.isNotEmpty) { setState(() { - selectedApps.clear(); + selectedAppIds.clear(); }); return true; } @@ -41,38 +43,62 @@ class AppsPageState extends State { } selectThese(List apps) { - if (selectedApps.isEmpty) { + if (selectedAppIds.isEmpty) { setState(() { for (var a in apps) { - selectedApps.add(a); + selectedAppIds.add(a.id); } }); } } + final GlobalKey _refreshIndicatorKey = + GlobalKey(); + @override Widget build(BuildContext context) { var appsProvider = context.watch(); var settingsProvider = context.watch(); - var sortedApps = appsProvider.apps.values.toList(); - var currentFilterIsUpdatesOnly = - filter.isIdenticalTo(updatesOnlyFilter, settingsProvider); + var sourceProvider = SourceProvider(); + var listedApps = appsProvider.getAppValues().toList(); - selectedApps = selectedApps - .where((element) => sortedApps.map((e) => e.app).contains(element)) + refresh() { + HapticFeedback.lightImpact(); + setState(() { + refreshingSince = DateTime.now(); + }); + return appsProvider.checkUpdates().catchError((e) { + showError(e is Map ? e['errors'] : e, context); + return []; + }).whenComplete(() { + setState(() { + refreshingSince = null; + }); + }); + } + + if (!appsProvider.loadingApps && + appsProvider.apps.isNotEmpty && + settingsProvider.checkJustStarted() && + settingsProvider.checkOnStart) { + _refreshIndicatorKey.currentState?.show(); + } + + selectedAppIds = selectedAppIds + .where((element) => listedApps.map((e) => e.app.id).contains(element)) .toSet(); toggleAppSelected(App app) { setState(() { - if (selectedApps.contains(app)) { - selectedApps.remove(app); + if (selectedAppIds.map((e) => e).contains(app.id)) { + selectedAppIds.removeWhere((a) => a == app.id); } else { - selectedApps.add(app); + selectedAppIds.add(app.id); } }); } - sortedApps = sortedApps.where((app) { + listedApps = listedApps.where((app) { if (app.app.installedVersion == app.app.latestVersion && !(filter.includeUptodate)) { return false; @@ -91,8 +117,7 @@ class AppsPageState extends State { .toList(); for (var t in nameTokens) { - var name = app.installedInfo?.name ?? app.app.name; - if (!name.toLowerCase().contains(t.toLowerCase())) { + if (!app.name.toLowerCase().contains(t.toLowerCase())) { return false; } } @@ -102,43 +127,62 @@ class AppsPageState extends State { } } } + if (filter.idFilter.isNotEmpty) { + if (!app.app.id.contains(filter.idFilter)) { + return false; + } + } if (filter.categoryFilter.isNotEmpty && filter.categoryFilter .intersection(app.app.categories.toSet()) .isEmpty) { return false; } + if (filter.sourceFilter.isNotEmpty && + sourceProvider + .getSource(app.app.url, + overrideSource: app.app.overrideSource) + .runtimeType + .toString() != + filter.sourceFilter) { + return false; + } return true; }).toList(); - sortedApps.sort((a, b) { - var nameA = a.installedInfo?.name ?? a.app.name; - var nameB = b.installedInfo?.name ?? b.app.name; + listedApps.sort((a, b) { int result = 0; if (settingsProvider.sortColumn == SortColumnSettings.authorName) { - result = (a.app.author + nameA).compareTo(b.app.author + nameB); + result = ((a.app.author + a.name).toLowerCase()) + .compareTo((b.app.author + b.name).toLowerCase()); } else if (settingsProvider.sortColumn == SortColumnSettings.nameAuthor) { - result = (nameA + a.app.author).compareTo(nameB + b.app.author); + result = ((a.name + a.app.author).toLowerCase()) + .compareTo((b.name + b.app.author).toLowerCase()); + } else if (settingsProvider.sortColumn == + SortColumnSettings.releaseDate) { + result = (a.app.releaseDate)?.compareTo( + b.app.releaseDate ?? DateTime.fromMicrosecondsSinceEpoch(0)) ?? + 0; } return result; }); if (settingsProvider.sortOrder == SortOrderSettings.descending) { - sortedApps = sortedApps.reversed.toList(); + listedApps = listedApps.reversed.toList(); } var existingUpdates = appsProvider.findExistingUpdates(installedOnly: true); var existingUpdateIdsAllOrSelected = existingUpdates - .where((element) => selectedApps.isEmpty - ? sortedApps.where((a) => a.app.id == element).isNotEmpty - : selectedApps.map((e) => e.id).contains(element)) + .where((element) => selectedAppIds.isEmpty + ? listedApps.where((a) => a.app.id == element).isNotEmpty + : selectedAppIds.map((e) => e).contains(element)) .toList(); var newInstallIdsAllOrSelected = appsProvider .findExistingUpdates(nonInstalledOnly: true) - .where((element) => selectedApps.isEmpty - ? sortedApps.where((a) => a.app.id == element).isNotEmpty - : selectedApps.map((e) => e.id).contains(element)) + .where((element) => selectedAppIds.isEmpty + ? listedApps.where((a) => a.app.id == element).isNotEmpty + : selectedAppIds.map((e) => e).contains(element)) .toList(); List trackOnlyUpdateIdsAllOrSelected = []; @@ -159,723 +203,874 @@ class AppsPageState extends State { if (settingsProvider.pinUpdates) { var temp = []; - sortedApps = sortedApps.where((sa) { + listedApps = listedApps.where((sa) { if (existingUpdates.contains(sa.app.id)) { temp.add(sa); return false; } return true; }).toList(); - sortedApps = [...temp, ...sortedApps]; + listedApps = [...temp, ...listedApps]; + } + + if (settingsProvider.buryNonInstalled) { + var temp = []; + listedApps = listedApps.where((sa) { + if (sa.app.installedVersion == null) { + temp.add(sa); + return false; + } + return true; + }).toList(); + listedApps = [...listedApps, ...temp]; } var tempPinned = []; var tempNotPinned = []; - for (var a in sortedApps) { + for (var a in listedApps) { if (a.app.pinned) { tempPinned.add(a); } else { tempNotPinned.add(a); } } - sortedApps = [...tempPinned, ...tempNotPinned]; + listedApps = [...tempPinned, ...tempNotPinned]; - return Scaffold( - backgroundColor: Theme.of(context).colorScheme.surface, - body: RefreshIndicator( - onRefresh: () { - HapticFeedback.lightImpact(); - setState(() { - refreshingSince = DateTime.now(); - }); - return appsProvider.checkUpdates().catchError((e) { - showError(e, context); - }).whenComplete(() { - setState(() { - refreshingSince = null; - }); - }); - }, - child: CustomScrollView(slivers: [ - CustomAppBar(title: tr('appsString')), - if (appsProvider.loadingApps || sortedApps.isEmpty) - SliverFillRemaining( - child: Center( - child: appsProvider.loadingApps - ? const CircularProgressIndicator() - : Text( - appsProvider.apps.isEmpty - ? tr('noApps') - : tr('noAppsForFilter'), - style: Theme.of(context).textTheme.headlineMedium, - textAlign: TextAlign.center, - ))), - if (refreshingSince != null) - SliverToBoxAdapter( - child: LinearProgressIndicator( - value: appsProvider.apps.values + List getListedCategories() { + var temp = listedApps + .map((e) => e.app.categories.isNotEmpty ? e.app.categories : [null]); + return temp.isNotEmpty + ? { + ...temp.reduce((v, e) => [...v, ...e]) + }.toList() + : []; + } + + var listedCategories = getListedCategories(); + listedCategories.sort((a, b) { + return a != null && b != null + ? a.toLowerCase().compareTo(b.toLowerCase()) + : a == null + ? 1 + : -1; + }); + + Set selectedApps = listedApps + .map((e) => e.app) + .where((a) => selectedAppIds.contains(a.id)) + .toSet(); + + showChangeLogDialog( + String? changesUrl, AppSource appSource, String changeLog, int index) { + showDialog( + context: context, + builder: (BuildContext context) { + return GeneratedFormModal( + title: tr('changes'), + items: const [], + message: listedApps[index].app.latestVersion, + additionalWidgets: [ + changesUrl != null + ? GestureDetector( + child: Text( + changesUrl, + style: const TextStyle( + decoration: TextDecoration.underline, + fontStyle: FontStyle.italic), + ), + onTap: () { + launchUrlString(changesUrl, + mode: LaunchMode.externalApplication); + }, + ) + : const SizedBox.shrink(), + changesUrl != null + ? const SizedBox( + height: 16, + ) + : const SizedBox.shrink(), + appSource.changeLogIfAnyIsMarkDown + ? SizedBox( + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height - 350, + child: Markdown( + data: changeLog, + onTapLink: (text, href, title) { + if (href != null) { + launchUrlString( + href.startsWith('http://') || + href.startsWith('https://') + ? href + : '${Uri.parse(listedApps[index].app.url).origin}/$href', + mode: LaunchMode.externalApplication); + } + }, + extensionSet: md.ExtensionSet( + md.ExtensionSet.gitHubFlavored.blockSyntaxes, + [ + md.EmojiSyntax(), + ...md.ExtensionSet.gitHubFlavored.inlineSyntaxes + ], + ), + )) + : Text(changeLog), + ], + singleNullReturnButton: tr('ok'), + ); + }); + } + + getLoadingWidgets() { + return [ + if (listedApps.isEmpty) + SliverFillRemaining( + child: Center( + child: Text( + appsProvider.apps.isEmpty ? tr('noApps') : tr('noAppsForFilter'), + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, + ))), + if (refreshingSince != null || appsProvider.loadingApps) + SliverToBoxAdapter( + child: LinearProgressIndicator( + value: appsProvider.loadingApps + ? null + : appsProvider + .getAppValues() .where((element) => !(element.app.lastUpdateCheck ?.isBefore(refreshingSince!) ?? true)) .length / - appsProvider.apps.length, - ), - ), - SliverList( - delegate: SliverChildBuilderDelegate( - (BuildContext context, int index) { - String? changesUrl = SourceProvider() - .getSource(sortedApps[index].app.url) - .changeLogPageFromStandardUrl(sortedApps[index].app.url); - var transparent = const Color.fromARGB(0, 0, 0, 0).value; - return Container( - decoration: BoxDecoration( - border: Border.symmetric( - vertical: BorderSide( - width: 4, - color: Color( - sortedApps[index].app.categories.isNotEmpty - ? settingsProvider.categories[ - sortedApps[index] - .app - .categories - .first] ?? - transparent - : transparent)))), - child: ListTile( - tileColor: sortedApps[index].app.pinned - ? Colors.grey.withOpacity(0.1) - : Colors.transparent, - selectedTileColor: Theme.of(context) - .colorScheme - .primary - .withOpacity(sortedApps[index].app.pinned ? 0.2 : 0.1), - selected: selectedApps.contains(sortedApps[index].app), - onLongPress: () { - toggleAppSelected(sortedApps[index].app); - }, - leading: sortedApps[index].installedInfo != null - ? Image.memory( - sortedApps[index].installedInfo!.icon!, - gaplessPlayback: true, - ) - : null, - title: Text( - sortedApps[index].installedInfo?.name ?? - sortedApps[index].app.name, - style: TextStyle( - fontWeight: sortedApps[index].app.pinned - ? FontWeight.bold - : FontWeight.normal, - ), - ), - subtitle: Text( - tr('byX', args: [sortedApps[index].app.author]), - style: TextStyle( - fontWeight: sortedApps[index].app.pinned - ? FontWeight.bold - : FontWeight.normal)), - trailing: SingleChildScrollView( - reverse: true, - child: sortedApps[index].downloadProgress != null - ? Text(tr('percentProgress', args: [ - sortedApps[index] - .downloadProgress - ?.toInt() - .toString() ?? - '100' - ])) - : (Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - SizedBox( - width: 100, - child: Text( - '${sortedApps[index].app.installedVersion ?? tr('notInstalled')}${sortedApps[index].app.additionalSettings['trackOnly'] == true ? ' ${tr('estimateInBrackets')}' : ''}', - overflow: TextOverflow.fade, - textAlign: TextAlign.end, - )), - sortedApps[index].app.installedVersion != - null && - sortedApps[index] - .app - .installedVersion != - sortedApps[index] - .app - .latestVersion - ? GestureDetector( - onTap: changesUrl == null - ? null - : () { - launchUrlString(changesUrl, - mode: LaunchMode - .externalApplication); - }, - child: appsProvider - .areDownloadsRunning() - ? Text(tr('pleaseWait')) - : Text( - '${tr('updateAvailable')}${sortedApps[index].app.additionalSettings['trackOnly'] == true ? ' ${tr('estimateInBracketsShort')}' : ''}', - style: TextStyle( - fontStyle: - FontStyle.italic, - decoration: changesUrl == - null - ? TextDecoration.none - : TextDecoration - .underline), - )) - : const SizedBox(), - ], - ))), - onTap: () { - if (selectedApps.isNotEmpty) { - toggleAppSelected(sortedApps[index].app); - } else { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - AppPage(appId: sortedApps[index].app.id)), - ); - } - }, - )); - }, childCount: sortedApps.length)) - ])), - persistentFooterButtons: [ - Row( - children: [ - selectedApps.isEmpty - ? TextButton.icon( - style: - const ButtonStyle(visualDensity: VisualDensity.compact), - onPressed: () { - selectThese(sortedApps.map((e) => e.app).toList()); - }, - icon: Icon( - Icons.select_all_outlined, - color: Theme.of(context).colorScheme.primary, - ), - label: Text(sortedApps.length.toString())) - : TextButton.icon( - style: - const ButtonStyle(visualDensity: VisualDensity.compact), - onPressed: () { - selectedApps.isEmpty - ? selectThese(sortedApps.map((e) => e.app).toList()) - : clearSelected(); - }, - icon: Icon( - selectedApps.isEmpty - ? Icons.select_all_outlined - : Icons.deselect_outlined, - color: Theme.of(context).colorScheme.primary, - ), - label: Text(selectedApps.length.toString())), - const VerticalDivider(), - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - IconButton( - visualDensity: VisualDensity.compact, - onPressed: selectedApps.isEmpty - ? null - : () { - showDialog?>( - context: context, - builder: (BuildContext ctx) { - return GeneratedFormModal( - title: - tr('removeSelectedAppsQuestion'), - items: const [], - initValid: true, - message: tr( - 'xWillBeRemovedButRemainInstalled', - args: [ - plural( - 'apps', selectedApps.length) - ]), - ); - }).then((values) { - if (values != null) { - appsProvider.removeApps(selectedApps - .map((e) => e.id) - .toList()); - } - }); - }, - tooltip: tr('removeSelectedApps'), - icon: const Icon(Icons.delete_outline_outlined), - ), - IconButton( - visualDensity: VisualDensity.compact, - onPressed: appsProvider.areDownloadsRunning() || - (existingUpdateIdsAllOrSelected.isEmpty && - newInstallIdsAllOrSelected.isEmpty && - trackOnlyUpdateIdsAllOrSelected.isEmpty) - ? null - : () { - HapticFeedback.heavyImpact(); - List formItems = []; - if (existingUpdateIdsAllOrSelected - .isNotEmpty) { - formItems.add(GeneratedFormSwitch( - 'updates', - label: tr('updateX', args: [ - plural( - 'apps', - existingUpdateIdsAllOrSelected - .length) - ]), - defaultValue: true)); - } - if (newInstallIdsAllOrSelected.isNotEmpty) { - formItems.add(GeneratedFormSwitch( - 'installs', - label: tr('installX', args: [ - plural( - 'apps', - newInstallIdsAllOrSelected - .length) - ]), - defaultValue: - existingUpdateIdsAllOrSelected - .isNotEmpty)); - } - if (trackOnlyUpdateIdsAllOrSelected - .isNotEmpty) { - formItems.add(GeneratedFormSwitch( - 'trackonlies', - label: tr('markXTrackOnlyAsUpdated', - args: [ - plural( - 'apps', - trackOnlyUpdateIdsAllOrSelected - .length) - ]), - defaultValue: - existingUpdateIdsAllOrSelected - .isNotEmpty || - newInstallIdsAllOrSelected - .isNotEmpty)); - } - showDialog?>( - context: context, - builder: (BuildContext ctx) { - var totalApps = - existingUpdateIdsAllOrSelected.length + - newInstallIdsAllOrSelected - .length + - trackOnlyUpdateIdsAllOrSelected - .length; - return GeneratedFormModal( - title: tr('changeX', args: [ - plural('apps', totalApps) - ]), - items: formItems - .map((e) => [e]) - .toList(), - initValid: true, - ); - }).then((values) { - if (values != null) { - if (values.isEmpty) { - values = - getDefaultValuesFromFormItems( - [formItems]); - } - bool shouldInstallUpdates = - values['updates'] == true; - bool shouldInstallNew = - values['installs'] == true; - bool shouldMarkTrackOnlies = - values['trackonlies'] == true; - (() async { - if (shouldInstallNew || - shouldInstallUpdates) { - await settingsProvider - .getInstallPermission(); - } - })() - .then((_) { - List toInstall = []; - if (shouldInstallUpdates) { - toInstall.addAll( - existingUpdateIdsAllOrSelected); - } - if (shouldInstallNew) { - toInstall.addAll( - newInstallIdsAllOrSelected); - } - if (shouldMarkTrackOnlies) { - toInstall.addAll( - trackOnlyUpdateIdsAllOrSelected); - } - appsProvider - .downloadAndInstallLatestApps( - toInstall, - globalNavigatorKey - .currentContext) - .catchError((e) { - showError(e, context); - }); - }); - } - }); - }, - tooltip: selectedApps.isEmpty - ? tr('installUpdateApps') - : tr('installUpdateSelectedApps'), - icon: const Icon( - Icons.file_download_outlined, - )), - IconButton( - visualDensity: VisualDensity.compact, - onPressed: selectedApps.isEmpty - ? null - : () async { - try { - Set? preselected; - var showPrompt = false; - for (var element in selectedApps) { - var currentCats = - element.categories.toSet(); - if (preselected == null) { - preselected = currentCats; - } else { - if (!settingsProvider.setEqual( - currentCats, preselected)) { - showPrompt = true; - break; - } - } - } - var cont = true; - if (showPrompt) { - cont = await showDialog< - Map?>( - context: context, - builder: (BuildContext ctx) { - return GeneratedFormModal( - title: tr('categorize'), - items: const [], - initValid: true, - message: tr( - 'selectedCategorizeWarning'), - ); - }) != - null; - } - if (cont) { - await showDialog?>( - context: context, - builder: (BuildContext ctx) { - return GeneratedFormModal( - title: tr('categorize'), - items: const [], - initValid: true, - singleNullReturnButton: - tr('continue'), - additionalWidgets: [ - CategoryEditorSelector( - preselected: !showPrompt - ? preselected ?? {} - : {}, - showLabelWhenNotEmpty: false, - onSelected: (categories) { - appsProvider.saveApps( - selectedApps.map((e) { - e.categories = categories; - return e; - }).toList()); - }, - ) - ], - ); - }); - } - } catch (err) { - showError(err, context); - } - }, - tooltip: tr('categorize'), - icon: const Icon(Icons.category_outlined), - ), - IconButton( - visualDensity: VisualDensity.compact, - onPressed: selectedApps.isEmpty - ? null - : () { - showDialog( - context: context, - builder: (BuildContext ctx) { - return AlertDialog( - scrollable: true, - content: Padding( - padding: - const EdgeInsets.only(top: 6), - child: Row( - mainAxisAlignment: - MainAxisAlignment - .spaceAround, - children: [ - IconButton( - onPressed: appsProvider - .areDownloadsRunning() - ? null - : () { - showDialog( - context: - context, - builder: - (BuildContext - ctx) { - return AlertDialog( - title: Text(tr( - 'markXSelectedAppsAsUpdated', - args: [ - selectedApps.length.toString() - ])), - content: - Text( - tr('onlyWorksWithNonEVDApps'), - style: const TextStyle( - fontWeight: - FontWeight.bold, - fontStyle: FontStyle.italic), - ), - actions: [ - TextButton( - onPressed: - () { - Navigator.of(context).pop(); - }, - child: - Text(tr('no'))), - TextButton( - onPressed: - () { - HapticFeedback.selectionClick(); - appsProvider.saveApps(selectedApps.map((a) { - if (a.installedVersion != null) { - a.installedVersion = a.latestVersion; - } - return a; - }).toList()); + (appsProvider.apps.isNotEmpty + ? appsProvider.apps.length + : 1), + ), + ) + ]; + } - Navigator.of(context).pop(); - }, - child: - Text(tr('yes'))) - ], - ); - }).whenComplete(() { - Navigator.of( - context) - .pop(); - }); - }, - tooltip: tr( - 'markSelectedAppsUpdated'), - icon: const Icon( - Icons.done)), - IconButton( - onPressed: () { - var pinStatus = - selectedApps - .where((element) => - element - .pinned) - .isEmpty; - appsProvider.saveApps( - selectedApps.map((e) { - e.pinned = pinStatus; - return e; - }).toList()); - Navigator.of(context) - .pop(); - }, - tooltip: selectedApps - .where((element) => - element.pinned) - .isEmpty - ? tr('pinToTop') - : tr('unpinFromTop'), - icon: Icon(selectedApps - .where((element) => - element.pinned) - .isEmpty - ? Icons - .bookmark_outline_rounded - : Icons - .bookmark_remove_outlined), - ), - IconButton( - onPressed: () { - String urls = ''; - for (var a - in selectedApps) { - urls += '${a.url}\n'; - } - urls = urls.substring( - 0, urls.length - 1); - Share.share(urls, - subject: tr( - 'selectedAppURLsFromObtainium')); - Navigator.of(context) - .pop(); - }, - tooltip: tr( - 'shareSelectedAppURLs'), - icon: - const Icon(Icons.share), - ), - IconButton( - onPressed: () { - showDialog( - context: context, - builder: (BuildContext - ctx) { - return GeneratedFormModal( - title: tr( - 'resetInstallStatusForSelectedAppsQuestion'), - items: const [], - initValid: true, - message: tr( - 'installStatusOfXWillBeResetExplanation', - args: [ - plural( - 'app', - selectedApps - .length) - ]), - ); - }).then((values) { - if (values != null) { - appsProvider.saveApps( - selectedApps - .map((e) { - e.installedVersion = - null; - return e; - }).toList()); - } - }).whenComplete(() { - Navigator.of(context) - .pop(); - }); - }, - tooltip: tr( - 'resetInstallStatus'), - icon: const Icon(Icons - .restore_page_outlined), - ), - ]), - ), - ); - }); - }, - tooltip: tr('more'), - icon: const Icon(Icons.more_horiz), + getChangeLogFn(int appIndex) { + AppSource appSource = SourceProvider().getSource( + listedApps[appIndex].app.url, + overrideSource: listedApps[appIndex].app.overrideSource); + String? changesUrl = + appSource.changeLogPageFromStandardUrl(listedApps[appIndex].app.url); + String? changeLog = listedApps[appIndex].app.changeLog; + return (changeLog == null && changesUrl == null) + ? null + : () { + if (changeLog != null) { + showChangeLogDialog(changesUrl, appSource, changeLog, appIndex); + } else { + launchUrlString(changesUrl!, + mode: LaunchMode.externalApplication); + } + }; + } + + getUpdateButton(int appIndex) { + return IconButton( + visualDensity: VisualDensity.compact, + color: Theme.of(context).colorScheme.primary, + tooltip: + listedApps[appIndex].app.additionalSettings['trackOnly'] == true + ? tr('markUpdated') + : tr('update'), + onPressed: appsProvider.areDownloadsRunning() + ? null + : () { + appsProvider.downloadAndInstallLatestApps( + [listedApps[appIndex].app.id], + globalNavigatorKey.currentContext).catchError((e) { + showError(e, context); + return []; + }); + }, + icon: Icon( + listedApps[appIndex].app.additionalSettings['trackOnly'] == true + ? Icons.check_circle_outline + : Icons.install_mobile)); + } + + getAppIcon(int appIndex) { + return listedApps[appIndex].icon != null + ? Image.memory( + listedApps[appIndex].icon!, + gaplessPlayback: true, + ) + : Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Transform( + alignment: Alignment.center, + transform: Matrix4.rotationZ(0.31), + child: Padding( + padding: const EdgeInsets.all(15), + child: Image( + image: const AssetImage( + 'assets/graphics/icon_small.png'), + color: Colors.white.withOpacity(0.3), + colorBlendMode: BlendMode.modulate, + gaplessPlayback: true, ), - ], - ))), - const VerticalDivider(), - IconButton( - visualDensity: VisualDensity.compact, - onPressed: () { - setState(() { - if (currentFilterIsUpdatesOnly) { - filter = AppsFilter(); - } else { - filter = updatesOnlyFilter; - } - }); - }, - tooltip: currentFilterIsUpdatesOnly - ? tr('removeOutdatedFilter') - : tr('showOutdatedOnly'), - icon: Icon( - currentFilterIsUpdatesOnly - ? Icons.update_disabled_rounded - : Icons.update_rounded, - color: Theme.of(context).colorScheme.primary, + )), + ]); + } + + getVersionText(int appIndex) { + return '${listedApps[appIndex].app.installedVersion ?? tr('notInstalled')}${listedApps[appIndex].app.additionalSettings['trackOnly'] == true ? ' ${tr('estimateInBrackets')}' : ''}'; + } + + getChangesButtonString(int appIndex, bool hasChangeLogFn) { + return listedApps[appIndex].app.releaseDate == null + ? hasChangeLogFn + ? tr('changes') + : '' + : DateFormat('yyyy-MM-dd') + .format(listedApps[appIndex].app.releaseDate!); + } + + getSingleAppHorizTile(int index) { + var showChangesFn = getChangeLogFn(index); + var hasUpdate = listedApps[index].app.installedVersion != null && + listedApps[index].app.installedVersion != + listedApps[index].app.latestVersion; + Widget trailingRow = Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + hasUpdate ? getUpdateButton(index) : const SizedBox.shrink(), + hasUpdate + ? const SizedBox( + width: 10, + ) + : const SizedBox.shrink(), + GestureDetector( + onTap: showChangesFn, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: settingsProvider.highlightTouchTargets && + showChangesFn != null + ? (Theme.of(context).brightness == Brightness.light + ? Theme.of(context).primaryColor + : Theme.of(context).primaryColorLight) + .withAlpha(20) + : null), + padding: settingsProvider.highlightTouchTargets + ? const EdgeInsetsDirectional.fromSTEB(12, 0, 12, 0) + : const EdgeInsetsDirectional.fromSTEB(24, 0, 0, 0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Row(mainAxisSize: MainAxisSize.min, children: [ + Container( + constraints: BoxConstraints( + maxWidth: + MediaQuery.of(context).size.width / 4), + child: Text(getVersionText(index), + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end)), + ]), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + getChangesButtonString( + index, showChangesFn != null), + style: TextStyle( + fontStyle: FontStyle.italic, + decoration: showChangesFn != null + ? TextDecoration.underline + : TextDecoration.none), + ) + ], + ), + ], + ))) + ], + ); + + var transparent = + Theme.of(context).colorScheme.background.withAlpha(0).value; + List stops = [ + ...listedApps[index] + .app + .categories + .asMap() + .entries + .map((e) => + ((e.key / (listedApps[index].app.categories.length - 1)))) + .toList(), + 1 + ]; + if (stops.length == 2) { + stops[0] = 1; + } + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + stops: stops, + begin: const Alignment(-1, 0), + end: const Alignment(-0.97, 0), + colors: [ + ...listedApps[index] + .app + .categories + .map((e) => + Color(settingsProvider.categories[e] ?? transparent) + .withAlpha(255)) + .toList(), + Color(transparent) + ])), + child: ListTile( + tileColor: listedApps[index].app.pinned + ? Colors.grey.withOpacity(0.1) + : Colors.transparent, + selectedTileColor: Theme.of(context) + .colorScheme + .primary + .withOpacity(listedApps[index].app.pinned ? 0.2 : 0.1), + selected: + selectedAppIds.map((e) => e).contains(listedApps[index].app.id), + onLongPress: () { + toggleAppSelected(listedApps[index].app); + }, + leading: getAppIcon(index), + title: Text( + maxLines: 1, + listedApps[index].name, + style: TextStyle( + overflow: TextOverflow.ellipsis, + fontWeight: listedApps[index].app.pinned + ? FontWeight.bold + : FontWeight.normal, ), ), - appsProvider.apps.isEmpty - ? const SizedBox() - : TextButton.icon( - style: - const ButtonStyle(visualDensity: VisualDensity.compact), - label: Text( - filter.isIdenticalTo(neutralFilter, settingsProvider) - ? tr('filter') - : tr('filterActive'), - style: TextStyle( - fontWeight: filter.isIdenticalTo( - neutralFilter, settingsProvider) - ? FontWeight.normal - : FontWeight.bold), - ), + subtitle: Text(tr('byX', args: [listedApps[index].app.author]), + maxLines: 1, + style: TextStyle( + overflow: TextOverflow.ellipsis, + fontWeight: listedApps[index].app.pinned + ? FontWeight.bold + : FontWeight.normal)), + trailing: listedApps[index].downloadProgress != null + ? SizedBox( + child: Text( + listedApps[index].downloadProgress! >= 0 + ? tr('percentProgress', args: [ + listedApps[index] + .downloadProgress! + .toInt() + .toString() + ]) + : tr('installing'), + textAlign: (listedApps[index].downloadProgress! >= 0) + ? TextAlign.start + : TextAlign.end, + )) + : trailingRow, + onTap: () { + if (selectedAppIds.isNotEmpty) { + toggleAppSelected(listedApps[index].app); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + AppPage(appId: listedApps[index].app.id)), + ); + } + }, + )); + } + + getCategoryCollapsibleTile(int index) { + var tiles = listedApps + .asMap() + .entries + .where((e) => + e.value.app.categories.contains(listedCategories[index]) || + e.value.app.categories.isEmpty && listedCategories[index] == null) + .map((e) => getSingleAppHorizTile(e.key)) + .toList(); + + capFirstChar(String str) => str[0].toUpperCase() + str.substring(1); + return ExpansionTile( + initiallyExpanded: true, + title: Text( + capFirstChar(listedCategories[index] ?? tr('noCategory')), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + controlAffinity: ListTileControlAffinity.leading, + trailing: Text(tiles.length.toString()), + children: tiles); + } + + getSelectAllButton() { + return selectedAppIds.isEmpty + ? TextButton.icon( + style: const ButtonStyle(visualDensity: VisualDensity.compact), + onPressed: () { + selectThese(listedApps.map((e) => e.app).toList()); + }, + icon: Icon( + Icons.select_all_outlined, + color: Theme.of(context).colorScheme.primary, + ), + label: Text(listedApps.length.toString())) + : TextButton.icon( + style: const ButtonStyle(visualDensity: VisualDensity.compact), + onPressed: () { + selectedAppIds.isEmpty + ? selectThese(listedApps.map((e) => e.app).toList()) + : clearSelected(); + }, + icon: Icon( + selectedAppIds.isEmpty + ? Icons.select_all_outlined + : Icons.deselect_outlined, + color: Theme.of(context).colorScheme.primary, + ), + label: Text(selectedAppIds.length.toString())); + } + + getMassObtainFunction() { + return appsProvider.areDownloadsRunning() || + (existingUpdateIdsAllOrSelected.isEmpty && + newInstallIdsAllOrSelected.isEmpty && + trackOnlyUpdateIdsAllOrSelected.isEmpty) + ? null + : () { + HapticFeedback.heavyImpact(); + List formItems = []; + if (existingUpdateIdsAllOrSelected.isNotEmpty) { + formItems.add(GeneratedFormSwitch('updates', + label: tr('updateX', args: [ + plural('apps', existingUpdateIdsAllOrSelected.length) + ]), + defaultValue: true)); + } + if (newInstallIdsAllOrSelected.isNotEmpty) { + formItems.add(GeneratedFormSwitch('installs', + label: tr('installX', args: [ + plural('apps', newInstallIdsAllOrSelected.length) + ]), + defaultValue: existingUpdateIdsAllOrSelected.isEmpty)); + } + if (trackOnlyUpdateIdsAllOrSelected.isNotEmpty) { + formItems.add(GeneratedFormSwitch('trackonlies', + label: tr('markXTrackOnlyAsUpdated', args: [ + plural('apps', trackOnlyUpdateIdsAllOrSelected.length) + ]), + defaultValue: existingUpdateIdsAllOrSelected.isEmpty && + newInstallIdsAllOrSelected.isEmpty)); + } + showDialog?>( + context: context, + builder: (BuildContext ctx) { + var totalApps = existingUpdateIdsAllOrSelected.length + + newInstallIdsAllOrSelected.length + + trackOnlyUpdateIdsAllOrSelected.length; + return GeneratedFormModal( + title: tr('changeX', args: [plural('apps', totalApps)]), + items: formItems.map((e) => [e]).toList(), + initValid: true, + ); + }).then((values) async { + if (values != null) { + if (values.isEmpty) { + values = getDefaultValuesFromFormItems([formItems]); + } + bool shouldInstallUpdates = values['updates'] == true; + bool shouldInstallNew = values['installs'] == true; + bool shouldMarkTrackOnlies = values['trackonlies'] == true; + List toInstall = []; + if (shouldInstallUpdates) { + toInstall.addAll(existingUpdateIdsAllOrSelected); + } + if (shouldInstallNew) { + toInstall.addAll(newInstallIdsAllOrSelected); + } + if (shouldMarkTrackOnlies) { + toInstall.addAll(trackOnlyUpdateIdsAllOrSelected); + } + appsProvider + .downloadAndInstallLatestApps( + toInstall, globalNavigatorKey.currentContext) + .catchError((e) { + showError(e, context); + return []; + }).then((value) { + if (shouldInstallUpdates) { + showMessage(tr('appsUpdated'), context); + } + }); + } + }); + }; + } + + launchCategorizeDialog() { + return () async { + try { + Set? preselected; + var showPrompt = false; + for (var element in selectedApps) { + var currentCats = element.categories.toSet(); + if (preselected == null) { + preselected = currentCats; + } else { + if (!settingsProvider.setEqual(currentCats, preselected)) { + showPrompt = true; + break; + } + } + } + var cont = true; + if (showPrompt) { + cont = await showDialog?>( + context: context, + builder: (BuildContext ctx) { + return GeneratedFormModal( + title: tr('categorize'), + items: const [], + initValid: true, + message: tr('selectedCategorizeWarning'), + ); + }) != + null; + } + if (cont) { + // ignore: use_build_context_synchronously + await showDialog?>( + context: context, + builder: (BuildContext ctx) { + return GeneratedFormModal( + title: tr('categorize'), + items: const [], + initValid: true, + singleNullReturnButton: tr('continue'), + additionalWidgets: [ + CategoryEditorSelector( + preselected: !showPrompt ? preselected ?? {} : {}, + showLabelWhenNotEmpty: false, + onSelected: (categories) { + appsProvider.saveApps(selectedApps.map((e) { + e.categories = categories; + return e; + }).toList()); + }, + ) + ], + ); + }); + } + } catch (err) { + showError(err, context); + } + }; + } + + showMassMarkDialog() { + return showDialog( + context: context, + builder: (BuildContext ctx) { + return AlertDialog( + title: Text(tr('markXSelectedAppsAsUpdated', + args: [selectedAppIds.length.toString()])), + content: Text( + tr('onlyWorksWithNonVersionDetectApps'), + style: const TextStyle( + fontWeight: FontWeight.bold, fontStyle: FontStyle.italic), + ), + actions: [ + TextButton( onPressed: () { - showDialog?>( - context: context, - builder: (BuildContext ctx) { - var vals = filter.toFormValuesMap(); - return GeneratedFormModal( - initValid: true, - title: tr('filterApps'), - items: [ - [ - GeneratedFormTextField('appName', - label: tr('appName'), - required: false, - defaultValue: vals['appName']), - GeneratedFormTextField('author', - label: tr('author'), - required: false, - defaultValue: vals['author']) - ], - [ - GeneratedFormSwitch('upToDateApps', - label: tr('upToDateApps'), - defaultValue: vals['upToDateApps']) - ], - [ - GeneratedFormSwitch('nonInstalledApps', - label: tr('nonInstalledApps'), - defaultValue: vals['nonInstalledApps']) - ] - ], - additionalWidgets: [ - const SizedBox( - height: 16, - ), - CategoryEditorSelector( - preselected: filter.categoryFilter, - onSelected: (categories) { - filter.categoryFilter = categories.toSet(); - }, - ) - ], - ); - }).then((values) { - if (values != null) { - setState(() { - filter.setFormValuesFromMap(values); - }); + Navigator.of(context).pop(); + }, + child: Text(tr('no'))), + TextButton( + onPressed: () { + HapticFeedback.selectionClick(); + appsProvider.saveApps(selectedApps.map((a) { + if (a.installedVersion != null && + !appsProvider.isVersionDetectionPossible( + appsProvider.apps[a.id])) { + a.installedVersion = a.latestVersion; } + return a; + }).toList()); + + Navigator.of(context).pop(); + }, + child: Text(tr('yes'))) + ], + ); + }).whenComplete(() { + Navigator.of(context).pop(); + }); + } + + pinSelectedApps() { + var pinStatus = selectedApps.where((element) => element.pinned).isEmpty; + appsProvider.saveApps(selectedApps.map((e) { + e.pinned = pinStatus; + return e; + }).toList()); + Navigator.of(context).pop(); + } + + resetSelectedAppsInstallStatuses() async { + try { + var values = await showDialog( + context: context, + builder: (BuildContext ctx) { + return GeneratedFormModal( + title: tr('resetInstallStatusForSelectedAppsQuestion'), + items: const [], + initValid: true, + message: tr('installStatusOfXWillBeResetExplanation', + args: [plural('apps', selectedAppIds.length)]), + ); + }); + if (values != null) { + appsProvider.saveApps(selectedApps.map((e) { + e.installedVersion = null; + return e; + }).toList()); + } + } finally { + Navigator.of(context).pop(); + } + } + + showMoreOptionsDialog() { + return showDialog( + context: context, + builder: (BuildContext ctx) { + return AlertDialog( + scrollable: true, + content: Padding( + padding: const EdgeInsets.only(top: 6), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + IconButton( + onPressed: appsProvider.areDownloadsRunning() + ? null + : showMassMarkDialog, + tooltip: tr('markSelectedAppsUpdated'), + icon: const Icon(Icons.done)), + IconButton( + onPressed: pinSelectedApps, + tooltip: selectedApps + .where((element) => element.pinned) + .isEmpty + ? tr('pinToTop') + : tr('unpinFromTop'), + icon: Icon(selectedApps + .where((element) => element.pinned) + .isEmpty + ? Icons.bookmark_outline_rounded + : Icons.bookmark_remove_outlined), + ), + IconButton( + onPressed: () { + String urls = ''; + for (var a in selectedApps) { + urls += '${a.url}\n'; + } + urls = urls.substring(0, urls.length - 1); + Share.share(urls, + subject: tr('selectedAppURLsFromObtainium')); + Navigator.of(context).pop(); + }, + tooltip: tr('shareSelectedAppURLs'), + icon: const Icon(Icons.share), + ), + IconButton( + onPressed: resetSelectedAppsInstallStatuses, + tooltip: tr('resetInstallStatus'), + icon: const Icon(Icons.restore_page_outlined), + ), + ]), + ), + ); + }); + } + + getMainBottomButtons() { + return [ + IconButton( + visualDensity: VisualDensity.compact, + onPressed: getMassObtainFunction(), + tooltip: selectedAppIds.isEmpty + ? tr('installUpdateApps') + : tr('installUpdateSelectedApps'), + icon: const Icon( + Icons.file_download_outlined, + )), + IconButton( + visualDensity: VisualDensity.compact, + onPressed: selectedAppIds.isEmpty + ? null + : () { + appsProvider.removeAppsWithModal( + context, selectedApps.toList()); + }, + tooltip: tr('removeSelectedApps'), + icon: const Icon(Icons.delete_outline_outlined), + ), + IconButton( + visualDensity: VisualDensity.compact, + onPressed: selectedAppIds.isEmpty ? null : launchCategorizeDialog(), + tooltip: tr('categorize'), + icon: const Icon(Icons.category_outlined), + ), + IconButton( + visualDensity: VisualDensity.compact, + onPressed: selectedAppIds.isEmpty ? null : showMoreOptionsDialog, + tooltip: tr('more'), + icon: const Icon(Icons.more_horiz), + ), + ]; + } + + showFilterDialog() async { + var values = await showDialog?>( + context: context, + builder: (BuildContext ctx) { + var vals = filter.toFormValuesMap(); + return GeneratedFormModal( + initValid: true, + title: tr('filterApps'), + items: [ + [ + GeneratedFormTextField('appName', + label: tr('appName'), + required: false, + defaultValue: vals['appName']), + GeneratedFormTextField('author', + label: tr('author'), + required: false, + defaultValue: vals['author']) + ], + [ + GeneratedFormTextField('appId', + label: tr('appId'), + required: false, + defaultValue: vals['appId']) + ], + [ + GeneratedFormSwitch('upToDateApps', + label: tr('upToDateApps'), + defaultValue: vals['upToDateApps']) + ], + [ + GeneratedFormSwitch('nonInstalledApps', + label: tr('nonInstalledApps'), + defaultValue: vals['nonInstalledApps']) + ], + [ + GeneratedFormDropdown( + 'sourceFilter', + label: tr('appSource'), + defaultValue: filter.sourceFilter, + [ + MapEntry('', tr('none')), + ...sourceProvider.sources + .map((e) => + MapEntry(e.runtimeType.toString(), e.name)) + .toList() + ]) + ] + ], + additionalWidgets: [ + const SizedBox( + height: 16, + ), + CategoryEditorSelector( + preselected: filter.categoryFilter, + onSelected: (categories) { + filter.categoryFilter = categories.toSet(); + }, + ) + ], + ); + }); + if (values != null) { + setState(() { + filter.setFormValuesFromMap(values); + }); + } + } + + getFilterButtonsRow() { + var isFilterOff = filter.isIdenticalTo(neutralFilter, settingsProvider); + return Row( + children: [ + getSelectAllButton(), + IconButton( + color: Theme.of(context).colorScheme.primary, + style: const ButtonStyle(visualDensity: VisualDensity.compact), + tooltip: isFilterOff ? tr('filter') : tr('filterActive'), + onPressed: isFilterOff + ? showFilterDialog + : () { + setState(() { + filter = AppsFilter(); }); }, - icon: const Icon(Icons.filter_list_rounded)) - ], - ), - ], + icon: Icon(isFilterOff + ? Icons.filter_list_rounded + : Icons.filter_list_off_rounded)), + const SizedBox( + width: 10, + ), + const VerticalDivider(), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: getMainBottomButtons(), + )), + ], + ); + } + + getDisplayedList() { + return settingsProvider.groupByCategory && + !(listedCategories.isEmpty || + (listedCategories.length == 1 && listedCategories[0] == null)) + ? SliverList( + delegate: + SliverChildBuilderDelegate((BuildContext context, int index) { + return getCategoryCollapsibleTile(index); + }, childCount: listedCategories.length)) + : SliverList( + delegate: + SliverChildBuilderDelegate((BuildContext context, int index) { + return getSingleAppHorizTile(index); + }, childCount: listedApps.length)); + } + + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.surface, + body: RefreshIndicator( + key: _refreshIndicatorKey, + onRefresh: refresh, + child: CustomScrollView(slivers: [ + CustomAppBar(title: tr('appsString')), + ...getLoadingWidgets(), + getDisplayedList() + ])), + persistentFooterButtons: appsProvider.apps.isEmpty + ? null + : [ + getFilterButtonsRow(), + ], ); } } @@ -883,37 +1078,47 @@ class AppsPageState extends State { class AppsFilter { late String nameFilter; late String authorFilter; + late String idFilter; late bool includeUptodate; late bool includeNonInstalled; late Set categoryFilter; + late String sourceFilter; AppsFilter( {this.nameFilter = '', this.authorFilter = '', + this.idFilter = '', this.includeUptodate = true, this.includeNonInstalled = true, - this.categoryFilter = const {}}); + this.categoryFilter = const {}, + this.sourceFilter = ''}); Map toFormValuesMap() { return { 'appName': nameFilter, 'author': authorFilter, + 'appId': idFilter, 'upToDateApps': includeUptodate, - 'nonInstalledApps': includeNonInstalled + 'nonInstalledApps': includeNonInstalled, + 'sourceFilter': sourceFilter }; } setFormValuesFromMap(Map values) { nameFilter = values['appName']!; authorFilter = values['author']!; + idFilter = values['appId']!; includeUptodate = values['upToDateApps']; includeNonInstalled = values['nonInstalledApps']; + sourceFilter = values['sourceFilter']; } bool isIdenticalTo(AppsFilter other, SettingsProvider settingsProvider) => authorFilter.trim() == other.authorFilter.trim() && nameFilter.trim() == other.nameFilter.trim() && + idFilter.trim() == other.idFilter.trim() && includeUptodate == other.includeUptodate && includeNonInstalled == other.includeNonInstalled && - settingsProvider.setEqual(categoryFilter, other.categoryFilter); + settingsProvider.setEqual(categoryFilter, other.categoryFilter) && + sourceFilter.trim() == other.sourceFilter.trim(); } diff --git a/lib/pages/home.dart b/lib/pages/home.dart index d0a7061..c14e83f 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -6,6 +6,9 @@ import 'package:obtainium/pages/add_app.dart'; import 'package:obtainium/pages/apps.dart'; import 'package:obtainium/pages/import_export.dart'; import 'package:obtainium/pages/settings.dart'; +import 'package:obtainium/providers/apps_provider.dart'; +import 'package:obtainium/providers/settings_provider.dart'; +import 'package:provider/provider.dart'; class HomePage extends StatefulWidget { const HomePage({super.key}); @@ -24,6 +27,9 @@ class NavigationPageItem { class _HomePageState extends State { List selectedIndexHistory = []; + bool isReversing = false; + int prevAppCount = -1; + bool prevIsLoading = true; List pages = [ NavigationPageItem(tr('appsString'), Icons.apps, @@ -36,10 +42,61 @@ class _HomePageState extends State { @override Widget build(BuildContext context) { + AppsProvider appsProvider = context.watch(); + SettingsProvider settingsProvider = context.watch(); + + setIsReversing(int targetIndex) { + bool reversing = selectedIndexHistory.isNotEmpty && + selectedIndexHistory.last > targetIndex; + setState(() { + isReversing = reversing; + }); + } + + switchToPage(int index) async { + setIsReversing(index); + if (index == 0) { + while ((pages[0].widget.key as GlobalKey).currentState != + null) { + // Avoid duplicate GlobalKey error + await Future.delayed(const Duration(microseconds: 1)); + } + setState(() { + selectedIndexHistory.clear(); + }); + } else if (selectedIndexHistory.isEmpty || + (selectedIndexHistory.isNotEmpty && + selectedIndexHistory.last != index)) { + setState(() { + int existingInd = selectedIndexHistory.indexOf(index); + if (existingInd >= 0) { + selectedIndexHistory.removeAt(existingInd); + } + selectedIndexHistory.add(index); + }); + } + } + + if (!prevIsLoading && + prevAppCount >= 0 && + appsProvider.apps.length > prevAppCount && + selectedIndexHistory.isNotEmpty && + selectedIndexHistory.last == 1) { + switchToPage(0); + } + prevAppCount = appsProvider.apps.length; + prevIsLoading = appsProvider.loadingApps; + return WillPopScope( child: Scaffold( backgroundColor: Theme.of(context).colorScheme.surface, body: PageTransitionSwitcher( + duration: Duration( + milliseconds: + settingsProvider.disablePageTransitions ? 0 : 300), + reverse: settingsProvider.reversePageTransitions + ? !isReversing + : isReversing, transitionBuilder: ( Widget child, Animation animation, @@ -63,27 +120,18 @@ class _HomePageState extends State { .map((e) => NavigationDestination(icon: Icon(e.icon), label: e.title)) .toList(), - onDestinationSelected: (int index) { + onDestinationSelected: (int index) async { HapticFeedback.selectionClick(); - setState(() { - if (index == 0) { - selectedIndexHistory.clear(); - } else if (selectedIndexHistory.isEmpty || - (selectedIndexHistory.isNotEmpty && - selectedIndexHistory.last != index)) { - int existingInd = selectedIndexHistory.indexOf(index); - if (existingInd >= 0) { - selectedIndexHistory.removeAt(existingInd); - } - selectedIndexHistory.add(index); - } - }); + switchToPage(index); }, selectedIndex: selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last, ), ), onWillPop: () async { + setIsReversing(selectedIndexHistory.length >= 2 + ? selectedIndexHistory.reversed.toList()[1] + : 0); if (selectedIndexHistory.isNotEmpty) { setState(() { selectedIndexHistory.removeLast(); diff --git a/lib/pages/import_export.dart b/lib/pages/import_export.dart index f6d1dcf..f99efb5 100644 --- a/lib/pages/import_export.dart +++ b/lib/pages/import_export.dart @@ -28,8 +28,9 @@ class _ImportExportPageState extends State { @override Widget build(BuildContext context) { SourceProvider sourceProvider = SourceProvider(); - var appsProvider = context.read(); - var settingsProvider = context.read(); + var appsProvider = context.watch(); + var settingsProvider = context.watch(); + var outlineButtonStyle = ButtonStyle( shape: MaterialStateProperty.all( StadiumBorder( @@ -41,6 +42,263 @@ class _ImportExportPageState extends State { ), ); + urlListImport({String? initValue, bool overrideInitValid = false}) { + showDialog?>( + context: context, + builder: (BuildContext ctx) { + return GeneratedFormModal( + initValid: overrideInitValid, + title: tr('importFromURLList'), + items: [ + [ + GeneratedFormTextField('appURLList', + defaultValue: initValue ?? '', + label: tr('appURLList'), + max: 7, + additionalValidators: [ + (dynamic value) { + if (value != null && value.isNotEmpty) { + var lines = value.trim().split('\n'); + for (int i = 0; i < lines.length; i++) { + try { + sourceProvider.getSource(lines[i]); + } catch (e) { + return '${tr('line')} ${i + 1}: $e'; + } + } + } + return null; + } + ]) + ] + ], + ); + }).then((values) { + if (values != null) { + var urls = (values['appURLList'] as String).split('\n'); + setState(() { + importInProgress = true; + }); + appsProvider.addAppsByURL(urls).then((errors) { + if (errors.isEmpty) { + showMessage(tr('importedX', args: [plural('apps', urls.length)]), + context); + } else { + showDialog( + context: context, + builder: (BuildContext ctx) { + return ImportErrorDialog( + urlsLength: urls.length, errors: errors); + }); + } + }).catchError((e) { + showError(e, context); + }).whenComplete(() { + setState(() { + importInProgress = false; + }); + }); + } + }); + } + + runObtainiumExport({bool pickOnly = false}) async { + HapticFeedback.selectionClick(); + appsProvider + .exportApps( + pickOnly: + pickOnly || (await settingsProvider.getExportDir()) == null, + sp: settingsProvider) + .then((String? result) { + if (result != null) { + showMessage(tr('exportedTo', args: [result]), context); + } + }).catchError((e) { + showError(e, context); + }); + } + + runObtainiumImport() { + HapticFeedback.selectionClick(); + FilePicker.platform.pickFiles().then((result) { + setState(() { + importInProgress = true; + }); + if (result != null) { + String data = File(result.files.single.path!).readAsStringSync(); + try { + jsonDecode(data); + } catch (e) { + throw ObtainiumError(tr('invalidInput')); + } + appsProvider.importApps(data).then((value) { + var cats = settingsProvider.categories; + appsProvider.apps.forEach((key, value) { + for (var c in value.app.categories) { + if (!cats.containsKey(c)) { + cats[c] = generateRandomLightColor().value; + } + } + }); + appsProvider.addMissingCategories(settingsProvider); + showMessage( + tr('importedX', args: [plural('apps', value)]), context); + }); + } else { + // User canceled the picker + } + }).catchError((e) { + showError(e, context); + }).whenComplete(() { + setState(() { + importInProgress = false; + }); + }); + } + + runUrlImport() { + FilePicker.platform.pickFiles().then((result) { + if (result != null) { + urlListImport( + overrideInitValid: true, + initValue: RegExp('https?://[^"]+') + .allMatches( + File(result.files.single.path!).readAsStringSync()) + .map((e) => e.input.substring(e.start, e.end)) + .toSet() + .toList() + .where((url) { + try { + sourceProvider.getSource(url); + return true; + } catch (e) { + return false; + } + }).join('\n')); + } + }); + } + + runSourceSearch(AppSource source) { + () async { + var values = await showDialog?>( + context: context, + builder: (BuildContext ctx) { + return GeneratedFormModal( + title: tr('searchX', args: [source.name]), + items: [ + [ + GeneratedFormTextField('searchQuery', + label: tr('searchQuery')) + ], + ...source.searchQuerySettingFormItems.map((e) => [e]) + ], + ); + }); + if (values != null && + (values['searchQuery'] as String?)?.isNotEmpty == true) { + setState(() { + importInProgress = true; + }); + var urlsWithDescriptions = await source + .search(values['searchQuery'] as String, querySettings: values); + if (urlsWithDescriptions.isNotEmpty) { + var selectedUrls = + // ignore: use_build_context_synchronously + await showDialog?>( + context: context, + builder: (BuildContext ctx) { + return UrlSelectionModal( + urlsWithDescriptions: urlsWithDescriptions, + selectedByDefault: false, + ); + }); + if (selectedUrls != null && selectedUrls.isNotEmpty) { + var errors = await appsProvider.addAppsByURL(selectedUrls); + if (errors.isEmpty) { + // ignore: use_build_context_synchronously + showMessage( + tr('importedX', + args: [plural('apps', selectedUrls.length)]), + context); + } else { + // ignore: use_build_context_synchronously + showDialog( + context: context, + builder: (BuildContext ctx) { + return ImportErrorDialog( + urlsLength: selectedUrls.length, errors: errors); + }); + } + } + } else { + throw ObtainiumError(tr('noResults')); + } + } + }() + .catchError((e) { + showError(e, context); + }).whenComplete(() { + setState(() { + importInProgress = false; + }); + }); + } + + runMassSourceImport(MassAppUrlSource source) { + () async { + var values = await showDialog?>( + context: context, + builder: (BuildContext ctx) { + return GeneratedFormModal( + title: tr('importX', args: [source.name]), + items: source.requiredArgs + .map((e) => [GeneratedFormTextField(e, label: e)]) + .toList(), + ); + }); + if (values != null) { + setState(() { + importInProgress = true; + }); + var urlsWithDescriptions = await source.getUrlsWithDescriptions( + values.values.map((e) => e.toString()).toList()); + var selectedUrls = + // ignore: use_build_context_synchronously + await showDialog?>( + context: context, + builder: (BuildContext ctx) { + return UrlSelectionModal( + urlsWithDescriptions: urlsWithDescriptions); + }); + if (selectedUrls != null) { + var errors = await appsProvider.addAppsByURL(selectedUrls); + if (errors.isEmpty) { + // ignore: use_build_context_synchronously + showMessage( + tr('importedX', args: [plural('apps', selectedUrls.length)]), + context); + } else { + // ignore: use_build_context_synchronously + showDialog( + context: context, + builder: (BuildContext ctx) { + return ImportErrorDialog( + urlsLength: selectedUrls.length, errors: errors); + }); + } + } + } + }() + .catchError((e) { + showError(e, context); + }).whenComplete(() { + setState(() { + importInProgress = false; + }); + }); + } + return Scaffold( backgroundColor: Theme.of(context).colorScheme.surface, body: CustomScrollView(slivers: [ @@ -52,94 +310,89 @@ class _ImportExportPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Row( - children: [ - Expanded( - child: TextButton( - style: outlineButtonStyle, - onPressed: appsProvider.apps.isEmpty || - importInProgress - ? null - : () { - HapticFeedback.selectionClick(); - appsProvider - .exportApps() - .then((String path) { - showError( - tr('exportedTo', args: [path]), - context); - }).catchError((e) { - showError(e, context); - }); - }, - child: Text(tr('obtainiumExport')))), - const SizedBox( - width: 16, - ), - Expanded( - child: TextButton( - style: outlineButtonStyle, - onPressed: importInProgress - ? null - : () { - HapticFeedback.selectionClick(); - FilePicker.platform - .pickFiles() - .then((result) { - setState(() { - importInProgress = true; - }); - if (result != null) { - String data = File( - result.files.single.path!) - .readAsStringSync(); - try { - jsonDecode(data); - } catch (e) { - throw ObtainiumError( - tr('invalidInput')); - } - appsProvider - .importApps(data) - .then((value) { - var cats = - settingsProvider.categories; - appsProvider.apps - .forEach((key, value) { - for (var c - in value.app.categories) { - if (!cats.containsKey(c)) { - cats[c] = - generateRandomLightColor() - .value; - } - } - }); - settingsProvider.categories = - cats; - showError( - tr('importedX', args: [ - plural('apps', value) - ]), - context); - }); - } else { - // User canceled the picker + FutureBuilder( + future: settingsProvider.getExportDir(), + builder: (context, snapshot) { + return Column( + children: [ + Row( + children: [ + Expanded( + child: TextButton( + style: outlineButtonStyle, + onPressed: appsProvider.apps.isEmpty || + importInProgress + ? null + : () { + runObtainiumExport(pickOnly: true); + }, + child: Text(tr('pickExportDir')), + )), + const SizedBox( + width: 16, + ), + Expanded( + child: TextButton( + style: outlineButtonStyle, + onPressed: appsProvider.apps.isEmpty || + importInProgress || + snapshot.data == null + ? null + : runObtainiumExport, + child: Text(tr('obtainiumExport')), + )), + ], + ), + const SizedBox( + height: 8, + ), + Row( + children: [ + Expanded( + child: TextButton( + style: outlineButtonStyle, + onPressed: importInProgress + ? null + : runObtainiumImport, + child: Text(tr('obtainiumImport')))), + ], + ), + if (snapshot.data != null) + Column( + children: [ + const SizedBox(height: 16), + GeneratedForm( + items: [ + [ + GeneratedFormSwitch( + 'autoExportOnChanges', + label: tr('autoExportOnChanges'), + defaultValue: settingsProvider + .autoExportOnChanges, + ) + ] + ], + onValueChanges: + (value, valid, isBuilding) { + if (valid && !isBuilding) { + if (value['autoExportOnChanges'] != + null) { + settingsProvider + .autoExportOnChanges = value[ + 'autoExportOnChanges'] == + true; } - }).catchError((e) { - showError(e, context); - }).whenComplete(() { - setState(() { - importInProgress = false; - }); - }); - }, - child: Text(tr('obtainiumImport')))) - ], + } + }), + ], + ), + ], + ); + }, ), if (importInProgress) - Column( - children: const [ + const Column( + children: [ SizedBox( height: 14, ), @@ -150,88 +403,26 @@ class _ImportExportPageState extends State { ], ) else - const Divider( - height: 32, + Column( + children: [ + const Divider( + height: 32, + ), + TextButton( + onPressed: + importInProgress ? null : urlListImport, + child: Text( + tr('importFromURLList'), + )), + const SizedBox(height: 8), + TextButton( + onPressed: + importInProgress ? null : runUrlImport, + child: Text( + tr('importFromURLsInFile'), + )), + ], ), - TextButton( - onPressed: importInProgress - ? null - : () { - showDialog?>( - context: context, - builder: (BuildContext ctx) { - return GeneratedFormModal( - title: tr('importFromURLList'), - items: [ - [ - GeneratedFormTextField( - 'appURLList', - label: tr('appURLList'), - max: 7, - additionalValidators: [ - (dynamic value) { - if (value != null && - value.isNotEmpty) { - var lines = value - .trim() - .split('\n'); - for (int i = 0; - i < lines.length; - i++) { - try { - sourceProvider - .getSource( - lines[i]); - } catch (e) { - return '${tr('line')} ${i + 1}: $e'; - } - } - } - return null; - } - ]) - ] - ], - ); - }).then((values) { - if (values != null) { - var urls = - (values['appURLList'] as String) - .split('\n'); - setState(() { - importInProgress = true; - }); - appsProvider - .addAppsByURL(urls) - .then((errors) { - if (errors.isEmpty) { - showError( - tr('importedX', args: [ - plural('apps', urls.length) - ]), - context); - } else { - showDialog( - context: context, - builder: (BuildContext ctx) { - return ImportErrorDialog( - urlsLength: urls.length, - errors: errors); - }); - } - }).catchError((e) { - showError(e, context); - }).whenComplete(() { - setState(() { - importInProgress = false; - }); - }); - } - }); - }, - child: Text( - tr('importFromURLList'), - )), ...sourceProvider.sources .where((element) => element.canSearch) .map((source) => Column( @@ -243,104 +434,7 @@ class _ImportExportPageState extends State { onPressed: importInProgress ? null : () { - () async { - var values = await showDialog< - Map?>( - context: context, - builder: - (BuildContext ctx) { - return GeneratedFormModal( - title: tr('searchX', - args: [ - source.name - ]), - items: [ - [ - GeneratedFormTextField( - 'searchQuery', - label: tr( - 'searchQuery')) - ] - ], - ); - }); - if (values != null && - (values['searchQuery'] - as String?) - ?.isNotEmpty == - true) { - setState(() { - importInProgress = true; - }); - var urlsWithDescriptions = - await source.search( - values['searchQuery'] - as String); - if (urlsWithDescriptions - .isNotEmpty) { - var selectedUrls = - await showDialog< - List< - String>?>( - context: context, - builder: - (BuildContext - ctx) { - return UrlSelectionModal( - urlsWithDescriptions: - urlsWithDescriptions, - selectedByDefault: - false, - ); - }); - if (selectedUrls != - null && - selectedUrls - .isNotEmpty) { - var errors = - await appsProvider - .addAppsByURL( - selectedUrls); - if (errors.isEmpty) { - // ignore: use_build_context_synchronously - showError( - tr('importedX', - args: [ - plural( - 'app', - selectedUrls - .length) - ]), - context); - } else { - showDialog( - context: context, - builder: - (BuildContext - ctx) { - return ImportErrorDialog( - urlsLength: - selectedUrls - .length, - errors: - errors); - }); - } - } - } else { - throw ObtainiumError( - tr('noResults')); - } - } - }() - .catchError((e) { - showError(e, context); - }).whenComplete(() { - setState(() { - importInProgress = false; - }); - }); + runSourceSearch(source); }, child: Text( tr('searchX', args: [source.name]))) @@ -356,91 +450,7 @@ class _ImportExportPageState extends State { onPressed: importInProgress ? null : () { - () async { - var values = await showDialog< - Map?>( - context: context, - builder: - (BuildContext ctx) { - return GeneratedFormModal( - title: tr('importX', - args: [ - source.name - ]), - items: - source - .requiredArgs - .map( - (e) => [ - GeneratedFormTextField(e, - label: e) - ]) - .toList(), - ); - }); - if (values != null) { - setState(() { - importInProgress = true; - }); - var urlsWithDescriptions = - await source - .getUrlsWithDescriptions( - values.values - .map((e) => - e.toString()) - .toList()); - var selectedUrls = - await showDialog< - List?>( - context: context, - builder: - (BuildContext - ctx) { - return UrlSelectionModal( - urlsWithDescriptions: - urlsWithDescriptions); - }); - if (selectedUrls != null) { - var errors = - await appsProvider - .addAppsByURL( - selectedUrls); - if (errors.isEmpty) { - // ignore: use_build_context_synchronously - showError( - tr('importedX', - args: [ - plural( - 'app', - selectedUrls - .length) - ]), - context); - } else { - showDialog( - context: context, - builder: - (BuildContext - ctx) { - return ImportErrorDialog( - urlsLength: - selectedUrls - .length, - errors: - errors); - }); - } - } - } - }() - .catchError((e) { - showError(e, context); - }).whenComplete(() { - setState(() { - importInProgress = false; - }); - }); + runMassSourceImport(source); }, child: Text( tr('importX', args: [source.name]))) @@ -456,7 +466,7 @@ class _ImportExportPageState extends State { fontStyle: FontStyle.italic, fontSize: 12)), const SizedBox( height: 8, - ) + ), ], ))) ])); @@ -528,7 +538,7 @@ class UrlSelectionModal extends StatefulWidget { this.selectedByDefault = true, this.onlyOneSelectionAllowed = false}); - Map urlsWithDescriptions; + Map> urlsWithDescriptions; bool selectedByDefault; bool onlyOneSelectionAllowed; @@ -537,7 +547,7 @@ class UrlSelectionModal extends StatefulWidget { } class _UrlSelectionModalState extends State { - Map, bool> urlWithDescriptionSelections = {}; + Map>, bool> urlWithDescriptionSelections = {}; @override void initState() { super.initState(); @@ -564,18 +574,79 @@ class _UrlSelectionModalState extends State { widget.onlyOneSelectionAllowed ? tr('selectURL') : tr('selectURLs')), content: Column(children: [ ...urlWithDescriptionSelections.keys.map((urlWithD) { - return Row(children: [ + selectThis(bool? value) { + setState(() { + value ??= false; + if (value! && widget.onlyOneSelectionAllowed) { + selectOnlyOne(urlWithD.key); + } else { + urlWithDescriptionSelections[urlWithD] = value!; + } + }); + } + + var urlLink = GestureDetector( + onTap: () { + launchUrlString(urlWithD.key, + mode: LaunchMode.externalApplication); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + urlWithD.value[0], + style: const TextStyle( + decoration: TextDecoration.underline, + fontWeight: FontWeight.bold), + textAlign: TextAlign.start, + ), + Text( + Uri.parse(urlWithD.key).host, + style: const TextStyle( + decoration: TextDecoration.underline, fontSize: 12), + ) + ], + )); + + var descriptionText = Text( + urlWithD.value[1].length > 128 + ? '${urlWithD.value[1].substring(0, 128)}...' + : urlWithD.value[1], + style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12), + ); + + var selectedUrlsWithDs = urlWithDescriptionSelections.entries + .where((e) => e.value) + .toList(); + + var singleSelectTile = ListTile( + title: urlLink, + subtitle: GestureDetector( + onTap: () { + setState(() { + selectOnlyOne(urlWithD.key); + }); + }, + child: descriptionText, + ), + leading: Radio( + value: urlWithD.key, + groupValue: selectedUrlsWithDs.isEmpty + ? null + : selectedUrlsWithDs.first.key.key, + onChanged: (value) { + setState(() { + selectOnlyOne(urlWithD.key); + }); + }, + ), + ); + + var multiSelectTile = Row(children: [ Checkbox( value: urlWithDescriptionSelections[urlWithD], onChanged: (value) { - setState(() { - value ??= false; - if (value! && widget.onlyOneSelectionAllowed) { - selectOnlyOne(urlWithD.key); - } else { - urlWithDescriptionSelections[urlWithD] = value!; - } - }); + selectThis(value); }), const SizedBox( width: 8, @@ -588,23 +659,13 @@ class _UrlSelectionModalState extends State { const SizedBox( height: 8, ), + urlLink, GestureDetector( - onTap: () { - launchUrlString(urlWithD.key, - mode: LaunchMode.externalApplication); - }, - child: Text( - Uri.parse(urlWithD.key).path.substring(1), - style: - const TextStyle(decoration: TextDecoration.underline), - textAlign: TextAlign.start, - )), - Text( - urlWithD.value.length > 128 - ? '${urlWithD.value.substring(0, 128)}...' - : urlWithD.value, - style: const TextStyle( - fontStyle: FontStyle.italic, fontSize: 12), + onTap: () { + selectThis( + !(urlWithDescriptionSelections[urlWithD] ?? false)); + }, + child: descriptionText, ), const SizedBox( height: 8, @@ -612,6 +673,10 @@ class _UrlSelectionModalState extends State { ], )) ]); + + return widget.onlyOneSelectionAllowed + ? singleSelectTile + : multiSelectTile; }) ]), actions: [ diff --git a/lib/pages/settings.dart b/lib/pages/settings.dart index 9698f95..c36c958 100644 --- a/lib/pages/settings.dart +++ b/lib/pages/settings.dart @@ -1,10 +1,9 @@ -import 'dart:math'; - +import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart'; +import 'package:device_info_plus/device_info_plus.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:obtainium/components/custom_app_bar.dart'; import 'package:obtainium/components/generated_form.dart'; -import 'package:obtainium/components/generated_form_modal.dart'; import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/main.dart'; import 'package:obtainium/providers/apps_provider.dart'; @@ -22,21 +21,6 @@ class SettingsPage extends StatefulWidget { State createState() => _SettingsPageState(); } -// Generates a random light color -// Courtesy of ChatGPT 😭 (with a bugfix 🥳) -Color generateRandomLightColor() { - // Create a random number generator - final Random random = Random(); - - // Generate random hue, saturation, and value values - final double hue = random.nextDouble() * 360; - final double saturation = 0.5 + random.nextDouble() * 0.5; - final double value = 0.9 + random.nextDouble() * 0.1; - - // Create a HSV color with the random values - return HSVColor.fromAHSV(1.0, hue, saturation, value).toColor(); -} - class _SettingsPageState extends State { @override Widget build(BuildContext context) { @@ -89,6 +73,7 @@ class _SettingsPageState extends State { }); var sortDropdown = DropdownButtonFormField( + isExpanded: true, decoration: InputDecoration(labelText: tr('appSortBy')), value: settingsProvider.sortColumn, items: [ @@ -103,6 +88,10 @@ class _SettingsPageState extends State { DropdownMenuItem( value: SortColumnSettings.added, child: Text(tr('asAdded')), + ), + DropdownMenuItem( + value: SortColumnSettings.releaseDate, + child: Text(tr('releaseDate')), ) ], onChanged: (value) { @@ -112,6 +101,7 @@ class _SettingsPageState extends State { }); var orderDropdown = DropdownButtonFormField( + isExpanded: true, decoration: InputDecoration(labelText: tr('appSortOrder')), value: settingsProvider.sortOrder, items: [ @@ -139,8 +129,8 @@ class _SettingsPageState extends State { child: Text(tr('followSystem')), ), ...supportedLocales.map((e) => DropdownMenuItem( - value: e.toLanguageTag(), - child: Text(e.toLanguageTag().toUpperCase()), + value: e.key.toLanguageTag(), + child: Text(e.value), )) ], onChanged: (value) { @@ -148,7 +138,7 @@ class _SettingsPageState extends State { if (value != null) { context.setLocale(Locale(value)); } else { - context.resetLocale(); + settingsProvider.resetLocaleSafe(context); } }); @@ -178,9 +168,9 @@ class _SettingsPageState extends State { }); var sourceSpecificFields = sourceProvider.sources.map((e) { - if (e.additionalSourceSpecificSettingFormItems.isNotEmpty) { + if (e.sourceConfigSettingFormItems.isNotEmpty) { return GeneratedForm( - items: e.additionalSourceSpecificSettingFormItems.map((e) { + items: e.sourceConfigSettingFormItems.map((e) { e.defaultValue = settingsProvider.getSettingString(e.key); return [e]; }).toList(), @@ -196,10 +186,18 @@ class _SettingsPageState extends State { } }); + const height8 = SizedBox( + height: 8, + ); + const height16 = SizedBox( height: 16, ); + const height32 = SizedBox( + height: 32, + ); + return Scaffold( backgroundColor: Theme.of(context).colorScheme.surface, body: CustomScrollView(slivers: [ @@ -212,13 +210,151 @@ class _SettingsPageState extends State { : Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + tr('updates'), + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary), + ), + intervalDropdown, + FutureBuilder( + builder: (ctx, val) { + return (val.data?.version.sdkInt ?? 0) >= 30 + ? Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + height16, + Row( + mainAxisAlignment: + MainAxisAlignment + .spaceBetween, + children: [ + Flexible( + child: Text(tr( + 'enableBackgroundUpdates'))), + Switch( + value: settingsProvider + .enableBackgroundUpdates, + onChanged: (value) { + settingsProvider + .enableBackgroundUpdates = + value; + }) + ], + ), + height8, + Text(tr('backgroundUpdateReqsExplanation'), + style: Theme.of(context) + .textTheme + .labelSmall), + Text(tr('backgroundUpdateLimitsExplanation'), + style: Theme.of(context) + .textTheme + .labelSmall), + height8, + if (settingsProvider + .enableBackgroundUpdates) + Column( + children: [ + height16, + Row( + mainAxisAlignment: + MainAxisAlignment + .spaceBetween, + children: [ + Flexible( + child: Text(tr( + 'bgUpdatesOnWiFiOnly'))), + Switch( + value: settingsProvider + .bgUpdatesOnWiFiOnly, + onChanged: (value) { + settingsProvider + .bgUpdatesOnWiFiOnly = + value; + }) + ], + ), + ], + ), + ], + ) + : const SizedBox.shrink(); + }, + future: DeviceInfoPlugin().androidInfo), + height16, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible(child: Text(tr('checkOnStart'))), + Switch( + value: settingsProvider.checkOnStart, + onChanged: (value) { + settingsProvider.checkOnStart = value; + }) + ], + ), + height16, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text(tr('checkUpdateOnDetailPage'))), + Switch( + value: settingsProvider + .checkUpdateOnDetailPage, + onChanged: (value) { + settingsProvider.checkUpdateOnDetailPage = + value; + }) + ], + ), + height16, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text(tr( + 'onlyCheckInstalledOrTrackOnlyApps'))), + Switch( + value: settingsProvider + .onlyCheckInstalledOrTrackOnlyApps, + onChanged: (value) { + settingsProvider + .onlyCheckInstalledOrTrackOnlyApps = + value; + }) + ], + ), + height32, + Text( + tr('sourceSpecific'), + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary), + ), + ...sourceSpecificFields, + height32, Text( tr('appearance'), style: TextStyle( + fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.primary), ), themeDropdown, height16, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible(child: Text(tr('useBlackTheme'))), + Switch( + value: settingsProvider.useBlackTheme, + onChanged: (value) { + settingsProvider.useBlackTheme = value; + }) + ], + ), colourDropdown, height16, Row( @@ -238,7 +374,7 @@ class _SettingsPageState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(tr('showWebInAppView')), + Flexible(child: Text(tr('showWebInAppView'))), Switch( value: settingsProvider.showAppWebpage, onChanged: (value) { @@ -250,7 +386,7 @@ class _SettingsPageState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(tr('pinUpdates')), + Flexible(child: Text(tr('pinUpdates'))), Switch( value: settingsProvider.pinUpdates, onChanged: (value) { @@ -258,31 +394,133 @@ class _SettingsPageState extends State { }) ], ), - const Divider( - height: 16, + height16, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + tr('moveNonInstalledAppsToBottom'))), + Switch( + value: settingsProvider.buryNonInstalled, + onChanged: (value) { + settingsProvider.buryNonInstalled = value; + }) + ], ), height16, - Text( - tr('updates'), - style: TextStyle( - color: Theme.of(context).colorScheme.primary), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: + Text(tr('removeOnExternalUninstall'))), + Switch( + value: settingsProvider + .removeOnExternalUninstall, + onChanged: (value) { + settingsProvider + .removeOnExternalUninstall = value; + }) + ], ), - intervalDropdown, - const Divider( - height: 48, + height16, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible(child: Text(tr('groupByCategory'))), + Switch( + value: settingsProvider.groupByCategory, + onChanged: (value) { + settingsProvider.groupByCategory = value; + }) + ], ), - Text( - tr('sourceSpecific'), - style: TextStyle( - color: Theme.of(context).colorScheme.primary), + height16, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: + Text(tr('dontShowTrackOnlyWarnings'))), + Switch( + value: + settingsProvider.hideTrackOnlyWarning, + onChanged: (value) { + settingsProvider.hideTrackOnlyWarning = + value; + }) + ], ), - ...sourceSpecificFields, - const Divider( - height: 48, + height16, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: + Text(tr('dontShowAPKOriginWarnings'))), + Switch( + value: + settingsProvider.hideAPKOriginWarning, + onChanged: (value) { + settingsProvider.hideAPKOriginWarning = + value; + }) + ], ), + height16, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text(tr('disablePageTransitions'))), + Switch( + value: + settingsProvider.disablePageTransitions, + onChanged: (value) { + settingsProvider.disablePageTransitions = + value; + }) + ], + ), + height16, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text(tr('reversePageTransitions'))), + Switch( + value: + settingsProvider.reversePageTransitions, + onChanged: settingsProvider + .disablePageTransitions + ? null + : (value) { + settingsProvider + .reversePageTransitions = value; + }) + ], + ), + height16, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text(tr('highlightTouchTargets'))), + Switch( + value: + settingsProvider.highlightTouchTargets, + onChanged: (value) { + settingsProvider.highlightTouchTargets = + value; + }) + ], + ), + height32, Text( tr('categories'), style: TextStyle( + fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.primary), ), height16, @@ -314,7 +552,8 @@ class _SettingsPageState extends State { onPressed: () { context.read().get().then((logs) { if (logs.isEmpty) { - showError(ObtainiumError(tr('noLogs')), context); + showMessage( + ObtainiumError(tr('noLogs')), context); } else { showDialog( context: context, @@ -328,7 +567,41 @@ class _SettingsPageState extends State { label: Text(tr('appLogs'))), ], ), - height16, + const Divider( + height: 32, + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column(children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible(child: Text(tr('debugMenu'))), + Switch( + value: settingsProvider.showDebugOpts, + onChanged: (value) { + settingsProvider.showDebugOpts = value; + }) + ], + ), + if (settingsProvider.showDebugOpts) + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + height16, + TextButton( + onPressed: () { + AndroidAlarmManager.oneShot( + const Duration(seconds: 0), + bgUpdateCheckAlarmId + 200, + bgUpdateCheck); + showMessage(tr('bgTaskStarted'), context); + }, + child: Text(tr('runBgCheckNow'))) + ], + ), + ]), + ), ], ), ) @@ -428,6 +701,7 @@ class _CategoryEditorSelectorState extends State { @override Widget build(BuildContext context) { var settingsProvider = context.watch(); + var appsProvider = context.watch(); storedValues = settingsProvider.categories.map((key, value) => MapEntry( key, MapEntry(value, @@ -451,8 +725,9 @@ class _CategoryEditorSelectorState extends State { if (!isBuilding) { storedValues = values['categories'] as Map>; - settingsProvider.categories = - storedValues.map((key, value) => MapEntry(key, value.key)); + settingsProvider.setCategories( + storedValues.map((key, value) => MapEntry(key, value.key)), + appsProvider: appsProvider); if (widget.onSelected != null) { widget.onSelected!(storedValues.keys .where((k) => storedValues[k]!.value) diff --git a/lib/providers/apps_provider.dart b/lib/providers/apps_provider.dart index 92be206..d4085d6 100644 --- a/lib/providers/apps_provider.dart +++ b/lib/providers/apps_provider.dart @@ -4,32 +4,48 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:math'; +import 'package:http/http.dart' as http; +import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart'; +import 'package:android_intent_plus/flag.dart'; +import 'package:android_package_installer/android_package_installer.dart'; +import 'package:android_package_manager/android_package_manager.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:install_plugin_v2/install_plugin_v2.dart'; -import 'package:installed_apps/app_info.dart'; -import 'package:installed_apps/installed_apps.dart'; +import 'package:obtainium/components/generated_form.dart'; +import 'package:obtainium/components/generated_form_modal.dart'; import 'package:obtainium/custom_errors.dart'; +import 'package:obtainium/main.dart'; import 'package:obtainium/providers/logs_provider.dart'; import 'package:obtainium/providers/notifications_provider.dart'; import 'package:obtainium/providers/settings_provider.dart'; -import 'package:package_archive_info/package_archive_info.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; import 'package:path_provider/path_provider.dart'; import 'package:flutter_fgbg/flutter_fgbg.dart'; import 'package:obtainium/providers/source_provider.dart'; import 'package:http/http.dart'; +import 'package:android_intent_plus/android_intent.dart'; +import 'package:flutter_archive/flutter_archive.dart'; +import 'package:shared_storage/shared_storage.dart' as saf; + +final pm = AndroidPackageManager(); class AppInMemory { late App app; double? downloadProgress; - AppInfo? installedInfo; + PackageInfo? installedInfo; + Uint8List? icon; - AppInMemory(this.app, this.downloadProgress, this.installedInfo); + AppInMemory(this.app, this.downloadProgress, this.installedInfo, this.icon); + AppInMemory deepCopy() => + AppInMemory(app.deepCopy(), downloadProgress, installedInfo, icon); + + String get name => app.overrideName ?? app.finalName; } class DownloadedApk { @@ -38,6 +54,13 @@ class DownloadedApk { DownloadedApk(this.appId, this.file); } +class DownloadedXApkDir { + String appId; + File file; + Directory extracted; + DownloadedXApkDir(this.appId, this.file, this.extracted); +} + List generateStandardVersionRegExStrings() { // TODO: Look into RegEx for non-Latin characters / non-Arabic numerals var basics = [ @@ -69,6 +92,53 @@ List generateStandardVersionRegExStrings() { List standardVersionRegExStrings = generateStandardVersionRegExStrings(); +Set findStandardFormatsForVersion(String version, bool strict) { + // If !strict, even a substring match is valid + Set results = {}; + for (var pattern in standardVersionRegExStrings) { + if (RegExp('${strict ? '^' : ''}$pattern${strict ? '\$' : ''}') + .hasMatch(version)) { + results.add(pattern); + } + } + return results; +} + +moveStrToEnd(List arr, String str, {String? strB}) { + String? temp; + arr.removeWhere((element) { + bool res = element == str || element == strB; + if (res) { + temp = element; + } + return res; + }); + if (temp != null) { + arr = [...arr, temp!]; + } + return arr; +} + +List> moveStrToEndMapEntryWithCount( + List> arr, MapEntry str, + {MapEntry? strB}) { + MapEntry? temp; + arr.removeWhere((element) { + bool resA = element.key == str.key; + bool resB = element.key == strB?.key; + if (resA) { + temp = str; + } else if (resB) { + temp = strB; + } + return resA || resB; + }); + if (temp != null) { + arr = [...arr, temp!]; + } + return arr; +} + class AppsProvider with ChangeNotifier { // In memory App state (should always be kept in sync with local storage versions) Map apps = {}; @@ -80,8 +150,12 @@ class AppsProvider with ChangeNotifier { bool isForeground = true; late Stream? foregroundStream; late StreamSubscription? foregroundSubscription; + late Directory APKDir; + late SettingsProvider settingsProvider = SettingsProvider(); - AppsProvider() { + Iterable getAppValues() => apps.values.map((a) => a.deepCopy()); + + AppsProvider({isBg = false}) { // Subscribe to changes in the app foreground status foregroundStream = FGBGEvents.stream.asBroadcastStream(); foregroundSubscription = foregroundStream?.listen((event) async { @@ -89,30 +163,77 @@ class AppsProvider with ChangeNotifier { if (isForeground) await loadApps(); }); () async { - // Load Apps into memory (in background, this is done later instead of in the constructor) - await loadApps(); - // Delete existing APKs - (await getExternalStorageDirectory()) - ?.listSync() - .where((element) => - element.path.endsWith('.apk') || - element.path.endsWith('.apk.part')) - .forEach((apk) { - apk.delete(); - }); + await settingsProvider.initializeSettings(); + var cacheDirs = await getExternalCacheDirectories(); + if (cacheDirs?.isNotEmpty ?? false) { + APKDir = cacheDirs!.first; + } else { + APKDir = + Directory('${(await getExternalStorageDirectory())!.path}/apks'); + if (!APKDir.existsSync()) { + APKDir.createSync(); + } + } + if (!isBg) { + // Load Apps into memory (in background processes, this is done later instead of in the constructor) + await loadApps(); + // Delete any partial APKs (if safe to do so) + var cutoff = DateTime.now().subtract(const Duration(days: 7)); + APKDir.listSync() + .where((element) => + element.path.endsWith('.part') || + element.statSync().modified.isBefore(cutoff)) + .forEach((partialApk) { + if (!areDownloadsRunning()) { + partialApk.delete(recursive: true); + } + }); + } }(); } - downloadFile(String url, String fileName, Function? onProgress, - {bool useExisting = true}) async { - var destDir = (await getExternalStorageDirectory())!.path; - StreamedResponse response = - await Client().send(Request('GET', Uri.parse(url))); - File downloadedFile = File('$destDir/$fileName'); + Future downloadFileWithRetry( + String url, String fileNameNoExt, Function? onProgress, + {bool useExisting = true, + Map? headers, + int retries = 3}) async { + try { + return await downloadFile(url, fileNameNoExt, onProgress, + useExisting: useExisting, headers: headers); + } catch (e) { + if (retries > 0 && e is ClientException) { + await Future.delayed(const Duration(seconds: 5)); + return await downloadFileWithRetry(url, fileNameNoExt, onProgress, + useExisting: useExisting, headers: headers, retries: (retries - 1)); + } else { + rethrow; + } + } + } + + Future downloadFile( + String url, String fileNameNoExt, Function? onProgress, + {bool useExisting = true, Map? headers}) async { + var destDir = APKDir.path; + var req = Request('GET', Uri.parse(url)); + if (headers != null) { + req.headers.addAll(headers); + } + var client = http.Client(); + StreamedResponse response = await client.send(req); + String ext = + response.headers['content-disposition']?.split('.').last ?? 'apk'; + if (ext.endsWith('"') || ext.endsWith("other")) { + ext = ext.substring(0, ext.length - 1); + } + if (url.toLowerCase().endsWith('.apk') && ext != 'apk') { + ext = 'apk'; + } + File downloadedFile = File('$destDir/$fileNameNoExt.$ext'); if (!(downloadedFile.existsSync() && useExisting)) { File tempDownloadedFile = File('${downloadedFile.path}.part'); if (tempDownloadedFile.existsSync()) { - tempDownloadedFile.deleteSync(); + tempDownloadedFile.deleteSync(recursive: true); } var length = response.contentLength; var received = 0; @@ -132,65 +253,124 @@ class AppsProvider with ChangeNotifier { onProgress(progress); } if (response.statusCode != 200) { - tempDownloadedFile.deleteSync(); + tempDownloadedFile.deleteSync(recursive: true); throw response.reasonPhrase ?? tr('unexpectedError'); } tempDownloadedFile.renameSync(downloadedFile.path); + } else { + client.close(); } return downloadedFile; } - Future downloadApp(App app, BuildContext? context) async { - var fileName = - '${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk'; - String downloadUrl = await SourceProvider() - .getSource(app.url) - .apkUrlPrefetchModifier(app.apkUrls[app.preferredApkIndex]); - NotificationsProvider? notificationsProvider = - context?.read(); - var notif = DownloadNotification(app.name, 100); - notificationsProvider?.cancel(notif.id); - int? prevProg; - File downloadedFile = - await downloadFile(downloadUrl, fileName, (double? progress) { - int? prog = progress?.ceil(); - if (apps[app.id] != null) { - apps[app.id]!.downloadProgress = progress; - notifyListeners(); - } - notif = DownloadNotification(app.name, prog ?? 100); - if (prog != null && prevProg != prog) { - notificationsProvider?.notify(notif); - } - prevProg = prog; - }); - notificationsProvider?.cancel(notif.id); - // Delete older versions of the APK if any - for (var file in downloadedFile.parent.listSync()) { - var fn = file.path.split('/').last; - if (fn.startsWith('${app.id}-') && - fn.endsWith('.apk') && - fn != fileName) { - file.delete(); - } - } + Future handleAPKIDChange(App app, PackageInfo? newInfo, + File downloadedFile, String downloadUrl) async { // If the APK package ID is different from the App ID, it is either new (using a placeholder ID) or the ID has changed // The former case should be handled (give the App its real ID), the latter is a security issue - var newInfo = await PackageArchiveInfo.fromPath(downloadedFile.path); - if (app.id != newInfo.packageName) { - if (apps[app.id] != null && !SourceProvider().isTempId(app.id)) { - throw IDChangedError(); + var isTempIdBool = isTempId(app); + if (newInfo != null) { + if (app.id != newInfo.packageName) { + if (apps[app.id] != null && !isTempIdBool && !app.allowIdChange) { + throw IDChangedError(newInfo.packageName!); + } + var idChangeWasAllowed = app.allowIdChange; + app.allowIdChange = false; + var originalAppId = app.id; + app.id = newInfo.packageName!; + downloadedFile = downloadedFile.renameSync( + '${downloadedFile.parent.path}/${app.id}-${downloadUrl.hashCode}.${downloadedFile.path.split('.').last}'); + if (apps[originalAppId] != null) { + await removeApps([originalAppId]); + await saveApps([app], + onlyIfExists: !isTempIdBool && !idChangeWasAllowed); + } } - var originalAppId = app.id; - app.id = newInfo.packageName; - downloadedFile = downloadedFile.renameSync( - '${downloadedFile.parent.path}/${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk'); - if (apps[originalAppId] != null) { - await removeApps([originalAppId]); - await saveApps([app]); + } else if (isTempIdBool) { + throw ObtainiumError('Could not get ID from APK'); + } + return downloadedFile; + } + + Future downloadApp(App app, BuildContext? context, + {NotificationsProvider? notificationsProvider}) async { + var notifId = DownloadNotification(app.finalName, 0).id; + if (apps[app.id] != null) { + apps[app.id]!.downloadProgress = 0; + notifyListeners(); + } + try { + AppSource source = SourceProvider() + .getSource(app.url, overrideSource: app.overrideSource); + String downloadUrl = await source.apkUrlPrefetchModifier( + app.apkUrls[app.preferredApkIndex].value, app.url); + var notif = DownloadNotification(app.finalName, 100); + notificationsProvider?.cancel(notif.id); + int? prevProg; + var fileNameNoExt = '${app.id}-${downloadUrl.hashCode}'; + var headers = await source.getRequestHeaders( + additionalSettings: app.additionalSettings, forAPKDownload: true); + var downloadedFile = await downloadFileWithRetry( + downloadUrl, fileNameNoExt, + headers: headers, (double? progress) { + int? prog = progress?.ceil(); + if (apps[app.id] != null) { + apps[app.id]!.downloadProgress = progress; + notifyListeners(); + } + notif = DownloadNotification(app.finalName, prog ?? 100); + if (prog != null && prevProg != prog) { + notificationsProvider?.notify(notif); + } + prevProg = prog; + }); + // Set to 90 for remaining steps, will make null in 'finally' + if (apps[app.id] != null) { + apps[app.id]!.downloadProgress = -1; + notifyListeners(); + notif = DownloadNotification(app.finalName, -1); + notificationsProvider?.notify(notif); + } + PackageInfo? newInfo; + var isAPK = downloadedFile.path.toLowerCase().endsWith('.apk'); + Directory? xapkDir; + if (isAPK) { + newInfo = await pm.getPackageArchiveInfo( + archiveFilePath: downloadedFile.path); + } else { + // Assume XAPK + String xapkDirPath = '${downloadedFile.path}-dir'; + await unzipFile(downloadedFile.path, '${downloadedFile.path}-dir'); + xapkDir = Directory(xapkDirPath); + var apks = xapkDir + .listSync() + .where((e) => e.path.toLowerCase().endsWith('.apk')) + .toList(); + newInfo = + await pm.getPackageArchiveInfo(archiveFilePath: apks.first.path); + } + downloadedFile = + await handleAPKIDChange(app, newInfo, downloadedFile, downloadUrl); + // Delete older versions of the file if any + for (var file in downloadedFile.parent.listSync()) { + var fn = file.path.split('/').last; + if (fn.startsWith('${app.id}-') && + FileSystemEntity.isFileSync(file.path) && + file.path != downloadedFile.path) { + file.delete(recursive: true); + } + } + if (isAPK) { + return DownloadedApk(app.id, downloadedFile); + } else { + return DownloadedXApkDir(app.id, downloadedFile, xapkDir!); + } + } finally { + notificationsProvider?.cancel(notifId); + if (apps[app.id] != null) { + apps[app.id]!.downloadProgress = null; + notifyListeners(); } } - return DownloadedApk(app.id, downloadedFile); } bool areDownloadsRunning() => apps.values @@ -198,16 +378,42 @@ class AppsProvider with ChangeNotifier { .isNotEmpty; Future canInstallSilently(App app) async { - return false; - // TODO: Uncomment the below if silent updates are ever figured out - // // NOTE: This is unreliable - try to get from OS in the future - // if (app.apkUrls.length > 1) { - // return false; - // } - // var osInfo = await DeviceInfoPlugin().androidInfo; - // return app.installedVersion != null && - // osInfo.version.sdkInt >= 30 && - // osInfo.version.release.compareTo('12') >= 0; + if (app.id == obtainiumId) { + return false; + } + if (!settingsProvider.enableBackgroundUpdates) { + return false; + } + if (app.additionalSettings['exemptFromBackgroundUpdates'] == true) { + return false; + } + if (app.apkUrls.length > 1) { + // Manual API selection means silent install is not possible + return false; + } + + var osInfo = await DeviceInfoPlugin().androidInfo; + String? installerPackageName; + try { + installerPackageName = osInfo.version.sdkInt >= 30 + ? (await pm.getInstallSourceInfo(packageName: app.id)) + ?.installingPackageName + : (await pm.getInstallerPackageName(packageName: app.id)); + } catch (e) { + // Probably not installed - ignore + } + if (installerPackageName != obtainiumId) { + // If we did not install the app (or it isn't installed), silent install is not possible + return false; + } + int? targetSDK = + (await getInstalledInfo(app.id))?.applicationInfo?.targetSdkVersion; + + // The OS must also be new enough and the APK should target a new enough API + return osInfo.version.sdkInt >= 31 && + targetSDK != null && + targetSDK >= // https://developer.android.com/reference/android/content/pm/PackageInstaller.SessionParams#setRequireUserAction(int) + (osInfo.version.sdkInt - 3); } Future waitForUserToReturnToForeground(BuildContext context) async { @@ -221,47 +427,119 @@ class AppsProvider with ChangeNotifier { } } - Future canDowngradeApps() async { + Future canDowngradeApps() async => + (await getInstalledInfo('com.berdik.letmedowngrade')) != null; + + Future unzipFile(String filePath, String destinationPath) async { + await ZipFile.extractToDirectory( + zipFile: File(filePath), destinationDir: Directory(destinationPath)); + } + + Future installXApkDir(DownloadedXApkDir dir, + {bool needsBGWorkaround = false}) async { + // We don't know which APKs in an XAPK are supported by the user's device + // So we try installing all of them and assume success if at least one installed + // If 0 APKs installed, throw the first install error encountered try { - await InstalledApps.getAppInfo('com.berdik.letmedowngrade'); - return true; - } catch (e) { - return false; + var somethingInstalled = false; + MultiAppMultiError errors = MultiAppMultiError(); + for (var file in dir.extracted + .listSync(recursive: true, followLinks: false) + .whereType()) { + if (file.path.toLowerCase().endsWith('.apk')) { + try { + somethingInstalled = somethingInstalled || + await installApk(DownloadedApk(dir.appId, file), + needsBGWorkaround: needsBGWorkaround); + } catch (e) { + logs.add( + 'Could not install APK from XAPK \'${file.path}\': ${e.toString()}'); + errors.add(dir.appId, e, appName: apps[dir.appId]?.name); + } + } else if (file.path.toLowerCase().endsWith('.obb')) { + await moveObbFile(file, dir.appId); + } + } + if (somethingInstalled) { + dir.file.delete(recursive: true); + } else if (errors.idsByErrorString.isNotEmpty) { + throw errors; + } + } finally { + dir.extracted.delete(recursive: true); } } - // Unfortunately this 'await' does not actually wait for the APK to finish installing - // So we only know that the install prompt was shown, but the user could still cancel w/o us knowing - // If appropriate criteria are met, the update (never a fresh install) happens silently in the background - // But even then, we don't know if it actually succeeded - Future installApk(DownloadedApk file) async { - var newInfo = await PackageArchiveInfo.fromPath(file.file.path); - AppInfo? appInfo; - try { - appInfo = await InstalledApps.getAppInfo(apps[file.appId]!.app.id); - } catch (e) { - // OK - } + Future installApk(DownloadedApk file, + {bool needsBGWorkaround = false}) async { + var newInfo = + await pm.getPackageArchiveInfo(archiveFilePath: file.file.path); + PackageInfo? appInfo = await getInstalledInfo(apps[file.appId]!.app.id); if (appInfo != null && - int.parse(newInfo.buildNumber) < appInfo.versionCode! && + newInfo!.versionCode! < appInfo.versionCode! && !(await canDowngradeApps())) { throw DowngradeError(); } - await InstallPlugin.installApk(file.file.path, 'dev.imranr.obtainium'); - apps[file.appId]!.app.installedVersion = - apps[file.appId]!.app.latestVersion; - // Don't correct install status as installation may not be done yet - await saveApps([apps[file.appId]!.app], - attemptToCorrectInstallStatus: false); + if (needsBGWorkaround) { + // The below 'await' will never return if we are in a background process + // To work around this, we should assume the install will be successful + // So we update the app's installed version first as we will never get to the later code + // We can't conditionally get rid of the 'await' as this causes install fails (BG process times out) - see #896 + // TODO: When fixed, update this function and the calls to it accordingly + apps[file.appId]!.app.installedVersion = + apps[file.appId]!.app.latestVersion; + await saveApps([apps[file.appId]!.app], + attemptToCorrectInstallStatus: false); + } + int? code = + await AndroidPackageInstaller.installApk(apkFilePath: file.file.path); + bool installed = false; + if (code != null && code != 0 && code != 3) { + throw InstallError(code); + } else if (code == 0) { + installed = true; + apps[file.appId]!.app.installedVersion = + apps[file.appId]!.app.latestVersion; + file.file.delete(recursive: true); + } + await saveApps([apps[file.appId]!.app]); + return installed; } - Future confirmApkUrl(App app, BuildContext? context) async { + Future moveObbFile(File file, String appId) async { + if (!file.path.toLowerCase().endsWith('.obb')) return; + + // TODO: Does not support Android 11+ + if ((await DeviceInfoPlugin().androidInfo).version.sdkInt <= 29) { + await Permission.storage.request(); + } + + String obbDirPath = "/storage/emulated/0/Android/obb/$appId"; + Directory(obbDirPath).createSync(recursive: true); + + String obbFileName = file.path.split("/").last; + await file.copy("$obbDirPath/$obbFileName"); + } + + void uninstallApp(String appId) async { + var intent = AndroidIntent( + action: 'android.intent.action.DELETE', + data: 'package:$appId', + flags: [Flag.FLAG_ACTIVITY_NEW_TASK], + package: 'vnd.android.package-archive'); + await intent.launch(); + } + + Future?> confirmApkUrl( + App app, BuildContext? context) async { // If the App has more than one APK, the user should pick one (if context provided) - String? apkUrl = app.apkUrls[app.preferredApkIndex]; + MapEntry? apkUrl = + app.apkUrls[app.preferredApkIndex >= 0 ? app.preferredApkIndex : 0]; // get device supported architecture List archs = (await DeviceInfoPlugin().androidInfo).supportedAbis; if (app.apkUrls.length > 1 && context != null) { + // ignore: use_build_context_synchronously apkUrl = await showDialog( context: context, builder: (BuildContext ctx) { @@ -279,15 +557,18 @@ class AppsProvider with ChangeNotifier { // If the picked APK comes from an origin different from the source, get user confirmation (if context provided) if (apkUrl != null && - getHost(apkUrl) != getHost(app.url) && + getHost(apkUrl.value) != getHost(app.url) && context != null) { - if (await showDialog( - context: context, - builder: (BuildContext ctx) { - return APKOriginWarningDialog( - sourceUrl: app.url, apkUrl: apkUrl!); - }) != - true) { + // ignore: use_build_context_synchronously + if (!(settingsProvider.hideAPKOriginWarning) && + // ignore: use_build_context_synchronously + await showDialog( + context: context, + builder: (BuildContext ctx) { + return APKOriginWarningDialog( + sourceUrl: app.url, apkUrl: apkUrl!.value); + }) != + true) { apkUrl = null; } } @@ -300,7 +581,10 @@ class AppsProvider with ChangeNotifier { // If user input is needed and the App is in the background, a notification is sent to get the user's attention // Returns an array of Ids for Apps that were successfully downloaded, regardless of installation result Future> downloadAndInstallLatestApps( - List appIds, BuildContext? context) async { + List appIds, BuildContext? context, + {NotificationsProvider? notificationsProvider}) async { + notificationsProvider = + notificationsProvider ?? context?.read(); List appsToInstall = []; List trackOnlyAppsToUpdate = []; // For all specified Apps, filter out those for which: @@ -310,14 +594,19 @@ class AppsProvider with ChangeNotifier { if (apps[id] == null) { throw ObtainiumError(tr('appNotFound')); } - String? apkUrl; + MapEntry? apkUrl; var trackOnly = apps[id]!.app.additionalSettings['trackOnly'] == true; if (!trackOnly) { apkUrl = await confirmApkUrl(apps[id]!.app, context); } if (apkUrl != null) { - int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl); - if (urlInd != apps[id]!.app.preferredApkIndex) { + int urlInd = apps[id]! + .app + .apkUrls + .map((e) => e.value) + .toList() + .indexOf(apkUrl.value); + if (urlInd >= 0 && urlInd != apps[id]!.app.preferredApkIndex) { apps[id]!.app.preferredApkIndex = urlInd; await saveApps([apps[id]!.app]); } @@ -335,295 +624,431 @@ class AppsProvider with ChangeNotifier { a.installedVersion = a.latestVersion; return a; }).toList()); - // Download APKs for all Apps to be installed + + // Prepare to download+install Apps MultiAppMultiError errors = MultiAppMultiError(); - List downloadedFiles = - await Future.wait(appsToInstall.map((id) async { + List installedIds = []; + + // Move Obtainium to the end of the line (let all other apps update first) + appsToInstall = + moveStrToEnd(appsToInstall, obtainiumId, strB: obtainiumTempId); + + for (var id in appsToInstall) { try { - return await downloadApp(apps[id]!.app, context); - } catch (e) { - errors.add(id, e.toString()); - } - return null; - })); - downloadedFiles = - downloadedFiles.where((element) => element != null).toList(); - // Separate the Apps to install into silent and regular lists - List silentUpdates = []; - List regularInstalls = []; - for (var f in downloadedFiles) { - bool willBeSilent = await canInstallSilently(apps[f!.appId]!.app); - if (willBeSilent) { - silentUpdates.add(f); - } else { - regularInstalls.add(f); - } - } - - // Move everything to the regular install list (since silent updates don't currently work) - // TODO: Remove this when silent updates work - regularInstalls.addAll(silentUpdates); - - // If Obtainium is being installed, it should be the last one - List moveObtainiumToStart(List items) { - DownloadedApk? temp; - items.removeWhere((element) { - bool res = - element.appId == obtainiumId || element.appId == obtainiumTempId; - if (res) { - temp = element; + var downloadedArtifact = + // ignore: use_build_context_synchronously + await downloadApp(apps[id]!.app, context, + notificationsProvider: notificationsProvider); + DownloadedApk? downloadedFile; + DownloadedXApkDir? downloadedDir; + if (downloadedArtifact is DownloadedApk) { + downloadedFile = downloadedArtifact; + } else { + downloadedDir = downloadedArtifact as DownloadedXApkDir; } - return res; - }); - if (temp != null) { - items = [temp!, ...items]; - } - return items; - } - - silentUpdates = moveObtainiumToStart(silentUpdates); - regularInstalls = moveObtainiumToStart(regularInstalls); - - // // Install silent updates (uncomment when it works - TODO) - // for (var u in silentUpdates) { - // await installApk(u, silent: true); // Would need to add silent option - // } - - // Do regular installs - if (regularInstalls.isNotEmpty && context != null) { - // ignore: use_build_context_synchronously - await waitForUserToReturnToForeground(context); - for (var i in regularInstalls) { + var appId = downloadedFile?.appId ?? downloadedDir!.appId; + bool willBeSilent = await canInstallSilently(apps[appId]!.app); + if (!(await settingsProvider.getInstallPermission(enforce: false))) { + throw ObtainiumError(tr('cancelled')); + } + if (!willBeSilent && context != null) { + // ignore: use_build_context_synchronously + await waitForUserToReturnToForeground(context); + } + apps[id]?.downloadProgress = -1; + notifyListeners(); try { - await installApk(i); - } catch (e) { - errors.add(i.appId, e.toString()); + if (downloadedFile != null) { + if (willBeSilent && context == null) { + installApk(downloadedFile, needsBGWorkaround: true); + } else { + await installApk(downloadedFile); + } + } else { + if (willBeSilent && context == null) { + installXApkDir(downloadedDir!, needsBGWorkaround: true); + } else { + await installXApkDir(downloadedDir!); + } + } + if (willBeSilent && context == null) { + notificationsProvider?.notify(SilentUpdateAttemptNotification( + [apps[appId]!.app], + id: appId.hashCode)); + } + } finally { + apps[id]?.downloadProgress = null; + notifyListeners(); } + installedIds.add(id); + } catch (e) { + errors.add(id, e, appName: apps[id]?.name); } } - if (errors.content.isNotEmpty) { + if (errors.idsByErrorString.isNotEmpty) { throw errors; } - NotificationsProvider().cancel(UpdateNotification([]).id); - - return downloadedFiles.map((e) => e!.appId).toList(); + return installedIds; } Future getAppsDir() async { - Directory appsDir = Directory( - '${(await getExternalStorageDirectory())?.path as String}/app_data'); + Directory appsDir = + Directory('${(await getExternalStorageDirectory())!.path}/app_data'); if (!appsDir.existsSync()) { appsDir.createSync(); } return appsDir; } - Future getInstalledInfo(String? packageName) async { + Future getInstalledInfo(String? packageName) async { if (packageName != null) { try { - return await InstalledApps.getAppInfo(packageName); + return await pm.getPackageInfo(packageName: packageName); } catch (e) { - // OK + print(e); // OK } } return null; } - Future doesInstalledAppsPluginWork() async { - bool res = false; - try { - res = (await InstalledApps.getAppInfo(obtainiumId)).versionName != null; - } catch (e) { - // + bool isVersionDetectionPossible(AppInMemory? app) { + if (app?.app == null) { + return false; } - if (!res) { - logs.add(tr('versionCorrectionDisabled')); - } - return res; + var naiveStandardVersionDetection = + app!.app.additionalSettings['naiveStandardVersionDetection'] == true || + SourceProvider() + .getSource(app.app.url, overrideSource: app.app.overrideSource) + .naiveStandardVersionDetection; + return app.app.additionalSettings['trackOnly'] != true && + app.app.additionalSettings['versionDetection'] != + 'releaseDateAsVersion' && + app.installedInfo?.versionName != null && + app.app.installedVersion != null && + (reconcileVersionDifferences(app.installedInfo!.versionName!, + app.app.installedVersion!) != + null || + naiveStandardVersionDetection); } - // If the App says it is installed but installedInfo is null, set it to not installed - // If there is any other mismatch between installedInfo and installedVersion, try reconciling them intelligently - // If that fails, just set it to the actual version string (all we can do at that point) - // Don't save changes, just return the object if changes were made (else null) - App? getCorrectedInstallStatusAppIfPossible(App app, AppInfo? installedInfo) { + // Given an App and it's on-device info... + // Reconcile unexpected differences between its reported installed version, real installed version, and reported latest version + App? getCorrectedInstallStatusAppIfPossible( + App app, PackageInfo? installedInfo) { var modded = false; var trackOnly = app.additionalSettings['trackOnly'] == true; - var noVersionDetection = - app.additionalSettings['noVersionDetection'] == true; + var versionDetectionIsStandard = + app.additionalSettings['versionDetection'] == + 'standardVersionDetection'; + var naiveStandardVersionDetection = + app.additionalSettings['naiveStandardVersionDetection'] == true || + SourceProvider() + .getSource(app.url, overrideSource: app.overrideSource) + .naiveStandardVersionDetection; + // FIRST, COMPARE THE APP'S REPORTED AND REAL INSTALLED VERSIONS, WHERE ONE IS NULL if (installedInfo == null && app.installedVersion != null && !trackOnly) { + // App says it's installed but isn't really (and isn't track only) - set to not installed app.installedVersion = null; modded = true; } else if (installedInfo?.versionName != null && app.installedVersion == null) { + // App says it's not installed but really is - set to installed and use real package versionName app.installedVersion = installedInfo!.versionName; modded = true; - } else if (installedInfo?.versionName != null && + } + // SECOND, RECONCILE DIFFERENCES BETWEEN THE APP'S REPORTED AND REAL INSTALLED VERSIONS, WHERE NEITHER IS NULL + if (installedInfo?.versionName != null && installedInfo!.versionName != app.installedVersion && - !noVersionDetection) { - String? correctedInstalledVersion = reconcileRealAndInternalVersions( + versionDetectionIsStandard) { + // App's reported version and real version don't match (and it uses standard version detection) + // If they share a standard format (and are still different under it), update the reported version accordingly + var correctedInstalledVersion = reconcileVersionDifferences( installedInfo.versionName!, app.installedVersion!); - if (correctedInstalledVersion != null) { - app.installedVersion = correctedInstalledVersion; + if (correctedInstalledVersion?.key == false) { + app.installedVersion = correctedInstalledVersion!.value; + modded = true; + } else if (naiveStandardVersionDetection) { + app.installedVersion = installedInfo.versionName; modded = true; } } + // THIRD, RECONCILE THE APP'S REPORTED INSTALLED AND LATEST VERSIONS if (app.installedVersion != null && app.installedVersion != app.latestVersion && - !noVersionDetection) { - app.installedVersion = reconcileRealAndInternalVersions( - app.installedVersion!, app.latestVersion, - matchMode: true) ?? - app.installedVersion; + versionDetectionIsStandard) { + // App's reported installed and latest versions don't match (and it uses standard version detection) + // If they share a standard format, make sure the App's reported installed version uses that format + var correctedInstalledVersion = + reconcileVersionDifferences(app.installedVersion!, app.latestVersion); + if (correctedInstalledVersion?.key == true) { + app.installedVersion = correctedInstalledVersion!.value; + modded = true; + } + } + // FOURTH, DISABLE VERSION DETECTION IF ENABLED AND THE REPORTED/REAL INSTALLED VERSIONS ARE NOT STANDARDIZED + if (installedInfo != null && + versionDetectionIsStandard && + !isVersionDetectionPossible( + AppInMemory(app, null, installedInfo, null))) { + app.additionalSettings['versionDetection'] = 'noVersionDetection'; + logs.add('Could not reconcile version formats for: ${app.id}'); modded = true; } + return modded ? app : null; } - String? reconcileRealAndInternalVersions( - String realVersion, String internalVersion, - {bool matchMode = false}) { - // 1. If one or both of these can't be converted to a "standard" format, return null (leave as is) - // 2. If both have a "standard" format under which they are equal, return null (leave as is) - // 3. If both have a "standard" format in common but are unequal, return realVersion (this means it was changed externally) - // If in matchMode, the outcomes of rules 2 and 3 are reversed, and the "real" version is not matched strictly - // Matchmode to be used when comparing internal install version and internal latest version - - bool doStringsMatchUnderRegEx( - String pattern, String value1, String value2) { - var r = RegExp(pattern); - var m1 = r.firstMatch(value1); - var m2 = r.firstMatch(value2); - return m1 != null && m2 != null - ? value1.substring(m1.start, m1.end) == - value2.substring(m2.start, m2.end) - : false; - } - - Set findStandardFormatsForVersion(String version, bool strict) { - Set results = {}; - for (var pattern in standardVersionRegExStrings) { - if (RegExp('${strict ? '^' : ''}$pattern${strict ? '\$' : ''}') - .hasMatch(version)) { - results.add(pattern); - } - } - return results; - } - - var realStandardVersionFormats = - findStandardFormatsForVersion(realVersion, true); - var internalStandardVersionFormats = - findStandardFormatsForVersion(internalVersion, false); + MapEntry? reconcileVersionDifferences( + String templateVersion, String comparisonVersion) { + // Returns null if the versions don't share a common standard format + // Returns if they share a common format and are equal + // Returns if they share a common format but are not equal + // templateVersion must fully match a standard format, while comparisonVersion can have a substring match + var templateVersionFormats = + findStandardFormatsForVersion(templateVersion, true); + var comparisonVersionFormats = + findStandardFormatsForVersion(comparisonVersion, false); var commonStandardFormats = - realStandardVersionFormats.intersection(internalStandardVersionFormats); + templateVersionFormats.intersection(comparisonVersionFormats); if (commonStandardFormats.isEmpty) { - return null; // Incompatible; no "enhanced detection" + return null; } for (String pattern in commonStandardFormats) { - if (doStringsMatchUnderRegEx(pattern, internalVersion, realVersion)) { - return matchMode - ? internalVersion - : null; // Enhanced detection says no change + if (doStringsMatchUnderRegEx( + pattern, comparisonVersion, templateVersion)) { + return MapEntry(true, comparisonVersion); } } - return matchMode - ? null - : realVersion; // Enhanced detection says something changed + return MapEntry(false, templateVersion); } - Future loadApps() async { + bool doStringsMatchUnderRegEx(String pattern, String value1, String value2) { + var r = RegExp(pattern); + var m1 = r.firstMatch(value1); + var m2 = r.firstMatch(value2); + return m1 != null && m2 != null + ? value1.substring(m1.start, m1.end) == + value2.substring(m2.start, m2.end) + : false; + } + + Future loadApps({String? singleId}) async { while (loadingApps) { await Future.delayed(const Duration(microseconds: 1)); } loadingApps = true; notifyListeners(); - List newApps = (await getAppsDir()) - .listSync() - .where((item) => item.path.toLowerCase().endsWith('.json')) - .map((e) => App.fromJson(jsonDecode(File(e.path).readAsStringSync()))) - .toList(); - var idsToDelete = apps.values - .map((e) => e.app.id) - .toSet() - .difference(newApps.map((e) => e.id).toSet()); - for (var id in idsToDelete) { - apps.remove(id); - } var sp = SourceProvider(); List> errors = []; - for (int i = 0; i < newApps.length; i++) { - var info = await getInstalledInfo(newApps[i].id); + List newApps = (await getAppsDir()) // Parse Apps from JSON + .listSync() + .where((item) => item.path.toLowerCase().endsWith('.json')) + .where((item) => + singleId == null || + item.path.split('/').last.toLowerCase() == + '${singleId.toLowerCase()}.json') + .map((e) { try { - sp.getSource(newApps[i].url); - apps[newApps[i].id] = AppInMemory(newApps[i], null, info); - } catch (e) { - errors.add([newApps[i].id, newApps[i].name, e.toString()]); + return App.fromJson(jsonDecode(File(e.path).readAsStringSync())); + } catch (err) { + if (err is FormatException) { + logs.add('Corrupt JSON when loading App (will be ignored): $e'); + e.renameSync('${e.path}.corrupt'); + } else { + rethrow; + } + } + }).toList(); + for (var app in newApps) { + // Put Apps into memory to list them (fast) + if (app != null) { + try { + sp.getSource(app.url, overrideSource: app.overrideSource); + apps.update( + app.id, + (value) => AppInMemory( + app, value.downloadProgress, value.installedInfo, value.icon), + ifAbsent: () => AppInMemory(app, null, null, null)); + } catch (e) { + errors.add([app.id, app.finalName, e.toString()]); + } } } + notifyListeners(); if (errors.isNotEmpty) { removeApps(errors.map((e) => e[0]).toList()); NotificationsProvider().notify( AppsRemovedNotification(errors.map((e) => [e[1], e[2]]).toList())); } - loadingApps = false; - notifyListeners(); - if (await doesInstalledAppsPluginWork()) { - List modifiedApps = []; - for (var app in apps.values) { - var moddedApp = - getCorrectedInstallStatusAppIfPossible(app.app, app.installedInfo); - if (moddedApp != null) { - modifiedApps.add(moddedApp); - } - } - if (modifiedApps.isNotEmpty) { - await saveApps(modifiedApps, attemptToCorrectInstallStatus: false); + + for (var app in apps.values) { + // Get install status and other OS info for each App (slow) + apps[app.app.id]?.installedInfo = await getInstalledInfo(app.app.id); + apps[app.app.id]?.icon = + await apps[app.app.id]?.installedInfo?.applicationInfo?.getAppIcon(); + apps[app.app.id]?.app.name = await (apps[app.app.id] + ?.installedInfo + ?.applicationInfo + ?.getAppLabel()) ?? + app.name; + notifyListeners(); + } + // Reconcile version differences + List modifiedApps = []; + for (var app in apps.values) { + var moddedApp = + getCorrectedInstallStatusAppIfPossible(app.app, app.installedInfo); + if (moddedApp != null) { + modifiedApps.add(moddedApp); } } + if (modifiedApps.isNotEmpty) { + await saveApps(modifiedApps, attemptToCorrectInstallStatus: false); + var removedAppIds = modifiedApps + .where((a) => a.installedVersion == null) + .map((e) => e.id) + .toList(); + // After reconciliation, delete externally uninstalled Apps if needed + if (removedAppIds.isNotEmpty) { + if (settingsProvider.removeOnExternalUninstall) { + await removeApps(removedAppIds); + } + } + } + + loadingApps = false; + notifyListeners(); } Future saveApps(List apps, - {bool attemptToCorrectInstallStatus = true}) async { - attemptToCorrectInstallStatus = - attemptToCorrectInstallStatus && (await doesInstalledAppsPluginWork()); - for (var app in apps) { - AppInfo? info = await getInstalledInfo(app.id); - app.name = info?.name ?? app.name; + {bool attemptToCorrectInstallStatus = true, + bool onlyIfExists = true}) async { + attemptToCorrectInstallStatus = attemptToCorrectInstallStatus; + for (var a in apps) { + var app = a.deepCopy(); + PackageInfo? info = await getInstalledInfo(app.id); + var icon = await info?.applicationInfo?.getAppIcon(); + app.name = await (info?.applicationInfo?.getAppLabel()) ?? app.name; if (attemptToCorrectInstallStatus) { app = getCorrectedInstallStatusAppIfPossible(app, info) ?? app; } - File('${(await getAppsDir()).path}/${app.id}.json') - .writeAsStringSync(jsonEncode(app.toJson())); - this.apps.update( - app.id, (value) => AppInMemory(app, value.downloadProgress, info), - ifAbsent: () => AppInMemory(app, null, info)); + if (!onlyIfExists || this.apps.containsKey(app.id)) { + File('${(await getAppsDir()).path}/${app.id}.json') + .writeAsStringSync(jsonEncode(app.toJson())); + } + try { + this.apps.update(app.id, + (value) => AppInMemory(app, value.downloadProgress, info, icon), + ifAbsent: + onlyIfExists ? null : () => AppInMemory(app, null, info, icon)); + } catch (e) { + if (e is! ArgumentError || e.name != 'key') { + rethrow; + } + } } notifyListeners(); + exportApps(isAuto: true); } Future removeApps(List appIds) async { + var apkFiles = APKDir.listSync(); for (var appId in appIds) { File file = File('${(await getAppsDir()).path}/$appId.json'); if (file.existsSync()) { - file.deleteSync(); + file.deleteSync(recursive: true); } + apkFiles + .where( + (element) => element.path.split('/').last.startsWith('$appId-')) + .forEach((element) { + element.delete(recursive: true); + }); if (apps.containsKey(appId)) { apps.remove(appId); } } if (appIds.isNotEmpty) { notifyListeners(); + exportApps(isAuto: true); } } + Future removeAppsWithModal(BuildContext context, List apps) async { + var showUninstallOption = apps + .where((a) => + a.installedVersion != null && + a.additionalSettings['trackOnly'] != true) + .isNotEmpty; + var values = await showDialog( + context: context, + builder: (BuildContext ctx) { + return GeneratedFormModal( + primaryActionColour: Theme.of(context).colorScheme.error, + title: plural('removeAppQuestion', apps.length), + items: !showUninstallOption + ? [] + : [ + [ + GeneratedFormSwitch('rmAppEntry', + label: tr('removeFromObtainium'), defaultValue: true) + ], + [ + GeneratedFormSwitch('uninstallApp', + label: tr('uninstallFromDevice')) + ] + ], + initValid: true, + ); + }); + if (values != null) { + bool uninstall = values['uninstallApp'] == true && showUninstallOption; + bool remove = values['rmAppEntry'] == true || !showUninstallOption; + if (uninstall) { + for (var i = 0; i < apps.length; i++) { + if (apps[i].installedVersion != null) { + uninstallApp(apps[i].id); + apps[i].installedVersion = null; + } + } + await saveApps(apps, attemptToCorrectInstallStatus: false); + } + if (remove) { + await removeApps(apps.map((e) => e.id).toList()); + } + return uninstall || remove; + } + return false; + } + + Future openAppSettings(String appId) async { + final AndroidIntent intent = AndroidIntent( + action: 'action_application_details_settings', + data: 'package:$appId', + ); + await intent.launch(); + } + + addMissingCategories(SettingsProvider settingsProvider) { + var cats = settingsProvider.categories; + apps.forEach((key, value) { + for (var c in value.app.categories) { + if (!cats.containsKey(c)) { + cats[c] = generateRandomLightColor().value; + } + } + }); + settingsProvider.setCategories(cats, appsProvider: this); + } + Future checkUpdate(String appId) async { App? currentApp = apps[appId]!.app; SourceProvider sourceProvider = SourceProvider(); App newApp = await sourceProvider.getApp( - sourceProvider.getSource(currentApp.url), + sourceProvider.getSource(currentApp.url, + overrideSource: currentApp.overrideSource), currentApp.url, currentApp.additionalSettings, currentApp: currentApp); @@ -634,46 +1059,73 @@ class AppsProvider with ChangeNotifier { return newApp.latestVersion != currentApp.latestVersion ? newApp : null; } + List getAppsSortedByUpdateCheckTime( + {DateTime? ignoreAppsCheckedAfter, + bool onlyCheckInstalledOrTrackOnlyApps = false}) { + List appIds = apps.values + .where((app) => + app.app.lastUpdateCheck == null || + ignoreAppsCheckedAfter == null || + app.app.lastUpdateCheck!.isBefore(ignoreAppsCheckedAfter)) + .where((app) { + if (!onlyCheckInstalledOrTrackOnlyApps) { + return true; + } else { + return app.app.installedVersion != null || + app.app.additionalSettings['trackOnly'] == true; + } + }) + .map((e) => e.app.id) + .toList(); + appIds.sort((a, b) => + (apps[a]!.app.lastUpdateCheck ?? DateTime.fromMicrosecondsSinceEpoch(0)) + .compareTo(apps[b]!.app.lastUpdateCheck ?? + DateTime.fromMicrosecondsSinceEpoch(0))); + return appIds; + } + Future> checkUpdates( {DateTime? ignoreAppsCheckedAfter, - bool throwErrorsForRetry = false}) async { + bool throwErrorsForRetry = false, + List? specificIds, + SettingsProvider? sp}) async { + SettingsProvider settingsProvider = sp ?? this.settingsProvider; List updates = []; MultiAppMultiError errors = MultiAppMultiError(); if (!gettingUpdates) { gettingUpdates = true; try { - List appIds = apps.values - .where((app) => - app.app.lastUpdateCheck == null || - ignoreAppsCheckedAfter == null || - app.app.lastUpdateCheck!.isBefore(ignoreAppsCheckedAfter)) - .map((e) => e.app.id) - .toList(); - appIds.sort((a, b) => (apps[a]!.app.lastUpdateCheck ?? - DateTime.fromMicrosecondsSinceEpoch(0)) - .compareTo(apps[b]!.app.lastUpdateCheck ?? - DateTime.fromMicrosecondsSinceEpoch(0))); - for (int i = 0; i < appIds.length; i++) { + List appIds = getAppsSortedByUpdateCheckTime( + ignoreAppsCheckedAfter: ignoreAppsCheckedAfter, + onlyCheckInstalledOrTrackOnlyApps: + settingsProvider.onlyCheckInstalledOrTrackOnlyApps); + if (specificIds != null) { + appIds = appIds.where((aId) => specificIds.contains(aId)).toList(); + } + await Future.wait(appIds.map((appId) async { App? newApp; try { - newApp = await checkUpdate(appIds[i]); + newApp = await checkUpdate(appId); } catch (e) { if ((e is RateLimitError || e is SocketException) && throwErrorsForRetry) { rethrow; } - errors.add(appIds[i], e.toString()); + errors.add(appId, e, appName: apps[appId]?.name); } if (newApp != null) { updates.add(newApp); } - } + }), eagerError: true); } finally { gettingUpdates = false; } } - if (errors.content.isNotEmpty) { - throw errors; + if (errors.idsByErrorString.isNotEmpty) { + var res = {}; + res['errors'] = errors; + res['updates'] = updates; + throw res; } return updates; } @@ -697,26 +1149,49 @@ class AppsProvider with ChangeNotifier { return updateAppIds; } - Future exportApps() async { - Directory? exportDir = Directory('/storage/emulated/0/Download'); - String path = 'Downloads'; // TODO: See if hardcoding this can be avoided - if (!exportDir.existsSync()) { - exportDir = await getExternalStorageDirectory(); - path = exportDir!.path; - } - if ((await DeviceInfoPlugin().androidInfo).version.sdkInt <= 28) { - if (await Permission.storage.isDenied) { - await Permission.storage.request(); + Future exportApps( + {bool pickOnly = false, isAuto = false, SettingsProvider? sp}) async { + SettingsProvider settingsProvider = sp ?? this.settingsProvider; + var exportDir = await settingsProvider.getExportDir(); + if (isAuto) { + if (settingsProvider.autoExportOnChanges != true) { + return null; } - if (await Permission.storage.isDenied) { - throw ObtainiumError(tr('storagePermissionDenied')); + if (exportDir == null) { + return null; + } + var files = await saf + .listFiles(exportDir, columns: [saf.DocumentFileColumn.id]) + .where((f) => f.uri.pathSegments.last.endsWith('-auto.json')) + .toList(); + if (files.isNotEmpty) { + for (var f in files) { + saf.delete(f.uri); + } } } - File export = File( - '${exportDir.path}/${tr('obtainiumExportHyphenatedLowercase')}-${DateTime.now().millisecondsSinceEpoch}.json'); - export.writeAsStringSync( - jsonEncode(apps.values.map((e) => e.app.toJson()).toList())); - return path; + if (exportDir == null || pickOnly) { + await settingsProvider.pickExportDir(); + exportDir = await settingsProvider.getExportDir(); + } + if (exportDir == null) { + return null; + } + String? returnPath; + if (!pickOnly) { + var result = await saf.createFile(exportDir, + displayName: + '${tr('obtainiumExportHyphenatedLowercase')}-${DateTime.now().toIso8601String().replaceAll(':', '-')}${isAuto ? '-auto' : ''}.json', + mimeType: 'application/json', + bytes: Uint8List.fromList(utf8.encode( + jsonEncode(apps.values.map((e) => e.app.toJson()).toList())))); + if (result == null) { + throw ObtainiumError(tr('unexpectedError')); + } + returnPath = + exportDir.pathSegments.join('/').replaceFirst('tree/primary:', '/'); + } + return returnPath; } Future importApps(String appsJSON) async { @@ -731,7 +1206,7 @@ class AppsProvider with ChangeNotifier { a.installedVersion = apps[a.id]?.app.installedVersion; } } - await saveApps(importedApps); + await saveApps(importedApps, onlyIfExists: false); notifyListeners(); return importedApps.length; } @@ -744,14 +1219,14 @@ class AppsProvider with ChangeNotifier { Future>> addAppsByURL(List urls) async { List results = await SourceProvider().getAppsByURLNaive(urls, - ignoreUrls: apps.values.map((e) => e.app.url).toList()); + alreadyAddedUrls: apps.values.map((e) => e.app.url).toList()); List pps = results[0]; Map errorsMap = results[1]; for (var app in pps) { if (apps.containsKey(app.id)) { errorsMap.addAll({app.id: tr('appAlreadyAdded')}); } else { - await saveApps([app]); + await saveApps([app], onlyIfExists: false); } } List> errors = @@ -764,7 +1239,7 @@ class APKPicker extends StatefulWidget { const APKPicker({super.key, required this.app, this.initVal, this.archs}); final App app; - final String? initVal; + final MapEntry? initVal; final List? archs; @override @@ -772,7 +1247,7 @@ class APKPicker extends StatefulWidget { } class _APKPickerState extends State { - String? apkUrl; + MapEntry? apkUrl; @override Widget build(BuildContext context) { @@ -781,19 +1256,17 @@ class _APKPickerState extends State { scrollable: true, title: Text(tr('pickAnAPK')), content: Column(children: [ - Text(tr('appHasMoreThanOnePackage', args: [widget.app.name])), + Text(tr('appHasMoreThanOnePackage', args: [widget.app.finalName])), const SizedBox(height: 16), ...widget.app.apkUrls.map( (u) => RadioListTile( - title: Text(Uri.parse(u) - .pathSegments - .where((element) => element.isNotEmpty) - .last), - value: u, - groupValue: apkUrl, + title: Text(u.key), + value: u.value, + groupValue: apkUrl!.value, onChanged: (String? val) { setState(() { - apkUrl = val; + apkUrl = + widget.app.apkUrls.where((e) => e.value == val).first; }); }), ), @@ -865,3 +1338,286 @@ class _APKOriginWarningDialogState extends State { ); } } + +/// Background updater function +/// +/// @param List>? toCheck: The appIds to check for updates (with the number of previous attempts made per appid) (defaults to all apps) +/// +/// @param List? toInstall: The appIds to attempt to update (if empty - which is the default - all pending updates are taken) +/// +/// When toCheck is empty, the function is in "install mode" (else it is in "update mode"). +/// In update mode, all apps in toCheck are checked for updates (in parallel). +/// If an update is available and it cannot be installed silently, the user is notified of the available update. +/// If there are any errors, the task is run again for the remaining apps after a few minutes (based on the error with the longest retry interval). +/// Any app that has reached it's retry limit, the user is notified that it could not be checked. +/// +/// Once all update checks are complete, the task is run again in install mode. +/// In this mode, all pending silent updates are downloaded and installed in the background (serially - one at a time). +/// If there is an error, the offending app is moved to the back of the line of remaining apps, and the task is retried. +/// If an app repeatedly fails to install up to its retry limit, the user is notified. +/// +@pragma('vm:entry-point') +Future bgUpdateCheck(int taskId, Map? params) async { + WidgetsFlutterBinding.ensureInitialized(); + await EasyLocalization.ensureInitialized(); + await AndroidAlarmManager.initialize(); + await loadTranslations(); + + LogsProvider logs = LogsProvider(); + NotificationsProvider notificationsProvider = NotificationsProvider(); + AppsProvider appsProvider = AppsProvider(isBg: true); + await appsProvider.loadApps(); + + int maxAttempts = 4; + + params ??= {}; + if (params['toCheck'] == null) { + appsProvider.settingsProvider.lastBGCheckTime = DateTime.now(); + } + List> toCheck = >[ + ...(params['toCheck'] + ?.map((entry) => MapEntry( + entry['key'] as String, entry['value'] as int)) + .toList() ?? + appsProvider + .getAppsSortedByUpdateCheckTime( + onlyCheckInstalledOrTrackOnlyApps: appsProvider + .settingsProvider.onlyCheckInstalledOrTrackOnlyApps) + .map((e) => MapEntry(e, 0))) + ]; + List> toInstall = >[ + ...(params['toInstall'] + ?.map((entry) => MapEntry( + entry['key'] as String, entry['value'] as int)) + .toList() ?? + (>>[])) + ]; + + var netResult = await (Connectivity().checkConnectivity()); + + if (netResult == ConnectivityResult.none) { + var networkBasedRetryInterval = 15; + var nextRegularCheck = appsProvider.settingsProvider.lastBGCheckTime + .add(Duration(minutes: appsProvider.settingsProvider.updateInterval)); + var potentialNetworkRetryCheck = + DateTime.now().add(Duration(minutes: networkBasedRetryInterval)); + var shouldRetry = potentialNetworkRetryCheck.isBefore(nextRegularCheck); + logs.add( + 'BG update task $taskId: No network. Will ${shouldRetry ? 'retry in $networkBasedRetryInterval minutes' : 'not retry'}.'); + AndroidAlarmManager.oneShot( + const Duration(minutes: 15), taskId + 1, bgUpdateCheck, + params: { + 'toCheck': toCheck + .map((entry) => {'key': entry.key, 'value': entry.value}) + .toList(), + 'toInstall': toInstall + .map((entry) => {'key': entry.key, 'value': entry.value}) + .toList(), + }); + return; + } + + var networkRestricted = false; + if (appsProvider.settingsProvider.bgUpdatesOnWiFiOnly) { + networkRestricted = (netResult != ConnectivityResult.wifi) && + (netResult != ConnectivityResult.ethernet); + } + + bool installMode = + toCheck.isEmpty; // Task is either in update mode or install mode + + logs.add( + 'BG ${installMode ? 'install' : 'update'} task $taskId: Started (${installMode ? toInstall.length : toCheck.length}).'); + + if (!installMode) { + // If in update mode, we check for updates. + // We divide the results into 4 groups: + // - toNotify - Apps with updates that the user will be notified about (can't be silently installed) + // - toRetry - Apps with update check errors that will be retried in a while + // - toThrow - Apps with update check errors that the user will be notified about (no retry) + // After grouping the updates, we take care of toNotify and toThrow first + // Then if toRetry is not empty, we schedule another update task to run in a while + // If toRetry is empty, we take care of schedule another task that will run in install mode (toCheck is empty) + + // Init. vars. + List updates = []; // All updates found (silent and non-silent) + List toNotify = + []; // All non-silent updates that the user will be notified about + List> toRetry = + []; // All apps that got errors while checking + var retryAfterXSeconds = + 0; // How long to wait until the next attempt (if there are errors) + MultiAppMultiError? + errors; // All errors including those that will lead to a retry + MultiAppMultiError toThrow = + MultiAppMultiError(); // All errors that will not lead to a retry, just a notification + CheckingUpdatesNotification notif = CheckingUpdatesNotification( + plural('apps', toCheck.length)); // The notif. to show while checking + + // Set a bool for when we're no on wifi/wired and the user doesn't want to download apps in that state + var networkRestricted = false; + if (appsProvider.settingsProvider.bgUpdatesOnWiFiOnly) { + var netResult = await (Connectivity().checkConnectivity()); + networkRestricted = (netResult != ConnectivityResult.wifi) && + (netResult != ConnectivityResult.ethernet); + } + + try { + // Check for updates + notificationsProvider.notify(notif, cancelExisting: true); + updates = await appsProvider.checkUpdates( + specificIds: toCheck.map((e) => e.key).toList(), + sp: appsProvider.settingsProvider); + } catch (e) { + // If there were errors, group them into toRetry and toThrow based on max retry count per app + if (e is Map) { + updates = e['updates']; + errors = e['errors']; + errors!.rawErrors.forEach((key, err) { + logs.add( + 'BG update task $taskId: Got error on checking for $key \'${err.toString()}\'.'); + var toCheckApp = toCheck.where((element) => element.key == key).first; + if (toCheckApp.value < maxAttempts) { + toRetry.add(MapEntry(toCheckApp.key, toCheckApp.value + 1)); + // Next task interval is based on the error with the longest retry time + var minRetryIntervalForThisApp = err is RateLimitError + ? (err.remainingMinutes * 60) + : e is ClientException + ? (15 * 60) + : pow(toCheckApp.value + 1, 2).toInt(); + if (minRetryIntervalForThisApp > retryAfterXSeconds) { + retryAfterXSeconds = minRetryIntervalForThisApp; + } + } else { + toThrow.add(key, err, appName: errors?.appIdNames[key]); + } + }); + } else { + // We don't expect to ever get here in any situation so no need to catch (but log it in case) + logs.add('Fatal error in BG update task: ${e.toString()}'); + rethrow; + } + } finally { + notificationsProvider.cancel(notif.id); + } + + // Filter out updates that will be installed silently (the rest go into toNotify) + for (var i = 0; i < updates.length; i++) { + if (networkRestricted || + !(await appsProvider.canInstallSilently(updates[i]))) { + if (updates[i].additionalSettings['skipUpdateNotifications'] != true) { + toNotify.add(updates[i]); + } + } + } + + // Send the update notification + if (toNotify.isNotEmpty) { + notificationsProvider.notify(UpdateNotification(toNotify)); + } + + // Send the error notifications (grouped by error string) + if (toThrow.rawErrors.isNotEmpty) { + for (var element in toThrow.idsByErrorString.entries) { + notificationsProvider.notify(ErrorCheckingUpdatesNotification( + errors!.errorsAppsString(element.key, element.value), + id: Random().nextInt(10000))); + } + } + + // if there are update checks to retry, schedule a retry task + if (toRetry.isNotEmpty) { + logs.add( + 'BG update task $taskId: Will retry in $retryAfterXSeconds seconds.'); + AndroidAlarmManager.oneShot( + Duration(seconds: retryAfterXSeconds), taskId + 1, bgUpdateCheck, + params: { + 'toCheck': toRetry + .map((entry) => {'key': entry.key, 'value': entry.value}) + .toList(), + 'toInstall': toInstall + .map((entry) => {'key': entry.key, 'value': entry.value}) + .toList(), + }); + } else { + // If there are no more update checks, schedule an install task + logs.add( + 'BG update task $taskId: Done. Scheduling install task to run immediately.'); + AndroidAlarmManager.oneShot( + const Duration(minutes: 0), taskId + 1, bgUpdateCheck, + params: { + 'toCheck': [], + 'toInstall': toInstall + .map((entry) => {'key': entry.key, 'value': entry.value}) + .toList() + }); + } + } else { + // In install mode... + // If you haven't explicitly been given updates to install (which is the case for new tasks), grab all available silent updates + if (toInstall.isEmpty && !networkRestricted) { + var temp = appsProvider.findExistingUpdates(installedOnly: true); + for (var i = 0; i < temp.length; i++) { + if (await appsProvider + .canInstallSilently(appsProvider.apps[temp[i]]!.app)) { + toInstall.add(MapEntry(temp[i], 0)); + } + } + } + var didCompleteInstalling = false; + var tempObtArr = toInstall.where((element) => element.key == obtainiumId); + if (tempObtArr.isNotEmpty) { + // Move obtainium to the end of the list as it must always install last + var obt = tempObtArr.first; + toInstall = moveStrToEndMapEntryWithCount(toInstall, obt); + } + // Loop through all updates and install each + for (var i = 0; i < toInstall.length; i++) { + var appId = toInstall[i].key; + var retryCount = toInstall[i].value; + try { + logs.add( + 'BG install task $taskId: Attempting to update $appId in the background.'); + await appsProvider.downloadAndInstallLatestApps([appId], null, + notificationsProvider: notificationsProvider); + await Future.delayed(const Duration( + seconds: + 5)); // Just in case task ending causes install fail (not clear) + if (i == (toCheck.length - 1)) { + didCompleteInstalling = true; + } + } catch (e) { + // If you got an error, move the offender to the back of the line (increment their fail count) and schedule another task to continue installing shortly + logs.add( + 'BG install task $taskId: Got error on updating $appId \'${e.toString()}\'.'); + if (retryCount < maxAttempts) { + var remainingSeconds = retryCount; + logs.add( + 'BG install task $taskId: Will continue in $remainingSeconds seconds (with $appId moved to the end of the line).'); + var remainingToInstall = moveStrToEndMapEntryWithCount( + toInstall.sublist(i), MapEntry(appId, retryCount + 1)); + AndroidAlarmManager.oneShot( + Duration(seconds: remainingSeconds), taskId + 1, bgUpdateCheck, + params: { + 'toCheck': toCheck + .map((entry) => {'key': entry.key, 'value': entry.value}) + .toList(), + 'toInstall': remainingToInstall + .map((entry) => {'key': entry.key, 'value': entry.value}) + .toList(), + }); + break; + } else { + // If the offender has reached its fail limit, notify the user and remove it from the list (task can continue) + toInstall.removeAt(i); + i--; + notificationsProvider + .notify(ErrorCheckingUpdatesNotification(e.toString())); + } + } + } + if (didCompleteInstalling || toInstall.isEmpty) { + logs.add('BG install task $taskId: Done.'); + } + } +} diff --git a/lib/providers/notifications_provider.dart b/lib/providers/notifications_provider.dart index b68a669..851fb18 100644 --- a/lib/providers/notifications_provider.dart +++ b/lib/providers/notifications_provider.dart @@ -22,52 +22,82 @@ class ObtainiumNotification { } class UpdateNotification extends ObtainiumNotification { - UpdateNotification(List updates) + UpdateNotification(List updates, {int? id}) : super( - 2, + id ?? 2, tr('updatesAvailable'), '', 'UPDATES_AVAILABLE', - tr('updatesAvailable'), + tr('updatesAvailableNotifChannel'), tr('updatesAvailableNotifDescription'), Importance.max) { message = updates.isEmpty ? tr('noNewUpdates') : updates.length == 1 - ? tr('xHasAnUpdate', args: [updates[0].name]) + ? tr('xHasAnUpdate', args: [updates[0].finalName]) : plural('xAndNMoreUpdatesAvailable', updates.length - 1, - args: [updates[0].name, (updates.length - 1).toString()]); + args: [updates[0].finalName, (updates.length - 1).toString()]); } } class SilentUpdateNotification extends ObtainiumNotification { - SilentUpdateNotification(List updates) - : super(3, tr('appsUpdated'), '', 'APPS_UPDATED', tr('appsUpdated'), - tr('appsUpdatedNotifDescription'), Importance.defaultImportance) { + SilentUpdateNotification(List updates, {int? id}) + : super( + id ?? 3, + tr('appsUpdated'), + '', + 'APPS_UPDATED', + tr('appsUpdatedNotifChannel'), + tr('appsUpdatedNotifDescription'), + Importance.defaultImportance) { message = updates.length == 1 ? tr('xWasUpdatedToY', - args: [updates[0].name, updates[0].latestVersion]) + args: [updates[0].finalName, updates[0].latestVersion]) : plural('xAndNMoreUpdatesInstalled', updates.length - 1, - args: [updates[0].name, (updates.length - 1).toString()]); + args: [updates[0].finalName, (updates.length - 1).toString()]); + } +} + +class SilentUpdateAttemptNotification extends ObtainiumNotification { + SilentUpdateAttemptNotification(List updates, {int? id}) + : super( + id ?? 3, + tr('appsPossiblyUpdated'), + '', + 'APPS_POSSIBLY_UPDATED', + tr('appsPossiblyUpdatedNotifChannel'), + tr('appsPossiblyUpdatedNotifDescription'), + Importance.defaultImportance) { + message = updates.length == 1 + ? tr('xWasPossiblyUpdatedToY', + args: [updates[0].finalName, updates[0].latestVersion]) + : plural('xAndNMoreUpdatesPossiblyInstalled', updates.length - 1, + args: [updates[0].finalName, (updates.length - 1).toString()]); } } class ErrorCheckingUpdatesNotification extends ObtainiumNotification { - ErrorCheckingUpdatesNotification(String error) + ErrorCheckingUpdatesNotification(String error, {int? id}) : super( - 5, + id ?? 5, tr('errorCheckingUpdates'), error, 'BG_UPDATE_CHECK_ERROR', - tr('errorCheckingUpdates'), + tr('errorCheckingUpdatesNotifChannel'), tr('errorCheckingUpdatesNotifDescription'), Importance.high); } class AppsRemovedNotification extends ObtainiumNotification { AppsRemovedNotification(List> namedReasons) - : super(6, tr('appsRemoved'), '', 'APPS_REMOVED', tr('appsRemoved'), - tr('appsRemovedNotifDescription'), Importance.max) { + : super( + 6, + tr('appsRemoved'), + '', + 'APPS_REMOVED', + tr('appsRemovedNotifChannel'), + tr('appsRemovedNotifDescription'), + Importance.max) { message = ''; for (var r in namedReasons) { message += '${tr('xWasRemovedDueToErrorY', args: [r[0], r[1]])} \n'; @@ -83,7 +113,7 @@ class DownloadNotification extends ObtainiumNotification { tr('downloadingX', args: [appName]), '', 'APP_DOWNLOADING', - tr('downloadingX', args: [tr('app')]), + tr('downloadingXNotifChannel', args: [tr('app')]), tr('downloadNotifDescription'), Importance.low, onlyAlertOnce: true, @@ -95,18 +125,21 @@ final completeInstallationNotification = ObtainiumNotification( tr('completeAppInstallation'), tr('obtainiumMustBeOpenToInstallApps'), 'COMPLETE_INSTALL', - tr('completeAppInstallation'), + tr('completeAppInstallationNotifChannel'), tr('completeAppInstallationNotifDescription'), Importance.max); -final checkingUpdatesNotification = ObtainiumNotification( - 4, - tr('checkingForUpdates'), - '', - 'BG_UPDATE_CHECK', - tr('checkingForUpdates'), - tr('checkingForUpdatesNotifDescription'), - Importance.min); +class CheckingUpdatesNotification extends ObtainiumNotification { + CheckingUpdatesNotification(String appName) + : super( + 4, + tr('checkingForUpdates'), + appName, + 'BG_UPDATE_CHECK', + tr('checkingForUpdatesNotifChannel'), + tr('checkingForUpdatesNotifDescription'), + Importance.min); +} class NotificationsProvider { FlutterLocalNotificationsPlugin notifications = @@ -167,7 +200,8 @@ class NotificationsProvider { progress: progPercent ?? 0, maxProgress: 100, showProgress: progPercent != null, - onlyAlertOnce: onlyAlertOnce))); + onlyAlertOnce: onlyAlertOnce, + indeterminate: progPercent != null && progPercent < 0))); } Future notify(ObtainiumNotification notif, diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 068ebf8..d72fd8d 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -6,10 +6,13 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:obtainium/app_sources/github.dart'; -import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/main.dart'; +import 'package:obtainium/providers/apps_provider.dart'; +import 'package:obtainium/providers/source_provider.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_storage/shared_storage.dart' as saf; String obtainiumTempId = 'imranr98_obtainium_${GitHub().host}'; String obtainiumId = 'dev.imranr.obtainium'; @@ -18,7 +21,7 @@ enum ThemeSettings { system, light, dark } enum ColourSettings { basic, materialYou } -enum SortColumnSettings { added, nameAuthor, authorName } +enum SortColumnSettings { added, nameAuthor, authorName, releaseDate } enum SortOrderSettings { ascending, descending } @@ -34,12 +37,15 @@ List updateIntervals = [15, 30, 60, 120, 180, 360, 720, 1440, 4320, 0] class SettingsProvider with ChangeNotifier { SharedPreferences? prefs; + String? defaultAppDir; + bool justStarted = true; String sourceUrl = 'https://github.com/ImranR98/Obtainium'; // Not done in constructor as we want to be able to await it Future initializeSettings() async { prefs = await SharedPreferences.getInstance(); + defaultAppDir = (await getExternalStorageDirectory())!.path; notifyListeners(); } @@ -63,6 +69,15 @@ class SettingsProvider with ChangeNotifier { notifyListeners(); } + bool get useBlackTheme { + return prefs?.getBool('useBlackTheme') ?? false; + } + + set useBlackTheme(bool useBlackTheme) { + prefs?.setBool('useBlackTheme', useBlackTheme); + notifyListeners(); + } + int get updateInterval { var min = prefs?.getInt('updateInterval') ?? 360; if (!updateIntervals.contains(min)) { @@ -82,6 +97,15 @@ class SettingsProvider with ChangeNotifier { notifyListeners(); } + bool get checkOnStart { + return prefs?.getBool('checkOnStart') ?? false; + } + + set checkOnStart(bool checkOnStart) { + prefs?.setBool('checkOnStart', checkOnStart); + notifyListeners(); + } + SortColumnSettings get sortColumn { return SortColumnSettings.values[ prefs?.getInt('sortColumn') ?? SortColumnSettings.nameAuthor.index]; @@ -110,16 +134,28 @@ class SettingsProvider with ChangeNotifier { return result; } - Future getInstallPermission() async { + bool checkJustStarted() { + if (justStarted) { + justStarted = false; + return true; + } + return false; + } + + Future getInstallPermission({bool enforce = false}) async { while (!(await Permission.requestInstallPackages.isGranted)) { // Explicit request as InstallPlugin request sometimes bugged Fluttertoast.showToast( msg: tr('pleaseAllowInstallPerm'), toastLength: Toast.LENGTH_LONG); if ((await Permission.requestInstallPackages.request()) == PermissionStatus.granted) { - break; + return true; + } + if (!enforce) { + return false; } } + return true; } bool get showAppWebpage { @@ -140,6 +176,42 @@ class SettingsProvider with ChangeNotifier { notifyListeners(); } + bool get buryNonInstalled { + return prefs?.getBool('buryNonInstalled') ?? false; + } + + set buryNonInstalled(bool show) { + prefs?.setBool('buryNonInstalled', show); + notifyListeners(); + } + + bool get groupByCategory { + return prefs?.getBool('groupByCategory') ?? false; + } + + set groupByCategory(bool show) { + prefs?.setBool('groupByCategory', show); + notifyListeners(); + } + + bool get hideTrackOnlyWarning { + return prefs?.getBool('hideTrackOnlyWarning') ?? false; + } + + set hideTrackOnlyWarning(bool show) { + prefs?.setBool('hideTrackOnlyWarning', show); + notifyListeners(); + } + + bool get hideAPKOriginWarning { + return prefs?.getBool('hideAPKOriginWarning') ?? false; + } + + set hideAPKOriginWarning(bool show) { + prefs?.setBool('hideAPKOriginWarning', show); + notifyListeners(); + } + String? getSettingString(String settingId) { return prefs?.getString(settingId); } @@ -152,7 +224,22 @@ class SettingsProvider with ChangeNotifier { Map get categories => Map.from(jsonDecode(prefs?.getString('categories') ?? '{}')); - set categories(Map cats) { + void setCategories(Map cats, {AppsProvider? appsProvider}) { + if (appsProvider != null) { + List changedApps = appsProvider + .getAppValues() + .map((a) { + var n1 = a.app.categories.length; + a.app.categories.removeWhere((c) => !cats.keys.contains(c)); + return n1 > a.app.categories.length ? a.app : null; + }) + .where((element) => element != null) + .map((e) => e as App) + .toList(); + if (changedApps.isNotEmpty) { + appsProvider.saveApps(changedApps); + } + } prefs?.setString('categories', jsonEncode(cats)); notifyListeners(); } @@ -160,7 +247,7 @@ class SettingsProvider with ChangeNotifier { String? get forcedLocale { var fl = prefs?.getString('forcedLocale'); return supportedLocales - .where((element) => element.toLanguageTag() == fl) + .where((element) => element.key.toLanguageTag() == fl) .isNotEmpty ? fl : null; @@ -170,7 +257,7 @@ class SettingsProvider with ChangeNotifier { if (fl == null) { prefs?.remove('forcedLocale'); } else if (supportedLocales - .where((element) => element.toLanguageTag() == fl) + .where((element) => element.key.toLanguageTag() == fl) .isNotEmpty) { prefs?.setString('forcedLocale', fl); } @@ -179,4 +266,153 @@ class SettingsProvider with ChangeNotifier { bool setEqual(Set a, Set b) => a.length == b.length && a.union(b).length == a.length; + + void resetLocaleSafe(BuildContext context) { + if (context.supportedLocales + .map((e) => e.languageCode) + .contains(context.deviceLocale.languageCode)) { + context.resetLocale(); + } else { + context.setLocale(context.fallbackLocale!); + context.deleteSaveLocale(); + } + } + + bool get removeOnExternalUninstall { + return prefs?.getBool('removeOnExternalUninstall') ?? false; + } + + set removeOnExternalUninstall(bool show) { + prefs?.setBool('removeOnExternalUninstall', show); + notifyListeners(); + } + + bool get checkUpdateOnDetailPage { + return prefs?.getBool('checkUpdateOnDetailPage') ?? true; + } + + set checkUpdateOnDetailPage(bool show) { + prefs?.setBool('checkUpdateOnDetailPage', show); + notifyListeners(); + } + + bool get disablePageTransitions { + return prefs?.getBool('disablePageTransitions') ?? false; + } + + set disablePageTransitions(bool show) { + prefs?.setBool('disablePageTransitions', show); + notifyListeners(); + } + + bool get reversePageTransitions { + return prefs?.getBool('reversePageTransitions') ?? false; + } + + set reversePageTransitions(bool show) { + prefs?.setBool('reversePageTransitions', show); + notifyListeners(); + } + + bool get enableBackgroundUpdates { + return prefs?.getBool('enableBackgroundUpdates') ?? true; + } + + set enableBackgroundUpdates(bool val) { + prefs?.setBool('enableBackgroundUpdates', val); + notifyListeners(); + } + + bool get bgUpdatesOnWiFiOnly { + return prefs?.getBool('bgUpdatesOnWiFiOnly') ?? false; + } + + set bgUpdatesOnWiFiOnly(bool val) { + prefs?.setBool('bgUpdatesOnWiFiOnly', val); + notifyListeners(); + } + + DateTime get lastBGCheckTime { + int? temp = prefs?.getInt('lastBGCheckTime'); + return temp != null + ? DateTime.fromMillisecondsSinceEpoch(temp) + : DateTime.fromMillisecondsSinceEpoch(0); + } + + set lastBGCheckTime(DateTime val) { + prefs?.setInt('lastBGCheckTime', val.millisecondsSinceEpoch); + notifyListeners(); + } + + bool get showDebugOpts { + return prefs?.getBool('showDebugOpts') ?? false; + } + + set showDebugOpts(bool val) { + prefs?.setBool('showDebugOpts', val); + notifyListeners(); + } + + bool get highlightTouchTargets { + return prefs?.getBool('highlightTouchTargets') ?? false; + } + + set highlightTouchTargets(bool val) { + prefs?.setBool('highlightTouchTargets', val); + notifyListeners(); + } + + Future getExportDir() async { + var uriString = prefs?.getString('exportDir'); + if (uriString != null) { + Uri? uri = Uri.parse(uriString); + if (!(await saf.canRead(uri) ?? false) || + !(await saf.canWrite(uri) ?? false)) { + uri = null; + prefs?.remove('exportDir'); + notifyListeners(); + } + return uri; + } else { + return null; + } + } + + Future pickExportDir({bool remove = false}) async { + var existingSAFPerms = (await saf.persistedUriPermissions()) ?? []; + var currentOneWayDataSyncDir = await getExportDir(); + Uri? newOneWayDataSyncDir; + if (!remove) { + newOneWayDataSyncDir = (await saf.openDocumentTree()); + } + if (currentOneWayDataSyncDir?.path != newOneWayDataSyncDir?.path) { + if (newOneWayDataSyncDir == null) { + prefs?.remove('exportDir'); + } else { + prefs?.setString('exportDir', newOneWayDataSyncDir.toString()); + } + notifyListeners(); + } + for (var e in existingSAFPerms) { + await saf.releasePersistableUriPermission(e.uri); + } + } + + bool get autoExportOnChanges { + return prefs?.getBool('autoExportOnChanges') ?? false; + } + + set autoExportOnChanges(bool val) { + prefs?.setBool('autoExportOnChanges', val); + notifyListeners(); + } + + bool get onlyCheckInstalledOrTrackOnlyApps { + return prefs?.getBool('onlyCheckInstalledOrTrackOnlyApps') ?? false; + } + + set onlyCheckInstalledOrTrackOnlyApps(bool val) { + prefs?.setBool('onlyCheckInstalledOrTrackOnlyApps', val); + notifyListeners(); + } } diff --git a/lib/providers/source_provider.dart b/lib/providers/source_provider.dart index d03f36b..5736027 100644 --- a/lib/providers/source_provider.dart +++ b/lib/providers/source_provider.dart @@ -3,24 +3,36 @@ import 'dart:convert'; +import 'package:device_info_plus/device_info_plus.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:html/dom.dart'; import 'package:http/http.dart'; import 'package:obtainium/app_sources/apkmirror.dart'; +import 'package:obtainium/app_sources/apkpure.dart'; +import 'package:obtainium/app_sources/aptoide.dart'; import 'package:obtainium/app_sources/codeberg.dart'; import 'package:obtainium/app_sources/fdroid.dart'; import 'package:obtainium/app_sources/fdroidrepo.dart'; import 'package:obtainium/app_sources/github.dart'; import 'package:obtainium/app_sources/gitlab.dart'; +import 'package:obtainium/app_sources/huaweiappgallery.dart'; import 'package:obtainium/app_sources/izzyondroid.dart'; import 'package:obtainium/app_sources/html.dart'; +import 'package:obtainium/app_sources/jenkins.dart'; import 'package:obtainium/app_sources/mullvad.dart'; +import 'package:obtainium/app_sources/neutroncode.dart'; import 'package:obtainium/app_sources/signal.dart'; import 'package:obtainium/app_sources/sourceforge.dart'; +import 'package:obtainium/app_sources/sourcehut.dart'; import 'package:obtainium/app_sources/steammobile.dart'; +import 'package:obtainium/app_sources/telegramapp.dart'; +import 'package:obtainium/app_sources/uptodown.dart'; +import 'package:obtainium/app_sources/vlc.dart'; +import 'package:obtainium/app_sources/whatsapp.dart'; import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/mass_app_sources/githubstars.dart'; +import 'package:obtainium/providers/settings_provider.dart'; class AppNames { late String author; @@ -31,10 +43,113 @@ class AppNames { class APKDetails { late String version; - late List apkUrls; + late List> apkUrls; late AppNames names; + late DateTime? releaseDate; + late String? changeLog; - APKDetails(this.version, this.apkUrls, this.names); + APKDetails(this.version, this.apkUrls, this.names, + {this.releaseDate, this.changeLog}); +} + +stringMapListTo2DList(List> mapList) => + mapList.map((e) => [e.key, e.value]).toList(); + +assumed2DlistToStringMapList(List arr) => + arr.map((e) => MapEntry(e[0] as String, e[1] as String)).toList(); + +// App JSON schema has changed multiple times over the many versions of Obtainium +// This function takes an App JSON and modifies it if needed to conform to the latest (current) version +appJSONCompatibilityModifiers(Map json) { + var source = SourceProvider() + .getSource(json['url'], overrideSource: json['overrideSource']); + var formItems = source.combinedAppSpecificSettingFormItems + .reduce((value, element) => [...value, ...element]); + Map additionalSettings = + getDefaultValuesFromFormItems([formItems]); + if (json['additionalSettings'] != null) { + additionalSettings.addEntries( + Map.from(jsonDecode(json['additionalSettings'])) + .entries); + } + // If needed, migrate old-style additionalData to newer-style additionalSettings (V1) + if (json['additionalData'] != null) { + List temp = List.from(jsonDecode(json['additionalData'])); + temp.asMap().forEach((i, value) { + if (i < formItems.length) { + if (formItems[i] is GeneratedFormSwitch) { + additionalSettings[formItems[i].key] = value == 'true'; + } else { + additionalSettings[formItems[i].key] = value; + } + } + }); + additionalSettings['trackOnly'] = + json['trackOnly'] == 'true' || json['trackOnly'] == true; + additionalSettings['noVersionDetection'] = + json['noVersionDetection'] == 'true' || json['trackOnly'] == true; + } + // Convert bool style version detection options to dropdown style + if (additionalSettings['noVersionDetection'] == true) { + additionalSettings['versionDetection'] = 'noVersionDetection'; + if (additionalSettings['releaseDateAsVersion'] == true) { + additionalSettings['versionDetection'] = 'releaseDateAsVersion'; + additionalSettings.remove('releaseDateAsVersion'); + } + if (additionalSettings['noVersionDetection'] != null) { + additionalSettings.remove('noVersionDetection'); + } + if (additionalSettings['releaseDateAsVersion'] != null) { + additionalSettings.remove('releaseDateAsVersion'); + } + } + // Ensure additionalSettings are correctly typed + for (var item in formItems) { + if (additionalSettings[item.key] != null) { + additionalSettings[item.key] = + item.ensureType(additionalSettings[item.key]); + } + } + int preferredApkIndex = + json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int; + if (preferredApkIndex < 0) { + preferredApkIndex = 0; + } + json['preferredApkIndex'] = preferredApkIndex; + // apkUrls can either be old list or new named list apkUrls + List> apkUrls = []; + if (json['apkUrls'] != null) { + var apkUrlJson = jsonDecode(json['apkUrls']); + try { + apkUrls = getApkUrlsFromUrls(List.from(apkUrlJson)); + } catch (e) { + apkUrls = assumed2DlistToStringMapList(List.from(apkUrlJson)); + apkUrls = List.from(apkUrlJson) + .map((e) => MapEntry(e[0] as String, e[1] as String)) + .toList(); + } + json['apkUrls'] = jsonEncode(stringMapListTo2DList(apkUrls)); + } + // Arch based APK filter option should be disabled if it previously did not exist + if (additionalSettings['autoApkFilterByArch'] == null) { + additionalSettings['autoApkFilterByArch'] = false; + } + json['additionalSettings'] = jsonEncode(additionalSettings); + // F-Droid no longer needs cloudflare exception since override can be used - migrate apps appropriately + // This allows us to reverse the changes made for issue #418 (support cloudflare.f-droid) + // While not causing problems for existing apps from that source that were added in a previous version + var overrideSourceWasUndefined = !json.keys.contains('overrideSource'); + if ((json['url'] as String).startsWith('https://cloudflare.f-droid.org')) { + json['overrideSource'] = FDroid().runtimeType.toString(); + } else if (overrideSourceWasUndefined) { + // Similar to above, but for third-party F-Droid repos + RegExpMatch? match = RegExp('^https?://.+/fdroid/([^/]+(/|\\?)|[^/]+\$)') + .firstMatch(json['url'] as String); + if (match != null) { + json['overrideSource'] = FDroidRepo().runtimeType.toString(); + } + } + return json; } class App { @@ -44,12 +159,16 @@ class App { late String name; String? installedVersion; late String latestVersion; - List apkUrls = []; + List> apkUrls = []; late int preferredApkIndex; late Map additionalSettings; late DateTime? lastUpdateCheck; bool pinned = false; List categories; + late DateTime? releaseDate; + late String? changeLog; + late String? overrideSource; + bool allowIdChange = false; App( this.id, this.url, @@ -62,54 +181,46 @@ class App { this.additionalSettings, this.lastUpdateCheck, this.pinned, - {this.categories = const []}); + {this.categories = const [], + this.releaseDate, + this.changeLog, + this.overrideSource, + this.allowIdChange = false}); @override String toString() { return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls PREFERREDAPK: $preferredApkIndex ADDITIONALSETTINGS: ${additionalSettings.toString()} LASTCHECK: ${lastUpdateCheck.toString()} PINNED $pinned'; } + String? get overrideName => + additionalSettings['appName']?.toString().trim().isNotEmpty == true + ? additionalSettings['appName'] + : null; + + String get finalName { + return overrideName ?? name; + } + + App deepCopy() => App( + id, + url, + author, + name, + installedVersion, + latestVersion, + apkUrls, + preferredApkIndex, + Map.from(additionalSettings), + lastUpdateCheck, + pinned, + categories: categories, + changeLog: changeLog, + releaseDate: releaseDate, + overrideSource: overrideSource, + allowIdChange: allowIdChange); + factory App.fromJson(Map json) { - var source = SourceProvider().getSource(json['url']); - var formItems = source.combinedAppSpecificSettingFormItems - .reduce((value, element) => [...value, ...element]); - Map additionalSettings = - getDefaultValuesFromFormItems([formItems]); - if (json['additionalSettings'] != null) { - additionalSettings.addEntries( - Map.from(jsonDecode(json['additionalSettings'])) - .entries); - } - // If needed, migrate old-style additionalData to newer-style additionalSettings (V1) - if (json['additionalData'] != null) { - List temp = List.from(jsonDecode(json['additionalData'])); - temp.asMap().forEach((i, value) { - if (i < formItems.length) { - if (formItems[i] is GeneratedFormSwitch) { - additionalSettings[formItems[i].key] = value == 'true'; - } else { - additionalSettings[formItems[i].key] = value; - } - } - }); - additionalSettings['trackOnly'] = - json['trackOnly'] == 'true' || json['trackOnly'] == true; - additionalSettings['noVersionDetection'] = - json['noVersionDetection'] == 'true' || json['trackOnly'] == true; - } - // Ensure additionalSettings are correctly typed - for (var item in formItems) { - if (additionalSettings[item.key] != null) { - additionalSettings[item.key] = - item.ensureType(additionalSettings[item.key]); - } - } - int preferredApkIndex = json['preferredApkIndex'] == null - ? 0 - : json['preferredApkIndex'] as int; - if (preferredApkIndex < 0) { - preferredApkIndex = 0; - } + json = appJSONCompatibilityModifiers(json); return App( json['id'] as String, json['url'] as String, @@ -119,11 +230,9 @@ class App { ? null : json['installedVersion'] as String, json['latestVersion'] as String, - json['apkUrls'] == null - ? [] - : List.from(jsonDecode(json['apkUrls'])), - preferredApkIndex, - additionalSettings, + assumed2DlistToStringMapList(jsonDecode(json['apkUrls'])), + json['preferredApkIndex'] as int, + jsonDecode(json['additionalSettings']) as Map, json['lastUpdateCheck'] == null ? null : DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']), @@ -134,7 +243,14 @@ class App { .toList() : json['category'] != null ? [json['category'] as String] - : []); + : [], + releaseDate: json['releaseDate'] == null + ? null + : DateTime.fromMicrosecondsSinceEpoch(json['releaseDate']), + changeLog: + json['changeLog'] == null ? null : json['changeLog'] as String, + overrideSource: json['overrideSource'], + allowIdChange: json['allowIdChange'] ?? false); } Map toJson() => { @@ -144,12 +260,16 @@ class App { 'name': name, 'installedVersion': installedVersion, 'latestVersion': latestVersion, - 'apkUrls': jsonEncode(apkUrls), + 'apkUrls': jsonEncode(stringMapListTo2DList(apkUrls)), 'preferredApkIndex': preferredApkIndex, 'additionalSettings': jsonEncode(additionalSettings), 'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch, 'pinned': pinned, - 'categories': categories + 'categories': categories, + 'releaseDate': releaseDate?.microsecondsSinceEpoch, + 'changeLog': changeLog, + 'overrideSource': overrideSource, + 'allowIdChange': allowIdChange }; } @@ -163,9 +283,6 @@ preStandardizeUrl(String url) { url.toLowerCase().indexOf('https://') != 0) { url = 'https://$url'; } - if (url.toLowerCase().indexOf('https://www.') == 0) { - url = 'https://${url.substring(12)}'; - } url = url .split('/') .where((e) => e.isNotEmpty) @@ -194,16 +311,88 @@ Map getDefaultValuesFromFormItems( .reduce((value, element) => [...value, ...element])); } -class AppSource { +List> getApkUrlsFromUrls(List urls) => + urls.map((e) { + var segments = e.split('/').where((el) => el.trim().isNotEmpty); + var apkSegs = segments.where((s) => s.toLowerCase().endsWith('.apk')); + return MapEntry(apkSegs.isNotEmpty ? apkSegs.last : segments.last, e); + }).toList(); + +abstract class AppSource { String? host; + bool hostChanged = false; late String name; bool enforceTrackOnly = false; + bool changeLogIfAnyIsMarkDown = true; + bool appIdInferIsOptional = false; + bool allowSubDomains = false; + bool naiveStandardVersionDetection = false; + bool neverAutoSelect = false; AppSource() { name = runtimeType.toString(); } - String standardizeURL(String url) { + overrideVersionDetectionFormDefault(String vd, + {bool disableStandard = false, bool disableRelDate = false}) { + additionalAppSpecificSourceAgnosticSettingFormItems = + additionalAppSpecificSourceAgnosticSettingFormItems.map((e) { + return e.map((e2) { + if (e2.key == 'versionDetection') { + var item = e2 as GeneratedFormDropdown; + item.defaultValue = vd; + item.disabledOptKeys = []; + if (disableStandard) { + item.disabledOptKeys?.add('standardVersionDetection'); + } + if (disableRelDate) { + item.disabledOptKeys?.add('releaseDateAsVersion'); + } + item.disabledOptKeys = + item.disabledOptKeys?.where((element) => element != vd).toList(); + } + return e2; + }).toList(); + }).toList(); + } + + String standardizeUrl(String url) { + url = preStandardizeUrl(url); + if (!hostChanged) { + url = sourceSpecificStandardizeURL(url); + } + return url; + } + + Future?> getRequestHeaders( + {Map additionalSettings = const {}, + bool forAPKDownload = false}) async { + return null; + } + + App endOfGetAppChanges(App app) { + return app; + } + + Future sourceRequest(String url, + {bool followRedirects = true, + Map additionalSettings = + const {}}) async { + var requestHeaders = + await getRequestHeaders(additionalSettings: additionalSettings); + if (requestHeaders != null || followRedirects == false) { + var req = Request('GET', Uri.parse(url)); + req.followRedirects = followRedirects; + if (requestHeaders != null) { + req.headers.addAll(requestHeaders); + } + return Response.fromStream(await Client().send(req)); + } else { + return get(Uri.parse(url)); + } + } + + String sourceSpecificStandardizeURL(String url) { throw NotImplementedError(); } @@ -217,7 +406,7 @@ class AppSource { []; // Some additional data may be needed for Apps regardless of Source - final List> + List> additionalAppSpecificSourceAgnosticSettingFormItems = [ [ GeneratedFormSwitch( @@ -225,7 +414,41 @@ class AppSource { label: tr('trackOnly'), ) ], - [GeneratedFormSwitch('noVersionDetection', label: tr('noVersionDetection'))] + [ + GeneratedFormDropdown( + 'versionDetection', + [ + MapEntry( + 'standardVersionDetection', tr('standardVersionDetection')), + MapEntry('releaseDateAsVersion', tr('releaseDateAsVersion')), + MapEntry('noVersionDetection', tr('noVersionDetection')) + ], + label: tr('versionDetection'), + defaultValue: 'standardVersionDetection') + ], + [ + GeneratedFormTextField('apkFilterRegEx', + label: tr('filterAPKsByRegEx'), + required: false, + additionalValidators: [ + (value) { + return regExValidator(value); + } + ]) + ], + [ + GeneratedFormSwitch('autoApkFilterByArch', + label: tr('autoApkFilterByArch'), defaultValue: true) + ], + [GeneratedFormTextField('appName', label: tr('appName'), required: false)], + [ + GeneratedFormSwitch('exemptFromBackgroundUpdates', + label: tr('exemptFromBackgroundUpdates')) + ], + [ + GeneratedFormSwitch('skipUpdateNotifications', + label: tr('skipUpdateNotifications')) + ] ]; // Previous 2 variables combined into one at runtime for convenient usage @@ -237,71 +460,153 @@ class AppSource { } // Some Sources may have additional settings at the Source level (not specific to Apps) - these use SettingsProvider - List additionalSourceSpecificSettingFormItems = []; + // If the source has been overridden, we expect the user to define one-time values as additional settings - don't use the stored values + List sourceConfigSettingFormItems = []; + Future> getSourceConfigValues( + Map additionalSettings, + SettingsProvider settingsProvider) async { + Map results = {}; + for (var e in sourceConfigSettingFormItems) { + var val = hostChanged + ? additionalSettings[e.key] + : settingsProvider.getSettingString(e.key); + if (val != null) { + results[e.key] = val; + } + } + return results; + } String? changeLogPageFromStandardUrl(String standardUrl) { return null; } - Future apkUrlPrefetchModifier(String apkUrl) async { + Future getSourceNote() async { + return null; + } + + Future apkUrlPrefetchModifier( + String apkUrl, String standardUrl) async { return apkUrl; } bool canSearch = false; - Future> search(String query) { + bool excludeFromMassSearch = false; + List searchQuerySettingFormItems = []; + Future>> search(String query, + {Map querySettings = const {}}) { throw NotImplementedError(); } - String? tryInferringAppId(String standardUrl, - {Map additionalSettings = const {}}) { + Future tryInferringAppId(String standardUrl, + {Map additionalSettings = const {}}) async { return null; } } ObtainiumError getObtainiumHttpError(Response res) { - return ObtainiumError(res.reasonPhrase ?? - tr('errorWithHttpStatusCode', args: [res.statusCode.toString()])); + return ObtainiumError((res.reasonPhrase != null && + res.reasonPhrase != null && + res.reasonPhrase!.isNotEmpty) + ? res.reasonPhrase! + : tr('errorWithHttpStatusCode', args: [res.statusCode.toString()])); } abstract class MassAppUrlSource { late String name; late List requiredArgs; - Future> getUrlsWithDescriptions(List args); + Future>> getUrlsWithDescriptions(List args); +} + +regExValidator(String? value) { + if (value == null || value.isEmpty) { + return null; + } + try { + RegExp(value); + } catch (e) { + return tr('invalidRegEx'); + } + return null; +} + +intValidator(String? value, {bool positive = false}) { + if (value == null) { + return tr('invalidInput'); + } + var num = int.tryParse(value); + if (num == null) { + return tr('invalidInput'); + } + if (positive && num <= 0) { + return tr('invalidInput'); + } + return null; +} + +bool isTempId(App app) { + // return app.id == generateTempID(app.url, app.additionalSettings); + return RegExp('^[0-9]+\$').hasMatch(app.id); } class SourceProvider { // Add more source classes here so they are available via the service - List sources = [ - GitHub(), - GitLab(), - Codeberg(), - FDroid(), - IzzyOnDroid(), - Mullvad(), - Signal(), - SourceForge(), - APKMirror(), - FDroidRepo(), - SteamMobile(), - HTML() // This should ALWAYS be the last option as they are tried in order - ]; + List get sources => [ + GitHub(), + GitLab(), + Codeberg(), + FDroid(), + FDroidRepo(), + IzzyOnDroid(), + SourceForge(), + SourceHut(), + APKPure(), + Aptoide(), + Uptodown(), + APKMirror(), + HuaweiAppGallery(), + Jenkins(), + // APKCombo(), // Can't get past their scraping blocking yet (get 403 Forbidden) + Mullvad(), + Signal(), + VLC(), + WhatsApp(), // As of 2023-03-20 this is unusable as the version on the webpage is months out of date + TelegramApp(), + SteamMobile(), + NeutronCode(), + HTML() // This should ALWAYS be the last option as they are tried in order + ]; // Add more mass url source classes here so they are available via the service List massUrlSources = [GitHubStars()]; - AppSource getSource(String url) { + AppSource getSource(String url, {String? overrideSource}) { url = preStandardizeUrl(url); + if (overrideSource != null) { + var srcs = + sources.where((e) => e.runtimeType.toString() == overrideSource); + if (srcs.isEmpty) { + throw UnsupportedURLError(); + } + var res = srcs.first; + res.host = Uri.parse(url).host; + res.hostChanged = true; + return srcs.first; + } AppSource? source; for (var s in sources.where((element) => element.host != null)) { - if (url.contains('://${s.host}')) { + if (RegExp( + '://(${s.allowSubDomains ? '([^\\.]+\\.)*' : ''}|www\\.)${s.host}(/|\\z)?') + .hasMatch(url)) { source = s; break; } } if (source == null) { - for (var s in sources.where((element) => element.host == null)) { + for (var s in sources.where( + (element) => element.host == null && !element.neverAutoSelect)) { try { - s.standardizeURL(url); + s.sourceSpecificStandardizeURL(url); source = s; break; } catch (e) { @@ -326,71 +631,89 @@ class SourceProvider { return false; } - String generateTempID(AppNames names, AppSource source) => - '${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}'; - - bool isTempId(String id) { - List parts = id.split('_'); - if (parts.length < 3) { - return false; - } - for (int i = 0; i < parts.length - 1; i++) { - if (RegExp('.*[A-Z].*').hasMatch(parts[i])) { - // TODO: Look into RegEx for non-Latin characters - return false; - } - } - return true; - } + String generateTempID( + String standardUrl, Map additionalSettings) => + (standardUrl + additionalSettings.toString()).hashCode.toString(); Future getApp( AppSource source, String url, Map additionalSettings, {App? currentApp, bool trackOnlyOverride = false, - noVersionDetectionOverride = false}) async { + String? overrideSource, + bool inferAppIdIfOptional = false}) async { if (trackOnlyOverride || source.enforceTrackOnly) { additionalSettings['trackOnly'] = true; } - if (noVersionDetectionOverride) { - additionalSettings['noVersionDetection'] = true; - } var trackOnly = additionalSettings['trackOnly'] == true; - String standardUrl = source.standardizeURL(preStandardizeUrl(url)); + String standardUrl = source.standardizeUrl(url); APKDetails apk = await source.getLatestAPKDetails(standardUrl, additionalSettings); + if (additionalSettings['versionDetection'] == 'releaseDateAsVersion' && + apk.releaseDate != null) { + apk.version = apk.releaseDate!.microsecondsSinceEpoch.toString(); + } + if (additionalSettings['apkFilterRegEx'] != null) { + var reg = RegExp(additionalSettings['apkFilterRegEx']); + apk.apkUrls = + apk.apkUrls.where((element) => reg.hasMatch(element.key)).toList(); + } if (apk.apkUrls.isEmpty && !trackOnly) { throw NoAPKError(); } - String apkVersion = apk.version.replaceAll('/', '-'); - var name = currentApp?.name.trim() ?? - apk.names.name[0].toUpperCase() + apk.names.name.substring(1); - return App( + if (apk.apkUrls.length > 1 && + additionalSettings['autoApkFilterByArch'] == true) { + var abis = (await DeviceInfoPlugin().androidInfo).supportedAbis; + for (var abi in abis) { + var urls2 = apk.apkUrls + .where((element) => RegExp('.*$abi.*').hasMatch(element.key)) + .toList(); + if (urls2.isNotEmpty && urls2.length < apk.apkUrls.length) { + apk.apkUrls = urls2; + break; + } + } + } + var name = currentApp != null ? currentApp.name.trim() : ''; + name = name.isNotEmpty ? name : apk.names.name; + App finalApp = App( currentApp?.id ?? - source.tryInferringAppId(standardUrl, - additionalSettings: additionalSettings) ?? - generateTempID(apk.names, source), + ((!source.appIdInferIsOptional || + (source.appIdInferIsOptional && inferAppIdIfOptional)) + ? await source.tryInferringAppId(standardUrl, + additionalSettings: additionalSettings) + : null) ?? + generateTempID(standardUrl, additionalSettings), standardUrl, - apk.names.author[0].toUpperCase() + apk.names.author.substring(1), - name.trim().isNotEmpty - ? name - : apk.names.name[0].toUpperCase() + apk.names.name.substring(1), + apk.names.author, + name, currentApp?.installedVersion, - apkVersion, + apk.version, apk.apkUrls, apk.apkUrls.length - 1 >= 0 ? apk.apkUrls.length - 1 : 0, additionalSettings, DateTime.now(), currentApp?.pinned ?? false, - categories: currentApp?.categories ?? const []); + categories: currentApp?.categories ?? const [], + releaseDate: apk.releaseDate, + changeLog: apk.changeLog, + overrideSource: overrideSource ?? currentApp?.overrideSource, + allowIdChange: currentApp?.allowIdChange ?? + source.appIdInferIsOptional && + inferAppIdIfOptional // Optional ID inferring may be incorrect - allow correction on first install + ); + return source.endOfGetAppChanges(finalApp); } // Returns errors in [results, errors] instead of throwing them Future> getAppsByURLNaive(List urls, - {List ignoreUrls = const []}) async { + {List alreadyAddedUrls = const []}) async { List apps = []; Map errors = {}; - for (var url in urls.where((element) => !ignoreUrls.contains(element))) { + for (var url in urls) { try { + if (alreadyAddedUrls.contains(url)) { + throw ObtainiumError(tr('appAlreadyAdded')); + } var source = getSource(url); apps.add(await getApp( source, diff --git a/pubspec.lock b/pubspec.lock index 3b629d8..7ab0e9f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,243 +5,333 @@ packages: dependency: "direct main" description: name: android_alarm_manager_plus - url: "https://pub.dartlang.org" + sha256: "82fb28c867c4b3dd7e9157728e46426b8916362f977dbba46b949210f00099f4" + url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "3.0.3" + android_intent_plus: + dependency: "direct main" + description: + name: android_intent_plus + sha256: e1c62bb41c90e15083b7fb84dc327fe90396cc9c1445b55ff1082144fabfb4d9 + url: "https://pub.dev" + source: hosted + version: "4.0.3" + android_package_installer: + dependency: "direct main" + description: + path: "." + ref: main + resolved-ref: ba2aa7a11edc2649d1d80c25ed9291521262f714 + url: "https://github.com/ImranR98/android_package_installer" + source: git + version: "0.0.1" + android_package_manager: + dependency: "direct main" + description: + name: android_package_manager + sha256: b873fe5856f7c442aca9751dac05d117285be9e4de08eb15d1ffb811fd1b688d + url: "https://pub.dev" + source: hosted + version: "0.6.0" animations: dependency: "direct main" description: name: animations - url: "https://pub.dartlang.org" + sha256: ef57563eed3620bd5d75ad96189846aca1e033c0c45fc9a7d26e80ab02b88a70 + url: "https://pub.dev" source: hosted - version: "2.0.7" + version: "2.0.8" archive: dependency: transitive description: name: archive - url: "https://pub.dartlang.org" + sha256: "7e0d52067d05f2e0324268097ba723b71cb41ac8a6a2b24d1edf9c536b987b03" + url: "https://pub.dev" source: hosted - version: "3.3.5" + version: "3.4.6" args: dependency: transitive description: name: args - url: "https://pub.dartlang.org" + sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.2" async: dependency: transitive description: name: async - url: "https://pub.dartlang.org" + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" source: hosted - version: "2.9.0" + version: "2.11.0" boolean_selector: dependency: transitive description: name: boolean_selector - url: "https://pub.dartlang.org" + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" characters: dependency: transitive description: name: characters - url: "https://pub.dartlang.org" + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.3.0" checked_yaml: dependency: transitive description: name: checked_yaml - url: "https://pub.dartlang.org" + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.0.3" cli_util: dependency: transitive description: name: cli_util - url: "https://pub.dartlang.org" + sha256: b8db3080e59b2503ca9e7922c3df2072cf13992354d5e944074ffa836fba43b7 + url: "https://pub.dev" source: hosted - version: "0.3.5" + version: "0.4.0" clock: dependency: transitive description: name: clock - url: "https://pub.dartlang.org" + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" source: hosted version: "1.1.1" collection: dependency: transitive description: name: collection - url: "https://pub.dartlang.org" + sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 + url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.2" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: b502a681ba415272ecc41400bd04fe543ed1a62632137dc84d25a91e7746f55f + url: "https://pub.dev" + source: hosted + version: "5.0.1" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: cf1d1c28f4416f8c654d7dc3cd638ec586076255d407cef3ddbdaf178272a71a + url: "https://pub.dev" + source: hosted + version: "1.2.4" convert: dependency: transitive description: name: convert - url: "https://pub.dartlang.org" + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" source: hosted version: "3.1.1" cross_file: dependency: transitive description: name: cross_file - url: "https://pub.dartlang.org" + sha256: "445db18de832dba8d851e287aff8ccf169bed30d2e94243cb54c7d2f1ed2142c" + url: "https://pub.dev" source: hosted - version: "0.3.3+2" + version: "0.3.3+6" crypto: dependency: transitive description: name: crypto - url: "https://pub.dartlang.org" + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.3" csslib: dependency: transitive description: name: csslib - url: "https://pub.dartlang.org" + sha256: "706b5707578e0c1b4b7550f64078f0a0f19dec3f50a178ffae7006b0a9ca58fb" + url: "https://pub.dev" source: hosted - version: "0.17.2" + version: "1.0.0" cupertino_icons: dependency: "direct main" description: name: cupertino_icons - url: "https://pub.dartlang.org" + sha256: d57953e10f9f8327ce64a508a355f0b1ec902193f66288e8cb5070e7c47eeb2d + url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "1.0.6" dbus: dependency: transitive description: name: dbus - url: "https://pub.dartlang.org" + sha256: "6f07cba3f7b3448d42d015bfd3d53fe12e5b36da2423f23838efc1d5fb31a263" + url: "https://pub.dev" source: hosted version: "0.7.8" device_info_plus: dependency: "direct main" description: name: device_info_plus - url: "https://pub.dartlang.org" + sha256: "7035152271ff67b072a211152846e9f1259cf1be41e34cd3e0b5463d2d6b8419" + url: "https://pub.dev" source: hosted - version: "8.0.0" + version: "9.1.0" device_info_plus_platform_interface: dependency: transitive description: name: device_info_plus_platform_interface - url: "https://pub.dartlang.org" + sha256: d3b01d5868b50ae571cd1dc6e502fc94d956b665756180f7b16ead09e836fd64 + url: "https://pub.dev" source: hosted version: "7.0.0" dynamic_color: dependency: "direct main" description: name: dynamic_color - url: "https://pub.dartlang.org" + sha256: "8b8bd1d798bd393e11eddeaa8ae95b12ff028bf7d5998fc5d003488cd5f4ce2f" + url: "https://pub.dev" source: hosted - version: "1.5.4" + version: "1.6.8" easy_localization: dependency: "direct main" description: name: easy_localization - url: "https://pub.dartlang.org" + sha256: de63e3b422adfc97f256cbb3f8cf12739b6a4993d390f3cadb3f51837afaefe5 + url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.3" easy_logger: dependency: transitive description: name: easy_logger - url: "https://pub.dartlang.org" + sha256: c764a6e024846f33405a2342caf91c62e357c24b02c04dbc712ef232bf30ffb7 + url: "https://pub.dev" source: hosted version: "0.0.2" fake_async: dependency: transitive description: name: fake_async - url: "https://pub.dartlang.org" + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" source: hosted version: "1.3.1" ffi: dependency: transitive description: name: ffi - url: "https://pub.dartlang.org" + sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" + url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.1.0" file: dependency: transitive description: name: file - url: "https://pub.dartlang.org" + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + url: "https://pub.dev" source: hosted - version: "6.1.4" + version: "7.0.0" file_picker: dependency: "direct main" description: name: file_picker - url: "https://pub.dartlang.org" + sha256: "903dd4ba13eae7cef64acc480e91bf54c3ddd23b5b90b639c170f3911e489620" + url: "https://pub.dev" source: hosted - version: "5.2.5" + version: "6.0.0" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_archive: + dependency: "direct main" + description: + name: flutter_archive + sha256: aec85d1da65e5b33a529db00a86df0b8e92bda78088a7cfaeeba5187701d0d85 + url: "https://pub.dev" + source: hosted + version: "5.0.0" flutter_fgbg: dependency: "direct main" description: name: flutter_fgbg - url: "https://pub.dartlang.org" + sha256: "08c4d2fd229e3df26083d5aecc3dea9ff4f2d188f8cd57aaf2b3f047bd08a047" + url: "https://pub.dev" source: hosted - version: "0.2.2" + version: "0.3.0" flutter_launcher_icons: dependency: "direct dev" description: name: flutter_launcher_icons - url: "https://pub.dartlang.org" + sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + url: "https://pub.dev" source: hosted - version: "0.11.0" + version: "0.13.1" flutter_lints: dependency: "direct dev" description: name: flutter_lints - url: "https://pub.dartlang.org" + sha256: e2a421b7e59244faef694ba7b30562e489c2b489866e505074eb005cd7060db7 + url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.1" flutter_local_notifications: dependency: "direct main" description: name: flutter_local_notifications - url: "https://pub.dartlang.org" + sha256: "6d11ea777496061e583623aaf31923f93a9409ef8fcaeeefdd6cd78bf4fe5bb3" + url: "https://pub.dev" source: hosted - version: "13.0.0" + version: "16.1.0" flutter_local_notifications_linux: dependency: transitive description: name: flutter_local_notifications_linux - url: "https://pub.dartlang.org" + sha256: "33f741ef47b5f63cc7f78fe75eeeac7e19f171ff3c3df054d84c1e38bedb6a03" + url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "4.0.0+1" flutter_local_notifications_platform_interface: dependency: transitive description: name: flutter_local_notifications_platform_interface - url: "https://pub.dartlang.org" + sha256: "7cf643d6d5022f3baed0be777b0662cce5919c0a7b86e700299f22dc4ae660ef" + url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "7.0.0+1" flutter_localizations: dependency: transitive description: flutter source: sdk version: "0.0.0" + flutter_markdown: + dependency: "direct main" + description: + name: flutter_markdown + sha256: "8afc9a6aa6d8e8063523192ba837149dbf3d377a37c0b0fc579149a1fbd4a619" + url: "https://pub.dev" + source: hosted + version: "0.6.18" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle - url: "https://pub.dartlang.org" + sha256: b068ffc46f82a55844acfa4fdbb61fad72fa2aef0905548419d97f0f95c456da + url: "https://pub.dev" source: hosted - version: "2.0.7" + version: "2.0.17" flutter_test: dependency: "direct dev" description: flutter @@ -256,324 +346,354 @@ packages: dependency: "direct main" description: name: fluttertoast - url: "https://pub.dartlang.org" + sha256: "474f7d506230897a3cd28c965ec21c5328ae5605fc9c400cd330e9e9d6ac175c" + url: "https://pub.dev" source: hosted - version: "8.1.2" + version: "8.2.2" + hsluv: + dependency: "direct main" + description: + name: hsluv + sha256: f33e63b0c24ceee0f6492874424aa8edc671ef9a20cc889e4b969284d8f02eb1 + url: "https://pub.dev" + source: hosted + version: "1.1.3" html: dependency: "direct main" description: name: html - url: "https://pub.dartlang.org" + sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" + url: "https://pub.dev" source: hosted - version: "0.15.1" + version: "0.15.4" http: dependency: "direct main" description: name: http - url: "https://pub.dartlang.org" + sha256: "759d1a329847dd0f39226c688d3e06a6b8679668e350e2891a6474f8b4bb8525" + url: "https://pub.dev" source: hosted - version: "0.13.5" + version: "1.1.0" http_parser: dependency: transitive description: name: http_parser - url: "https://pub.dartlang.org" + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" source: hosted version: "4.0.2" image: dependency: transitive description: name: image - url: "https://pub.dartlang.org" + sha256: "028f61960d56f26414eb616b48b04eb37d700cbe477b7fb09bf1d7ce57fd9271" + url: "https://pub.dev" source: hosted - version: "3.3.0" - install_plugin_v2: - dependency: "direct main" - description: - name: install_plugin_v2 - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - installed_apps: - dependency: "direct main" - description: - name: installed_apps - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.1" + version: "4.1.3" intl: dependency: transitive description: name: intl - url: "https://pub.dartlang.org" + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + url: "https://pub.dev" source: hosted - version: "0.17.0" + version: "0.18.1" js: dependency: transitive description: name: js - url: "https://pub.dartlang.org" + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" source: hosted - version: "0.6.4" + version: "0.6.7" json_annotation: dependency: transitive description: name: json_annotation - url: "https://pub.dartlang.org" + sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 + url: "https://pub.dev" source: hosted - version: "4.8.0" + version: "4.8.1" lints: dependency: transitive description: name: lints - url: "https://pub.dartlang.org" + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: acf35edccc0463a9d7384e437c015a3535772e09714cf60e07eeef3a15870dcd + url: "https://pub.dev" + source: hosted + version: "7.1.1" matcher: dependency: transitive description: name: matcher - url: "https://pub.dartlang.org" + sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" + url: "https://pub.dev" source: hosted - version: "0.12.12" + version: "0.12.16" material_color_utilities: dependency: transitive description: name: material_color_utilities - url: "https://pub.dartlang.org" + sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" + url: "https://pub.dev" source: hosted - version: "0.1.5" + version: "0.5.0" meta: dependency: transitive description: name: meta - url: "https://pub.dartlang.org" + sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" + url: "https://pub.dev" source: hosted - version: "1.8.0" + version: "1.9.1" mime: dependency: transitive description: name: mime - url: "https://pub.dartlang.org" + sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e + url: "https://pub.dev" source: hosted version: "1.0.4" nested: dependency: transitive description: name: nested - url: "https://pub.dartlang.org" + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" source: hosted version: "1.0.0" - package_archive_info: - dependency: "direct main" - description: - name: package_archive_info - url: "https://pub.dartlang.org" - source: hosted - version: "0.1.0" - package_info: + nm: dependency: transitive description: - name: package_info - url: "https://pub.dartlang.org" + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "0.5.0" path: dependency: transitive description: name: path - url: "https://pub.dartlang.org" + sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" + url: "https://pub.dev" source: hosted - version: "1.8.2" + version: "1.8.3" path_provider: dependency: "direct main" description: name: path_provider - url: "https://pub.dartlang.org" + sha256: a1aa8aaa2542a6bc57e381f132af822420216c80d4781f7aa085ca3229208aaa + url: "https://pub.dev" source: hosted - version: "2.0.11" + version: "2.1.1" path_provider_android: dependency: transitive description: name: path_provider_android - url: "https://pub.dartlang.org" + sha256: e595b98692943b4881b219f0a9e3945118d3c16bd7e2813f98ec6e532d905f72 + url: "https://pub.dev" source: hosted - version: "2.0.22" - path_provider_ios: + version: "2.2.1" + path_provider_foundation: dependency: transitive description: - name: path_provider_ios - url: "https://pub.dartlang.org" + name: path_provider_foundation + sha256: "19314d595120f82aca0ba62787d58dde2cc6b5df7d2f0daf72489e38d1b57f2d" + url: "https://pub.dev" source: hosted - version: "2.0.11" + version: "2.3.1" path_provider_linux: dependency: transitive description: name: path_provider_linux - url: "https://pub.dartlang.org" + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" source: hosted - version: "2.1.7" - path_provider_macos: - dependency: transitive - description: - name: path_provider_macos - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.7" + version: "2.2.1" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - url: "https://pub.dartlang.org" + sha256: "94b1e0dd80970c1ce43d5d4e050a9918fce4f4a775e6142424c30a29a363265c" + url: "https://pub.dev" source: hosted - version: "2.0.5" + version: "2.1.1" path_provider_windows: dependency: transitive description: name: path_provider_windows - url: "https://pub.dartlang.org" + sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" + url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.2.1" permission_handler: dependency: "direct main" description: name: permission_handler - url: "https://pub.dartlang.org" + sha256: "284a66179cabdf942f838543e10413246f06424d960c92ba95c84439154fcac8" + url: "https://pub.dev" source: hosted - version: "10.2.0" + version: "11.0.1" permission_handler_android: dependency: transitive description: name: permission_handler_android - url: "https://pub.dartlang.org" + sha256: f9fddd3b46109bd69ff3f9efa5006d2d309b7aec0f3c1c5637a60a2d5659e76e + url: "https://pub.dev" source: hosted - version: "10.2.0" + version: "11.1.0" permission_handler_apple: dependency: transitive description: name: permission_handler_apple - url: "https://pub.dartlang.org" + sha256: "99e220bce3f8877c78e4ace901082fb29fa1b4ebde529ad0932d8d664b34f3f5" + url: "https://pub.dev" source: hosted - version: "9.0.7" + version: "9.1.4" permission_handler_platform_interface: dependency: transitive description: name: permission_handler_platform_interface - url: "https://pub.dartlang.org" + sha256: "6760eb5ef34589224771010805bea6054ad28453906936f843a8cc4d3a55c4a4" + url: "https://pub.dev" source: hosted - version: "3.9.0" + version: "3.12.0" permission_handler_windows: dependency: transitive description: name: permission_handler_windows - url: "https://pub.dartlang.org" + sha256: cc074aace208760f1eee6aa4fae766b45d947df85bc831cde77009cdb4720098 + url: "https://pub.dev" source: hosted - version: "0.1.2" + version: "0.1.3" petitparser: dependency: transitive description: name: petitparser - url: "https://pub.dartlang.org" + sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750 + url: "https://pub.dev" source: hosted - version: "5.1.0" + version: "5.4.0" platform: dependency: transitive description: name: platform - url: "https://pub.dartlang.org" + sha256: "0a279f0707af40c890e80b1e9df8bb761694c074ba7e1d4ab1bc4b728e200b59" + url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.3" plugin_platform_interface: dependency: transitive description: name: plugin_platform_interface - url: "https://pub.dartlang.org" + sha256: da3fdfeccc4d4ff2da8f8c556704c08f912542c5fb3cf2233ed75372384a034d + url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.6" pointycastle: dependency: transitive description: name: pointycastle - url: "https://pub.dartlang.org" + sha256: "7c1e5f0d23c9016c5bbd8b1473d0d3fb3fc851b876046039509e18e0c7485f2c" + url: "https://pub.dev" source: hosted - version: "3.6.2" - process: - dependency: transitive - description: - name: process - url: "https://pub.dartlang.org" - source: hosted - version: "4.2.4" + version: "3.7.3" provider: dependency: "direct main" description: name: provider - url: "https://pub.dartlang.org" + sha256: cdbe7530b12ecd9eb455bdaa2fcb8d4dad22e80b8afb4798b41479d5ce26847f + url: "https://pub.dev" source: hosted version: "6.0.5" share_plus: dependency: "direct main" description: name: share_plus - url: "https://pub.dartlang.org" + sha256: f74fc3f1cbd99f39760182e176802f693fa0ec9625c045561cfad54681ea93dd + url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "7.2.1" share_plus_platform_interface: dependency: transitive description: name: share_plus_platform_interface - url: "https://pub.dartlang.org" + sha256: df08bc3a07d01f5ea47b45d03ffcba1fa9cd5370fb44b3f38c70e42cced0f956 + url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "3.3.1" shared_preferences: dependency: "direct main" description: name: shared_preferences - url: "https://pub.dartlang.org" + sha256: "81429e4481e1ccfb51ede496e916348668fd0921627779233bd24cc3ff6abd02" + url: "https://pub.dev" source: hosted - version: "2.0.17" + version: "2.2.2" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - url: "https://pub.dartlang.org" + sha256: "8568a389334b6e83415b6aae55378e158fbc2314e074983362d20c562780fb06" + url: "https://pub.dev" source: hosted - version: "2.0.15" + version: "2.2.1" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - url: "https://pub.dartlang.org" + sha256: "7bf53a9f2d007329ee6f3df7268fd498f8373602f943c975598bbb34649b62a7" + url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.3.4" shared_preferences_linux: dependency: transitive description: name: shared_preferences_linux - url: "https://pub.dartlang.org" + sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa" + url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.3.2" shared_preferences_platform_interface: dependency: transitive description: name: shared_preferences_platform_interface - url: "https://pub.dartlang.org" + sha256: d4ec5fc9ebb2f2e056c617112aa75dcf92fc2e4faaf2ae999caa297473f75d8a + url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.3.1" shared_preferences_web: dependency: transitive description: name: shared_preferences_web - url: "https://pub.dartlang.org" + sha256: d762709c2bbe80626ecc819143013cc820fa49ca5e363620ee20a8b15a3e3daf + url: "https://pub.dev" source: hosted - version: "2.0.4" + version: "2.2.1" shared_preferences_windows: dependency: transitive description: name: shared_preferences_windows - url: "https://pub.dartlang.org" + sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59" + url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.3.2" + shared_storage: + dependency: "direct main" + description: + name: shared_storage + sha256: "7c65a9d64f0f5521256be974cfd74010af12196657cec9f9fb7b03b2f11bcaf6" + url: "https://pub.dev" + source: hosted + version: "0.8.0" sky_engine: dependency: transitive description: flutter @@ -583,205 +703,258 @@ packages: dependency: transitive description: name: source_span - url: "https://pub.dartlang.org" + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" sqflite: dependency: "direct main" description: name: sqflite - url: "https://pub.dartlang.org" + sha256: "591f1602816e9c31377d5f008c2d9ef7b8aca8941c3f89cc5fd9d84da0c38a9a" + url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.3.0" sqflite_common: dependency: transitive description: name: sqflite_common - url: "https://pub.dartlang.org" + sha256: "1b92f368f44b0dee2425bb861cfa17b6f6cf3961f762ff6f941d20b33355660a" + url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.5.0" stack_trace: dependency: transitive description: name: stack_trace - url: "https://pub.dartlang.org" + sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 + url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.11.0" stream_channel: dependency: transitive description: name: stream_channel - url: "https://pub.dartlang.org" + sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" + url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" string_scanner: dependency: transitive description: name: string_scanner - url: "https://pub.dartlang.org" + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.2.0" synchronized: dependency: transitive description: name: synchronized - url: "https://pub.dartlang.org" + sha256: "5fcbd27688af6082f5abd611af56ee575342c30e87541d0245f7ff99faa02c60" + url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.1.0" term_glyph: dependency: transitive description: name: term_glyph - url: "https://pub.dartlang.org" + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" source: hosted version: "1.2.1" test_api: dependency: transitive description: name: test_api - url: "https://pub.dartlang.org" + sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8" + url: "https://pub.dev" source: hosted - version: "0.4.12" + version: "0.6.0" timezone: dependency: transitive description: name: timezone - url: "https://pub.dartlang.org" + sha256: "1cfd8ddc2d1cfd836bc93e67b9be88c3adaeca6f40a00ca999104c30693cdca0" + url: "https://pub.dev" source: hosted - version: "0.9.1" + version: "0.9.2" typed_data: dependency: transitive description: name: typed_data - url: "https://pub.dartlang.org" + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.2" url_launcher: dependency: "direct main" description: name: url_launcher - url: "https://pub.dartlang.org" + sha256: b1c9e98774adf8820c96fbc7ae3601231d324a7d5ebd8babe27b6dfac91357ba + url: "https://pub.dev" source: hosted - version: "6.1.8" + version: "6.2.1" url_launcher_android: dependency: transitive description: name: url_launcher_android - url: "https://pub.dartlang.org" + sha256: "31222ffb0063171b526d3e569079cf1f8b294075ba323443fdc690842bfd4def" + url: "https://pub.dev" source: hosted - version: "6.0.23" + version: "6.2.0" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - url: "https://pub.dartlang.org" + sha256: "4ac97281cf60e2e8c5cc703b2b28528f9b50c8f7cebc71df6bdf0845f647268a" + url: "https://pub.dev" source: hosted - version: "6.0.18" + version: "6.2.0" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - url: "https://pub.dartlang.org" + sha256: "9f2d390e096fdbe1e6e6256f97851e51afc2d9c423d3432f1d6a02a8a9a8b9fd" + url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.1.0" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - url: "https://pub.dartlang.org" + sha256: b7244901ea3cf489c5335bdacda07264a6e960b1c1b1a9f91e4bc371d9e68234 + url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.1.0" url_launcher_platform_interface: dependency: transitive description: name: url_launcher_platform_interface - url: "https://pub.dartlang.org" + sha256: "980e8d9af422f477be6948bdfb68df8433be71f5743a188968b0c1b887807e50" + url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.2.0" url_launcher_web: dependency: transitive description: name: url_launcher_web - url: "https://pub.dartlang.org" + sha256: "7fd2f55fe86cea2897b963e864dc01a7eb0719ecc65fcef4c1cc3d686d718bb2" + url: "https://pub.dev" source: hosted - version: "2.0.14" + version: "2.2.0" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - url: "https://pub.dartlang.org" + sha256: "7754a1ad30ee896b265f8d14078b0513a4dba28d358eabb9d5f339886f4a1adc" + url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.1.0" uuid: dependency: transitive description: name: uuid - url: "https://pub.dartlang.org" + sha256: b715b8d3858b6fa9f68f87d20d98830283628014750c2b09b6f516c1da4af2a7 + url: "https://pub.dev" source: hosted - version: "3.0.7" + version: "4.1.0" vector_math: dependency: transitive description: name: vector_math - url: "https://pub.dartlang.org" + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" + web: + dependency: transitive + description: + name: web + sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10 + url: "https://pub.dev" + source: hosted + version: "0.1.4-beta" webview_flutter: dependency: "direct main" description: name: webview_flutter - url: "https://pub.dartlang.org" + sha256: "42393b4492e629aa3a88618530a4a00de8bb46e50e7b3993fedbfdc5352f0dbf" + url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.4.2" webview_flutter_android: dependency: transitive description: name: webview_flutter_android - url: "https://pub.dartlang.org" + sha256: "8326ee235f87605a2bfc444a4abc897f4abc78d83f054ba7d3d1074ce82b4fbf" + url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.12.1" webview_flutter_platform_interface: dependency: transitive description: name: webview_flutter_platform_interface - url: "https://pub.dartlang.org" + sha256: "6d9213c65f1060116757a7c473247c60f3f7f332cac33dc417c9e362a9a13e4f" + url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.6.0" webview_flutter_wkwebview: dependency: transitive description: name: webview_flutter_wkwebview - url: "https://pub.dartlang.org" + sha256: af6f5ab05918070b33507b0d453ba9fb7d39338a3256c23cf9433dc68100774a + url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.9.3" win32: dependency: transitive description: name: win32 - url: "https://pub.dartlang.org" + sha256: "350a11abd2d1d97e0cc7a28a81b781c08002aa2864d9e3f192ca0ffa18b06ed3" + url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "5.0.9" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "41fd8a189940d8696b1b810efb9abcf60827b6cbfab90b0c43e8439e3a39d85a" + url: "https://pub.dev" + source: hosted + version: "1.1.2" xdg_directories: dependency: transitive description: name: xdg_directories - url: "https://pub.dartlang.org" + sha256: "589ada45ba9e39405c198fe34eb0f607cddb2108527e658136120892beac46d2" + url: "https://pub.dev" source: hosted - version: "0.2.0+3" + version: "1.0.3" xml: dependency: transitive description: name: xml - url: "https://pub.dartlang.org" + sha256: "5bc72e1e45e941d825fd7468b9b4cc3b9327942649aeb6fc5cdbf135f0a86e84" + url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "6.3.0" yaml: dependency: transitive description: name: yaml - url: "https://pub.dartlang.org" + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.2" sdks: - dart: ">=2.18.2 <3.0.0" - flutter: ">=3.3.0" + dart: ">=3.1.0 <4.0.0" + flutter: ">=3.13.0" diff --git a/pubspec.yaml b/pubspec.yaml index cb72a09..6c7cb05 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: obtainium -description: A new Flutter project. +description: Get Android App Updates Directly From the Source. # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. @@ -17,10 +17,10 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.10.4+110 # When changing this, update the tag in main() accordingly +version: 0.14.32+226 # When changing this, update the tag in main() accordingly environment: - sdk: '>=2.18.2 <3.0.0' + sdk: '>=3.0.0 <4.0.0' # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions @@ -37,46 +37,51 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.5 path_provider: ^2.0.11 - flutter_fgbg: ^0.2.0 # Try removing reliance on this - flutter_local_notifications: ^13.0.0 + flutter_fgbg: ^0.3.0 # Try removing reliance on this + flutter_local_notifications: ^16.1.0 provider: ^6.0.3 - http: ^0.13.5 + http: ^1.0.0 webview_flutter: ^4.0.0 dynamic_color: ^1.5.4 html: ^0.15.0 shared_preferences: ^2.0.15 url_launcher: ^6.1.5 - permission_handler: ^10.0.0 + permission_handler: ^11.0.0 fluttertoast: ^8.0.9 - device_info_plus: ^8.0.0 - file_picker: ^5.1.0 + device_info_plus: ^9.0.0 + file_picker: ^6.0.0 animations: ^2.0.4 - install_plugin_v2: ^1.0.0 - share_plus: ^6.0.1 - installed_apps: ^1.3.1 - package_archive_info: ^0.1.0 - android_alarm_manager_plus: ^2.1.0 + android_package_installer: + git: + url: https://github.com/ImranR98/android_package_installer + ref: main + android_package_manager: ^0.6.0 + share_plus: ^7.0.0 + android_alarm_manager_plus: ^3.0.0 sqflite: ^2.2.0+3 easy_localization: ^3.0.1 - + android_intent_plus: ^4.0.0 + flutter_markdown: ^0.6.14 + flutter_archive: ^5.0.0 + hsluv: ^1.1.3 + connectivity_plus: ^5.0.0 + shared_storage: ^0.8.0 dev_dependencies: flutter_test: sdk: flutter - flutter_launcher_icons: ^0.11.0 + flutter_launcher_icons: ^0.13.1 # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is # activated in the `analysis_options.yaml` file located at the root of your # package. See that file for information about deactivating specific lint # rules and activating additional ones. - flutter_lints: ^2.0.1 + flutter_lints: ^3.0.0 -flutter_icons: - android: true +flutter_launcher_icons: + android: "ic_launcher" image_path: "assets/graphics/icon.png" - adaptive_icon_background: "#FFFFFF" - adaptive_icon_foreground: "assets/graphics/icon.png" # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -96,6 +101,8 @@ flutter: assets: - assets/translations/ + - assets/graphics/ + - assets/ca/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware