Compare commits

...

56 Commits

Author SHA1 Message Date
09d729261c Merge pull request #2368 from leywino/main
Create ml.json
2025-07-02 16:01:37 -04:00
e50e9e40fc Merge pull request #2370 from DwainZwerg/main
Update/Add German
2025-07-02 16:01:26 -04:00
3f1ca3b957 Update full_description.txt
fix link
2025-06-24 11:05:26 +02:00
8d95da46b1 add german 2025-06-22 15:55:30 +02:00
a11de53c97 Merge branch 'ImranR98:main' into main 2025-06-22 15:06:06 +02:00
cf088f15ae Create ml.json 2025-06-22 17:30:05 +05:30
6055ae6a69 Merge pull request #2360 from solokot/main
Update Russian translation
2025-06-21 12:44:52 -04:00
2223616f2a Update Russian translation
Unification of terminology, correction of machine translation errors
2025-06-18 13:52:03 +03:00
dab5e4246b Update de.json
spelling
2025-06-17 12:42:53 +02:00
39b76a41cf Merge pull request #2357 from ImranR98/dev
Fix versionCode issue for universal builds (#2352)
2025-06-15 14:44:24 -04:00
377ebeeca2 Increment version 2025-06-15 14:44:14 -04:00
c88f55af5a Fix versionCode issue for universal builds (#2352) 2025-06-15 14:42:58 -04:00
2dca74edb2 Update README.md 2025-06-13 17:53:09 -04:00
e35fd1e01e Merge pull request #2350 from ImranR98/dev
- Minor wording changes (#2226, #2313, #2317, #2338)
- HTML bugfix: Incorrect URL resolution on redirected pages (#2315)
2025-06-13 17:23:40 -04:00
86be6a77d7 Increment version 2025-06-13 17:22:50 -04:00
7d2f215b80 Minor wording changes (#2226, #2313, #2317, #2338) 2025-06-13 17:22:03 -04:00
7d9a641e24 Dart fix + Flutter upgrade 2025-06-13 17:07:09 -04:00
e0c69b9cf4 Lint all files 2025-06-13 16:53:36 -04:00
5f971dcddb HTML bugfix: Incorrect URL resolution on redirected pages (#2315) 2025-06-13 16:49:59 -04:00
b539f0a926 Merge pull request #2328 from Cambrells/main
Update Catalan Translation
2025-06-13 16:19:10 -04:00
db82fe7b8f Merge pull request #2329 from lagodimos/format-selected-apps-export
Format json file also when exporting selected apps
2025-06-13 16:18:57 -04:00
81c9f4ad47 Format json file also when exporting selected apps 2025-05-31 14:35:31 +03:00
092f81cb00 Update Catalan Translation 2025-05-31 11:51:49 +02:00
fc4596c0bc Merge pull request #2327 from ImranR98/dev
- Better error message for file deletion failures (#2298)
- Experiment with smarter version detection (#2324)
- Support Debian-style standard versions (#2314)
- Minor UI tweak (pseudo-version in italics on apps page instead of readout)
- Search should not select any sources by default
- Update Android-side Gradle + upgrade to Java 21 + upgrade Flutter packages where possible
2025-05-31 04:32:02 -04:00
f6587ae8da Fix GH workflow 2025-05-31 04:02:55 -04:00
0a30bf6d8e Update to Java 21 + hardcode NDK to avoid warnings 2025-05-31 03:57:16 -04:00
8bec3cf053 Update Android-side Gradle stuff + dependencies + upgrade all Flutter packages to latest 2025-05-31 03:35:29 -04:00
43efc044d7 Upgrade packages, increment version, improve build.sh 2025-05-31 00:09:56 -04:00
63e71624f9 Better error message for file deletion failures (#2298) 2025-05-30 23:48:14 -04:00
3edaa143e4 Minor UI tweak (pseudo-version in italics on apps page instead of readout) 2025-05-30 22:29:32 -04:00
798bddd17f Merge remote-tracking branch 'origin/main' into dev 2025-05-30 22:03:45 -04:00
4f27e25a23 Experiment with smarter version detection (#2324)
DO NOT merge without thorough testing
2025-05-30 21:52:59 -04:00
a2a1f48310 Merge pull request #2308 from summoner001/main
Update hu.json
2025-05-30 21:32:47 -04:00
c246548436 Search should not select any sources by default 2025-05-25 15:03:31 -04:00
f67cfeb231 Support Debian-style standard versions (#2314) 2025-05-25 14:58:57 -04:00
9758f8b391 Update hu.json
Translate new string
2025-05-20 08:02:03 +02:00
c45aaff610 Merge pull request #2307 from ImranR98/dev
Update translations
2025-05-19 17:51:36 -04:00
bd32a10de7 Update translations 2025-05-19 17:51:11 -04:00
ef83be07cd Merge pull request #2306 from ImranR98/dev
Minor updates
2025-05-19 15:55:51 -04:00
9fe33737eb Upgrade packages, increment version 2025-05-19 15:55:02 -04:00
51c7acf466 Update ar language name 2025-05-19 15:53:51 -04:00
d8490c289d Merge remote-tracking branch 'origin/main' into dev 2025-05-19 15:51:46 -04:00
1d73d4dcbb Merge pull request #2305 from bernikr/apkpure-api
Rewrite APKPure Source to use the mobile API instead of web scraping
2025-05-19 15:50:06 -04:00
6d28d15d4f add translations 2025-05-19 21:47:04 +02:00
3890785fc7 Add Arabic to language menu 2025-05-19 15:19:34 -04:00
38d34c810b Merge pull request #2303 from Hussain96o/main
Create ar.json
2025-05-19 15:15:59 -04:00
b2d6752b80 apkpure: use provided asset download links 2025-05-19 21:10:18 +02:00
47a6e0dc7c apkpure: improve typing 2025-05-19 19:24:33 +02:00
5b6299496f apkpure: determine APK/XAPK only by asset type, ignore APKs
apks install resulted in "incompatible" error with some apps on testing
2025-05-19 19:14:11 +02:00
369127806f apkpure: throw NoReleasesError when version list is empty 2025-05-19 19:00:54 +02:00
ee9b0e710c apkpure: properly throw NoAPKError if none found 2025-05-19 18:42:55 +02:00
6e0819b0a7 apkpure: only use nc query param if not empty 2025-05-19 18:41:28 +02:00
d8c9cd6579 apkpure: detect 'universal' and 'unlimited' architectures 2025-05-19 18:34:15 +02:00
ae2dad01ff rename newst apk option 2025-05-19 18:29:45 +02:00
3345b26fa9 rewrite apkpure source to use api instead of web scraping 2025-05-19 18:17:32 +02:00
99e3b8ac4f Create ar.json
Create an Arabic language file
2025-05-18 20:35:06 +03:00
90 changed files with 8353 additions and 5917 deletions

View File

@ -20,7 +20,7 @@ jobs:
- uses: actions/setup-java@v4
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
java-version: '21'
- name: Flutter Doctor
id: flutter_doctor
@ -44,7 +44,7 @@ jobs:
- name: Build APKs
run: |
sed -i 's/signingConfig signingConfigs.release//g' android/app/build.gradle
sed -i 's/signingConfig = signingConfigs.getByName("release")//g' android/app/build.gradle.kts
flutter build apk --flavor normal && flutter build apk --split-per-abi --flavor normal
for file in build/app/outputs/flutter-apk/app-*normal*.apk*; do mv "$file" "${file//-normal/}"; done
flutter build apk --flavor fdroid -t lib/main_fdroid.dart && flutter build apk --split-per-abi --flavor fdroid -t lib/main_fdroid.dart

View File

@ -1,6 +1,6 @@
# ![Obtainium Icon](./assets/graphics/icon_small.png) Obtainium
<div align="center"><a href="https://github.com/Safouene1/support-palestine-banner/blob/master/Markdown-pages/Support.md"><img src="https://raw.githubusercontent.com/Safouene1/support-palestine-banner/master/banner-support.svg" alt="Support Palestine" style="width: 100%;"></a></div>
[![Ceasefire Now](https://badge.techforpalestine.org/default)](https://techforpalestine.org/learn-more)
# ![Obtainium Icon](./assets/graphics/icon_small.png) Obtainium
Get Android app updates straight from the source.
@ -63,7 +63,7 @@ Or, contribute some configurations to the website by creating a PR at [this repo
Verification info:
- Package ID: `dev.imranr.obtainium`
- SHA-256 Hash of Signing Certificate: `B3:53:60:1F:6A:1D:5F:D6:60:3A:E2:F5:0B:E8:0C:F3:01:36:7B:86:B6:AB:8B:1F:66:24:3D:A9:6C:D5:73:62`
- SHA-256 hash of signing certificate: `B3:53:60:1F:6A:1D:5F:D6:60:3A:E2:F5:0B:E8:0C:F3:01:36:7B:86:B6:AB:8B:1F:66:24:3D:A9:6C:D5:73:62`
- Note: The above signature is also valid for the F-Droid flavour of Obtainium, thanks to [reproducible builds](https://f-droid.org/docs/Reproducible_Builds/).
- [PGP Public Key](https://keyserver.ubuntu.com/pks/lookup?search=contact%40imranr.dev&fingerprint=on&op=index) (to verify APK hashes)

View File

@ -1,105 +0,0 @@
plugins {
id "com.android.application"
id "kotlin-android"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id "dev.flutter.flutter-gradle-plugin"
}
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader('UTF-8') { reader ->
localProperties.load(reader)
}
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
flutterVersionCode = '1'
}
def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
namespace = "dev.imranr.obtainium"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8
}
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://flutter.dev/to/review-gradle-config.
minSdk = 24
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
flavorDimensions "flavor"
productFlavors {
normal {
dimension "flavor"
applicationIdSuffix ""
}
fdroid {
dimension "flavor"
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
}
debug {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
}
}
}
flutter {
source = "../.."
}
ext.abiCodes = ["x86_64": 1, "armeabi-v7a": 2, "arm64-v8a": 3]
import com.android.build.OutputFile
android.applicationVariants.all { variant ->
variant.outputs.each { output ->
def abiVersionCode = project.ext.abiCodes.get(output.getFilter(OutputFile.ABI))
if (abiVersionCode != null) {
output.versionCodeOverride = variant.versionCode * 10 + abiVersionCode
} else {
output.versionCodeOverride = variant.versionCode * 10
}
}
}

View File

@ -0,0 +1,107 @@
import java.io.FileInputStream
import java.util.Properties
import com.android.build.api.variant.FilterConfiguration.FilterType.*
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
val localProperties = Properties()
val localPropertiesFile = rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
localPropertiesFile.reader(Charsets.UTF_8).use { reader ->
localProperties.load(reader)
}
}
var flutterVersionCode = localProperties.getProperty("flutter.versionCode") ?: "1"
var flutterVersionName = localProperties.getProperty("flutter.versionName") ?: "1.0"
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
namespace = "dev.imranr.obtainium"
compileSdk = flutter.compileSdkVersion
ndkVersion = "27.0.12077973" // 'flutter.ndkVersion' produces warnings (TODO can/should we switch back?)
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
}
defaultConfig {
applicationId = "dev.imranr.obtainium"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 24
targetSdk = flutter.targetSdkVersion
versionCode = flutterVersionCode.toInt()
versionName = flutterVersionName
}
flavorDimensions("flavor")
productFlavors {
create("normal") {
dimension = "flavor"
applicationIdSuffix = ""
}
create("fdroid") {
dimension = "flavor"
applicationIdSuffix = ".fdroid"
}
}
signingConfigs {
create("release") {
keyAlias = keystoreProperties["keyAlias"].toString()
keyPassword = keystoreProperties["keyPassword"].toString()
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
storePassword = keystoreProperties["storePassword"].toString()
}
}
buildTypes {
getByName("release") {
signingConfig = signingConfigs.getByName("release")
}
getByName("debug") {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
}
}
}
val abiCodes = mapOf("x86_64" to 1, "armeabi-v7a" to 2, "arm64-v8a" to 3)
androidComponents {
onVariants { variant ->
variant.outputs.forEach { output ->
val name = output.filters.find { it.filterType == ABI }?.identifier
val baseAbiCode = abiCodes[name] ?: 0
if (baseAbiCode != null) {
output.versionCode.set(baseAbiCode + ((output.versionCode.get() ?: 0) * 10))
}
}
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
}
flutter {
source = "../.."
}

View File

@ -2,4 +2,4 @@ package dev.imranr.obtainium
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity()
class MainActivity : FlutterActivity()

View File

@ -1,22 +0,0 @@
allprojects {
repositories {
google()
mavenCentral()
maven {
// [required] background_fetch
url "${project(':background_fetch').projectDir}/libs"
}
}
}
rootProject.buildDir = "../build"
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

25
android/build.gradle.kts Normal file
View File

@ -0,0 +1,25 @@
allprojects {
repositories {
google()
mavenCentral()
maven {
// [required] background_fetch
url = uri("${project(":background_fetch").projectDir}/libs")
}
}
}
val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@ -1,3 +1,3 @@
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true

View File

@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip

View File

@ -1,25 +0,0 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.1.0" apply false
id "org.jetbrains.kotlin.android" version "2.1.21" apply false
}
include ":app"

View File

@ -0,0 +1,25 @@
pluginManagement {
val flutterSdkPath = run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
}
include(":app")

393
assets/translations/ar.json Normal file
View File

@ -0,0 +1,393 @@
{
"invalidURLForSource": "رابط تطبيق {} غير صالح",
"noReleaseFound": "تعذر العثور على إصدار مناسب",
"noVersionFound": "تعذر تحديد إصدار التطبيق",
"urlMatchesNoSource": "الرابط لا يتطابق مع مصدر معروف",
"cantInstallOlderVersion": "لا يمكن تثبيت إصدار أقدم من التطبيق",
"appIdMismatch": "معرّف الحزمة المحملة لا يتطابق مع معرّف التطبيق الحالي",
"functionNotImplemented": "هذه الوظيفة غير مطبقة في هذا الصنف",
"placeholder": "عنصر نائب",
"someErrors": "حدثت بعض الأخطاء",
"unexpectedError": "خطأ غير متوقع",
"ok": "موافق",
"and": "و",
"githubPATLabel": "رمز الوصول الشخصي لـ GitHub (يزيد من الحد المسموح)",
"includePrereleases": "تضمين الإصدارات الأولية",
"fallbackToOlderReleases": "الرجوع إلى الإصدارات الأقدم",
"filterReleaseTitlesByRegEx": "تصفية عناوين الإصدارات باستخدام تعبير نمطي",
"invalidRegEx": "تعبير نمطي غير صالح",
"noDescription": "لا يوجد وصف",
"cancel": "إلغاء",
"continue": "متابعة",
"requiredInBrackets": "(مطلوب)",
"dropdownNoOptsError": "خطأ: يجب أن يحتوي القائمة المنسدلة على خيار واحد على الأقل",
"colour": "لون",
"standard": "قياسي",
"custom": "مخصص",
"useMaterialYou": "استخدم Material You",
"githubStarredRepos": "مستودعات GitHub المفضلة",
"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": "تحديث متاح",
"notInstalled": "غير مثبت",
"pseudoVersion": "إصدار وهمي",
"selectAll": "تحديد الكل",
"deselectX": "إلغاء تحديد {}",
"xWillBeRemovedButRemainInstalled": "سيتم إزالة {} من Obtainium لكنه سيظل مثبتًا على الجهاز.",
"removeSelectedAppsQuestion": "إزالة التطبيقات المحددة؟",
"removeSelectedApps": "إزالة التطبيقات المحددة",
"updateX": "تحديث {}",
"installX": "تثبيت {}",
"markXTrackOnlyAsUpdated": "تعليم {}\n(للتعقب فقط)\nكمحدث",
"changeX": "تغيير {}",
"installUpdateApps": "تثبيت/تحديث التطبيقات",
"installUpdateSelectedApps": "تثبيت/تحديث التطبيقات المحددة",
"markXSelectedAppsAsUpdated": "تعليم {} تطبيقات محددة كمحدثة؟",
"no": "لا",
"yes": "نعم",
"markSelectedAppsUpdated": "تعليم التطبيقات المحددة كمحدثة",
"pinToTop": "تثبيت في الأعلى",
"unpinFromTop": "إلغاء التثبيت من الأعلى",
"resetInstallStatusForSelectedAppsQuestion": "إعادة تعيين حالة التثبيت للتطبيقات المحددة؟",
"installStatusOfXWillBeResetExplanation": "سيتم إعادة تعيين حالة التثبيت لأي تطبيقات محددة.\n\nقد يساعد هذا عندما يكون إصدار التطبيق المعروض في Obtainium غير صحيح بسبب فشل التحديثات أو مشاكل أخرى.",
"customLinkMessage": "هذه الروابط تعمل على الأجهزة التي بها Obtainium مثبتًا",
"shareAppConfigLinks": "مشاركة تكوين التطبيق كرابط HTML",
"shareSelectedAppURLs": "مشاركة روابط التطبيقات المحددة",
"resetInstallStatus": "إعادة تعيين حالة التثبيت",
"more": "المزيد",
"removeOutdatedFilter": "إزالة فلتر التطبيقات غير المحدثة",
"showOutdatedOnly": "عرض التطبيقات غير المحدثة فقط",
"filter": "تصفية",
"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": "حدثت أخطاء في الروابط التالية:",
"selectURL": "اختر رابطًا",
"selectURLs": "اختر روابط",
"pick": "اختر",
"theme": "المظهر",
"dark": "مظلم",
"light": "فاتح",
"followSystem": "اتباع النظام",
"followSystemThemeExplanation": "اتباع مظهر النظام ممكن فقط باستخدام تطبيقات طرف ثالث",
"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": "تم تحديث التطبيقات",
"appsNotUpdated": "فشل تحديث التطبيقات",
"appsUpdatedNotifDescription": "يخطر المستخدم بأن تحديثات لواحد أو أكثر من التطبيقات تم تطبيقها في الخلفية",
"xWasUpdatedToY": "تم تحديث {} إلى {}.",
"xWasNotUpdatedToY": "فشل تحديث {} إلى {}.",
"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": "معرّف التطبيق أو الاسم",
"appId": "معرّف التطبيق",
"appWithIdOrNameNotFound": "لم يتم العثور على تطبيق بهذا المعرّف أو الاسم",
"reposHaveMultipleApps": "قد تحتوي المستودعات على عدة تطبيقات",
"fdroidThirdPartyRepo": "مستودع F-Droid طرف ثالث",
"install": "تثبيت",
"markInstalled": "تعليم كمثبت",
"update": "تحديث",
"markUpdated": "تعليم كمحدث",
"additionalOptions": "خيارات إضافية",
"disableVersionDetection": "تعطيل اكتشاف الإصدار",
"noVersionDetectionExplanation": "يجب استخدام هذا الخيار فقط للتطبيقات التي لا يعمل فيها اكتشاف الإصدار بشكل صحيح.",
"downloadingX": "جاري تنزيل {}",
"downloadX": "تنزيل {}",
"downloadedX": "تم تنزيل {}",
"releaseAsset": "ملف الإصدار",
"downloadNotifDescription": "يخطر المستخدم بالتقدم في تنزيل التطبيق",
"noAPKFound": "لم يتم العثور على ملف APK",
"noVersionDetection": "لا يوجد اكتشاف للإصدار",
"categorize": "تصنيف",
"categories": "التصنيفات",
"category": "تصنيف",
"noCategory": "بدون تصنيف",
"noCategories": "لا توجد تصنيفات",
"deleteCategoriesQuestion": "حذف التصنيفات؟",
"categoryDeleteWarning": "سيتم تعيين جميع التطبيقات في التصنيفات المحذوفة كغير مصنفة.",
"addCategory": "إضافة تصنيف",
"label": "تسمية",
"language": "اللغة",
"copiedToClipboard": "تم النسخ إلى الحافظة",
"storagePermissionDenied": "تم رفض إذن التخزين",
"selectedCategorizeWarning": "سيؤدي هذا إلى استبدال أي إعدادات تصنيف موجودة للتطبيقات المحددة.",
"filterAPKsByRegEx": "تصفية ملفات APK باستخدام تعبير نمطي",
"removeFromObtainium": "إزالة من Obtainium",
"uninstallFromDevice": "إلغاء التثبيت من الجهاز",
"onlyWorksWithNonVersionDetectApps": "يعمل فقط مع التطبيقات التي تم تعطيل اكتشاف الإصدار لها.",
"releaseDateAsVersion": "استخدام تاريخ الإصدار كسلسلة إصدار",
"releaseTitleAsVersion": "استخدام عنوان الإصدار كسلسلة إصدار",
"releaseDateAsVersionExplanation": "يجب استخدام هذا الخيار فقط للتطبيقات التي لا يعمل فيها اكتشاف الإصدار بشكل صحيح، ولكن تاريخ الإصدار متاح.",
"changes": "التغييرات",
"releaseDate": "تاريخ الإصدار",
"importFromURLsInFile": "استيراد من روابط في ملف (مثل OPML)",
"versionDetectionExplanation": "تنسيق سلسلة الإصدار مع الإصدار المكتشف من النظام",
"versionDetection": "اكتشاف الإصدار",
"standardVersionDetection": "اكتشاف الإصدار القياسي",
"groupByCategory": "التجميع حسب التصنيف",
"autoApkFilterByArch": "محاولة تصفية ملفات APK حسب بنية المعالج إذا أمكن",
"autoLinkFilterByArch": "محاولة تصفية الروابط حسب بنية المعالج إذا أمكن",
"overrideSource": "تجاوز المصدر",
"dontShowAgain": "عدم إظهار هذا مرة أخرى",
"dontShowTrackOnlyWarnings": "عدم إظهار تحذيرات 'للتعقب فقط'",
"dontShowAPKOriginWarnings": "عدم إظهار تحذيرات مصدر APK",
"moveNonInstalledAppsToBottom": "نقل التطبيقات غير المثبتة إلى أسفل قائمة التطبيقات",
"gitlabPATLabel": "رمز الوصول الشخصي لـ GitLab",
"about": "حول",
"requiresCredentialsInSettings": "{} يحتاج إلى بيانات اعتماد إضافية (في الإعدادات)",
"checkOnStart": "التحقق من التحديثات عند البدء",
"tryInferAppIdFromCode": "محاولة استنتاج معرّف التطبيق من الكود المصدري",
"removeOnExternalUninstall": "إزالة التطبيقات المثبتة خارجيًا تلقائيًا",
"pickHighestVersionCode": "تحديد أعلى إصدار تلقائيًا",
"checkUpdateOnDetailPage": "التحقق من التحديثات عند فتح صفحة التفاصيل",
"disablePageTransitions": "تعطيل انتقالات الصفحات",
"reversePageTransitions": "عكس انتقالات الصفحات",
"minStarCount": "الحد الأدنى لعدد النجوم",
"addInfoBelow": "أضف هذه المعلومات أدناه.",
"addInfoInSettings": "أضف هذه المعلومات في الإعدادات.",
"githubSourceNote": "يمكن تجنب الحد المسموح لـ GitHub باستخدام مفتاح API.",
"sortByLastLinkSegment": "الفرز حسب الجزء الأخير من الرابط فقط",
"filterReleaseNotesByRegEx": "تصفية ملاحظات الإصدار باستخدام تعبير نمطي",
"customLinkFilterRegex": "تصفية روابط APK المخصصة باستخدام تعبير نمطي (افتراضيًا '.apk$')",
"appsPossiblyUpdated": "تمت محاولة تحديث التطبيقات",
"appsPossiblyUpdatedNotifDescription": "يخطر المستخدم بأن تحديثات لواحد أو أكثر من التطبيقات قد تم تطبيقها في الخلفية",
"xWasPossiblyUpdatedToY": "{} قد تم تحديثه إلى {}.",
"enableBackgroundUpdates": "تمكين التحديثات في الخلفية",
"backgroundUpdateReqsExplanation": "قد لا تكون التحديثات في الخلفية ممكنة لجميع التطبيقات.",
"backgroundUpdateLimitsExplanation": "يمكن تحديد نجاح التثبيت في الخلفية فقط عند فتح Obtainium.",
"verifyLatestTag": "التحقق من علامة 'الأحدث'",
"intermediateLinkRegex": "تصفية للوصول إلى رابط 'وسيط'",
"filterByLinkText": "تصفية الروابط حسب نص الرابط",
"intermediateLinkNotFound": "لم يتم العثور على رابط وسيط",
"intermediateLink": "رابط وسيط",
"exemptFromBackgroundUpdates": "إعفاء من التحديثات في الخلفية (إذا تم تمكينها)",
"bgUpdatesOnWiFiOnly": "تعطيل التحديثات في الخلفية عند عدم الاتصال بشبكة Wi-Fi",
"bgUpdatesWhileChargingOnly": "تعطيل التحديثات في الخلفية عند عدم الشحن",
"autoSelectHighestVersionCode": "تحديد أعلى إصدار تلقائيًا",
"versionExtractionRegEx": "تعبير نمطي لاستخراج الإصدار",
"trimVersionString": "قص سلسلة الإصدار باستخدام تعبير نمطي",
"matchGroupToUseForX": "مجموعة المطابقة لاستخدامها لـ \"{}\"",
"matchGroupToUse": "مجموعة المطابقة لاستخدامها في تعبير استخراج الإصدار",
"highlightTouchTargets": "تمييز أهداف اللمس الأقل وضوحًا",
"pickExportDir": "اختر دليل التصدير",
"autoExportOnChanges": "تصدير تلقائي عند التغييرات",
"includeSettings": "تضمين الإعدادات",
"filterVersionsByRegEx": "تصفية الإصدارات باستخدام تعبير نمطي",
"trySelectingSuggestedVersionCode": "محاولة تحديد الإصدار المقترح تلقائيًا",
"dontSortReleasesList": "الحفاظ على ترتيب الإصدارات من API",
"reverseSort": "فرز عكسي",
"takeFirstLink": "أخذ أول رابط",
"skipSort": "تخطي الفرز",
"debugMenu": "قائمة التصحيح",
"bgTaskStarted": "بدأت المهمة في الخلفية - تحقق من السجلات.",
"runBgCheckNow": "تشغيل التحقق من التحديثات في الخلفية الآن",
"versionExtractWholePage": "تطبيق تعبير استخراج الإصدار على الصفحة بأكملها",
"installing": "جاري التثبيت",
"skipUpdateNotifications": "تخطي إشعارات التحديث",
"updatesAvailableNotifChannel": "تحديثات متاحة",
"appsUpdatedNotifChannel": "تم تحديث التطبيقات",
"appsPossiblyUpdatedNotifChannel": "تمت محاولة تحديث التطبيقات",
"errorCheckingUpdatesNotifChannel": "خطأ في التحقق من التحديثات",
"appsRemovedNotifChannel": "تمت إزالة التطبيقات",
"downloadingXNotifChannel": "جاري تنزيل {}",
"completeAppInstallationNotifChannel": "إكمال تثبيت التطبيق",
"checkingForUpdatesNotifChannel": "جاري التحقق من التحديثات",
"onlyCheckInstalledOrTrackOnlyApps": "التحقق فقط من التطبيقات المثبتة أو للتعقب فقط",
"supportFixedAPKURL": "دعم روابط APK الثابتة",
"selectX": "اختر {}",
"parallelDownloads": "السماح بالتنزيل المتوازي",
"useShizuku": "استخدم Shizuku أو Sui للتثبيت",
"shizukuBinderNotFound": "خدمة Shizuku غير قيد التشغيل",
"shizukuOld": "إصدار قديم من Shizuku (<11) - قم بتحديثه",
"shizukuOldAndroidWithADB": "Shizuku يعمل على إصدار أندرويد < 8.1 مع ADB - قم بتحديث أندرويد أو استخدم Sui بدلاً من ذلك",
"shizukuPretendToBeGooglePlay": "تعيين Google Play كمصدر التثبيت (إذا تم استخدام Shizuku)",
"useSystemFont": "استخدم خط النظام",
"useVersionCodeAsOSVersion": "استخدم versionCode كإصدار مكتشف من النظام",
"requestHeader": "رأس الطلب",
"useLatestAssetDateAsReleaseDate": "استخدام أحدث تاريخ تحميل كتاريخ إصدار",
"defaultPseudoVersioningMethod": "طريقة الإصدار الوهمي الافتراضية",
"partialAPKHash": "جزء من تجزئة APK",
"APKLinkHash": "تجزئة رابط APK",
"directAPKLink": "رابط APK مباشر",
"pseudoVersionInUse": "يتم استخدام إصدار وهمي",
"installed": "مثبت",
"latest": "الأحدث",
"invertRegEx": "عكس التعبير النمطي",
"note": "ملاحظة",
"selfHostedNote": "يمكن استخدام القائمة المنسدلة \"{}\" للوصول إلى نسخ مخصصة/مستضافة ذاتيًا لأي مصدر.",
"badDownload": "تعذر تحليل ملف APK (تنزيل غير متوافق أو غير مكتمل)",
"beforeNewInstallsShareToAppVerifier": "مشاركة التطبيقات الجديدة مع AppVerifier (إذا كان متاحًا)",
"appVerifierInstructionToast": "شارك مع AppVerifier، ثم عد هنا عندما تكون جاهزًا.",
"wiki": "المساعدة/ويكي",
"crowdsourcedConfigsLabel": "تكوينات التطبيقات المقدمة من الجمهور (استخدم على مسؤوليتك)",
"crowdsourcedConfigsShort": "تكوينات التطبيقات المقدمة من الجمهور",
"allowInsecure": "السماح بطلبات HTTP غير الآمنة",
"stayOneVersionBehind": "البقاء بإصدار واحد خلف الأحدث",
"useFirstApkOfVersion": "التحديد التلقائي الأول من بين عدة ملفات APK",
"refreshBeforeDownload": "تحديث تفاصيل التطبيق قبل التنزيل",
"tencentAppStore": "متجر Tencent للتطبيقات",
"coolApk": "CoolApk",
"vivoAppStore": "متجر vivo للتطبيقات (CN)",
"name": "الاسم",
"smartname": "الاسم (ذكي)",
"sortMethod": "طريقة الفرز",
"welcome": "مرحبًا",
"documentationLinksNote": "تحتوي صفحة Obtainium على GitHub المرتبطة أدناه على روابط لمقاطع فيديو، مقالات، مناقشات وموارد أخرى ستساعدك على فهم كيفية استخدام التطبيق.",
"batteryOptimizationNote": "لاحظ أن التنزيلات في الخلفية قد تعمل بشكل أكثر موثوقية إذا قمت بتعطيل تحسينات بطارية النظام لـ Obtainium.",
"fileDeletionError": "فشل حذف الملف (حاول حذفه يدويًا ثم حاول مرة أخرى): \"{}\"",
"removeAppQuestion": {
"one": "إزالة التطبيق؟",
"other": "إزالة التطبيقات؟"
},
"tooManyRequestsTryAgainInMinutes": {
"one": "طلبات كثيرة جدًا (تم تحديد المعدل) - حاول مرة أخرى بعد {} دقيقة",
"other": "طلبات كثيرة جدًا (تم تحديد المعدل) - حاول مرة أخرى بعد {} دقائق"
},
"bgUpdateGotErrorRetryInMinutes": {
"one": "واجه التحقق من التحديثات في الخلفية {}، سيتم جدولة محاولة أخرى بعد {} دقيقة",
"other": "واجه التحقق من التحديثات في الخلفية {}، سيتم جدولة محاولة أخرى بعد {} دقائق"
},
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
"one": "وجد التحقق من التحديثات في الخلفية {} تحديث - سيتم إخطار المستخدم إذا لزم الأمر",
"other": "وجد التحقق من التحديثات في الخلفية {} تحديثات - سيتم إخطار المستخدم إذا لزم الأمر"
},
"apps": {
"one": "{} تطبيق",
"other": "{} تطبيقات"
},
"url": {
"one": "{} رابط",
"other": "{} روابط"
},
"minute": {
"one": "{} دقيقة",
"other": "{} دقائق"
},
"hour": {
"one": "{} ساعة",
"other": "{} ساعات"
},
"day": {
"one": "{} يوم",
"other": "{} أيام"
},
"clearedNLogsBeforeXAfterY": {
"one": "تم مسح {n} سجل (قبل = {before}, بعد = {after})",
"other": "تم مسح {n} سجلات (قبل = {before}, بعد = {after})"
},
"xAndNMoreUpdatesAvailable": {
"one": "{} وتطبيق واحد آخر لديه تحديثات.",
"other": "{} و{} تطبيقات أخرى لديها تحديثات."
},
"xAndNMoreUpdatesInstalled": {
"one": "{} وتطبيق واحد آخر تم تحديثه.",
"other": "{} و{} تطبيقات أخرى تم تحديثها."
},
"xAndNMoreUpdatesFailed": {
"one": "فشل تحديث {} وتطبيق واحد آخر.",
"other": "فشل تحديث {} و{} تطبيقات أخرى."
},
"xAndNMoreUpdatesPossiblyInstalled": {
"one": "{} وتطبيق واحد آخر قد تم تحديثه.",
"other": "{} و{} تطبيقات أخرى قد تم تحديثها."
},
"apk": {
"one": "{} APK",
"other": "{} APKs"
}
}

View File

@ -318,15 +318,18 @@
"crowdsourcedConfigsShort": "Crowdsourced App Configurations",
"allowInsecure": "Allow insecure HTTP requests",
"stayOneVersionBehind": "Stay one version behind latest",
"useFirstApkOfVersion": "Auto-select first of multiple APKs",
"refreshBeforeDownload": "Refresh app details before download",
"tencentAppStore": "Tencent App Store",
"coolApk": "CoolApk",
"vivoAppStore": "vivo App Store (CN)",
"name": "Name",
"smartname": "Name (Smart)",
"sortMethod": "Sort Method",
"welcome": "Welcome",
"documentationLinksNote": "The Obtainium GitHub page linked below contains links to videos, articles, discussions and other resources that will help you understand how to use the app.",
"batteryOptimizationNote": "Note that background downloads may work more reliably if you disable OS battery optimizations for Obtainium.",
"fileDeletionError": "Failed to delete file (try deleting it manually then try again): \"{}\"",
"removeAppQuestion": {
"one": "Želite li ukloniti aplikaciju?",
"other": "Želite li ukloniti aplikacije?"

View File

@ -318,15 +318,18 @@
"crowdsourcedConfigsShort": "Configuració de les aplicacions crowdsourcing",
"allowInsecure": "Permet les sol·licituds HTTP insegures",
"stayOneVersionBehind": "Roman a la versió anterior a l'última",
"useFirstApkOfVersion": "Auto-select first of multiple APKs",
"refreshBeforeDownload": "Actualitza les dades de l'aplicació abans de descarregar-la",
"tencentAppStore": "Tencent App Store",
"coolApk": "CoolApk",
"vivoAppStore": "vivo App Store (CN)",
"name": "Nom",
"smartname": "Nom (smart)",
"sortMethod": "Mètode d'ordenació",
"welcome": "Benvinguda",
"documentationLinksNote": "La pàgina GitHub d'Obtainium enllaçada a sota conté enllaços a vídeos, articles, debats i altres recursos que t'ajudaran a entendre com usar l'aplicació.",
"batteryOptimizationNote": "Tingues present que les descàrregues en segon pla funcionaran millor si inhabilites l'optimització de bateria per a Obtainium.",
"fileDeletionError": "No s'ha pogut suprimir el fitxer (intenta suprimir-lo manualment i torna-ho a provar): \"{}\"",
"removeAppQuestion": {
"one": "¿Suprimeixo l'aplicació?",
"other": "¿Suprimeixo les aplicacions?"

View File

@ -36,7 +36,7 @@
"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?",
"alreadyUpToDateQuestion": "Aplikace je již aktualizovaná?",
"addApp": "Přidat aplikaci",
"appSourceURL": "Zdrojová adresa URL aplikace",
"error": "Chyba",
@ -318,15 +318,18 @@
"crowdsourcedConfigsShort": "Konfigurace aplikací s využitím crowdsourcingu",
"allowInsecure": "Povolení nezabezpečených požadavků HTTP",
"stayOneVersionBehind": "Zůstaňte o jednu verzi pozadu za nejnovější",
"useFirstApkOfVersion": "Automatický výběr prvního z více souborů APK",
"refreshBeforeDownload": "Obnovení údajů o aplikaci před stažením",
"tencentAppStore": "Tencent App Store",
"coolApk": "CoolApk",
"vivoAppStore": "vivo App Store (CN)",
"name": "Název",
"smartname": "Název (Smart)",
"sortMethod": "Metoda třídění",
"welcome": "Vítejte na",
"documentationLinksNote": "Níže odkazovaná stránka Obtainium GitHub obsahuje odkazy na videa, články, diskuse a další zdroje, které vám pomohou pochopit, jak aplikaci používat.",
"batteryOptimizationNote": "Note that background downloads may work more reliably if you disable OS battery optimizations for Obtainium.",
"batteryOptimizationNote": "Všimněte si, že stahování na pozadí může fungovat spolehlivěji, pokud vypnete optimalizaci baterie operačního systému pro Obtainium.",
"fileDeletionError": "Soubor se nepodařilo odstranit (zkuste jej odstranit ručně a pak to zkuste znovu): \"{}\"",
"removeAppQuestion": {
"one": "Odstranit Apku?",
"other": "Odstranit Apky?"

View File

@ -318,15 +318,18 @@
"crowdsourcedConfigsShort": "Crowdsourcede app-konfigurationer",
"allowInsecure": "Tillad usikre HTTP-anmodninger",
"stayOneVersionBehind": "Forbliv én version bagud den seneste",
"useFirstApkOfVersion": "Vælg automatisk den første af flere APK'er",
"refreshBeforeDownload": "Opdater app-detaljer før download",
"tencentAppStore": "Tencent App Store",
"coolApk": "CoolApk",
"vivoAppStore": "vivo App Store (CN)",
"name": "Navn",
"smartname": "Navn (Smart)",
"sortMethod": "Sorteringsmetode",
"welcome": "Velkommen",
"documentationLinksNote": "Obtainiums GitHub-side, som der linkes til nedenfor, indeholder links til videoer, artikler, diskussioner og andre ressourcer, som kan hjælpe dig med at forstå, hvordan du bruger appen.",
"batteryOptimizationNote": "Note that background downloads may work more reliably if you disable OS battery optimizations for Obtainium.",
"batteryOptimizationNote": "Bemærk, at baggrundsdownloads kan fungere mere pålideligt, hvis du deaktiverer OS-batterioptimering for Obtainium.",
"fileDeletionError": "Kunne ikke slette filen (prøv at slette den manuelt og prøv igen): \"{}\"",
"removeAppQuestion": {
"one": "Fjern app?",
"other": "Fjern apps?"

View File

@ -19,7 +19,7 @@
"noDescription": "Keine Beschreibung",
"cancel": "Abbrechen",
"continue": "Weiter",
"requiredInBrackets": "(wird benötigt)",
"requiredInBrackets": "(notwendig)",
"dropdownNoOptsError": "FEHLER: DROPDOWN MUSS MINDESTENS EINE OPTION HABEN",
"colour": "Farbe",
"standard": "Standard",
@ -32,7 +32,7 @@
"source": "Quelle",
"app": "App",
"appsFromSourceAreTrackOnly": "Apps aus dieser Quelle sind nur zur Nachverfolgung.",
"youPickedTrackOnly": "Du hast die Option „Nur nachverfolgen“ gewählt.",
"youPickedTrackOnly": "Sie haben die Option „Nur nachverfolgen“ gewählt.",
"trackOnlyAppDescription": "Die App wird auf neue verfügbare Versionen überwacht, aber Obtainium wird sie nicht herunterladen oder installieren.",
"cancelled": "Abgebrochen",
"appAlreadyAdded": "App bereits hinzugefügt",
@ -46,7 +46,7 @@
"additionalOptsFor": "Zusatzoptionen für {}",
"supportedSources": "Unterstützte Quellen",
"trackOnlyInBrackets": "(Nur nachverfolgen)",
"searchableInBrackets": "(Durchsuchbar)",
"searchableInBrackets": "(durchsuchbar)",
"appsString": "Apps",
"noApps": "Keine Apps",
"noAppsForFilter": "Keine Apps für ausgewählten Filter",
@ -98,26 +98,26 @@
"importFromURLList": "Aus URL-Liste importieren",
"searchQuery": "Suchanfrage",
"appURLList": "App-URL-Liste",
"line": "Linie",
"line": "Zeile",
"searchX": "{} suchen",
"noResults": "Keine Ergebnisse gefunden",
"importX": "{} importieren",
"importedAppsIdDisclaimer": "Importierte Apps werden manchmal fälschlicherweise als „Nicht installiert“ angezeigt. Um dies zu beheben, installiere sie erneut über Obtainium. Dies hat keine Auswirkungen auf App-Daten. Es betrifft nur URL- und Drittanbieter-Importmethoden.",
"importedAppsIdDisclaimer": "Importierte Apps werden manchmal fälschlicherweise als „Nicht installiert“ angezeigt. Um dies zu beheben, installieren Sie sie erneut über Obtainium. Dies hat keine Auswirkungen auf App-Daten. Es betrifft nur URL- und Drittanbieter-Importmethoden.",
"importErrors": "Importfehler",
"importedXOfYApps": "{} von {} Apps importiert.",
"followingURLsHadErrors": "Bei folgenden URLs traten Fehler auf:",
"selectURL": "URL auswählen",
"selectURLs": "URLs auswählen",
"pick": "Auswählen",
"theme": "Design",
"theme": "Erscheinungsbild",
"dark": "Dunkel",
"light": "Hell",
"followSystem": "Systemstandard",
"followSystemThemeExplanation": "Das Abrufen des Systemdesigns ist unter Android < 10 nur mit Hilfe von Drittanbieterapps möglich",
"useBlackTheme": "Rein schwarzen Hintergrund verwenden",
"appSortBy": "App sortieren nach",
"authorName": "Hrsg./Name",
"nameAuthor": "Name/Hrsg.",
"authorName": "Herausgebende/Name",
"nameAuthor": "Name/Herausgebende",
"asAdded": "Wie hinzugefügt",
"appSortOrder": "App sortieren nach",
"ascending": "Aufsteigend",
@ -138,12 +138,12 @@
"obtainiumExportHyphenatedLowercase": "Obtainium-Export",
"pickAnAPK": "APK auswählen",
"appHasMoreThanOnePackage": "{} verfügt über mehr als ein Paket:",
"deviceSupportsXArch": "Dein Gerät unterstützt die CPU-Architektur {}.",
"deviceSupportsFollowingArchs": "Dein Gerät unterstützt die folgenden CPU-Architekturen:",
"deviceSupportsXArch": "Ihr Gerät unterstützt die CPU-Architektur {}.",
"deviceSupportsFollowingArchs": "Ihr Gerät unterstützt die folgenden CPU-Architekturen:",
"warning": "Warnung",
"sourceIsXButPackageFromYPrompt": "Die App-Quelle ist '{}', aber das Release-Paket stammt von '{}'. Fortfahren?",
"updatesAvailable": "Aktualisierungen verfügbar",
"updatesAvailableNotifDescription": "Benachrichtigt, wenn Aktualisierungen für eine oder mehrere von Obtainium verfolgte Apps verfügbar sind",
"updatesAvailableNotifDescription": "Benachrichtigt, wenn Aktualisierungen für eine oder mehrere von Obtainium überwachte Apps verfügbar sind",
"noNewUpdates": "Keine neuen Aktualisierungen.",
"xHasAnUpdate": "{} hat eine Aktualisierung.",
"appsUpdated": "App wurde aktualisiert",
@ -161,7 +161,7 @@
"completeAppInstallationNotifDescription": "Aufforderung zu Obtainium zurückzukehren, um die Installation einer App abzuschließen",
"checkingForUpdates": "Nach Aktualisierungen suchen",
"checkingForUpdatesNotifDescription": "Vorübergehende Benachrichtigung, die bei der Suche nach Aktualisierungen angezeigt wird",
"pleaseAllowInstallPerm": "Bitte erlaube Obtainium die Installation von Apps",
"pleaseAllowInstallPerm": "Bitte erlauben Sie Obtainium die Installation von Apps",
"trackOnly": "Nur nachverfolgen",
"errorWithHttpStatusCode": "Fehler {}",
"versionCorrectionDisabled": "Versionskorrektur deaktiviert (Plugin scheint nicht zu funktionieren)",
@ -189,8 +189,8 @@
"downloadingX": "{} wird heruntergeladen",
"downloadX": "{} herunterladen",
"downloadedX": "{} heruntergeladen",
"releaseAsset": "Release-Asset",
"downloadNotifDescription": "Zeigt den Fortschritt beim Herunterladen einer App",
"releaseAsset": "Release-Anhang",
"downloadNotifDescription": "Fortschrittsanzeige beim Herunterladen einer App",
"noAPKFound": "Keine APK gefunden",
"noVersionDetection": "Keine Versionserkennung",
"categorize": "Kategorisieren",
@ -221,16 +221,16 @@
"standardVersionDetection": "Standardversionserkennung",
"groupByCategory": "Nach Kategorie gruppieren",
"autoApkFilterByArch": "Nach Möglichkeit versuchen, APKs nach CPU-Architektur zu filtern",
"autoLinkFilterByArch": "Versuchen Sie, Links nach CPU-Architektur zu filtern, wenn möglich",
"autoLinkFilterByArch": "Nach Möglichkeit versuchen, APKs nach CPU-Architektur zu filtern",
"overrideSource": "Quelle überschreiben",
"dontShowAgain": "Nicht noch einmal anzeigen",
"dontShowTrackOnlyWarnings": "Warnung für „Nur nachverfolgen“ nicht anzeigen",
"dontShowTrackOnlyWarnings": Warnung für Nur nachverfolgen nicht anzeigen",
"dontShowAPKOriginWarnings": "Warnung für APK-Herkunft nicht anzeigen",
"moveNonInstalledAppsToBottom": "Nicht installierte Apps ans Ende der App-Ansicht verschieben",
"gitlabPATLabel": "Persönlicher Zugangstoken für GitLab",
"about": "Über",
"about": "Weitere Informationen",
"requiresCredentialsInSettings": "{}: Benötigt zusätzliche Anmeldedaten (in den Einstellungen)",
"checkOnStart": "Einmalig beim Start überprüfen",
"checkOnStart": "Einmalig beim Start auf Aktualisierungen prüfen",
"tryInferAppIdFromCode": "Versuchen, die App-ID aus dem Quellcode zu ermitteln",
"removeOnExternalUninstall": "Extern deinstallierte Apps automatische entfernen",
"pickHighestVersionCode": "Automatische Auswahl des APK mit höchstem Versionscode",
@ -246,7 +246,7 @@
"customLinkFilterRegex": "Benutzerdefinierter APK-Linkfilter durch regulären Ausdruck (Standard '.apk$')",
"appsPossiblyUpdated": "App-Aktualisierungen wurden versucht",
"appsPossiblyUpdatedNotifDescription": "Benachrichtigt, dass möglicherweise eine oder mehrere Apps im Hintergrund aktualisiert wurden",
"xWasPossiblyUpdatedToY": "{} wurde eventuell auf Version {} aktualisiert.",
"xWasPossiblyUpdatedToY": "{} wurde möglicherweise auf Version {} aktualisiert.",
"enableBackgroundUpdates": "Hintergrundaktualisierungen aktivieren",
"backgroundUpdateReqsExplanation": "Die Hintergrundaktualisierung ist unter Umständen nicht für alle Apps möglich.",
"backgroundUpdateLimitsExplanation": "Der Erfolg einer Hintergrundinstallation kann nur festgestellt werden, wenn Obtainium geöffnet wird.",
@ -287,19 +287,19 @@
"downloadingXNotifChannel": "{} herunterladen",
"completeAppInstallationNotifChannel": "App-Installation abschließen",
"checkingForUpdatesNotifChannel": "Nach Aktualisierungen suchen",
"onlyCheckInstalledOrTrackOnlyApps": "Nur installierte und mit „Nur nachverfolgen“ markierte Apps auf Aktualisierungen prüfen",
"onlyCheckInstalledOrTrackOnlyApps": "Nur mit Nur nachverfolgen markierte und installierte Apps auf Aktualisierungen prüfen",
"supportFixedAPKURL": "Unterstützung von festen APK-URLs",
"selectX": "{} wählen",
"parallelDownloads": "Parallele Downloads erlauben",
"useShizuku": "Shizuku oder Sui zur Installation verwenden",
"shizukuBinderNotFound": "Kompatibler Shizuku-Dienst wurde nicht gefunden",
"shizukuOld": "Veraltete Shizuku-Version (< 11) - bitte aktualisiere sie",
"shizukuOldAndroidWithADB": "Shizuku läuft auf Android < 8.1 mit ADB - aktualisiere die Android-Version oder verwende stattdessen Sui",
"shizukuOld": "Veraltete Shizuku-Version (< 11) bitte aktualisieren Sie sie",
"shizukuOldAndroidWithADB": "Shizuku läuft auf Android < 8.1 mit ADB aktualisieren Sie die Android-Version oder verwenden Sie stattdessen Sui",
"shizukuPretendToBeGooglePlay": "(Mittels Shizuku) Google Play als Installationsquelle registrieren",
"useSystemFont": "Systemschriftart verwenden",
"useVersionCodeAsOSVersion": "Versionscode (versionCode) als erkannte Version vom Betriebssystem verwenden",
"useVersionCodeAsOSVersion": "Versionscode (versionCode) als vom Betriebssystem erkannte Version verwenden",
"requestHeader": "Kopfzeile (Header) anfordern",
"useLatestAssetDateAsReleaseDate": "Letzten Asset-Upload als Veröffentlichungsdatum verwenden",
"useLatestAssetDateAsReleaseDate": "Letzten Upload eines Release-Anhangs als Veröffentlichungsdatum verwenden",
"defaultPseudoVersioningMethod": "Standardmäßiges Verfahren zur Pseudo-Versionierung",
"partialAPKHash": "Partieller APK-Hash",
"APKLinkHash": "APK-Link-Hash",
@ -318,22 +318,25 @@
"crowdsourcedConfigsShort": "Crowdsourced App-Konfigurationen",
"allowInsecure": "Unsichere HTTP-Anfragen zulassen",
"stayOneVersionBehind": "Eine Version hinter der neuesten Version bleiben",
"useFirstApkOfVersion": "Automatische Auswahl der ersten von mehreren APKs",
"refreshBeforeDownload": "App-Details vor dem Download aktualisieren",
"tencentAppStore": "Tencent App Store",
"coolApk": "CoolApk",
"vivoAppStore": "vivo App Store (China)",
"name": "Name",
"smartname": "Name (Smart)",
"sortMethod": "Sortierverfahren",
"welcome": "Willkommen",
"documentationLinksNote": "Die unten verlinkte GitHub-Seite von Obtainium enthält Links zu Videos, Artikeln, Diskussionen und anderen Ressourcen, die Ihnen helfen werden, die Verwendung der App zu verstehen.",
"batteryOptimizationNote": "Note that background downloads may work more reliably if you disable OS battery optimizations for Obtainium.",
"batteryOptimizationNote": "Beachten Sie, dass Downloads im Hintergrund möglicherweise zuverlässiger funktionieren, wenn Sie die Batterieoptimierung des Betriebssystems für Obtainium deaktivieren.",
"fileDeletionError": "Die Datei konnte nicht gelöscht werden (versuchen Sie, sie manuell zu löschen und versuchen Sie es dann erneut): \"{}\"",
"removeAppQuestion": {
"one": "App entfernen?",
"other": "Apps entfernen?"
},
"tooManyRequestsTryAgainInMinutes": {
"one": "Zu viele Anfragen (Rate begrenzt) versuche es in {} Minute erneut",
"other": "Zu viele Anfragen (Rate begrenzt) versuche 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",

View File

@ -318,15 +318,18 @@
"crowdsourcedConfigsShort": "Crowdsourced App Configurations",
"allowInsecure": "Allow insecure HTTP requests",
"stayOneVersionBehind": "Stay one version behind latest",
"useFirstApkOfVersion": "Auto-select first of multiple APKs",
"refreshBeforeDownload": "Refresh app details before download",
"tencentAppStore": "Tencent App Store",
"coolApk": "CoolApk",
"vivoAppStore": "vivo App Store (CN)",
"name": "Name",
"smartname": "Name (Smart)",
"sortMethod": "Sort Method",
"welcome": "Welcome",
"documentationLinksNote": "The Obtainium GitHub page linked below contains links to videos, articles, discussions and other resources that will help you understand how to use the app.",
"batteryOptimizationNote": "Note that background downloads may work more reliably if you disable OS battery optimizations for Obtainium.",
"fileDeletionError": "Failed to delete file (try deleting it manually then try again): \"{}\"",
"removeAppQuestion": {
"one": "Forigi la aplikaĵon?",
"other": "Forigi la aplikaĵojn?"

View File

@ -318,6 +318,7 @@
"crowdsourcedConfigsShort": "Crowdsourced app configurations",
"allowInsecure": "Allow insecure HTTP requests",
"stayOneVersionBehind": "Stay one version behind latest",
"useFirstApkOfVersion": "Auto-select first of multiple APKs",
"refreshBeforeDownload": "Refresh app details before download",
"tencentAppStore": "Tencent App Store",
"coolApk": "CoolApk",
@ -328,6 +329,7 @@
"welcome": "Welcome",
"documentationLinksNote": "The Obtainium GitHub page linked below contains links to videos, articles, discussions and other resources that will help you understand how to use the app.",
"batteryOptimizationNote": "Note that background downloads may work more reliably if you disable OS battery optimizations for Obtainium.",
"fileDeletionError": "Failed to delete file (try deleting it manually then try again): \"{}\"",
"removeAppQuestion": {
"one": "Remove app?",
"other": "Remove apps?"

View File

@ -318,15 +318,18 @@
"crowdsourcedConfigsShort": "Configuración de aplicaciones por crowdsourcing",
"allowInsecure": "Permitir peticiones HTTP inseguras",
"stayOneVersionBehind": "Mantenerse una versión por detrás de la última",
"useFirstApkOfVersion": "Auto-selección del primero de múltiples APKs",
"refreshBeforeDownload": "Actualiza los datos de la aplicación antes de descargarla",
"tencentAppStore": "Tencent App Store",
"coolApk": "CoolApk",
"vivoAppStore": "vivo App Store (CN)",
"name": "Nombre",
"smartname": "Nombre (Smart)",
"sortMethod": "Método de clasificación",
"welcome": "Bienvenido",
"documentationLinksNote": "La página GitHub de Obtainium enlazada a continuación contiene enlaces a vídeos, artículos, debates y otros recursos que te ayudarán a entender cómo utilizar la aplicación.",
"batteryOptimizationNote": "Note that background downloads may work more reliably if you disable OS battery optimizations for Obtainium.",
"batteryOptimizationNote": "Ten en cuenta que las descargas en segundo plano pueden funcionar de forma más fiable si desactivas las optimizaciones de batería del sistema operativo para Obtainium.",
"fileDeletionError": "No se ha podido eliminar el archivo (intente eliminarlo manualmente y vuelva a intentarlo): \"{}\"",
"removeAppQuestion": {
"one": "¿Eliminar aplicación?",
"other": "¿Eliminar aplicaciones?"

View File

@ -318,15 +318,18 @@
"crowdsourcedConfigsShort": "تنظیمات برنامه های مشارکت جمعی",
"allowInsecure": "درخواست های HTTP ناامن را مجاز کنید",
"stayOneVersionBehind": "یک نسخه از آخرین نسخه پشت سر بگذارید",
"useFirstApkOfVersion": "Auto-select first of multiple APKs",
"refreshBeforeDownload": "قبل از دانلود، جزئیات برنامه را بازخوانی کنید",
"tencentAppStore": "Tencent App Store",
"coolApk": "CoolApk",
"vivoAppStore": "vivo App Store (CN)",
"name": "Name",
"smartname": "Name (Smart)",
"sortMethod": "Sort Method",
"welcome": "Welcome",
"documentationLinksNote": "The Obtainium GitHub page linked below contains links to videos, articles, discussions and other resources that will help you understand how to use the app.",
"batteryOptimizationNote": "Note that background downloads may work more reliably if you disable OS battery optimizations for Obtainium.",
"fileDeletionError": "Failed to delete file (try deleting it manually then try again): \"{}\"",
"removeAppQuestion": {
"one": "برنامه حذف شود؟",
"other": "برنامه ها حذف شوند؟"

View File

@ -318,15 +318,18 @@
"crowdsourcedConfigsShort": "Applications communautaires",
"allowInsecure": "Autoriser les requêtes HTTP non sécurisées",
"stayOneVersionBehind": "Rester une version en arrière de la dernière",
"useFirstApkOfVersion": "Sélection automatique du premier APK parmi plusieurs",
"refreshBeforeDownload": "Actualiser les détails de l'application avant de la télécharger",
"tencentAppStore": "Tencent App Store",
"coolApk": "CoolApk",
"vivoAppStore": "vivo App Store (CN)",
"name": "Nom",
"smartname": "Nom (Smart)",
"sortMethod": "Méthode de tri",
"welcome": "Bienvenue",
"documentationLinksNote": "La page GitHub d'Obtainium, dont le lien figure ci-dessous, contient des liens vers des vidéos, des articles, des discussions et d'autres ressources qui vous aideront à comprendre comment utiliser l'application.",
"batteryOptimizationNote": "Note that background downloads may work more reliably if you disable OS battery optimizations for Obtainium.",
"batteryOptimizationNote": "Notez que les téléchargements en arrière-plan peuvent fonctionner de manière plus fiable si vous désactivez les optimisations de la batterie du système d'exploitation pour Obtainium.",
"fileDeletionError": "Échec de la suppression du fichier (essayez de le supprimer manuellement puis réessayez) : \"{}\"",
"removeAppQuestion": {
"one": "Supprimer l'application ?",
"other": "Supprimer les applications ?"

View File

@ -318,15 +318,18 @@
"crowdsourcedConfigsShort": "Alkalmazáslista",
"allowInsecure": "Nem biztonságos HTTP-kérések engedélyezése",
"stayOneVersionBehind": "Maradjon egy verzióval a legújabb mögött",
"useFirstApkOfVersion": "A legelső APK automatikus kiválasztása, ha több APK is található",
"refreshBeforeDownload": "Az alkalmazás adatainak frissítése a letöltés előtt",
"tencentAppStore": "Tencent Appstore",
"coolApk": "CoolApk",
"vivoAppStore": "vivo App Store (CN)",
"name": "Név",
"smartname": "Név (Okos)",
"sortMethod": "Rendezési eljárás",
"welcome": "Üdvözöljük!",
"documentationLinksNote": "Az alábbi hivatkozás az Obtainium GitHub oldalára vezet, amely további videók, cikkek, beszélgetések és egyéb források hivatkozásait tartalmazza, amelyek segítenek megérteni az alkalmazás használatát.",
"batteryOptimizationNote": "Megjegyzés: A háttérfrissítések megbízhatóbban működhetnek, ha kikapcsolja a rendszer akkumulátor-optimalizálását az Obtainium számára.",
"fileDeletionError": "Nem sikerült törölni a fájlt (próbálja meg kézzel törölni, majd próbálja meg újra): \"{}\"",
"removeAppQuestion": {
"one": "Eltávolítja az alkalmazást?",
"other": "Eltávolítja az alkalmazásokat?"

View File

@ -318,15 +318,18 @@
"crowdsourcedConfigsShort": "Konfigurasi Aplikasi Crowdsourced",
"allowInsecure": "Izinkan permintaan HTTP yang tidak aman",
"stayOneVersionBehind": "Tetap satu versi di belakang versi terbaru",
"useFirstApkOfVersion": "Pilih otomatis yang pertama dari beberapa APK",
"refreshBeforeDownload": "Segarkan detail aplikasi sebelum mengunduh",
"tencentAppStore": "Tencent App Store",
"coolApk": "CoolApk",
"vivoAppStore": "vivo App Store (CN)",
"name": "Nama",
"smartname": "Nama (Cerdas)",
"sortMethod": "Metode Penyortiran",
"welcome": "Selamat datang.",
"documentationLinksNote": "Halaman GitHub Obtainium yang ditautkan di bawah ini berisi tautan ke video, artikel, diskusi, dan sumber daya lain yang akan membantu Anda memahami cara menggunakan aplikasi.",
"batteryOptimizationNote": "Note that background downloads may work more reliably if you disable OS battery optimizations for Obtainium.",
"batteryOptimizationNote": "Perhatikan bahwa unduhan latar belakang dapat bekerja lebih andal jika Anda menonaktifkan optimasi baterai OS untuk Obtainium.",
"fileDeletionError": "Gagal menghapus file (coba hapus secara manual, lalu coba lagi): \"{}\"",
"removeAppQuestion": {
"one": "Hapus aplikasi?",
"other": "Hapus aplikasi?"

View File

@ -318,15 +318,18 @@
"crowdsourcedConfigsShort": "Configurazioni di app in crowdsourcing",
"allowInsecure": "Consentire le richieste HTTP non sicure",
"stayOneVersionBehind": "Rimanere una versione indietro rispetto alla più recente",
"useFirstApkOfVersion": "Selezione automatica del primo di più APK",
"refreshBeforeDownload": "Aggiornare i dettagli dell'app prima del download",
"tencentAppStore": "Tencent App Store",
"coolApk": "CoolApk",
"vivoAppStore": "vivo App Store (CN)",
"name": "Nome",
"smartname": "Nome (intelligente)",
"sortMethod": "Metodo di ordinamento",
"welcome": "Benvenuti",
"documentationLinksNote": "La pagina GitHub di Obtainium collegata qui sotto contiene collegamenti a video, articoli, discussioni e altre risorse che vi aiuteranno a capire come utilizzare l'applicazione.",
"batteryOptimizationNote": "Note that background downloads may work more reliably if you disable OS battery optimizations for Obtainium.",
"batteryOptimizationNote": "Si noti che i download in background potrebbero funzionare in modo più affidabile se si disabilita l'ottimizzazione della batteria del sistema operativo per Obtainium.",
"fileDeletionError": "Errore nell'eliminazione del file (provare a cancellarlo manualmente e poi riprovare): \"{}\"",
"removeAppQuestion": {
"one": "Rimuovere l'app?",
"other": "Rimuovere le app?"

View File

@ -318,15 +318,18 @@
"crowdsourcedConfigsShort": "クラウドソーシングによるアプリの設定",
"allowInsecure": "安全でないHTTPリクエストを許可する",
"stayOneVersionBehind": "最新のバージョンから1つ前のものを使用する",
"useFirstApkOfVersion": "複数のAPKから最初のAPKを自動選択する",
"refreshBeforeDownload": "ダウンロード前にアプリの詳細を更新する",
"tencentAppStore": "Tencent App Store",
"coolApk": "CoolApk",
"coolApk": "クールApk",
"vivoAppStore": "vivo App Store (CN)",
"name": "名称",
"smartname": "名前(スマート)",
"sortMethod": "ソート方法",
"welcome": "ようこそ",
"documentationLinksNote": "以下のリンクにあるObtainium GitHubページには、ビデオ、記事、ディスカッション、その他のリソースへのリンクがあり、アプリの使い方を理解するのに役立ちます。",
"batteryOptimizationNote": "Note that background downloads may work more reliably if you disable OS battery optimizations for Obtainium.",
"batteryOptimizationNote": "ObtainiumのOSバッテリー最適化を無効にすると、バックグラウンドダウンロードがより確実に動作するようになります。",
"fileDeletionError": "ファイルの削除に失敗しました(手動で削除してから再試行してください):\"{}\"",
"removeAppQuestion": {
"one": "アプリを削除しますか?",
"other": "アプリを削除しますか?"

View File

@ -318,15 +318,18 @@
"crowdsourcedConfigsShort": "크라우드소싱 앱 구성",
"allowInsecure": "안전하지 않은 HTTP 요청 허용",
"stayOneVersionBehind": "최신 버전보다 한 버전 뒤에 머무르기",
"useFirstApkOfVersion": "여러 앱 중 첫 번째 앱 자동 선택",
"refreshBeforeDownload": "다운로드 전에 앱 세부 정보 새로 고침",
"tencentAppStore": "텐센트 앱 스토어",
"coolApk": "CoolApk",
"vivoAppStore": "vivo App Store (CN)",
"name": "이름",
"smartname": "이름(스마트)",
"sortMethod": "정렬 방법",
"welcome": "환영",
"documentationLinksNote": "아래에 링크된 Obtainium 깃허브 페이지에는 앱 사용 방법을 이해하는 데 도움이 되는 동영상, 기사, 토론 및 기타 리소스에 대한 링크가 포함되어 있습니다.",
"batteryOptimizationNote": "Note that background downloads may work more reliably if you disable OS battery optimizations for Obtainium.",
"batteryOptimizationNote": "Obtainium의 OS 배터리 최적화를 비활성화하면 백그라운드 다운로드가 더 안정적으로 작동할 수 있습니다.",
"fileDeletionError": "파일을 삭제하지 못했습니다(수동으로 삭제한 후 다시 시도하세요): \"{}\"",
"removeAppQuestion": {
"one": "앱을 제거하시겠습니까?",
"other": "앱을 제거하시겠습니까?"

393
assets/translations/ml.json Normal file
View File

@ -0,0 +1,393 @@
{
"invalidURLForSource": "ഒരു സാധുവായ {} ആപ്പ് URL അല്ല",
"noReleaseFound": "അനുയോജ്യമായ റിലീസ് കണ്ടെത്താനായില്ല",
"noVersionFound": "റിലീസ് പതിപ്പ് നിർണ്ണയിക്കാനായില്ല",
"urlMatchesNoSource": "URL അറിയപ്പെടുന്ന ഒരു ഉറവിടവുമായും യോജിക്കുന്നില്ല",
"cantInstallOlderVersion": "ആപ്പിന്റെ പഴയ പതിപ്പ് ഇൻസ്റ്റാൾ ചെയ്യാൻ കഴിയില്ല.",
"appIdMismatch": "ഡൗൺലോഡ് ചെയ്ത പാക്കേജ് ഐഡി നിലവിലുള്ള ആപ്പ് ഐഡിയുമായി ചേരുന്നില്ല",
"functionNotImplemented": "ഈ ക്ലാസ് ഈ ഫംഗ്ഷൻ നടപ്പിലാക്കിയിട്ടില്ല",
"placeholder": "സ്ഥലസൂചകം",
"someErrors": "ചില പിശകുകൾ സംഭവിച്ചു",
"unexpectedError": "പ്രതീക്ഷിക്കാത്ത പിശക്",
"ok": "ശരി",
"and": "കൂടാതെ",
"githubPATLabel": "GitHub പേഴ്സണൽ ആക്സസ് ടോക്കൺ (നിരക്ക് പരിധി വർദ്ധിപ്പിക്കുന്നു)",
"includePrereleases": "പ്രീ-റിലീസുകൾ ഉൾപ്പെടുത്തുക",
"fallbackToOlderReleases": "പഴയ റിലീസുകളിലേക്ക് മടങ്ങുക",
"filterReleaseTitlesByRegEx": "റിലീസ് ശീർഷകങ്ങൾ റെഗുലർ എക്സ്പ്രഷൻ ഉപയോഗിച്ച് ഫിൽട്ടർ ചെയ്യുക",
"invalidRegEx": "സാധുവായ റെഗുലർ എക്സ്പ്രഷൻ അല്ല",
"noDescription": "വിവരണം ലഭ്യമല്ല",
"cancel": "റദ്ദാക്കുക",
"continue": "തുടരുക",
"requiredInBrackets": "(ആവശ്യമാണ്)",
"dropdownNoOptsError": "തെറ്റ്: ഡ്രോപ്പ്ഡൗണിന് കുറഞ്ഞത് ഒരു ഓപ്ഷനെങ്കിലും ഉണ്ടായിരിക്കണം",
"colour": "നിറം",
"standard": "സാധാരണ",
"custom": "ഇഷ്‌ടാനുസൃതം",
"useMaterialYou": "മെറ്റീരിയൽ യൂ ഉപയോഗിക്കുക",
"githubStarredRepos": "GitHub സ്റ്റാർ ചെയ്ത റെപ്പോസിറ്ററികൾ",
"uname": "ഉപയോക്തൃനാമം",
"wrongArgNum": "തെറ്റായ എണ്ണം ആർഗ്യുമെന്റുകൾ നൽകി",
"xIsTrackOnly": "{} ട്രാക്ക്-മാത്രം ആണ്",
"source": "ഉറവിടം",
"app": "ആപ്പ്",
"appsFromSourceAreTrackOnly": "ഈ ഉറവിടത്തിൽ നിന്നുള്ള ആപ്പുകൾ 'ട്രാക്ക്-മാത്രം' ആണ്.",
"youPickedTrackOnly": "നിങ്ങൾ 'ട്രാക്ക്-മാത്രം' ഓപ്ഷൻ തിരഞ്ഞെടുത്തു.",
"trackOnlyAppDescription": "ആപ്പ് അപ്‌ഡേറ്റുകൾക്കായി ട്രാക്ക് ചെയ്യപ്പെടും, പക്ഷേ ഒബ്‌റ്റേനിയത്തിന് അത് ഡൗൺലോഡ് ചെയ്യാനോ ഇൻസ്റ്റാൾ ചെയ്യാനോ കഴിയില്ല.",
"cancelled": "റദ്ദാക്കി",
"appAlreadyAdded": "ആപ്പ് ഇതിനകം കൂട്ടിച്ചേർത്തിട്ടുണ്ട്",
"alreadyUpToDateQuestion": "ആപ്പ് ഇതിനകം ഏറ്റവും പുതിയ പതിപ്പിലാണോ?",
"addApp": "ആപ്പ് ചേർക്കുക",
"appSourceURL": "ആപ്പ് ഉറവിട URL",
"error": "പിശക്",
"add": "ചേർക്കുക",
"searchSomeSourcesLabel": "തിരയുക (ചില ഉറവിടങ്ങളിൽ മാത്രം)",
"search": "തിരയുക",
"additionalOptsFor": "{} നായുള്ള അധിക ഓപ്ഷനുകൾ",
"supportedSources": "പിന്തുണയ്ക്കുന്ന ഉറവിടങ്ങൾ",
"trackOnlyInBrackets": "(ട്രാക്ക്-മാത്രം)",
"searchableInBrackets": "(തിരയാവുന്നത്)",
"appsString": "ആപ്പുകൾ",
"noApps": "ആപ്പുകളൊന്നുമില്ല",
"noAppsForFilter": "ഈ ഫിൽട്ടറിനായി ആപ്പുകളൊന്നുമില്ല",
"byX": "{} വഴി",
"percentProgress": "പുരോഗതി: {}%",
"pleaseWait": "ദയവായി കാത്തിരിക്കുക",
"updateAvailable": "അപ്‌ഡേറ്റ് ലഭ്യമാണ്",
"notInstalled": "ഇൻസ്റ്റാൾ ചെയ്തിട്ടില്ല",
"pseudoVersion": "സ്യൂഡോ-പതിപ്പ്",
"selectAll": "എല്ലാം തിരഞ്ഞെടുക്കുക",
"deselectX": "{} തിരഞ്ഞെടുക്കൽ റദ്ദാക്കുക",
"xWillBeRemovedButRemainInstalled": "{} ഒബ്‌റ്റേനിയത്തിൽ നിന്ന് നീക്കം ചെയ്യപ്പെടും, പക്ഷേ ഉപകരണത്തിൽ ഇൻസ്റ്റാൾ ചെയ്ത നിലയിൽ തുടരും.",
"removeSelectedAppsQuestion": "തിരഞ്ഞെടുത്ത ആപ്പുകൾ നീക്കം ചെയ്യണോ?",
"removeSelectedApps": "തിരഞ്ഞെടുത്ത ആപ്പുകൾ നീക്കം ചെയ്യുക",
"updateX": "{} അപ്‌ഡേറ്റ് ചെയ്യുക",
"installX": "{} ഇൻസ്റ്റാൾ ചെയ്യുക",
"markXTrackOnlyAsUpdated": "{}\n(ട്രാക്ക്-മാത്രം)\nഅപ്ഡേറ്റ് ചെയ്തതായി അടയാളപ്പെടുത്തുക",
"changeX": "{} മാറ്റുക",
"installUpdateApps": "ആപ്പുകൾ ഇൻസ്റ്റാൾ ചെയ്യുക/അപ്‌ഡേറ്റ് ചെയ്യുക",
"installUpdateSelectedApps": "തിരഞ്ഞെടുത്ത ആപ്പുകൾ ഇൻസ്റ്റാൾ ചെയ്യുക/അപ്‌ഡേറ്റ് ചെയ്യുക",
"markXSelectedAppsAsUpdated": "{} തിരഞ്ഞെടുത്ത ആപ്പുകൾ അപ്‌ഡേറ്റ് ചെയ്തതായി അടയാളപ്പെടുത്തണോ?",
"no": "ഇല്ല",
"yes": "അതെ",
"markSelectedAppsUpdated": "തിരഞ്ഞെടുത്ത ആപ്പുകൾ അപ്‌ഡേറ്റ് ചെയ്തതായി അടയാളപ്പെടുത്തുക",
"pinToTop": "മുകളിൽ പിൻ ചെയ്യുക",
"unpinFromTop": "മുകളിൽ നിന്ന് അൺപിൻ ചെയ്യുക",
"resetInstallStatusForSelectedAppsQuestion": "തിരഞ്ഞെടുത്ത ആപ്പുകളുടെ ഇൻസ്റ്റാൾ സ്റ്റാറ്റസ് റീസെറ്റ് ചെയ്യണോ?",
"installStatusOfXWillBeResetExplanation": "തിരഞ്ഞെടുത്ത ഏതെങ്കിലും ആപ്പുകളുടെ ഇൻസ്റ്റാൾ സ്റ്റാറ്റസ് റീസെറ്റ് ചെയ്യപ്പെടും.\n\nപരാജയപ്പെട്ട അപ്‌ഡേറ്റുകളോ മറ്റ് പ്രശ്നങ്ങളോ കാരണം ഒബ്‌റ്റേനിയത്തിൽ കാണിക്കുന്ന ആപ്പ് പതിപ്പ് തെറ്റായിരിക്കുമ്പോൾ ഇത് സഹായകമാകും.",
"customLinkMessage": "ഈ ലിങ്കുകൾ ഒബ്‌റ്റേനിയം ഇൻസ്റ്റാൾ ചെയ്ത ഉപകരണങ്ങളിൽ പ്രവർത്തിക്കും",
"shareAppConfigLinks": "ആപ്പ് കോൺഫിഗറേഷൻ HTML ലിങ്കായി പങ്കിടുക",
"shareSelectedAppURLs": "തിരഞ്ഞെടുത്ത ആപ്പ് URL-കൾ പങ്കിടുക",
"resetInstallStatus": "ഇൻസ്റ്റാൾ സ്റ്റാറ്റസ് റീസെറ്റ് ചെയ്യുക",
"more": "കൂടുതൽ",
"removeOutdatedFilter": "കാലഹരണപ്പെട്ട ആപ്പ് ഫിൽട്ടർ നീക്കം ചെയ്യുക",
"showOutdatedOnly": "കാലഹരണപ്പെട്ട ആപ്പുകൾ മാത്രം കാണിക്കുക",
"filter": "ഫിൽട്ടർ",
"filterApps": "ആപ്പുകൾ ഫിൽട്ടർ ചെയ്യുക",
"appName": "ആപ്പിന്റെ പേര്",
"author": "ഡെവലപ്പർ",
"upToDateApps": "ഏറ്റവും പുതിയ പതിപ്പിലുള്ള ആപ്പുകൾ",
"nonInstalledApps": "ഇൻസ്റ്റാൾ ചെയ്യാത്ത ആപ്പുകൾ",
"importExport": "ഇംപോർട്ട്/എക്സ്പോർട്ട്",
"settings": "ക്രമീകരണങ്ങൾ",
"exportedTo": "{} ലേക്ക് എക്സ്പോർട്ട് ചെയ്തു",
"obtainiumExport": "ഒബ്‌റ്റേനിയം എക്സ്പോർട്ട്",
"invalidInput": "തെറ്റായ ഇൻപുട്ട്",
"importedX": "{} ഇംപോർട്ട് ചെയ്തു",
"obtainiumImport": "ഒബ്‌റ്റേനിയം ഇംപോർട്ട്",
"importFromURLList": "URL ലിസ്റ്റിൽ നിന്ന് ഇംപോർട്ട് ചെയ്യുക",
"searchQuery": "തിരയൽ ചോദ്യം",
"appURLList": "ആപ്പ് URL ലിസ്റ്റ്",
"line": "വരി",
"searchX": "{} തിരയുക",
"noResults": "ഫലങ്ങളൊന്നും കണ്ടെത്തിയില്ല",
"importX": "{} ഇംപോർട്ട് ചെയ്യുക",
"importedAppsIdDisclaimer": "ഇംപോർട്ട് ചെയ്ത ആപ്പുകൾ \"ഇൻസ്റ്റാൾ ചെയ്തിട്ടില്ല\" എന്ന് തെറ്റായി കാണിച്ചേക്കാം.\nഇത് ശരിയാക്കാൻ, ഒബ്‌റ്റേനിയം വഴി അവ വീണ്ടും ഇൻസ്റ്റാൾ ചെയ്യുക.\nഇത് ആപ്പ് ഡാറ്റയെ ബാധിക്കില്ല.\n\nURL, മൂന്നാം കക്ഷി ഇംപോർട്ട് രീതികളെ മാത്രം ബാധിക്കുന്നു.",
"importErrors": "ഇംപോർട്ട് പിശകുകൾ",
"importedXOfYApps": "{} ആപ്പുകളിൽ {} എണ്ണം ഇംപോർട്ട് ചെയ്തു.",
"followingURLsHadErrors": "ഇനിപ്പറയുന്ന URL-കളിൽ പിശകുകൾ ഉണ്ടായിരുന്നു:",
"selectURL": "URL തിരഞ്ഞെടുക്കുക",
"selectURLs": "URL-കൾ തിരഞ്ഞെടുക്കുക",
"pick": "തിരഞ്ഞെടുക്കുക",
"theme": "തീം",
"dark": "ഇരുണ്ട",
"light": "ഇളം",
"followSystem": "സിസ്റ്റം പിന്തുടരുക",
"followSystemThemeExplanation": "സിസ്റ്റം തീം പിന്തുടരുന്നത് തേർഡ് പാർട്ടി ആപ്ലിക്കേഷനുകൾ ഉപയോഗിച്ച് മാത്രമേ സാധ്യമാകൂ",
"useBlackTheme": "തനി കറുപ്പ് തീം ഉപയോഗിക്കുക",
"appSortBy": "ആപ്പ് അടുക്കേണ്ട രീതി",
"authorName": "ഡെവലപ്പർ/പേര്",
"nameAuthor": "പേര്/ഡെവലപ്പർ",
"asAdded": "ചേർത്ത ക്രമത്തിൽ",
"appSortOrder": "ആപ്പ് അടുക്കേണ്ട ക്രമം",
"ascending": "ആരോഹണ ക്രമത്തിൽ",
"descending": "അവരോഹണ ക്രമത്തിൽ",
"bgUpdateCheckInterval": "ബാക്ക്ഗ്രൗണ്ട് അപ്‌ഡേറ്റ് പരിശോധന ഇടവേള",
"neverManualOnly": "ഒരിക്കലുമില്ല - സ്വമേധയാ മാത്രം",
"appearance": "രൂപം",
"showWebInAppView": "ആപ്പ് കാഴ്ചയിൽ ഉറവിട വെബ്പേജ് കാണിക്കുക",
"pinUpdates": "അപ്‌ഡേറ്റുകൾ ആപ്പ് കാഴ്ചയുടെ മുകളിൽ പിൻ ചെയ്യുക",
"updates": "അപ്‌ഡേറ്റുകൾ",
"sourceSpecific": "ഉറവിടം അടിസ്ഥാനമാക്കി",
"appSource": "ആപ്പ് ഉറവിടം",
"noLogs": "ലോഗുകളൊന്നുമില്ല",
"appLogs": "ആപ്പ് ലോഗുകൾ",
"close": "അടയ്ക്കുക",
"share": "പങ്കിടുക",
"appNotFound": "ആപ്പ് കണ്ടെത്തിയില്ല",
"obtainiumExportHyphenatedLowercase": "ഒബ്‌റ്റേനിയം-എക്സ്പോർട്ട്",
"pickAnAPK": "ഒരു APK തിരഞ്ഞെടുക്കുക",
"appHasMoreThanOnePackage": "{}-ന് ഒന്നിൽ കൂടുതൽ പാക്കേജുകൾ ഉണ്ട്:",
"deviceSupportsXArch": "നിങ്ങളുടെ ഉപകരണം {} CPU ആർക്കിടെക്ചറിനെ പിന്തുണയ്ക്കുന്നു.",
"deviceSupportsFollowingArchs": "നിങ്ങളുടെ ഉപകരണം ഇനിപ്പറയുന്ന CPU ആർക്കിടെക്ചറുകളെ പിന്തുണയ്ക്കുന്നു:",
"warning": "മുന്നറിയിപ്പ്",
"sourceIsXButPackageFromYPrompt": "ആപ്പ് ഉറവിടം '{}' ആണ്, പക്ഷേ റിലീസ് പാക്കേജ് '{}'-ൽ നിന്നാണ് വരുന്നത്. തുടരണോ?",
"updatesAvailable": "അപ്‌ഡേറ്റുകൾ ലഭ്യമാണ്",
"updatesAvailableNotifDescription": "ഒബ്‌റ്റേനിയം ട്രാക്ക് ചെയ്യുന്ന ഒന്നോ അതിലധികമോ ആപ്പുകൾക്ക് അപ്‌ഡേറ്റുകൾ ലഭ്യമാണെന്ന് ഉപയോക്താവിനെ അറിയിക്കുന്നു",
"noNewUpdates": "പുതിയ അപ്‌ഡേറ്റുകളൊന്നുമില്ല.",
"xHasAnUpdate": "{}-ന് ഒരു അപ്‌ഡേറ്റ് ഉണ്ട്.",
"appsUpdated": "ആപ്പുകൾ അപ്‌ഡേറ്റ് ചെയ്തു",
"appsNotUpdated": "ആപ്ലിക്കേഷനുകൾ അപ്‌ഡേറ്റ് ചെയ്യുന്നതിൽ പരാജയപ്പെട്ടു",
"appsUpdatedNotifDescription": "ഒന്നോ അതിലധികമോ ആപ്പുകളിലേക്കുള്ള അപ്‌ഡേറ്റുകൾ ബാക്ക്ഗ്രൗണ്ടിൽ പ്രയോഗിച്ചു എന്ന് ഉപയോക്താവിനെ അറിയിക്കുന്നു",
"xWasUpdatedToY": "{} {} ലേക്ക് അപ്‌ഡേറ്റ് ചെയ്തു.",
"xWasNotUpdatedToY": "{} {} ലേക്ക് അപ്‌ഡേറ്റ് ചെയ്യാൻ പരാജയപ്പെട്ടു.",
"errorCheckingUpdates": "അപ്‌ഡേറ്റുകൾ പരിശോധിക്കുന്നതിൽ പിശക്",
"errorCheckingUpdatesNotifDescription": "ബാക്ക്ഗ്രൗണ്ട് അപ്‌ഡേറ്റ് പരിശോധന പരാജയപ്പെടുമ്പോൾ കാണിക്കുന്ന അറിയിപ്പ്",
"appsRemoved": "ആപ്പുകൾ നീക്കം ചെയ്തു",
"appsRemovedNotifDescription": "ഒന്നോ അതിലധികമോ ആപ്പുകൾ ലോഡ് ചെയ്യുന്നതിൽ പിശകുകൾ കാരണം നീക്കം ചെയ്തു എന്ന് ഉപയോക്താവിനെ അറിയിക്കുന്നു",
"xWasRemovedDueToErrorY": "{} ഈ പിശക് കാരണം നീക്കം ചെയ്തു: {}",
"completeAppInstallation": "ആപ്പ് ഇൻസ്റ്റാളേഷൻ പൂർത്തിയാക്കുക",
"obtainiumMustBeOpenToInstallApps": "ആപ്പുകൾ ഇൻസ്റ്റാൾ ചെയ്യാൻ ഒബ്‌റ്റേനിയം തുറന്നിരിക്കണം",
"completeAppInstallationNotifDescription": "ഒരു ആപ്പ് ഇൻസ്റ്റാൾ ചെയ്യുന്നത് പൂർത്തിയാക്കാൻ ഒബ്‌റ്റേനിയത്തിലേക്ക് മടങ്ങാൻ ഉപയോക്താവിനോട് ആവശ്യപ്പെടുന്നു",
"checkingForUpdates": "അപ്‌ഡേറ്റുകൾ പരിശോധിക്കുന്നു",
"checkingForUpdatesNotifDescription": "അപ്‌ഡേറ്റുകൾ പരിശോധിക്കുമ്പോൾ ദൃശ്യമാകുന്ന താൽക്കാലിക അറിയിപ്പ്",
"pleaseAllowInstallPerm": "ആപ്പുകൾ ഇൻസ്റ്റാൾ ചെയ്യാൻ ഒബ്‌റ്റേനിയത്തിന് അനുമതി നൽകുക",
"trackOnly": "ട്രാക്ക്-മാത്രം",
"errorWithHttpStatusCode": "പിശക് {}",
"versionCorrectionDisabled": "പതിപ്പ് തിരുത്തൽ പ്രവർത്തനരഹിതമാക്കി (പ്ലഗിൻ പ്രവർത്തിക്കുന്നതായി തോന്നുന്നില്ല)",
"unknown": "അജ്ഞാതം",
"none": "ഒന്നുമില്ല",
"never": "ഒരിക്കലുമില്ല",
"latestVersionX": "ഏറ്റവും പുതിയത്: {}",
"installedVersionX": "ഇൻസ്റ്റാൾ ചെയ്തത്: {}",
"lastUpdateCheckX": "അവസാന അപ്‌ഡേറ്റ് പരിശോധന: {}",
"remove": "നീക്കം ചെയ്യുക",
"yesMarkUpdated": "അതെ, അപ്‌ഡേറ്റ് ചെയ്തതായി അടയാളപ്പെടുത്തുക",
"fdroid": "എഫ്-ഡ്രോയിഡ് ഒഫീഷ്യൽ",
"appIdOrName": "ആപ്പ് ഐഡി അല്ലെങ്കിൽ പേര്",
"appId": "ആപ്പ് ഐഡി",
"appWithIdOrNameNotFound": "ആ ഐഡിയിലോ പേരിലോ ആപ്പുകളൊന്നും കണ്ടെത്തിയില്ല",
"reposHaveMultipleApps": "റെപ്പോകളിൽ ഒന്നിലധികം ആപ്പുകൾ അടങ്ങിയിരിക്കാം",
"fdroidThirdPartyRepo": "എഫ്-ഡ്രോയിഡ് തേർഡ് പാർട്ടി റെപ്പോ",
"install": "ഇൻസ്റ്റാൾ ചെയ്യുക",
"markInstalled": "ഇൻസ്റ്റാൾ ചെയ്തതായി അടയാളപ്പെടുത്തുക",
"update": "അപ്‌ഡേറ്റ് ചെയ്യുക",
"markUpdated": "അപ്‌ഡേറ്റ് ചെയ്തതായി അടയാളപ്പെടുത്തുക",
"additionalOptions": "കൂടുതൽ ഓപ്ഷനുകൾ",
"disableVersionDetection": "പതിപ്പ് കണ്ടെത്തൽ പ്രവർത്തനരഹിതമാക്കുക",
"noVersionDetectionExplanation": "പതിപ്പ് കണ്ടെത്തൽ ശരിയായി പ്രവർത്തിക്കാത്ത ആപ്പുകൾക്ക് മാത്രമേ ഈ ഓപ്ഷൻ ഉപയോഗിക്കാവൂ.",
"downloadingX": "{} ഡൗൺലോഡ് ചെയ്യുന്നു",
"downloadX": "{} ഡൗൺലോഡ് ചെയ്യുക",
"downloadedX": "{} ഡൗൺലോഡ് ചെയ്തു",
"releaseAsset": "റിലീസ് അസറ്റ്",
"downloadNotifDescription": "ഒരു ആപ്പ് ഡൗൺലോഡ് ചെയ്യുന്നതിലെ പുരോഗതി ഉപയോക്താവിനെ അറിയിക്കുന്നു",
"noAPKFound": "APK കണ്ടെത്തിയില്ല",
"noVersionDetection": "പതിപ്പ് കണ്ടെത്തൽ ഇല്ല",
"categorize": "വിഭാഗീകരിക്കുക",
"categories": "വിഭാഗങ്ങൾ",
"category": "വിഭാഗം",
"noCategory": "വിഭാഗമൊന്നുമില്ല",
"noCategories": "വിഭാഗങ്ങളൊന്നുമില്ല",
"deleteCategoriesQuestion": "വിഭാഗങ്ങൾ ഇല്ലാതാക്കണോ?",
"categoryDeleteWarning": "ഇല്ലാതാക്കിയ വിഭാഗങ്ങളിലെ എല്ലാ ആപ്പുകളും വിഭാഗരഹിതമായി ക്രമീകരിക്കും.",
"addCategory": "വിഭാഗം ചേർക്കുക",
"label": "ലേബൽ",
"language": "ഭാഷ",
"copiedToClipboard": "ക്ലിപ്ബോർഡിലേക്ക് പകർത്തി",
"storagePermissionDenied": "സ്റ്റോറേജ് അനുമതി നിഷേധിച്ചു",
"selectedCategorizeWarning": "ഇത് തിരഞ്ഞെടുത്ത ആപ്പുകൾക്കായി നിലവിലുള്ള ഏതൊരു വിഭാഗ ക്രമീകരണങ്ങളെയും മാറ്റിസ്ഥാപിക്കും.",
"filterAPKsByRegEx": "റെഗുലർ എക്സ്പ്രഷൻ ഉപയോഗിച്ച് APK-കൾ ഫിൽട്ടർ ചെയ്യുക",
"removeFromObtainium": "ഒബ്‌റ്റേനിയത്തിൽ നിന്ന് നീക്കം ചെയ്യുക",
"uninstallFromDevice": "ഉപകരണത്തിൽ നിന്ന് അൺഇൻസ്റ്റാൾ ചെയ്യുക",
"onlyWorksWithNonVersionDetectApps": "പതിപ്പ് കണ്ടെത്തൽ പ്രവർത്തനരഹിതമാക്കിയ ആപ്പുകൾക്ക് മാത്രമേ ഇത് പ്രവർത്തിക്കൂ.",
"releaseDateAsVersion": "റിലീസ് തീയതി പതിപ്പ് സ്ട്രിംഗായി ഉപയോഗിക്കുക",
"releaseTitleAsVersion": "റിലീസ് ശീർഷകം പതിപ്പ് സ്ട്രിംഗായി ഉപയോഗിക്കുക",
"releaseDateAsVersionExplanation": "പതിപ്പ് കണ്ടെത്തൽ ശരിയായി പ്രവർത്തിക്കാത്തതും എന്നാൽ റിലീസ് തീയതി ലഭ്യമായതുമായ ആപ്പുകൾക്ക് മാത്രമേ ഈ ഓപ്ഷൻ ഉപയോഗിക്കാവൂ.",
"changes": "മാറ്റങ്ങൾ",
"releaseDate": "റിലീസ് തീയതി",
"importFromURLsInFile": "ഫയലിലെ URL-കളിൽ നിന്ന് ഇംപോർട്ട് ചെയ്യുക (OPML പോലെ)",
"versionDetectionExplanation": "കണ്ടെത്തിയ പതിപ്പ് സ്ട്രിംഗ് OS-ൽ നിന്ന് കണ്ടെത്തിയ പതിപ്പുമായി പൊരുത്തപ്പെടുത്തുക",
"versionDetection": "പതിപ്പ് കണ്ടെത്തൽ",
"standardVersionDetection": "സാധാരണ പതിപ്പ് കണ്ടെത്തൽ",
"groupByCategory": "വിഭാഗം തിരിച്ച് ഗ്രൂപ്പ് ചെയ്യുക",
"autoApkFilterByArch": "സാധ്യമെങ്കിൽ CPU ആർക്കിടെക്ചർ അനുസരിച്ച് APK-കൾ ഫിൽട്ടർ ചെയ്യാൻ ശ്രമിക്കുക",
"autoLinkFilterByArch": "സാധ്യമെങ്കിൽ CPU ആർക്കിടെക്ചർ അനുസരിച്ച് ലിങ്കുകൾ ഫിൽട്ടർ ചെയ്യാൻ ശ്രമിക്കുക",
"overrideSource": "ഉറവിടം അസാധുവാക്കുക",
"dontShowAgain": "ഇത് വീണ്ടും കാണിക്കരുത്",
"dontShowTrackOnlyWarnings": "'ട്രാക്ക്-മാത്രം' മുന്നറിയിപ്പുകൾ കാണിക്കരുത്",
"dontShowAPKOriginWarnings": "APK ഉറവിട മുന്നറിയിപ്പുകൾ കാണിക്കരുത്",
"moveNonInstalledAppsToBottom": "ഇൻസ്റ്റാൾ ചെയ്യാത്ത ആപ്പുകൾ ആപ്പ് കാഴ്ചയുടെ താഴേക്ക് മാറ്റുക",
"gitlabPATLabel": "GitLab പേഴ്സണൽ ആക്സസ് ടോക്കൺ",
"about": "വിവരങ്ങൾ",
"requiresCredentialsInSettings": "{}-ന് അധിക ക്രെഡൻഷ്യലുകൾ ആവശ്യമാണ് (ക്രമീകരണങ്ങളിൽ)",
"checkOnStart": "തുടങ്ങുമ്പോൾ അപ്‌ഡേറ്റുകൾക്കായി പരിശോധിക്കുക",
"tryInferAppIdFromCode": "സോഴ്സ് കോഡിൽ നിന്ന് ആപ്പ് ഐഡി അനുമാനിക്കാൻ ശ്രമിക്കുക",
"removeOnExternalUninstall": "പുറത്ത് നിന്ന് അൺഇൻസ്റ്റാൾ ചെയ്ത ആപ്പുകൾ സ്വയമേവ നീക്കം ചെയ്യുക",
"pickHighestVersionCode": "ഏറ്റവും ഉയർന്ന പതിപ്പ് കോഡ് APK സ്വയമേവ തിരഞ്ഞെടുക്കുക",
"checkUpdateOnDetailPage": "ഒരു ആപ്പ് വിശദാംശ പേജ് തുറക്കുമ്പോൾ അപ്‌ഡേറ്റുകൾക്കായി പരിശോധിക്കുക",
"disablePageTransitions": "പേജ് ട്രാൻസിഷൻ ആനിമേഷനുകൾ പ്രവർത്തനരഹിതമാക്കുക",
"reversePageTransitions": "പേജ് ട്രാൻസിഷൻ ആനിമേഷനുകൾ വിപരീതമാക്കുക",
"minStarCount": "കുറഞ്ഞ നക്ഷത്രങ്ങളുടെ എണ്ണം",
"addInfoBelow": "ഈ വിവരങ്ങൾ താഴെ ചേർക്കുക.",
"addInfoInSettings": "ഈ വിവരങ്ങൾ ക്രമീകരണങ്ങളിൽ ചേർക്കുക.",
"githubSourceNote": "API കീ ഉപയോഗിച്ച് GitHub നിരക്ക് പരിധി ഒഴിവാക്കാം.",
"sortByLastLinkSegment": "ലിങ്കിന്റെ അവസാന ഭാഗം മാത്രം ഉപയോഗിച്ച് അടുക്കുക",
"filterReleaseNotesByRegEx": "റെഗുലർ എക്സ്പ്രഷൻ ഉപയോഗിച്ച് റിലീസ് നോട്ടുകൾ ഫിൽട്ടർ ചെയ്യുക",
"customLinkFilterRegex": "റെഗുലർ എക്സ്പ്രഷൻ ഉപയോഗിച്ചുള്ള കസ്റ്റം APK ലിങ്ക് ഫിൽട്ടർ (സ്ഥിരസ്ഥിതി '.apk$')",
"appsPossiblyUpdated": "ആപ്പ് അപ്‌ഡേറ്റുകൾ ശ്രമിച്ചു",
"appsPossiblyUpdatedNotifDescription": "ഒന്നോ അതിലധികമോ ആപ്പുകളിലേക്കുള്ള അപ്‌ഡേറ്റുകൾ ബാക്ക്ഗ്രൗണ്ടിൽ പ്രയോഗിക്കാൻ സാധ്യതയുണ്ടെന്ന് ഉപയോക്താവിനെ അറിയിക്കുന്നു",
"xWasPossiblyUpdatedToY": "{} {} ലേക്ക് അപ്‌ഡേറ്റ് ചെയ്തിരിക്കാം.",
"enableBackgroundUpdates": "ബാക്ക്ഗ്രൗണ്ട് അപ്‌ഡേറ്റുകൾ പ്രവർത്തനക്ഷമമാക്കുക",
"backgroundUpdateReqsExplanation": "എല്ലാ ആപ്പുകൾക്കും ബാക്ക്ഗ്രൗണ്ട് അപ്‌ഡേറ്റുകൾ സാധ്യമായെന്ന് വരില്ല.",
"backgroundUpdateLimitsExplanation": "ബാക്ക്ഗ്രൗണ്ട് ഇൻസ്റ്റാളിന്റെ വിജയം ഒബ്‌റ്റേനിയം തുറക്കുമ്പോൾ മാത്രമേ നിർണ്ണയിക്കാൻ കഴിയൂ.",
"verifyLatestTag": "'ഏറ്റവും പുതിയ' ടാഗ് പരിശോധിക്കുക",
"intermediateLinkRegex": "സന്ദർശിക്കാൻ ഒരു 'ഇന്റർമീഡിയറ്റ്' ലിങ്കിനായി ഫിൽട്ടർ ചെയ്യുക",
"filterByLinkText": "ലിങ്ക് ടെക്സ്റ്റ് ഉപയോഗിച്ച് ലിങ്കുകൾ ഫിൽട്ടർ ചെയ്യുക",
"intermediateLinkNotFound": "ഇന്റർമീഡിയറ്റ് ലിങ്ക് കണ്ടെത്തിയില്ല",
"intermediateLink": "ഇന്റർമീഡിയറ്റ് ലിങ്ക്",
"exemptFromBackgroundUpdates": "ബാക്ക്ഗ്രൗണ്ട് അപ്‌ഡേറ്റുകളിൽ നിന്ന് ഒഴിവാക്കുക (പ്രവർത്തനക്ഷമമാക്കിയിട്ടുണ്ടെങ്കിൽ)",
"bgUpdatesOnWiFiOnly": "വൈഫൈയിൽ അല്ലാത്തപ്പോൾ ബാക്ക്ഗ്രൗണ്ട് അപ്‌ഡേറ്റുകൾ പ്രവർത്തനരഹിതമാക്കുക",
"bgUpdatesWhileChargingOnly": "ചാർജ് ചെയ്യാത്തപ്പോൾ ബാക്ക്ഗ്രൗണ്ട് അപ്‌ഡേറ്റുകൾ പ്രവർത്തനരഹിതമാക്കുക",
"autoSelectHighestVersionCode": "ഏറ്റവും ഉയർന്ന versionCode APK സ്വയമേവ തിരഞ്ഞെടുക്കുക",
"versionExtractionRegEx": "പതിപ്പ് സ്ട്രിംഗ് വേർതിരിച്ചെടുക്കുന്നതിനുള്ള റെഗുലർ എക്സ്പ്രഷൻ",
"trimVersionString": "റെഗുലർ എക്സ്പ്രഷൻ ഉപയോഗിച്ച് പതിപ്പ് സ്ട്രിംഗ് ട്രിം ചെയ്യുക",
"matchGroupToUseForX": "\"{}\" നായി ഉപയോഗിക്കേണ്ട ഗ്രൂപ്പ് മാച്ച് ചെയ്യുക",
"matchGroupToUse": "പതിപ്പ് സ്ട്രിംഗ് വേർതിരിച്ചെടുക്കുന്നതിനുള്ള റെഗുലർ എക്സ്പ്രഷനായി ഉപയോഗിക്കേണ്ട ഗ്രൂപ്പ് മാച്ച് ചെയ്യുക",
"highlightTouchTargets": "കുറഞ്ഞ വ്യക്തതയുള്ള ടച്ച് ടാർഗെറ്റുകൾ ഹൈലൈറ്റ് ചെയ്യുക",
"pickExportDir": "എക്സ്പോർട്ട് ഡയറക്ടറി തിരഞ്ഞെടുക്കുക",
"autoExportOnChanges": "മാറ്റങ്ങൾ വരുമ്പോൾ സ്വയമേവ എക്സ്പോർട്ട് ചെയ്യുക",
"includeSettings": "ക്രമീകരണങ്ങൾ ഉൾപ്പെടുത്തുക",
"filterVersionsByRegEx": "റെഗുലർ എക്സ്പ്രഷൻ ഉപയോഗിച്ച് പതിപ്പുകൾ ഫിൽട്ടർ ചെയ്യുക",
"trySelectingSuggestedVersionCode": "നിർദ്ദേശിച്ച versionCode APK തിരഞ്ഞെടുക്കാൻ ശ്രമിക്കുക",
"dontSortReleasesList": "API-യിൽ നിന്നുള്ള റിലീസ് ഓർഡർ നിലനിർത്തുക",
"reverseSort": "റിവേഴ്സ് സോർട്ടിംഗ്",
"takeFirstLink": "ആദ്യ ലിങ്ക് എടുക്കുക",
"skipSort": "സോർട്ടിംഗ് ഒഴിവാക്കുക",
"debugMenu": "ഡീബഗ് മെനു",
"bgTaskStarted": "ബാക്ക്ഗ്രൗണ്ട് ടാസ്ക് ആരംഭിച്ചു - ലോഗുകൾ പരിശോധിക്കുക.",
"runBgCheckNow": "ഇപ്പോൾ ബാക്ക്ഗ്രൗണ്ട് അപ്‌ഡേറ്റ് പരിശോധന പ്രവർത്തിപ്പിക്കുക",
"versionExtractWholePage": "മുഴുവൻ പേജിലും പതിപ്പ് സ്ട്രിംഗ് വേർതിരിച്ചെടുക്കുന്ന റെഗുലർ എക്സ്പ്രഷൻ പ്രയോഗിക്കുക",
"installing": "ഇൻസ്റ്റാൾ ചെയ്യുന്നു",
"skipUpdateNotifications": "അപ്‌ഡേറ്റ് അറിയിപ്പുകൾ ഒഴിവാക്കുക",
"updatesAvailableNotifChannel": "അപ്‌ഡേറ്റുകൾ ലഭ്യമാണ്",
"appsUpdatedNotifChannel": "ആപ്പുകൾ അപ്‌ഡേറ്റ് ചെയ്തു",
"appsPossiblyUpdatedNotifChannel": "ആപ്പ് അപ്‌ഡേറ്റുകൾ ശ്രമിച്ചു",
"errorCheckingUpdatesNotifChannel": "അപ്‌ഡേറ്റുകൾ പരിശോധിക്കുന്നതിൽ പിശക്",
"appsRemovedNotifChannel": "ആപ്പുകൾ നീക്കം ചെയ്തു",
"downloadingXNotifChannel": "{} ഡൗൺലോഡ് ചെയ്യുന്നു",
"completeAppInstallationNotifChannel": "ആപ്പ് ഇൻസ്റ്റാളേഷൻ പൂർത്തിയാക്കുക",
"checkingForUpdatesNotifChannel": "അപ്‌ഡേറ്റുകൾ പരിശോധിക്കുന്നു",
"onlyCheckInstalledOrTrackOnlyApps": "ഇൻസ്റ്റാൾ ചെയ്തതും ട്രാക്ക്-മാത്രം ആപ്പുകളും മാത്രം അപ്‌ഡേറ്റുകൾക്കായി പരിശോധിക്കുക",
"supportFixedAPKURL": "സ്ഥിരമായ APK URL-കളെ പിന്തുണയ്ക്കുക",
"selectX": "{} തിരഞ്ഞെടുക്കുക",
"parallelDownloads": "പാരലൽ ഡൗൺലോഡുകൾ അനുവദിക്കുക",
"useShizuku": "ഇൻസ്റ്റാൾ ചെയ്യാൻ ഷിസുകു അല്ലെങ്കിൽ സ്യൂ ഉപയോഗിക്കുക",
"shizukuBinderNotFound": "ഷിസുകു സേവനം പ്രവർത്തിക്കുന്നില്ല",
"shizukuOld": "പഴയ ഷിസുകു പതിപ്പ് (<11) - അത് അപ്‌ഡേറ്റ് ചെയ്യുക",
"shizukuOldAndroidWithADB": "ആൻഡ്രോയിഡ് < 8.1 ഉള്ള ഷിസുകു ADB ഉപയോഗിച്ച് പ്രവർത്തിക്കുന്നു - ആൻഡ്രോയിഡ് അപ്‌ഡേറ്റ് ചെയ്യുക അല്ലെങ്കിൽ പകരം സ്യൂ ഉപയോഗിക്കുക",
"shizukuPretendToBeGooglePlay": "Google Play ഇൻസ്റ്റാളേഷൻ ഉറവിടമായി സജ്ജമാക്കുക (ഷിസുകു ഉപയോഗിക്കുകയാണെങ്കിൽ)",
"useSystemFont": "സിസ്റ്റം ഫോണ്ട് ഉപയോഗിക്കുക",
"useVersionCodeAsOSVersion": "ആപ്പ് versionCode OS-ൽ കണ്ടെത്തിയ പതിപ്പായി ഉപയോഗിക്കുക",
"requestHeader": "അഭ്യർത്ഥന തലക്കെട്ട്",
"useLatestAssetDateAsReleaseDate": "ഏറ്റവും പുതിയ അസറ്റ് അപ്‌ലോഡ് റിലീസ് തീയതിയായി ഉപയോഗിക്കുക",
"defaultPseudoVersioningMethod": "സ്ഥിരസ്ഥിതി സ്യൂഡോ-പതിപ്പ് രീതി",
"partialAPKHash": "ഭാഗിക APK ഹാഷ്",
"APKLinkHash": "APK ലിങ്ക് ഹാഷ്",
"directAPKLink": "നേരിട്ടുള്ള APK ലിങ്ക്",
"pseudoVersionInUse": "ഒരു സ്യൂഡോ-പതിപ്പ് ഉപയോഗത്തിലുണ്ട്",
"installed": "ഇൻസ്റ്റാൾ ചെയ്തത്",
"latest": "ഏറ്റവും പുതിയത്",
"invertRegEx": "റെഗുലർ എക്സ്പ്രഷൻ വിപരീതമാക്കുക",
"note": "ശ്രദ്ധിക്കുക",
"selfHostedNote": "ഏത് ഉറവിടത്തിന്റെയും സെൽഫ്-ഹോസ്റ്റ് ചെയ്ത/കസ്റ്റം ഇൻസ്റ്റൻസുകളിലേക്ക് എത്താൻ \"{}\" ഡ്രോപ്പ്ഡൗൺ ഉപയോഗിക്കാം.",
"badDownload": "APK പാഴ്സ് ചെയ്യാൻ കഴിഞ്ഞില്ല (അനുയോജ്യമല്ലാത്തതോ ഭാഗികമായ ഡൗൺലോഡോ)",
"beforeNewInstallsShareToAppVerifier": "പുതിയ ആപ്പുകൾ AppVerifier-ലേക്ക് പങ്കിടുക (ലഭ്യമെങ്കിൽ)",
"appVerifierInstructionToast": "AppVerifier-ലേക്ക് പങ്കിടുക, തുടർന്ന് തയ്യാറാകുമ്പോൾ ഇവിടെ തിരിച്ചെത്തുക.",
"wiki": "സഹായം/വിക്കി",
"crowdsourcedConfigsLabel": "ക്രൗഡ്‌സോഴ്‌സ് ചെയ്ത ആപ്പ് കോൺഫിഗറേഷനുകൾ (സ്വന്തം ഉത്തരവാദിത്തത്തിൽ ഉപയോഗിക്കുക)",
"crowdsourcedConfigsShort": "ക്രൗഡ്‌സോഴ്‌സ് ചെയ്ത ആപ്പ് കോൺഫിഗറേഷനുകൾ",
"allowInsecure": "സുരക്ഷിതമല്ലാത്ത HTTP അഭ്യർത്ഥനകൾ അനുവദിക്കുക",
"stayOneVersionBehind": "ഏറ്റവും പുതിയ പതിപ്പിന് ഒരു പതിപ്പ് പിന്നിൽ തുടരുക",
"useFirstApkOfVersion": "ഒന്നിലധികം APK-കളിൽ നിന്ന് ആദ്യത്തേത് സ്വയമേവ തിരഞ്ഞെടുക്കുക",
"refreshBeforeDownload": "ഡൗൺലോഡ് ചെയ്യുന്നതിന് മുമ്പ് ആപ്പ് വിവരങ്ങൾ പുതുക്കുക",
"tencentAppStore": "ടെൻസെന്റ് ആപ്പ് സ്റ്റോർ",
"coolApk": "കൂൾ APK",
"vivoAppStore": "വിവോ ആപ്പ് സ്റ്റോർ (CN)",
"name": "പേര്",
"smartname": "പേര് (സ്മാർട്ട്)",
"sortMethod": "അടുക്കുന്ന രീതി",
"welcome": "സ്വാഗതം",
"documentationLinksNote": "താഴെ ലിങ്ക് ചെയ്തിട്ടുള്ള ഒബ്‌റ്റേനിയം GitHub പേജിൽ ആപ്പ് എങ്ങനെ ഉപയോഗിക്കാമെന്ന് മനസ്സിലാക്കാൻ സഹായിക്കുന്ന വീഡിയോകൾ, ലേഖനങ്ങൾ, ചർച്ചകൾ, മറ്റ് വിഭവങ്ങൾ എന്നിവയുടെ ലിങ്കുകൾ അടങ്ങിയിരിക്കുന്നു.",
"batteryOptimizationNote": "ഒബ്‌റ്റേനിയത്തിനായുള്ള OS ബാറ്ററി ഒപ്റ്റിമൈസേഷനുകൾ പ്രവർത്തനരഹിതമാക്കുകയാണെങ്കിൽ ബാക്ക്ഗ്രൗണ്ട് ഡൗൺലോഡുകൾ കൂടുതൽ വിശ്വസനീയമായി പ്രവർത്തിച്ചേക്കാം എന്നത് ശ്രദ്ധിക്കുക.",
"fileDeletionError": "ഫയൽ ഇല്ലാതാക്കാൻ പരാജയപ്പെട്ടു (സ്വമേധയാ ഇല്ലാതാക്കാൻ ശ്രമിക്കുക, എന്നിട്ട് വീണ്ടും ശ്രമിക്കുക): \"{}\"",
"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": "{} കൂടാതെ {} ആപ്പുകൾ കൂടി അപ്‌ഡേറ്റ് ചെയ്തു."
},
"xAndNMoreUpdatesFailed": {
"one": "{} കൂടാതെ 1 ആപ്പ് കൂടി അപ്‌ഡേറ്റ് ചെയ്യാൻ പരാജയപ്പെട്ടു.",
"other": "{} കൂടാതെ {} ആപ്പുകൾ കൂടി അപ്‌ഡേറ്റ് ചെയ്യാൻ പരാജയപ്പെട്ടു."
},
"xAndNMoreUpdatesPossiblyInstalled": {
"one": "{} കൂടാതെ 1 ആപ്പ് കൂടി അപ്‌ഡേറ്റ് ചെയ്തിരിക്കാം.",
"other": "{} കൂടാതെ {} ആപ്പുകൾ കൂടി അപ്‌ഡേറ്റ് ചെയ്തിരിക്കാം."
},
"apk": {
"one": "{} APK",
"other": "{} APK-കൾ"
}
}

View File

@ -318,15 +318,18 @@
"crowdsourcedConfigsShort": "App-configuraties door menigte",
"allowInsecure": "Onveilige HTTP-verzoeken toestaan",
"stayOneVersionBehind": "Blijf een versie achter op de nieuwste",
"useFirstApkOfVersion": "Automatisch de eerste van meerdere APK's selecteren",
"refreshBeforeDownload": "Vernieuw app details voor download",
"tencentAppStore": "Tencent App Store",
"coolApk": "CoolApk",
"vivoAppStore": "vivo App Store (CN)",
"name": "Naam",
"smartname": "Naam (Slim)",
"sortMethod": "Sorteermethode",
"welcome": "Welkom",
"documentationLinksNote": "De GitHub pagina van Obtainium waarnaar hieronder wordt gelinkt bevat links naar video's, artikelen, discussies en andere bronnen die je zullen helpen begrijpen hoe je de app kunt gebruiken.",
"batteryOptimizationNote": "Note that background downloads may work more reliably if you disable OS battery optimizations for Obtainium.",
"batteryOptimizationNote": "Merk op dat downloads op de achtergrond mogelijk betrouwbaarder werken als je de batterijoptimalisatie van het besturingssysteem voor Obtainium uitschakelt.",
"fileDeletionError": "Bestand is niet verwijderd (probeer het handmatig te verwijderen en probeer het opnieuw): \"{}\"",
"removeAppQuestion": {
"one": "App verwijderen?",
"other": "Apps verwijderen?"

View File

@ -318,15 +318,18 @@
"crowdsourcedConfigsShort": "Baza konfiguracji",
"allowInsecure": "Zezwalaj na niezabezpieczone żądania HTTP",
"stayOneVersionBehind": "Pozostań jedną wersję w tyle za najnowszą",
"useFirstApkOfVersion": "Automatyczny wybór pierwszego z wielu plików APK",
"refreshBeforeDownload": "Odśwież szczegóły aplikacji przed pobraniem",
"tencentAppStore": "Tencent App Store",
"coolApk": "CoolApk",
"vivoAppStore": "vivo App Store (CN)",
"name": "Nazwa",
"smartname": "Nazwa (Smart)",
"sortMethod": "Metoda sortowania",
"welcome": "Witamy",
"documentationLinksNote": "Strona Obtainium GitHub, do której link znajduje się poniżej, zawiera linki do filmów, artykułów, dyskusji i innych zasobów, które pomogą ci zrozumieć, jak korzystać z aplikacji.",
"batteryOptimizationNote": "Note that background downloads may work more reliably if you disable OS battery optimizations for Obtainium.",
"batteryOptimizationNote": "Należy pamiętać, że pobieranie w tle może działać bardziej niezawodnie po wyłączeniu optymalizacji baterii systemu operacyjnego dla Obtainium.",
"fileDeletionError": "Nie udało się usunąć pliku (spróbuj usunąć go ręcznie, a następnie spróbuj ponownie): \"{}\"",
"removeAppQuestion": {
"one": "Usunąć aplikację?",
"few": "Usunąć aplikacje?",

View File

@ -30,7 +30,7 @@
"wrongArgNum": "Número errado de argumentos fornecidos",
"xIsTrackOnly": "{} é somente de rastreio",
"source": "Fonte",
"app": "App",
"app": "Aplicativo",
"appsFromSourceAreTrackOnly": "Apps desta fonte são somente para rastreamento.",
"youPickedTrackOnly": "Você selecionou a opção de somente rastreamento.",
"trackOnlyAppDescription": "As atualizações do app serão rastreadas, mas o Obtainium não baixará ou instalará elas.",
@ -47,7 +47,7 @@
"supportedSources": "Fontes suportadas",
"trackOnlyInBrackets": "(somente rastreamento)",
"searchableInBrackets": "(pesquisável)",
"appsString": "Apps",
"appsString": "Aplicativos",
"noApps": "Nenhum app",
"noAppsForFilter": "Nenhum app pro filtro",
"byX": "Por {}",
@ -318,15 +318,18 @@
"crowdsourcedConfigsShort": "Configurações de app da comunidade",
"allowInsecure": "Permitir solicitações de HTTP inseguras",
"stayOneVersionBehind": "Ficar uma versão antes da mais recente",
"useFirstApkOfVersion": "Seleção automática do primeiro de vários APKs",
"refreshBeforeDownload": "Atualizar detalhes do app antes de baixar",
"tencentAppStore": "Loja de Apps da Tencent",
"coolApk": "CoolApk",
"vivoAppStore": "Vivo App Store (CN)",
"name": "Nome",
"smartname": "Nome (inteligente)",
"sortMethod": "Método de ordenação",
"welcome": "Boas vindas",
"documentationLinksNote": "A página do Obtainium no GitHub visível abaixo contém links de vídeos, artigos, discussões, e outros recursos que podem te ajudar ao usar o app.",
"batteryOptimizationNote": "Note that background downloads may work more reliably if you disable OS battery optimizations for Obtainium.",
"batteryOptimizationNote": "Observe que os downloads em segundo plano podem funcionar de forma mais confiável se você desativar as otimizações de bateria do sistema operacional para o Obtainium.",
"fileDeletionError": "Falha ao excluir o arquivo (tente excluí-lo manualmente e tente novamente): \"{}\"",
"removeAppQuestion": {
"one": "Remover app?",
"other": "Remover apps?"
@ -340,8 +343,8 @@
"other": "A busca de atualizações em segundo plano encontrou um {}, será agendado uma nova tentativa em {} minutos"
},
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
"one": "BG update checking found {} update - will notify user if needed",
"other": "BG update checking found {} updates - will notify user if needed"
"one": "Verificação da atualização do BG encontrada {} atualização - notificará o usuário se necessário",
"other": "Verificação de atualização do BG encontrou atualizações {} - notificará o usuário se necessário"
},
"apps": {
"one": "{} app",
@ -364,8 +367,8 @@
"other": "{} dias"
},
"clearedNLogsBeforeXAfterY": {
"one": "Cleared {n} log (before = {before}, after = {after})",
"other": "Cleared {n} logs (before = {before}, after = {after})"
"one": "Limpou {n} registros (before = {before}, after = {after})",
"other": "Limpou {n} registros (antes = {before}, depois = {after})"
},
"xAndNMoreUpdatesAvailable": {
"one": "{} e mais 1 app têm atualizações.",

View File

@ -318,15 +318,18 @@
"crowdsourcedConfigsShort": "Configurações de aplicações com base em crowdsourcing",
"allowInsecure": "Permitir pedidos HTTP inseguros",
"stayOneVersionBehind": "Manter-se uma versão atrás da mais recente",
"useFirstApkOfVersion": "Seleção automática do primeiro de vários APKs",
"refreshBeforeDownload": "Atualizar os detalhes da aplicação antes da transferência",
"tencentAppStore": "Tencent App Store",
"coolApk": "CoolApk",
"vivoAppStore": "vivo App Store (CN)",
"name": "Nome",
"smartname": "Nome (Smart)",
"sortMethod": "Método de ordenação",
"welcome": "Bem-vindo",
"documentationLinksNote": "A página do Obtainium no GitHub com a ligação abaixo contém ligações para vídeos, artigos, discussões e outros recursos que o ajudarão a compreender como utilizar a aplicação.",
"batteryOptimizationNote": "Note that background downloads may work more reliably if you disable OS battery optimizations for Obtainium.",
"batteryOptimizationNote": "Note que os downloads em segundo plano podem funcionar de forma mais fiável se desativar as optimizações da bateria do SO para o Obtainium.",
"fileDeletionError": "Falha ao eliminar o ficheiro (tente eliminá-lo manualmente e depois tente novamente): \"{}\"",
"removeAppQuestion": {
"one": "Remover aplicativo?",
"other": "Remover aplicativos?"

View File

@ -1,7 +1,7 @@
{
"invalidURLForSource": "Неверный URL-адрес приложения: {}",
"noReleaseFound": "Не удалось найти подходящий релиз",
"noVersionFound": "Не удалось определить версию релиза",
"noReleaseFound": "Не удалось найти подходящий выпуск",
"noVersionFound": "Не удалось определить версию выпуска",
"urlMatchesNoSource": "URL-адрес не соответствует известному источнику",
"cantInstallOlderVersion": "Невозможно установить более старую версию приложения",
"appIdMismatch": "ID загруженного пакета не совпадает с существующим ID приложения",
@ -9,12 +9,12 @@
"placeholder": "Заполнитель",
"someErrors": "Возникли некоторые ошибки",
"unexpectedError": "Неожиданная ошибка",
"ok": "Ок",
"ok": "OK",
"and": "и",
"githubPATLabel": "Персональный токен доступа GitHub\n(увеличивает лимит запросов)",
"includePrereleases": "Включить предварительные релизы",
"includePrereleases": "Включать предварительные выпуски",
"fallbackToOlderReleases": "Откатываться к предыдущей версии",
"filterReleaseTitlesByRegEx": "Фильтровать заголовки релизов\n(регулярное выражение)",
"filterReleaseTitlesByRegEx": "Фильтровать заголовки выпусков\n(регулярное выражение)",
"invalidRegEx": "Неверное регулярное выражение",
"noDescription": "Нет описания",
"cancel": "Отмена",
@ -32,13 +32,13 @@
"source": "Источник",
"app": "Приложение",
"appsFromSourceAreTrackOnly": "Приложения из этого источника настроены только для отслеживания",
"youPickedTrackOnly": "Вы выбрали опцию 'Только для отслеживания'",
"youPickedTrackOnly": "Выбрана настройка «только для отслеживания»",
"trackOnlyAppDescription": "Приложение будет отслеживаться на предмет обновлений, но Obtainium не сможет загрузить или установить его",
"cancelled": "Отменено",
"appAlreadyAdded": "Приложение уже добавлено",
"alreadyUpToDateQuestion": "Приложение уже обновлено?",
"addApp": "Добавить",
"appSourceURL": "URL-источник приложения",
"appSourceURL": "URL-адрес источника приложения",
"error": "Ошибка",
"add": "Добавить",
"searchSomeSourcesLabel": "Поиск (в некоторых источниках)",
@ -63,10 +63,10 @@
"removeSelectedApps": "Удалить выбранные приложения",
"updateX": "Обновить {}",
"installX": "Установить {}",
"markXTrackOnlyAsUpdated": "Отметить {}\n(Только для отслеживания)\nкак обновленное",
"markXTrackOnlyAsUpdated": "Отметить {}\n(только для отслеживания)\nкак обновлённое",
"changeX": "Изменить {}",
"installUpdateApps": "Установить/Обновить приложения",
"installUpdateSelectedApps": "Установить/Обновить выбранные приложения",
"installUpdateApps": "Установить/обновить приложения",
"installUpdateSelectedApps": "Установить/обновить выбранные приложения",
"markXSelectedAppsAsUpdated": "Выбрано приложений: {}. Отметить как обновлённые?",
"no": "Нет",
"yes": "Да",
@ -74,10 +74,10 @@
"pinToTop": "Закрепить сверху",
"unpinFromTop": "Открепить",
"resetInstallStatusForSelectedAppsQuestion": "Сбросить статус установки для выбранных приложений?",
"installStatusOfXWillBeResetExplanation": "Статус установки для выбранных приложений будет сброшен.\n\nЭто может помочь, если версия приложения, отображаемая в Obtainium, некорректная из-за неудачных обновлений или других проблем",
"installStatusOfXWillBeResetExplanation": "Статус установки для выбранных приложений будет сброшен.\n\nЭто может помочь, если версия приложения, отображаемая в Obtainium, некорректная (из-за неудачных обновлений или других проблем).",
"customLinkMessage": "Эти ссылки работают на устройствах с установленным Obtainium",
"shareAppConfigLinks": "Поделиться конфигурацией приложения в виде HTML-ссылки",
"shareSelectedAppURLs": "Поделиться выбранными URL-адресами приложений",
"shareSelectedAppURLs": "Поделиться URL-адресами выбранных приложений",
"resetInstallStatus": "Сбросить статус установки",
"more": "Ещё",
"removeOutdatedFilter": "Удалить фильтр для устаревших приложений",
@ -86,7 +86,7 @@
"filterApps": "Фильтровать приложения",
"appName": "Название приложения",
"author": "Автор",
"upToDateApps": "Приложения со свежими обновлениями",
"upToDateApps": "Приложения с обновлениями",
"nonInstalledApps": "Неустановленные приложения",
"importExport": "Данные",
"settings": "Настройки",
@ -97,12 +97,12 @@
"obtainiumImport": "Импорт в Obtainium",
"importFromURLList": "Импорт из списка URL-адресов",
"searchQuery": "Поисковый запрос",
"appURLList": "Список URL приложений",
"appURLList": "Список URL-адресов приложений",
"line": "Строка",
"searchX": "Поиск {}",
"noResults": "Результатов не найдено",
"noResults": "Ничего не найдено",
"importX": "Импорт {}",
"importedAppsIdDisclaimer": "Импортированные приложения могут неверно отображаться как неустановленные.\nДля исправления этой проблемы повторно установите их через Obtainium.\nЭто не должно повлиять на данные приложения.\n\nПроблемы возникают только при импорте из URL-адреса и сторонних источников",
"importedAppsIdDisclaimer": "Импортированные приложения могут неверно отображаться как неустановленные.\nДля исправления этой проблемы повторно установите их через Obtainium.\nЭто не должно повлиять на данные приложения.\n\nПроблемы возникают только при импорте из URL-адреса и сторонних источников.",
"importErrors": "Ошибка импорта",
"importedXOfYApps": "Импортировано приложений: {} из {}",
"followingURLsHadErrors": "При импорте следующие URL-адреса содержали ошибки:",
@ -112,12 +112,12 @@
"theme": "Тема",
"dark": "Тёмная",
"light": "Светлая",
"followSystem": "Системная",
"followSystem": "Как в системе",
"followSystemThemeExplanation": "Следование системной теме возможно только при использовании сторонних приложений",
"useBlackTheme": "Использовать чёрную тему",
"appSortBy": "Сортировка приложений",
"authorName": "Автор/Название",
"nameAuthor": "Название/Автор",
"authorName": "Автор/название",
"nameAuthor": "Название/автор",
"asAdded": "В порядке добавления",
"appSortOrder": "Порядок",
"ascending": "По возрастанию",
@ -126,12 +126,12 @@
"neverManualOnly": "Никогда — только вручную",
"appearance": "Внешний вид",
"showWebInAppView": "Показывать исходную веб-страницу на странице приложения",
"pinUpdates": "Отображать обновления приложений сверху списка",
"pinUpdates": "Показывать обновления приложений сверху списка",
"updates": "Обновления",
"sourceSpecific": "Настройки источников",
"appSource": "Источник",
"noLogs": "Нет журналов",
"appLogs": "Логи",
"appLogs": "Журналы",
"close": "Закрыть",
"share": "Поделиться",
"appNotFound": "Приложение не найдено",
@ -141,21 +141,21 @@
"deviceSupportsXArch": "Ваше устройство поддерживает архитектуру процессора {}",
"deviceSupportsFollowingArchs": "Ваше устройство поддерживает следующие архитектуры процессора:",
"warning": "Предупреждение",
"sourceIsXButPackageFromYPrompt": "Источник приложения — '{}', но пакет для установки получен из '{}'. Продолжить?",
"sourceIsXButPackageFromYPrompt": "Источник приложения — «{}», но пакет для установки получен из «{}». Продолжить?",
"updatesAvailable": "Доступны обновления",
"updatesAvailableNotifDescription": "Уведомляет о наличии обновлений для одного или нескольких приложений в Obtainium",
"noNewUpdates": "Нет новых обновлений",
"xHasAnUpdate": "{} есть обновление",
"noNewUpdates": "Нет обновлений",
"xHasAnUpdate": "Для {} есть обновление",
"appsUpdated": "Приложения обновлены",
"appsNotUpdated": "Не удалось обновить приложения",
"appsUpdatedNotifDescription": "Уведомляет об обновлении одного или нескольких приложений в фоновом режиме",
"xWasUpdatedToY": "{} была обновлена до версии {}",
"xWasUpdatedToY": "{} обновлено до версии {}",
"xWasNotUpdatedToY": "Не удалось обновить {} до версии {}",
"errorCheckingUpdates": "Ошибка при проверке обновлений",
"errorCheckingUpdatesNotifDescription": "Уведомление о завершении проверки обновлений в фоновом режиме с ошибкой",
"appsRemoved": "Приложение удалено",
"appsRemovedNotifDescription": "Уведомление об удалении одного или несколько приложений из-за ошибок при их загрузке",
"xWasRemovedDueToErrorY": "{} был удален из-за ошибки: {}",
"xWasRemovedDueToErrorY": "{} удалено из-за ошибки: {}",
"completeAppInstallation": "Завершение установки приложения",
"obtainiumMustBeOpenToInstallApps": "Obtainium должен быть открыт для установки приложений",
"completeAppInstallationNotifDescription": "Уведомление о необходимости открыть Obtainium для завершения установки приложения",
@ -172,7 +172,7 @@
"installedVersionX": "Установленная версия: {}",
"lastUpdateCheckX": "Последняя проверка: {}",
"remove": "Удалить",
"yesMarkUpdated": "Да, отметить как обновленное",
"yesMarkUpdated": "Да, отметить как обновлённое",
"fdroid": "Официальные репозитории F-Droid",
"appIdOrName": "ID или название приложения",
"appId": "ID приложения",
@ -180,39 +180,39 @@
"reposHaveMultipleApps": "В хранилище несколько приложений",
"fdroidThirdPartyRepo": "Сторонние репозитории F-Droid",
"install": "Установить",
"markInstalled": "Пометить как установленное",
"markInstalled": "Отметить установленным",
"update": "Обновить",
"markUpdated": "Отметить обновлённым",
"additionalOptions": "Дополнительные настройки",
"disableVersionDetection": "Отключить обнаружение версии",
"noVersionDetectionExplanation": "Эта настройка должна использоваться только для приложений, где обнаружение версии не работает корректно",
"disableVersionDetection": "Отключить определение версии",
"noVersionDetectionExplanation": "Эта настройка должна использоваться только для приложений, где определение версии не работает корректно",
"downloadingX": "Загрузка {}",
"downloadX": "Скачать {}",
"downloadX": "Загрузить {}",
"downloadedX": "Загружено {}",
"releaseAsset": "Релизный объект",
"releaseAsset": "Объект выпуска",
"downloadNotifDescription": "Уведомляет пользователя о прогрессе загрузки приложения",
"noAPKFound": "APK не найден",
"noVersionDetection": "Обнаружение версий отключено",
"noAPKFound": "APK-файл не найден",
"noVersionDetection": "Определение версий отключено",
"categorize": "Категоризировать",
"categories": "Категории",
"category": "Категория",
"noCategory": "Без категории",
"noCategories": "Без категорий",
"deleteCategoriesQuestion": "Удалить категории?",
"categoryDeleteWarning": "Все приложения в удаленных категориях будут помечены как без категории",
"categoryDeleteWarning": "Все приложения в удалённых категориях будут помечены как «без категории»",
"addCategory": "Добавить категорию",
"label": "Метка",
"language": "Язык",
"copiedToClipboard": "Скопировано в буфер обмена",
"storagePermissionDenied": "Отказано в доступе к хранилищу",
"selectedCategorizeWarning": "Это заменит все текущие настройки категорий для выбранных приложений",
"filterAPKsByRegEx": "Отфильтровать APK-файлы\n(регулярное выражение)",
"filterAPKsByRegEx": "Фильтровать APK-файлы\n(регулярное выражение)",
"removeFromObtainium": "Удалить из Obtainium",
"uninstallFromDevice": "Удалить с устройства",
"onlyWorksWithNonVersionDetectApps": "Работает только для приложений с отключенным определением версии",
"releaseDateAsVersion": "Дата выпуска вместо версии",
"releaseTitleAsVersion": "Используйте название релиза в качестве строки версии",
"releaseDateAsVersionExplanation": "Этот параметр следует использовать только для приложений, в которых определение версии не работает правильно, но имеется дата выпуска",
"onlyWorksWithNonVersionDetectApps": "Работает только для приложений с отключённым определением версии",
"releaseDateAsVersion": "Использовать дату выпуска в качестве версии",
"releaseTitleAsVersion": "Использовать название выпуска в качестве версии",
"releaseDateAsVersionExplanation": "Эта настройка должна использоваться только для приложений, в которых определение версии не работает правильно, но имеется дата выпуска",
"changes": "Изменения",
"releaseDate": "Дата выпуска",
"importFromURLsInFile": "Импорт из файла URL-адресов (например: OPML)",
@ -220,56 +220,56 @@
"versionDetection": "Определение версии",
"standardVersionDetection": "Стандартное",
"groupByCategory": "Группировать по категориям",
"autoApkFilterByArch": опытаться отфильтровать APK-файлы по архитектуре процессора",
"autoLinkFilterByArch": опытайтесь отфильтровать ссылки по архитектуре процессора, если это возможно",
"autoApkFilterByArch": "Пытаться фильтровать APK по архитектуре процессора",
"autoLinkFilterByArch": "Пытаться фильтровать ссылки по архитектуре процессора, если это возможно",
"overrideSource": "Переопределить источник",
"dontShowAgain": "Не показывать снова",
"dontShowTrackOnlyWarnings": "Не показывать предупреждения о только отслеживаемых приложениях",
"dontShowAPKOriginWarnings": "Не показывать предупреждения об отличающемся источнике APK-файлов",
"moveNonInstalledAppsToBottom": "Отображать неустановленные приложения внизу списка",
"moveNonInstalledAppsToBottom": "Показывать неустановленные приложения внизу списка",
"gitlabPATLabel": "Персональный токен доступа GitLab",
"about": "Описание",
"requiresCredentialsInSettings": "{}: Для этого требуются дополнительные учетные данные (в настройках)",
"requiresCredentialsInSettings": "{}: для этого требуются дополнительные учётные данные (в настройках)",
"checkOnStart": "Проверять наличие обновлений при запуске",
"tryInferAppIdFromCode": опытаться определить ID приложения из исходного кода",
"removeOnExternalUninstall": "Автоматически убирать из списка удаленные извне приложения",
"tryInferAppIdFromCode": "Пытаться определить ID приложения из исходного кода",
"removeOnExternalUninstall": "Автоматически убирать из списка удалённые извне приложения",
"pickHighestVersionCode": "Автовыбор актуальной версии кода APK",
"checkUpdateOnDetailPage": "Проверять наличие обновлений при открытии страницы приложения",
"disablePageTransitions": "Отключить анимацию перехода между страницами",
"reversePageTransitions": "Реверс анимации перехода между страницами",
"minStarCount": "Минимальное количество звёзд",
"addInfoBelow": "Добавьте эту информацию ниже",
"addInfoInSettings": "Добавьте эту информацию в Настройки",
"githubSourceNote": "Используя ключ API можно обойти лимит запросов GitHub",
"addInfoInSettings": "Добавьте эту информацию в «Настройки»",
"githubSourceNote": "Используя ключ API, можно обойти ограничение запросов GitHub",
"sortByLastLinkSegment": "Сортировать только по последнему сегменту ссылки",
"filterReleaseNotesByRegEx": "Фильтровать примечания к выпуску\n(регулярное выражение)",
"customLinkFilterRegex": "Пользовательский фильтр ссылок APK\n(регулярное выражение, по умолчанию: '.apk$')",
"customLinkFilterRegex": "Пользовательский фильтр ссылок APK\n(регулярное выражение, по умолчанию: «.apk$»)",
"appsPossiblyUpdated": "Попытки обновления приложений",
"appsPossiblyUpdatedNotifDescription": "Уведомление о возможных обновлениях одного или нескольких приложений в фоновом режиме",
"xWasPossiblyUpdatedToY": "{} возможно был обновлен до {}",
"xWasPossiblyUpdatedToY": "{} возможно был обновлён до {}",
"enableBackgroundUpdates": "Включить обновления в фоне",
"backgroundUpdateReqsExplanation": "Фоновые обновления могут быть возможны не для всех приложений",
"backgroundUpdateLimitsExplanation": "Успешность фоновой установки можно определить только после открытия Obtainium",
"verifyLatestTag": "Проверять тег 'latest'",
"intermediateLinkRegex": "Фильтр для \"промежуточной\" ссылки для посещения",
"verifyLatestTag": "Проверять метку «latest»",
"intermediateLinkRegex": "Фильтр для «промежуточной» ссылки для посещения",
"filterByLinkText": "Фильтрация ссылок по тексту ссылки",
"intermediateLinkNotFound": "Промежуточная ссылка не найдена",
"intermediateLink": "Промежуточная ссылка",
"exemptFromBackgroundUpdates": "Исключить из фоновых обновлений (если включено)",
"bgUpdatesOnWiFiOnly": "Отключить фоновые обновления, если нет соединения с Wi-Fi",
"bgUpdatesWhileChargingOnly": "Отключение фоновых обновлений при отсутствии зарядки",
"autoSelectHighestVersionCode": "Автоматически выбирать APK с актуальной версией кода",
"versionExtractionRegEx": "Регулярное выражение для извлечения версии",
"trimVersionString": "Обрезка строки версии с помощью RegEx",
"matchGroupToUseForX": "Группа соответствия, которую следует использовать для \"{}\"",
"matchGroupToUse": "Выберите группу для использования",
"highlightTouchTargets": "Выделить менее очевидные элементы управления касанием",
"bgUpdatesOnWiFiOnly": "Отключить фоновые обновления, если нет подключения к Wi-Fi",
"bgUpdatesWhileChargingOnly": "Отключить фоновые обновления, если не идёт зарядка",
"autoSelectHighestVersionCode": "Автовыбор APK с актуальной версией кода",
"versionExtractionRegEx": "Извлечение версии\n(регулярное выражение)",
"trimVersionString": "Обрезка строки версии\n(регулярное выражение)",
"matchGroupToUseForX": "Группа соответствия для «{}»",
"matchGroupToUse": "Выбрать группу соответствия",
"highlightTouchTargets": "Выделять менее очевидные элементы управления касанием",
"pickExportDir": "Выбрать каталог для экспорта",
"autoExportOnChanges": "Автоэкспорт при изменениях",
"includeSettings": "Включить настройки",
"filterVersionsByRegEx": "Фильтровать версии по регулярному выражению",
"trySelectingSuggestedVersionCode": опробуйте выбрать предложенный код версии APK",
"dontSortReleasesList": "Сохранить порядок релизов от API",
"trySelectingSuggestedVersionCode": "Пробовать выбрать предложенный код версии APK",
"dontSortReleasesList": "Сохранить порядок выпусков от API",
"reverseSort": "Обратная сортировка",
"takeFirstLink": "Взять первую ссылку",
"skipSort": "Пропустить сортировку",
@ -290,18 +290,18 @@
"onlyCheckInstalledOrTrackOnlyApps": "Проверять обновления только у установленных или отслеживаемых приложений",
"supportFixedAPKURL": "Поддержка фиксированных URL-адресов APK",
"selectX": "Выбрать {}",
"parallelDownloads": "Разрешить параллельные загрузки",
"parallelDownloads": "Использовать параллельные загрузки",
"useShizuku": "Использовать Shizuku или Sui для установки",
"shizukuBinderNotFound": "Совместимый сервис Shizuku не найден, возможно он не запущен",
"shizukuOld": "Устаревшая версия Shizuku (<11), обновите",
"shizukuOldAndroidWithADB": "Shizuku работает на Android < 8.1 с ADB, обновите Android или используйте Sui",
"shizukuPretendToBeGooglePlay": "Указать Google Play как источник установки (если используется Shizuku)",
"shizukuPretendToBeGooglePlay": "Указывать Google Play как источник установки (если используется Shizuku)",
"useSystemFont": "Использовать системный шрифт",
"useVersionCodeAsOSVersion": "Использовать код версии приложения как версию, обнаруженную ОС",
"requestHeader": "Заголовок запроса",
"useLatestAssetDateAsReleaseDate": "Использовать последнюю загрузку ресурса в качестве даты выпуска",
"useLatestAssetDateAsReleaseDate": "Использовать последнюю загрузку объекта в качестве даты выпуска",
"defaultPseudoVersioningMethod": "Метод псевдоверсионирования по умолчанию",
"partialAPKHash": "Частичный хэш APK",
"partialAPKHash": "Частичный хеш APK",
"APKLinkHash": "Хеш ссылки APK",
"directAPKLink": "Прямая ссылка на APK",
"pseudoVersionInUse": "Используется псевдоверсия",
@ -309,82 +309,98 @@
"latest": "Последний",
"invertRegEx": "Инвертировать регулярное выражение",
"note": "Примечание",
"selfHostedNote": "Выпадающий список \"{}\" можно использовать для доступа к самостоятельно размещенным/настроенным экземплярам любого источника.",
"badDownload": "APK не удалось разобрать (несовместимая или неполная загрузка)",
"beforeNewInstallsShareToAppVerifier": "Поделитесь новыми приложениями с AppVerifier (если доступно)",
"selfHostedNote": "Выпадающий список «{}» можно использовать для доступа к самостоятельно размещённым/настроенным экземплярам любого источника.",
"badDownload": "Не удалось разобрать APK (несовместимая или неполная загрузка)",
"beforeNewInstallsShareToAppVerifier": "Делиться новыми приложениями с AppVerifier (если доступно)",
"appVerifierInstructionToast": "Поделитесь с AppVerifier, а затем вернитесь сюда, когда будете готовы.",
"wiki": "Помощь/Вики",
"crowdsourcedConfigsLabel": "Конфигурации приложений на основе краудсорсинга (используйте на свой страх и риск)",
"crowdsourcedConfigsShort": "Конфиги приложений с помощью краудсорсинга",
"crowdsourcedConfigsShort": "Краудсорсинг конфигураций приложений",
"allowInsecure": "Разрешить небезопасные HTTP-запросы",
"stayOneVersionBehind": "Не отставайте от последней версии",
"refreshBeforeDownload": "Обновляйте информацию о приложении перед загрузкой",
"stayOneVersionBehind": "Оставаться на одну версию ниже последней",
"useFirstApkOfVersion": "Автовыбор первого из нескольких APK",
"refreshBeforeDownload": "Обновлять информацию о приложении перед загрузкой",
"tencentAppStore": "Tencent App Store",
"coolApk": "CoolApk",
"vivoAppStore": "vivo App Store (CN)",
"name": "Имя",
"smartname": "Имя (умное)",
"sortMethod": "Метод сортировки",
"welcome": "Добро пожаловать",
"documentationLinksNote": "На странице Obtainium GitHub, ссылка на которую приведена ниже, содержатся ссылки на видео, статьи, обсуждения и другие ресурсы, которые помогут вам понять, как пользоваться приложением.",
"batteryOptimizationNote": "Note that background downloads may work more reliably if you disable OS battery optimizations for Obtainium.",
"documentationLinksNote": "На странице Obtainium в GitHub, ссылка на которую приведена ниже, содержатся ссылки на видео, статьи, обсуждения и другие ресурсы, которые помогут вам понять, как пользоваться приложением.",
"batteryOptimizationNote": "Обратите внимание, что фоновая загрузка может работать более надёжно, если отключить оптимизацию батареи ОС для Obtainium.",
"fileDeletionError": "Не удалось удалить файл (попробуйте удалить его вручную, а затем повторите попытку): «{}»",
"removeAppQuestion": {
"one": "Удалить приложение?",
"other": "Удалить приложения?"
},
"tooManyRequestsTryAgainInMinutes": {
"one": "Слишком много запросов (ограничение скорости) — попробуйте снова через {} минуту",
"other": "Слишком много запросов (ограничение скорости) — попробуйте снова через {} минуты"
"few": "Слишком много запросов (ограничение скорости) — попробуйте снова через {} минуты",
"other": "Слишком много запросов (ограничение скорости) — попробуйте снова через {} минут"
},
"bgUpdateGotErrorRetryInMinutes": {
"one": "При проверке обновлений в фоновом режиме возникла ошибка {}, повторная проверка будет запланирована через {} минуту",
"other": "При проверке обновлений в фоновом режиме возникла ошибка {}, повторная проверка будет запланирована через {} минуты"
"one": "При проверке обновлений в фоновом режиме возникла ошибка {}, повторная проверка будет через {} минуту",
"few": "При проверке обновлений в фоновом режиме возникла ошибка {}, повторная проверка будет через {} минуты",
"other": "При проверке обновлений в фоновом режиме возникла ошибка {}, повторная проверка будет через {} минут"
},
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
"one": "В ходе проверки обновления в фоновом режиме было обнаружено {} обновление — Пользователю будет отправлено уведомление, если это необходимо",
"other": "В ходе проверки обновления в фоновом режиме было обнаружено {} обновленийПользователю будет отправлено уведомление, если это необходимо"
"one": "При проверке в фоновом режиме было обнаружено {} обновление — пользователь будет уведомлен по необходимости",
"one": "При проверке в фоновом режиме было обнаружено {} обновленияпользователь будет уведомлен по необходимости",
"other": "При проверке в фоновом режиме было обнаружено {} обновлений — пользователь будет уведомлен по необходимости"
},
"apps": {
"one": "{} приложение",
"few": "{} приложения",
"other": "{} приложений"
},
"url": {
"one": "{} URL-адрес",
"other": "{} URL-адреса"
"few": "{} URL-адреса",
"other": "{} URL-адресов"
},
"minute": {
"one": "{} минута",
"other": "{} минуты"
"few": "{} минуты",
"other": "{} минут"
},
"hour": {
"one": "{} час",
"few": "{} часа",
"other": "{} часов"
},
"day": {
"one": "{} день",
"few": "{} дня",
"other": "{} дней"
},
"clearedNLogsBeforeXAfterY": {
"one": "Очищен {n} журнал (до = {before}, после = {after})",
"few": "Очищены {n} журнала (до = {before}, после = {after})",
"other": "Очищено {n} журналов (до = {before}, после = {after})"
},
"xAndNMoreUpdatesAvailable": {
"one": "У {} и еще 1 приложения есть обновление",
"one": "У {} и ещё 1 приложения есть обновление",
"other": "У {} и ещё {} приложений есть обновления"
},
"xAndNMoreUpdatesInstalled": {
"one": "{} и ещё 1 приложение были обновлены",
"few": "{} и ещё {} приложения были обновлены",
"other": "{} и ещё {} приложений были обновлены"
},
"xAndNMoreUpdatesFailed": {
"one": "Не удалось обновить {} и ещё 1 приложение",
"few": "Не удалось обновить {} и ещё {} приложения",
"other": "Не удалось обновить {} и ещё {} приложений"
},
"xAndNMoreUpdatesPossiblyInstalled": {
"one": "{} и ещё 1 приложение могли быть обновлены",
"few": "{} и ещё {} приложения могли быть обновлены",
"other": "{} и ещё {} приложений могли быть обновлены"
},
"apk": {
"one": "{} APK",
"other": "{} APKи"
"one": "{} APK-файл",
"few": "{} APK-файла",
"other": "{} APK-файлов"
}
}

View File

@ -16,6 +16,10 @@ const neverAutoTranslate = {
placeholder: ['pl'],
importExport: ['fr'],
url: ['fr'],
vivoAppStore: ['cs', 'ja', 'ko'],
coolapk: ['ja'],
obtainiumImport: ['nl'],
appLogs: ['nl'],
tencentAppStore: ['*']
}

View File

@ -318,15 +318,18 @@
"crowdsourcedConfigsShort": "Appkonfigurationer med hjälp av crowdsourcing",
"allowInsecure": "Tillåt osäkra HTTP-förfrågningar",
"stayOneVersionBehind": "Håll dig en version bakom den senaste",
"useFirstApkOfVersion": "Välj automatiskt den första av flera APK:er",
"refreshBeforeDownload": "Uppdatera appdetaljerna före nedladdning",
"tencentAppStore": "Tencent App Store",
"coolApk": "CoolApk",
"vivoAppStore": "vivo App Store (CN)",
"name": "Namn",
"smartname": "Namn (Smart)",
"sortMethod": "Sorteringsmetod",
"welcome": "Välkommen",
"documentationLinksNote": "Obtainium GitHub-sidan som länkas nedan innehåller länkar till videor, artiklar, diskussioner och andra resurser som hjälper dig att förstå hur du använder appen.",
"batteryOptimizationNote": "Note that background downloads may work more reliably if you disable OS battery optimizations for Obtainium.",
"batteryOptimizationNote": "Observera att nedladdningar i bakgrunden kan fungera mer tillförlitligt om du inaktiverar OS-batterioptimeringar för Obtainium.",
"fileDeletionError": "Misslyckades med att radera filen (försök radera den manuellt och försök sedan igen): \"{}\"",
"removeAppQuestion": {
"one": "Ta Bort App?",
"other": "Ta Bort Appar?"

View File

@ -318,15 +318,18 @@
"crowdsourcedConfigsShort": "Kitle Kaynaklı Uygulama Yapılandırmaları",
"allowInsecure": "Güvensiz HTTP isteklerine izin ver",
"stayOneVersionBehind": "En son sürümün bir sürüm gerisinde kalın",
"useFirstApkOfVersion": "Birden fazla APK arasından ilkini otomatik seçme",
"refreshBeforeDownload": "İndirmeden önce uygulama ayrıntılarını yenileyin",
"tencentAppStore": "Tencent App Store",
"coolApk": "CoolApk",
"vivoAppStore": "vivo App Store (CN)",
"name": "İsim",
"smartname": "İsim (Akıllı)",
"sortMethod": "Sıralama Yöntemi",
"welcome": "Hoş geldiniz",
"documentationLinksNote": "Aşağıda bağlantısı verilen Obtainium GitHub sayfası, uygulamayı nasıl kullanacağınızı anlamanıza yardımcı olacak videolara, makalelere, tartışmalara ve diğer kaynaklara bağlantılar içerir.",
"batteryOptimizationNote": "Note that background downloads may work more reliably if you disable OS battery optimizations for Obtainium.",
"batteryOptimizationNote": "Obtainium için işletim sistemi pil optimizasyonlarını devre dışı bırakırsanız arka planda indirmelerin daha güvenilir şekilde çalışabileceğini unutmayın.",
"fileDeletionError": "Dosya silinemedi (elle silmeyi deneyin ve sonra tekrar deneyin): \"{}\"",
"removeAppQuestion": {
"one": "Uygulamayı Kaldır?",
"other": "Uygulamaları Kaldır?"

View File

@ -318,15 +318,18 @@
"crowdsourcedConfigsShort": "Налаштування краудсорсингових додатків",
"allowInsecure": "Дозволити незахищені HTTP-запити",
"stayOneVersionBehind": "Залишайтеся на одну версію актуальнішою",
"useFirstApkOfVersion": "Автоматичний вибір першого з декількох APK",
"refreshBeforeDownload": "Оновіть інформацію про програму перед завантаженням",
"tencentAppStore": "Tencent App Store",
"coolApk": "CoolApk",
"vivoAppStore": "vivo App Store (CN)",
"name": "Ім'я",
"smartname": "Ім'я (Smart)",
"sortMethod": "Метод сортування",
"welcome": "Ласкаво просимо.",
"documentationLinksNote": "Сторінка Obtainium на GitHub, посилання на яку наведено нижче, містить посилання на відео, статті, дискусії та інші ресурси, які допоможуть вам зрозуміти, як користуватися додатком.",
"batteryOptimizationNote": "Note that background downloads may work more reliably if you disable OS battery optimizations for Obtainium.",
"batteryOptimizationNote": "Зауважте, що фонові завантаження можуть працювати надійніше, якщо ви вимкнете оптимізацію батареї ОС для Obtainium.",
"fileDeletionError": "Не вдалося видалити файл (спробуйте видалити його вручну, а потім спробуйте ще раз): \"{}\"",
"removeAppQuestion": {
"one": "Видалити застосунок?",
"other": "Видалити застосунки?"

View File

@ -318,15 +318,18 @@
"crowdsourcedConfigsShort": "Crowdsourced App Configurations",
"allowInsecure": "Allow insecure HTTP requests",
"stayOneVersionBehind": "Stay one version behind latest",
"useFirstApkOfVersion": "Auto-select first of multiple APKs",
"refreshBeforeDownload": "Refresh app details before download",
"tencentAppStore": "Tencent App Store",
"coolApk": "CoolApk",
"vivoAppStore": "vivo App Store (CN)",
"name": "Name",
"smartname": "Name (Smart)",
"sortMethod": "Sort Method",
"welcome": "Welcome",
"documentationLinksNote": "The Obtainium GitHub page linked below contains links to videos, articles, discussions and other resources that will help you understand how to use the app.",
"batteryOptimizationNote": "Note that background downloads may work more reliably if you disable OS battery optimizations for Obtainium.",
"fileDeletionError": "Failed to delete file (try deleting it manually then try again): \"{}\"",
"removeAppQuestion": {
"one": "Gỡ ứng dụng?",
"other": "Gỡ ứng dụng?"

View File

@ -318,15 +318,18 @@
"crowdsourcedConfigsShort": "群眾外包的應用程式設定",
"allowInsecure": "允許不安全的 HTTP 請求",
"stayOneVersionBehind": "保持比最新版本落後一個版本",
"useFirstApkOfVersion": "Auto-select first of multiple APKs",
"refreshBeforeDownload": "下載前刷新應用程式詳細資訊",
"tencentAppStore": "騰訊應用寶",
"coolApk": "CoolApk",
"vivoAppStore": "vivo App Store (CN)",
"name": "名稱",
"smartname": "名稱(智慧)",
"sortMethod": "排序方式",
"welcome": "歡迎",
"documentationLinksNote": "下方連結的 Obtainium GitHub 頁面包含影片、文章、討論及其他資源,能幫助你瞭解如何使用這款應用程式。",
"batteryOptimizationNote": "Note that background downloads may work more reliably if you disable OS battery optimizations for Obtainium.",
"fileDeletionError": "Failed to delete file (try deleting it manually then try again): \"{}\"",
"removeAppQuestion": {
"one": "移除應用程式?",
"other": "移除應用程式?"

View File

@ -318,6 +318,7 @@
"crowdsourcedConfigsShort": "众包应用程序配置",
"allowInsecure": "允许不安全的 HTTP 请求",
"stayOneVersionBehind": "比最新版本晚一个版本",
"useFirstApkOfVersion": "从多个 APK 中自动选择第一个",
"refreshBeforeDownload": "下载前刷新应用程序详细信息",
"tencentAppStore": "腾讯应用宝",
"coolApk": "酷安",
@ -327,7 +328,8 @@
"sortMethod": "排序方法",
"welcome": "欢迎光临",
"documentationLinksNote": "下面链接的 Obtainium GitHub 页面包含视频、文章、讨论和其他资源的链接,可帮助您了解如何使用该应用程序。",
"batteryOptimizationNote": "Note that background downloads may work more reliably if you disable OS battery optimizations for Obtainium.",
"batteryOptimizationNote": "请注意,如果为 Obtainium 禁用操作系统电池优化功能,后台下载可能会更稳定。",
"fileDeletionError": "删除文件失败(尝试手动删除,然后再试一次):\"{}\"",
"removeAppQuestion": {
"one": "是否删除应用?",
"other": "是否删除应用?"

View File

@ -2,15 +2,35 @@
# Convenience script
CURR_DIR="$(pwd)"
trap "cd "$CURR_DIR"" EXIT
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
trap "cd \"$CURR_DIR\"" EXIT
cd "$SCRIPT_DIR"
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
# Update local Flutter
git submodule update --remote
cd .flutter
git fetch
git checkout "$(flutter --version | head -2 | tail -1 | awk '{print $4}')" # Ensure included Flutter submodule version equals my environment
git checkout stable
git pull
FLUTTER_GIT_URL="https://github.com/flutter/flutter/" ./bin/flutter upgrade
cd ..
# Keep global Flutter, if any, in sync
if [ -f ~/flutter/bin/flutter ]; then
cd ~/flutter
./bin/flutter channel stable
./bin/flutter upgrade
cd "$SCRIPT_DIR"
fi
if [ -z "$(which flutter)" ]; then
export PATH="$PATH:$SCRIPT_DIR/.flutter/bin"
fi
rm ./build/app/outputs/flutter-apk/* 2>/dev/null # Get rid of older builds if any
flutter build apk --flavor normal && flutter build apk --split-per-abi --flavor normal # Build (both split and combined APKs)
for file in ./build/app/outputs/flutter-apk/app-*normal*.apk*; do mv "$file" "${file//-normal/}"; done

View File

@ -51,7 +51,7 @@ RUN \
mv ${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools ${ANDROID_SDK_ROOT}/cmdline-tools/latest && \
rm -v /tmp/tools.zip && \
mkdir -p /root/.android/ && touch /root/.android/repositories.cfg &&\
apt-get install -y --no-install-recommends openjdk-17-jdk openjdk-17-jre &&\
apt-get install -y --no-install-recommends openjdk-21-jdk openjdk-21-jre &&\
yes | sdkmanager --licenses &&\
sdkmanager --update

View File

@ -0,0 +1,48 @@
<p>Obtainium erlaubt Ihnen die Installation und Aktualisierung von Apps direkt von deren Veröffentlichungsseiten aus; Sie erhalten außerdem Benachrichtigungen, wenn neue Versionen verfügbar sind.</p>
<p>Lesen Sie das <a href="https://wiki.obtainium.imranr.dev/de/">Wiki</a></p>
<p>
<b>Derzeit unterstützte App-Quellen:</b>
</p>
<ul>
<li>
<p>Open Source Allgemein:</p>
<ul>
<li>GitHub</li>
<li>GitLab</li>
<li>Forgejo (Codeberg)</li>
<li>F-Droid</li>
<li>Third Party F-Droid Repos</li>
<li>IzzyOnDroid</li>
<li>SourceHut</li>
</ul>
</li>
<li>
<p>Andere Allgemein:</p>
<ul>
<li>APKPure</li>
<li>Aptoide</li>
<li>Uptodown</li>
<li>APKMirror (Track-Only)</li>
<li>Huawei AppGallery</li>
<li>Tencent App Store</li>
<li>CoolApk</li>
<li>vivo App Store (China)</li>
<li>Jenkins Jobs</li>
<li>RuStore</li>
</ul>
</li>
<li>
<p>Andere - App-Spezifisch:</p>
<ul>
<li>Telegram App</li>
<li>Neutron Code</li>
</ul>
</li>
<li><p>„HTML“ (Fallback): Jede andere URL, die eine HTML-Seite mit Links zu APK-Dateien zurückgibt.</p></li>
</ul>
<p>
<b>Einschränkungen:</b>
</p>
<p>
Bei einigen Quellen werden die Daten mit Hilfe von Web Scraping gesammelt und können aufgrund von Änderungen am Design der Website leicht beschädigt werden. In solchen Fällen sind zuverlässigere Methoden nicht verfügbar.
</p>

View File

@ -0,0 +1 @@
… Android-App-Updates direkt von der Quelle erhalten

View File

@ -0,0 +1 @@
Obtainium

View File

@ -12,8 +12,9 @@ class APKCombo extends AppSource {
@override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegEx = RegExp(
'^https?://(www\\.)?${getSourceRegex(hosts)}/+[^/]+/+[^/]+',
caseSensitive: false);
'^https?://(www\\.)?${getSourceRegex(hosts)}/+[^/]+/+[^/]+',
caseSensitive: false,
);
var match = standardUrlRegEx.firstMatch(url);
if (match == null) {
throw InvalidURLError(name);
@ -22,25 +23,30 @@ class APKCombo extends AppSource {
}
@override
Future<String?> tryInferringAppId(String standardUrl,
{Map<String, dynamic> additionalSettings = const {}}) async {
Future<String?> tryInferringAppId(
String standardUrl, {
Map<String, dynamic> additionalSettings = const {},
}) async {
return Uri.parse(standardUrl).pathSegments.last;
}
@override
Future<Map<String, String>?> getRequestHeaders(
Map<String, dynamic> additionalSettings,
{bool forAPKDownload = false}) async {
Map<String, dynamic> additionalSettings, {
bool forAPKDownload = false,
}) async {
return {
"User-Agent": "curl/8.0.1",
"Accept": "*/*",
"Connection": "keep-alive",
"Host": hosts[0]
"Host": hosts[0],
};
}
Future<List<MapEntry<String, String>>> getApkUrls(
String standardUrl, Map<String, dynamic> additionalSettings) async {
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
var res = await sourceRequest('$standardUrl/download/apk', {});
if (res.statusCode != 200) {
throw getObtainiumHttpError(res);
@ -65,7 +71,9 @@ class APKCombo extends AppSource {
String verCode =
e.querySelector('.info .header .vercode')?.text.trim() ?? '';
return MapEntry<String, String>(
arch != null ? '$arch-$verCode.apk' : '', url ?? '');
arch != null ? '$arch-$verCode.apk' : '',
url ?? '',
);
}).toList();
})
.reduce((value, element) => [...value, ...element])
@ -74,8 +82,11 @@ class APKCombo extends AppSource {
}
@override
Future<String> apkUrlPrefetchModifier(String apkUrl, String standardUrl,
Map<String, dynamic> additionalSettings) async {
Future<String> apkUrlPrefetchModifier(
String apkUrl,
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
var freshURLs = await getApkUrls(standardUrl, additionalSettings);
var path2Match = Uri.parse(apkUrl).path;
for (var url in freshURLs) {
@ -116,9 +127,10 @@ class APKCombo extends AppSource {
}
}
return APKDetails(
version,
await getApkUrls(standardUrl, additionalSettings),
AppNames(author, appName),
releaseDate: releaseDate);
version,
await getApkUrls(standardUrl, additionalSettings),
AppNames(author, appName),
releaseDate: releaseDate,
);
}
}

View File

@ -17,37 +17,44 @@ class APKMirror extends AppSource {
additionalSourceAppSpecificSettingFormItems = [
[
GeneratedFormSwitch('fallbackToOlderReleases',
label: tr('fallbackToOlderReleases'), defaultValue: true)
GeneratedFormSwitch(
'fallbackToOlderReleases',
label: tr('fallbackToOlderReleases'),
defaultValue: true,
),
],
[
GeneratedFormTextField('filterReleaseTitlesByRegEx',
label: tr('filterReleaseTitlesByRegEx'),
required: false,
additionalValidators: [
(value) {
return regExValidator(value);
}
])
]
GeneratedFormTextField(
'filterReleaseTitlesByRegEx',
label: tr('filterReleaseTitlesByRegEx'),
required: false,
additionalValidators: [
(value) {
return regExValidator(value);
},
],
),
],
];
}
@override
Future<Map<String, String>?> getRequestHeaders(
Map<String, dynamic> additionalSettings,
{bool forAPKDownload = false}) async {
Map<String, dynamic> additionalSettings, {
bool forAPKDownload = false,
}) async {
return {
"User-Agent":
"Obtainium/${(await getInstalledInfo(obtainiumId))?.versionName ?? '1.0.0'}"
"Obtainium/${(await getInstalledInfo(obtainiumId))?.versionName ?? '1.0.0'}",
};
}
@override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegEx = RegExp(
'^https?://(www\\.)?${getSourceRegex(hosts)}/apk/[^/]+/[^/]+',
caseSensitive: false);
'^https?://(www\\.)?${getSourceRegex(hosts)}/apk/[^/]+/[^/]+',
caseSensitive: false,
);
RegExpMatch? match = standardUrlRegEx.firstMatch(url);
if (match == null) {
throw InvalidURLError(name);
@ -68,12 +75,14 @@ class APKMirror extends AppSource {
additionalSettings['fallbackToOlderReleases'] == true;
String? regexFilter =
(additionalSettings['filterReleaseTitlesByRegEx'] as String?)
?.isNotEmpty ==
true
? additionalSettings['filterReleaseTitlesByRegEx']
: null;
Response res =
await sourceRequest('$standardUrl/feed/', additionalSettings);
?.isNotEmpty ==
true
? additionalSettings['filterReleaseTitlesByRegEx']
: null;
Response res = await sourceRequest(
'$standardUrl/feed/',
additionalSettings,
);
if (res.statusCode == 200) {
var items = parse(res.body).querySelectorAll('item');
dynamic targetRelease;
@ -95,11 +104,14 @@ class APKMirror extends AppSource {
.split(' ')
.sublist(0, 5)
.join(' ');
DateTime? releaseDate =
dateString != null ? HttpDate.parse('$dateString GMT') : null;
DateTime? releaseDate = dateString != null
? HttpDate.parse('$dateString GMT')
: null;
String? version = titleString
?.substring(RegExp('[0-9]').firstMatch(titleString)?.start ?? 0,
RegExp(' by ').allMatches(titleString).last.start)
?.substring(
RegExp('[0-9]').firstMatch(titleString)?.start ?? 0,
RegExp(' by ').allMatches(titleString).last.start,
)
.trim();
if (version == null || version.isEmpty) {
version = titleString;
@ -107,8 +119,12 @@ class APKMirror extends AppSource {
if (version == null || version.isEmpty) {
throw NoVersionError();
}
return APKDetails(version, [], getAppNames(standardUrl),
releaseDate: releaseDate);
return APKDetails(
version,
[],
getAppNames(standardUrl),
releaseDate: releaseDate,
);
} else {
throw getObtainiumHttpError(res);
}

View File

@ -1,24 +1,18 @@
import 'dart:convert';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:html/parser.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/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
extension Unique<E, Id> on List<E> {
List<E> unique([Id Function(E element)? id, bool inplace = true]) {
final ids = <dynamic>{};
var list = inplace ? this : List<E>.from(this);
list.retainWhere((x) => ids.add(id != null ? id(x) : x as Id));
return list;
}
return releaseDate;
}
class APKPure extends AppSource {
@ -29,29 +23,44 @@ class APKPure extends AppSource {
showReleaseDateAsVersionToggle = true;
additionalSourceAppSpecificSettingFormItems = [
[
GeneratedFormSwitch('fallbackToOlderReleases',
label: tr('fallbackToOlderReleases'), defaultValue: true)
GeneratedFormSwitch(
'fallbackToOlderReleases',
label: tr('fallbackToOlderReleases'),
defaultValue: true,
),
],
[
GeneratedFormSwitch('stayOneVersionBehind',
label: tr('stayOneVersionBehind'), defaultValue: false)
]
GeneratedFormSwitch(
'stayOneVersionBehind',
label: tr('stayOneVersionBehind'),
defaultValue: false,
),
],
[
GeneratedFormSwitch(
'useFirstApkOfVersion',
label: tr('useFirstApkOfVersion'),
defaultValue: true,
),
],
];
}
@override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegExB = RegExp(
'^https?://m.${getSourceRegex(hosts)}(/+[^/]{2})?/+[^/]+/+[^/]+',
caseSensitive: false);
'^https?://m.${getSourceRegex(hosts)}(/+[^/]{2})?/+[^/]+/+[^/]+',
caseSensitive: false,
);
RegExpMatch? match = standardUrlRegExB.firstMatch(url);
if (match != null) {
var uri = Uri.parse(url);
url = 'https://${uri.host.substring(2)}${uri.path}';
}
RegExp standardUrlRegExA = RegExp(
'^https?://(www\\.)?${getSourceRegex(hosts)}(/+[^/]{2})?/+[^/]+/+[^/]+',
caseSensitive: false);
'^https?://(www\\.)?${getSourceRegex(hosts)}(/+[^/]{2})?/+[^/]+/+[^/]+',
caseSensitive: false,
);
match = standardUrlRegExA.firstMatch(url);
if (match == null) {
throw InvalidURLError(name);
@ -60,114 +69,88 @@ class APKPure extends AppSource {
}
@override
Future<String?> tryInferringAppId(String standardUrl,
{Map<String, dynamic> additionalSettings = const {}}) async {
Future<String?> tryInferringAppId(
String standardUrl, {
Map<String, dynamic> additionalSettings = const {},
}) async {
return Uri.parse(standardUrl).pathSegments.last;
}
getDetailsForVersionLink(
String standardUrl,
String appId,
String host,
List<String> supportedArchs,
String link,
Map<String, dynamic> additionalSettings) async {
var res = await sourceRequest(link, additionalSettings);
if (res.statusCode == 200) {
var html = parse(res.body);
var apksDiv =
html.querySelector('#version-list div div.show-more-content');
DateTime? topReleaseDate;
var apkUrls = apksDiv
?.querySelectorAll('div.group-title')
.map((e) {
String architectureString = e.text.trim();
if (architectureString.toLowerCase() == 'unlimited' ||
architectureString.toLowerCase() == 'universal') {
architectureString = '';
}
List<String> architectures = architectureString
.split(',')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
// Only take the first APK for each architecture, ignore others for now, for simplicity
// Unclear why there can even be multiple APKs for the same version and arch
var apkInfo = e.nextElementSibling?.querySelector('div.info');
String? versionCode = RegExp('[0-9]+')
.firstMatch(
apkInfo?.querySelector('div.info-top .code')?.text ??
'')
?.group(0)
?.trim();
var types = apkInfo
?.querySelectorAll('div.info-top span.tag')
.map((e) => e.text.trim())
.map((t) => t == 'APKs' ? 'APK' : t) ??
[];
String type = types.isEmpty ? 'APK' : types.first;
String? dateString = apkInfo
?.querySelector('div.info-bottom span.time')
?.text
.trim();
DateTime? releaseDate = parseDateTimeMMMddCommayyyy(dateString);
if (additionalSettings['autoApkFilterByArch'] == true &&
architectures.isNotEmpty &&
architectures
.where((a) => supportedArchs.contains(a))
.isEmpty) {
return const MapEntry('', '');
}
topReleaseDate ??=
releaseDate; // Just use the release date of the first APK in the list as the release date for this version
return MapEntry(
'$appId-$versionCode-$architectureString.${type.toLowerCase()}',
'https://d.${hosts.contains(host) ? 'cdnpure.com' : host}/b/$type/$appId?versionCode=$versionCode');
})
.where((e) => e.key.isNotEmpty)
.toList() ??
[];
if (apkUrls.isEmpty) {
var link =
html.querySelector("a.download-start-btn")?.attributes['href'];
RegExp downloadLinkRegEx = RegExp(
r'^https:\/\/d\.[^/]+\/b\/([^/]+)\/[^/?]+\?versionCode=([0-9]+)$',
caseSensitive: false);
RegExpMatch? match = downloadLinkRegEx.firstMatch(link ?? '');
if (match == null) {
throw NoAPKError();
}
String type = match.group(1)!;
String versionCode = match.group(2)!;
apkUrls = [
MapEntry('$appId-$versionCode-.${type.toLowerCase()}',
'https://d.${hosts.contains(host) ? 'cdnpure.com' : host}/b/$type/$appId?versionCode=$versionCode')
];
}
String version = Uri.parse(link).pathSegments.last;
String? author;
try {
author = html
.querySelector('span.info-sdk')
?.text
.trim()
.substring(version.length + 4) ??
Uri.parse(standardUrl).pathSegments.reversed.last;
} catch (e) {
author = html.querySelector('span.info-sdk')?.text.trim() ??
Uri.parse(standardUrl).pathSegments.reversed.last;
}
String appName =
html.querySelector('h1.info-title')?.text.trim() ?? appId;
String? changeLog = html
.querySelector('div.module.change-log')
?.innerHtml
.trim()
.replaceAll("<br>", " \n");
return APKDetails(version, apkUrls, AppNames(author, appName),
releaseDate: topReleaseDate, changeLog: changeLog);
Future<APKDetails> getDetailsForVersion(
List<Map<String, dynamic>> versionVariants,
List<String> supportedArchs,
Map<String, dynamic> additionalSettings,
) async {
var apkUrls = versionVariants
.map((e) {
String appId = e['package_name'];
String versionCode = e['version_code'];
List<String> architectures = e['native_code']?.cast<String>();
String architectureString = architectures.join(',');
if (architectures.contains("universal") ||
architectures.contains("unlimited")) {
architectures = [];
}
if (additionalSettings['autoApkFilterByArch'] == true &&
architectures.isNotEmpty &&
architectures.where((a) => supportedArchs.contains(a)).isEmpty) {
return null;
}
String type = e['asset']['type'];
String downloadUri = e['asset']['url'];
return MapEntry(
'$appId-$versionCode-$architectureString.${type.toLowerCase()}',
downloadUri,
);
})
.nonNulls
.toList()
.unique((e) => e.key);
if (apkUrls.isEmpty) {
throw NoAPKError();
}
// get version details from first variant
var v = versionVariants.first;
String version = v['version_name'];
String author = v['developer'];
String appName = v['title'];
DateTime releaseDate = DateTime.parse(v['update_date']);
String? changeLog = v['whatsnew'];
if (changeLog != null && changeLog.isEmpty) {
changeLog = null;
}
if (additionalSettings['useFirstApkOfVersion'] == true) {
apkUrls = [apkUrls.first];
}
return APKDetails(
version,
apkUrls,
AppNames(author, appName),
releaseDate: releaseDate,
changeLog: changeLog,
);
}
@override
Future<Map<String, String>?> getRequestHeaders(
Map<String, dynamic> additionalSettings, {
bool forAPKDownload = false,
}) async {
if (forAPKDownload) {
return null;
} else {
throw getObtainiumHttpError(res);
return {
"Ual-Access-Businessid": "projecta",
"Ual-Access-ProjectA":
'{"device_info":{"os_ver":"${((await DeviceInfoPlugin().androidInfo).version.sdkInt)}"}}',
};
}
}
@ -177,41 +160,56 @@ class APKPure extends AppSource {
Map<String, dynamic> additionalSettings,
) async {
String appId = (await tryInferringAppId(standardUrl))!;
String host = Uri.parse(standardUrl).host;
var res0 = await sourceRequest('$standardUrl/versions', additionalSettings);
var decodedStandardUrl = standardUrl;
try {
decodedStandardUrl = Uri.decodeFull(decodedStandardUrl);
} catch (e) {
//
List<String> supportedArchs =
(await DeviceInfoPlugin().androidInfo).supportedAbis;
// request versions from API
var res = await sourceRequest(
"https://tapi.pureapk.com/v3/get_app_his_version?package_name=$appId&hl=en",
additionalSettings,
);
if (res.statusCode != 200) {
throw getObtainiumHttpError(res);
}
var versionLinks = await grabLinksCommon(res0, {
'skipSort': true,
'customLinkFilterRegex': '$decodedStandardUrl/download/[^/]+\$'
});
List<Map<String, dynamic>> apks = jsonDecode(
res.body,
)['version_list'].cast<Map<String, dynamic>>();
var supportedArchs = (await DeviceInfoPlugin().androidInfo).supportedAbis;
// group by version
List<List<Map<String, dynamic>>> versions = apks
.fold<Map<String, List<Map<String, dynamic>>>>({}, (
Map<String, List<Map<String, dynamic>>> val,
Map<String, dynamic> element,
) {
String v = element['version_name'];
if (!val.containsKey(v)) {
val[v] = [];
}
val[v]?.add(element);
return val;
})
.values
.toList();
if (additionalSettings['autoApkFilterByArch'] != true) {
// No need to request multiple versions when we're not going to filter them (always pick the top one)
versionLinks = versionLinks.sublist(0, 1);
}
if (versionLinks.isEmpty) {
if (versions.isEmpty) {
throw NoReleasesError();
}
for (var i = 0; i < versionLinks.length; i++) {
var link = versionLinks[i];
for (var i = 0; i < versions.length; i++) {
var v = versions[i];
try {
if (i == 0 && additionalSettings['stayOneVersionBehind'] == true) {
throw NoReleasesError();
}
return await getDetailsForVersionLink(standardUrl, appId, host,
supportedArchs, link.key, additionalSettings);
return await getDetailsForVersion(
v,
supportedArchs,
additionalSettings,
);
} catch (e) {
if (additionalSettings['fallbackToOlderReleases'] != true ||
i == versionLinks.length - 1) {
i == versions.length - 1) {
rethrow;
}
}

View File

@ -16,8 +16,9 @@ class Aptoide extends AppSource {
@override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegEx = RegExp(
'^https?://([^\\.]+\\.){2,}${getSourceRegex(hosts)}',
caseSensitive: false);
'^https?://([^\\.]+\\.){2,}${getSourceRegex(hosts)}',
caseSensitive: false,
);
RegExpMatch? match = standardUrlRegEx.firstMatch(url);
if (match == null) {
throw InvalidURLError(name);
@ -26,14 +27,20 @@ class Aptoide extends AppSource {
}
@override
Future<String?> tryInferringAppId(String standardUrl,
{Map<String, dynamic> additionalSettings = const {}}) async {
Future<String?> tryInferringAppId(
String standardUrl, {
Map<String, dynamic> additionalSettings = const {},
}) async {
return (await getAppDetailsJSON(
standardUrl, additionalSettings))['package'];
standardUrl,
additionalSettings,
))['package'];
}
Future<Map<String, dynamic>> getAppDetailsJSON(
String standardUrl, Map<String, dynamic> additionalSettings) async {
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
var res = await sourceRequest(standardUrl, additionalSettings);
if (res.statusCode != 200) {
throw getObtainiumHttpError(res);
@ -46,7 +53,9 @@ class Aptoide extends AppSource {
throw NoReleasesError();
}
var res2 = await sourceRequest(
'https://ws2.aptoide.com/api/7/getApp/app_id/$id', additionalSettings);
'https://ws2.aptoide.com/api/7/getApp/app_id/$id',
additionalSettings,
);
if (res2.statusCode != 200) {
throw getObtainiumHttpError(res);
}
@ -76,7 +85,10 @@ class Aptoide extends AppSource {
}
return APKDetails(
version, getApkUrlsFromUrls([apkUrl]), AppNames(author, appName),
releaseDate: relDate);
version,
getApkUrlsFromUrls([apkUrl]),
AppNames(author, appName),
releaseDate: relDate,
);
}
}

View File

@ -18,8 +18,9 @@ class Codeberg extends AppSource {
@override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegEx = RegExp(
'^https?://(www\\.)?${getSourceRegex(hosts)}/[^/]+/[^/]+',
caseSensitive: false);
'^https?://(www\\.)?${getSourceRegex(hosts)}/[^/]+/[^/]+',
caseSensitive: false,
);
RegExpMatch? match = standardUrlRegEx.firstMatch(url);
if (match == null) {
throw InvalidURLError(name);
@ -36,8 +37,9 @@ class Codeberg extends AppSource {
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
return await gh.getLatestAPKDetailsCommon2(standardUrl, additionalSettings,
(bool useTagUrl) async {
return await gh.getLatestAPKDetailsCommon2(standardUrl, additionalSettings, (
bool useTagUrl,
) async {
return 'https://${hosts[0]}/api/v1/repos${standardUrl.substring('https://${hosts[0]}'.length)}/${useTagUrl ? 'tags' : 'releases'}?per_page=100';
}, null);
}
@ -49,12 +51,15 @@ class Codeberg extends AppSource {
}
@override
Future<Map<String, List<String>>> search(String query,
{Map<String, dynamic> querySettings = const {}}) async {
Future<Map<String, List<String>>> search(
String query, {
Map<String, dynamic> querySettings = const {},
}) async {
return gh.searchCommon(
query,
'https://${hosts[0]}/api/v1/repos/search?q=${Uri.encodeQueryComponent(query)}&limit=100',
'data',
querySettings: querySettings);
query,
'https://${hosts[0]}/api/v1/repos/search?q=${Uri.encodeQueryComponent(query)}&limit=100',
'data',
querySettings: querySettings,
);
}
}

View File

@ -19,8 +19,9 @@ class CoolApk extends AppSource {
@override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegEx = RegExp(
r'^https?://(www\.)?coolapk\.com/apk/[^/]+',
caseSensitive: false);
r'^https?://(www\.)?coolapk\.com/apk/[^/]+',
caseSensitive: false,
);
var match = standardUrlRegEx.firstMatch(url);
if (match == null) {
throw InvalidURLError(name);
@ -30,17 +31,19 @@ class CoolApk extends AppSource {
}
@override
Future<String?> tryInferringAppId(String standardUrl,
{Map<String, dynamic> additionalSettings = const {}}) async {
Future<String?> tryInferringAppId(
String standardUrl, {
Map<String, dynamic> additionalSettings = const {},
}) async {
String appId = Uri.parse(standardUrl).pathSegments.last;
return appId;
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
String appId = (await tryInferringAppId(standardUrl))!;
String apiUrl = 'https://api2.coolapk.com';
@ -65,13 +68,19 @@ class CoolApk extends AppSource {
String changelog = detail['changelog']?.toString() ?? '';
int? releaseDate = detail['lastupdate'] != null
? (detail['lastupdate'] is int
? detail['lastupdate'] * 1000
: int.parse(detail['lastupdate'].toString()) * 1000)
? detail['lastupdate'] * 1000
: int.parse(detail['lastupdate'].toString()) * 1000)
: null;
String aid = detail['id'].toString();
// get apk url
String apkUrl = await _getLatestApkUrl(apiUrl, appId, aid, version, headers);
String apkUrl = await _getLatestApkUrl(
apiUrl,
appId,
aid,
version,
headers,
);
if (apkUrl.isEmpty) {
throw NoAPKError();
}
@ -89,8 +98,13 @@ class CoolApk extends AppSource {
);
}
Future<String> _getLatestApkUrl(String apiUrl, String appId, String aid,
String version, Map<String, String>? headers) async {
Future<String> _getLatestApkUrl(
String apiUrl,
String appId,
String aid,
String version,
Map<String, String>? headers,
) async {
String url = '$apiUrl/v6/apk/download?pn=$appId&aid=$aid';
var res = await sourceRequest(url, {}, followRedirects: false);
if (res.statusCode >= 300 && res.statusCode < 400) {
@ -102,13 +116,14 @@ class CoolApk extends AppSource {
@override
Future<Map<String, String>?> getRequestHeaders(
Map<String, dynamic> additionalSettings,
{bool forAPKDownload = false}) async {
Map<String, dynamic> additionalSettings, {
bool forAPKDownload = false,
}) async {
var tokenPair = _getToken();
// CoolAPK header
return {
'User-Agent':
'Dalvik/2.1.0 (Linux; U; Android 9; MI 8 SE MIUI/9.5.9) (#Build; Xiaomi; MI 8 SE; PKQ1.181121.001; 9) +CoolMarket/12.4.2-2208241-universal',
'Dalvik/2.1.0 (Linux; U; Android 9; MI 8 SE MIUI/9.5.9) (#Build; Xiaomi; MI 8 SE; PKQ1.181121.001; 9) +CoolMarket/12.4.2-2208241-universal',
'X-App-Id': 'com.coolapk.market',
'X-Requested-With': 'XMLHttpRequest',
'X-Sdk-Int': '30',
@ -128,14 +143,15 @@ class CoolApk extends AppSource {
Map<String, String> _getToken() {
final rand = Random();
String randHexString(int n) =>
List.generate(n, (_) => rand.nextInt(256).toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase();
String randHexString(int n) => List.generate(
n,
(_) => rand.nextInt(256).toRadixString(16).padLeft(2, '0'),
).join().toUpperCase();
String randMacAddress() =>
List.generate(6, (_) => rand.nextInt(256).toRadixString(16).padLeft(2, '0'))
.join(':');
String randMacAddress() => List.generate(
6,
(_) => rand.nextInt(256).toRadixString(16).padLeft(2, '0'),
).join(':');
// 加密算法来自 https://github.com/XiaoMengXinX/FuckCoolapkTokenV2、https://github.com/Coolapk-UWP/Coolapk-UWP
// device
@ -147,11 +163,13 @@ class CoolApk extends AppSource {
const buildNumber = 'SQ1D.220105.007';
// generate deviceCode
String deviceCode =
base64.encode('$aid; ; ; $mac; $manufactor; $brand; $model; $buildNumber'.codeUnits);
String deviceCode = base64.encode(
'$aid; ; ; $mac; $manufactor; $brand; $model; $buildNumber'.codeUnits,
);
// generate timestamp
String timeStamp = (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString();
String timeStamp = (DateTime.now().millisecondsSinceEpoch ~/ 1000)
.toString();
String base64TimeStamp = base64.encode(timeStamp.codeUnits);
String md5TimeStamp = md5.convert(timeStamp.codeUnits).toString();
String md5DeviceCode = md5.convert(deviceCode.codeUnits).toString();
@ -164,7 +182,8 @@ class CoolApk extends AppSource {
String md5Token = md5.convert(token.codeUnits).toString();
// generate salt and hash
String bcryptSalt = '\$2a\$10\$${base64TimeStamp.substring(0, 14)}/${md5Token.substring(0, 6)}u';
String bcryptSalt =
'\$2a\$10\$${base64TimeStamp.substring(0, 14)}/${md5Token.substring(0, 6)}u';
String bcryptResult = BCrypt.hashpw(md5Base64Token, bcryptSalt);
String reBcryptResult = bcryptResult.replaceRange(0, 3, '\$2y');
String finalToken = 'v2${base64.encode(reBcryptResult.codeUnits)}';

View File

@ -11,20 +11,23 @@ class DirectAPKLink extends AppSource {
name = tr('directAPKLink');
additionalSourceAppSpecificSettingFormItems = [
...html.additionalSourceAppSpecificSettingFormItems
.where((element) => element
.where((element) => element.key == 'requestHeader')
.isNotEmpty)
.toList(),
.where(
(element) => element
.where((element) => element.key == 'requestHeader')
.isNotEmpty,
)
,
[
GeneratedFormDropdown(
'defaultPseudoVersioningMethod',
[
MapEntry('partialAPKHash', tr('partialAPKHash')),
MapEntry('ETag', 'ETag')
],
label: tr('defaultPseudoVersioningMethod'),
defaultValue: 'partialAPKHash')
]
'defaultPseudoVersioningMethod',
[
MapEntry('partialAPKHash', tr('partialAPKHash')),
MapEntry('ETag', 'ETag'),
],
label: tr('defaultPseudoVersioningMethod'),
defaultValue: 'partialAPKHash',
),
],
];
excludeCommonSettingKeys = [
'versionExtractionRegEx',
@ -32,7 +35,7 @@ class DirectAPKLink extends AppSource {
'versionDetection',
'useVersionCodeAsOSVersion',
'apkFilterRegEx',
'autoApkFilterByArch'
'autoApkFilterByArch',
];
}
@ -51,10 +54,13 @@ class DirectAPKLink extends AppSource {
@override
Future<Map<String, String>?> getRequestHeaders(
Map<String, dynamic> additionalSettings,
{bool forAPKDownload = false}) {
return html.getRequestHeaders(additionalSettings,
forAPKDownload: forAPKDownload);
Map<String, dynamic> additionalSettings, {
bool forAPKDownload = false,
}) {
return html.getRequestHeaders(
additionalSettings,
forAPKDownload: forAPKDownload,
);
}
@override
@ -62,8 +68,9 @@ class DirectAPKLink extends AppSource {
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
var additionalSettingsNew =
getDefaultValuesFromFormItems(html.combinedAppSpecificSettingFormItems);
var additionalSettingsNew = getDefaultValuesFromFormItems(
html.combinedAppSpecificSettingFormItems,
);
for (var s in additionalSettings.keys) {
if (additionalSettingsNew.containsKey(s)) {
additionalSettingsNew[s] = additionalSettings[s];

View File

@ -17,22 +17,28 @@ class FDroid extends AppSource {
canSearch = true;
additionalSourceAppSpecificSettingFormItems = [
[
GeneratedFormTextField('filterVersionsByRegEx',
label: tr('filterVersionsByRegEx'),
required: false,
additionalValidators: [
(value) {
return regExValidator(value);
}
])
GeneratedFormTextField(
'filterVersionsByRegEx',
label: tr('filterVersionsByRegEx'),
required: false,
additionalValidators: [
(value) {
return regExValidator(value);
},
],
),
],
[
GeneratedFormSwitch('trySelectingSuggestedVersionCode',
label: tr('trySelectingSuggestedVersionCode'))
GeneratedFormSwitch(
'trySelectingSuggestedVersionCode',
label: tr('trySelectingSuggestedVersionCode'),
),
],
[
GeneratedFormSwitch('autoSelectHighestVersionCode',
label: tr('autoSelectHighestVersionCode'))
GeneratedFormSwitch(
'autoSelectHighestVersionCode',
label: tr('autoSelectHighestVersionCode'),
),
],
];
}
@ -40,16 +46,18 @@ class FDroid extends AppSource {
@override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegExB = RegExp(
'^https?://(www\\.)?${getSourceRegex(hosts)}/+[^/]+/+packages/+[^/]+',
caseSensitive: false);
'^https?://(www\\.)?${getSourceRegex(hosts)}/+[^/]+/+packages/+[^/]+',
caseSensitive: false,
);
RegExpMatch? match = standardUrlRegExB.firstMatch(url);
if (match != null) {
url =
'https://${Uri.parse(match.group(0)!).host}/packages/${Uri.parse(url).pathSegments.where((s) => s.trim().isNotEmpty).last}';
}
RegExp standardUrlRegExA = RegExp(
'^https?://(www\\.)?${getSourceRegex(hosts)}/+packages/+[^/]+',
caseSensitive: false);
'^https?://(www\\.)?${getSourceRegex(hosts)}/+packages/+[^/]+',
caseSensitive: false,
);
match = standardUrlRegExA.firstMatch(url);
if (match == null) {
throw InvalidURLError(name);
@ -58,8 +66,10 @@ class FDroid extends AppSource {
}
@override
Future<String?> tryInferringAppId(String standardUrl,
{Map<String, dynamic> additionalSettings = const {}}) async {
Future<String?> tryInferringAppId(
String standardUrl, {
Map<String, dynamic> additionalSettings = const {},
}) async {
return Uri.parse(standardUrl).pathSegments.last;
}
@ -71,22 +81,28 @@ class FDroid extends AppSource {
String? appId = await tryInferringAppId(standardUrl);
String host = Uri.parse(standardUrl).host;
var details = getAPKUrlsFromFDroidPackagesAPIResponse(
await sourceRequest(
'https://$host/api/v1/packages/$appId', additionalSettings),
'https://$host/repo/$appId',
standardUrl,
name,
additionalSettings: additionalSettings);
await sourceRequest(
'https://$host/api/v1/packages/$appId',
additionalSettings,
),
'https://$host/repo/$appId',
standardUrl,
name,
additionalSettings: additionalSettings,
);
if (!hostChanged) {
try {
var res = await sourceRequest(
'https://gitlab.com/fdroid/fdroiddata/-/raw/master/metadata/$appId.yml',
additionalSettings);
'https://gitlab.com/fdroid/fdroiddata/-/raw/master/metadata/$appId.yml',
additionalSettings,
);
var lines = res.body.split('\n');
var authorLines = lines.where((l) => l.startsWith('AuthorName: '));
if (authorLines.isNotEmpty) {
details.names.author =
authorLines.first.split(': ').sublist(1).join(': ');
details.names.author = authorLines.first
.split(': ')
.sublist(1)
.join(': ');
}
var changelogUrls = lines
.where((l) => l.startsWith('Changelog: '))
@ -110,9 +126,9 @@ class FDroid extends AppSource {
if ((isGitHub || isGitLab) &&
(details.changeLog?.indexOf('/blob/') ?? -1) >= 0) {
details.changeLog = (await sourceRequest(
details.changeLog!.replaceFirst('/blob/', '/raw/'),
additionalSettings))
.body;
details.changeLog!.replaceFirst('/blob/', '/raw/'),
additionalSettings,
)).body;
}
}
} catch (e) {
@ -126,10 +142,14 @@ class FDroid extends AppSource {
}
@override
Future<Map<String, List<String>>> search(String query,
{Map<String, dynamic> querySettings = const {}}) async {
Future<Map<String, List<String>>> search(
String query, {
Map<String, dynamic> querySettings = const {},
}) async {
Response res = await sourceRequest(
'https://search.${hosts[0]}/?q=${Uri.encodeQueryComponent(query)}', {});
'https://search.${hosts[0]}/?q=${Uri.encodeQueryComponent(query)}',
{},
);
if (res.statusCode == 200) {
Map<String, List<String>> urlsWithDescriptions = {};
parse(res.body).querySelectorAll('.package-header').forEach((e) {
@ -145,7 +165,7 @@ class FDroid extends AppSource {
urlsWithDescriptions[url] = [
e.querySelector('.package-name')?.text.trim() ?? '',
e.querySelector('.package-summary')?.text.trim() ??
tr('noDescription')
tr('noDescription'),
];
}
});
@ -156,29 +176,36 @@ class FDroid extends AppSource {
}
APKDetails getAPKUrlsFromFDroidPackagesAPIResponse(
Response res, String apkUrlPrefix, String standardUrl, String sourceName,
{Map<String, dynamic> additionalSettings = const {}}) {
Response res,
String apkUrlPrefix,
String standardUrl,
String sourceName, {
Map<String, dynamic> additionalSettings = const {},
}) {
var autoSelectHighestVersionCode =
additionalSettings['autoSelectHighestVersionCode'] == true;
var trySelectingSuggestedVersionCode =
additionalSettings['trySelectingSuggestedVersionCode'] == true;
var filterVersionsByRegEx =
(additionalSettings['filterVersionsByRegEx'] as String?)?.isNotEmpty ==
true
? additionalSettings['filterVersionsByRegEx']
: null;
true
? additionalSettings['filterVersionsByRegEx']
: null;
var apkFilterRegEx =
(additionalSettings['apkFilterRegEx'] as String?)?.isNotEmpty == true
? additionalSettings['apkFilterRegEx']
: null;
? additionalSettings['apkFilterRegEx']
: null;
if (res.statusCode == 200) {
var response = jsonDecode(res.body);
List<dynamic> releases = response['packages'] ?? [];
if (apkFilterRegEx != null) {
releases = releases.where((rel) {
String apk = '${apkUrlPrefix}_${rel['versionCode']}.apk';
return filterApks([MapEntry(apk, apk)], apkFilterRegEx, false)
.isNotEmpty;
return filterApks(
[MapEntry(apk, apk)],
apkFilterRegEx,
false,
).isNotEmpty;
}).toList();
}
if (releases.isEmpty) {
@ -191,8 +218,10 @@ class FDroid extends AppSource {
if (trySelectingSuggestedVersionCode &&
response['suggestedVersionCode'] != null &&
filterVersionsByRegEx == null) {
var suggestedReleases = releases.where((element) =>
element['versionCode'] == response['suggestedVersionCode']);
var suggestedReleases = releases.where(
(element) =>
element['versionCode'] == response['suggestedVersionCode'],
);
if (suggestedReleases.isNotEmpty) {
releaseChoices = suggestedReleases;
version = suggestedReleases.first['versionName'];
@ -203,8 +232,9 @@ class FDroid extends AppSource {
version = null;
releaseChoices = [];
for (var i = 0; i < releases.length; i++) {
if (RegExp(filterVersionsByRegEx!)
.hasMatch(releases[i]['versionName'])) {
if (RegExp(
filterVersionsByRegEx!,
).hasMatch(releases[i]['versionName'])) {
version = releases[i]['versionName'];
}
}
@ -219,8 +249,9 @@ class FDroid extends AppSource {
}
// 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);
releaseChoices = releases.where(
(element) => element['versionName'] == version,
);
}
// For the remaining releases, use the toggles to auto-select one if possible
if (releaseChoices.length > 1) {
@ -228,8 +259,10 @@ class FDroid extends AppSource {
releaseChoices = [releaseChoices.first];
} else if (trySelectingSuggestedVersionCode &&
response['suggestedVersionCode'] != null) {
var suggestedReleases = releaseChoices.where((element) =>
element['versionCode'] == response['suggestedVersionCode']);
var suggestedReleases = releaseChoices.where(
(element) =>
element['versionCode'] == response['suggestedVersionCode'],
);
if (suggestedReleases.isNotEmpty) {
releaseChoices = suggestedReleases;
}
@ -241,8 +274,11 @@ class FDroid extends AppSource {
List<String> apkUrls = releaseChoices
.map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk')
.toList();
return APKDetails(version, getApkUrlsFromUrls(apkUrls.toSet().toList()),
AppNames(sourceName, Uri.parse(standardUrl).pathSegments.last));
return APKDetails(
version,
getApkUrlsFromUrls(apkUrls.toSet().toList()),
AppNames(sourceName, Uri.parse(standardUrl).pathSegments.last),
);
} else {
throw getObtainiumHttpError(res);
}

View File

@ -15,15 +15,20 @@ class FDroidRepo extends AppSource {
additionalSourceAppSpecificSettingFormItems = [
[
GeneratedFormTextField('appIdOrName',
label: tr('appIdOrName'),
hint: tr('reposHaveMultipleApps'),
required: true)
GeneratedFormTextField(
'appIdOrName',
label: tr('appIdOrName'),
hint: tr('reposHaveMultipleApps'),
required: true,
),
],
[
GeneratedFormSwitch('pickHighestVersionCode',
label: tr('pickHighestVersionCode'), defaultValue: false)
]
GeneratedFormSwitch(
'pickHighestVersionCode',
label: tr('pickHighestVersionCode'),
defaultValue: false,
),
],
];
}
@ -54,8 +59,10 @@ class FDroidRepo extends AppSource {
}
@override
Future<Map<String, List<String>>> search(String query,
{Map<String, dynamic> querySettings = const {}}) async {
Future<Map<String, List<String>>> search(
String query, {
Map<String, dynamic> querySettings = const {},
}) async {
String? url = querySettings['url'];
if (url == null) {
throw NoReleasesError();
@ -73,11 +80,8 @@ class FDroidRepo extends AppSource {
appId.contains(query) ||
appName.contains(query) ||
appDesc.contains(query)) {
results[
'${res.request!.url.toString().split('/').reversed.toList().sublist(1).reversed.join('/')}?appId=$appId'] = [
appName,
appDesc
];
results['${res.request!.url.toString().split('/').reversed.toList().sublist(1).reversed.join('/')}?appId=$appId'] =
[appName, appDesc];
}
});
return results;
@ -90,21 +94,21 @@ class FDroidRepo extends AppSource {
void runOnAddAppInputChange(String userInput) {
additionalSourceAppSpecificSettingFormItems =
additionalSourceAppSpecificSettingFormItems.map((row) {
row = row.map((item) {
if (item.key == 'appIdOrName') {
try {
var appId = Uri.parse(userInput).queryParameters['appId'];
if (appId != null && item is GeneratedFormTextField) {
item.required = false;
row = row.map((item) {
if (item.key == 'appIdOrName') {
try {
var appId = Uri.parse(userInput).queryParameters['appId'];
if (appId != null && item is GeneratedFormTextField) {
item.required = false;
}
} catch (e) {
//
}
}
} catch (e) {
//
}
}
return item;
}).toList();
return row;
}).toList();
return item;
}).toList();
return row;
}).toList();
}
@override
@ -119,8 +123,11 @@ class FDroidRepo extends AppSource {
if (appId != null) {
app.url = uri
.replace(
queryParameters: Map.fromEntries(
[...uri.queryParameters.entries, MapEntry('appId', appId)]))
queryParameters: Map.fromEntries([
...uri.queryParameters.entries,
MapEntry('appId', appId),
]),
)
.toString();
app.additionalSettings['appIdOrName'] = appId;
app.id = appId;
@ -133,8 +140,9 @@ class FDroidRepo extends AppSource {
Map<String, dynamic> additionalSettings,
) async {
var res = await sourceRequest(
'$url${url.endsWith('/index.xml') ? '' : '/index.xml'}',
additionalSettings);
'$url${url.endsWith('/index.xml') ? '' : '/index.xml'}',
additionalSettings,
);
if (res.statusCode != 200) {
var base = url.endsWith('/index.xml')
? url.split('/').reversed.toList().sublist(1).reversed.join('/')
@ -142,7 +150,9 @@ class FDroidRepo extends AppSource {
res = await sourceRequest('$base/repo/index.xml', additionalSettings);
if (res.statusCode != 200) {
res = await sourceRequest(
'$base/fdroid/repo/index.xml', additionalSettings);
'$base/fdroid/repo/index.xml',
additionalSettings,
);
}
}
return res;
@ -164,8 +174,10 @@ class FDroidRepo extends AppSource {
throw NoReleasesError();
}
additionalSettings['appIdOrName'] = appIdOrName;
var res =
await sourceRequestWithURLVariants(standardUrl, additionalSettings);
var res = await sourceRequestWithURLVariants(
standardUrl,
additionalSettings,
);
if (res.statusCode == 200) {
var body = parse(res.body);
var foundApps = body.querySelectorAll('application').where((element) {
@ -202,24 +214,32 @@ class FDroidRepo extends AppSource {
throw NoVersionError();
}
var latestVersionReleases = releases
.where((element) =>
element.querySelector('version')?.innerHtml == latestVersion &&
element.querySelector('apkname') != null)
.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));
return int.parse(
e2.querySelector('versioncode')!.innerHtml,
).compareTo(int.parse(e1.querySelector('versioncode')!.innerHtml));
});
latestVersionReleases = [latestVersionReleases[0]];
}
List<String> apkUrls = latestVersionReleases
.map((e) =>
'${res.request!.url.toString().split('/').reversed.toList().sublist(1).reversed.join('/')}/${e.querySelector('apkname')!.innerHtml}')
.map(
(e) =>
'${res.request!.url.toString().split('/').reversed.toList().sublist(1).reversed.join('/')}/${e.querySelector('apkname')!.innerHtml}',
)
.toList();
return APKDetails(latestVersion, getApkUrlsFromUrls(apkUrls),
AppNames(authorName, appName),
releaseDate: releaseDate);
return APKDetails(
latestVersion,
getApkUrlsFromUrls(apkUrls),
AppNames(authorName, appName),
releaseDate: releaseDate,
);
} else {
throw getObtainiumHttpError(res);
}

View File

@ -19,145 +19,185 @@ class GitHub extends AppSource {
showReleaseDateAsVersionToggle = true;
sourceConfigSettingFormItems = [
GeneratedFormTextField('github-creds',
label: tr('githubPATLabel'),
password: true,
required: false,
belowWidgets: [
const SizedBox(
height: 4,
GeneratedFormTextField(
'github-creds',
label: tr('githubPATLabel'),
password: true,
required: false,
belowWidgets: [
const SizedBox(height: 4),
GestureDetector(
onTap: () {
launchUrlString(
'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token',
mode: LaunchMode.externalApplication,
);
},
child: Text(
tr('about'),
style: const TextStyle(
decoration: TextDecoration.underline,
fontSize: 12,
),
),
GestureDetector(
onTap: () {
launchUrlString(
'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token',
mode: LaunchMode.externalApplication);
},
child: Text(
tr('about'),
style: const TextStyle(
decoration: TextDecoration.underline, fontSize: 12),
)),
const SizedBox(
height: 4,
),
])
),
const SizedBox(height: 4),
],
),
];
additionalSourceAppSpecificSettingFormItems = [
[
GeneratedFormSwitch('includePrereleases',
label: tr('includePrereleases'), defaultValue: false)
GeneratedFormSwitch(
'includePrereleases',
label: tr('includePrereleases'),
defaultValue: false,
),
],
[
GeneratedFormSwitch('fallbackToOlderReleases',
label: tr('fallbackToOlderReleases'), defaultValue: true)
GeneratedFormSwitch(
'fallbackToOlderReleases',
label: tr('fallbackToOlderReleases'),
defaultValue: true,
),
],
[
GeneratedFormTextField('filterReleaseTitlesByRegEx',
label: tr('filterReleaseTitlesByRegEx'),
required: false,
additionalValidators: [
(value) {
return regExValidator(value);
}
])
GeneratedFormTextField(
'filterReleaseTitlesByRegEx',
label: tr('filterReleaseTitlesByRegEx'),
required: false,
additionalValidators: [
(value) {
return regExValidator(value);
},
],
),
],
[
GeneratedFormTextField('filterReleaseNotesByRegEx',
label: tr('filterReleaseNotesByRegEx'),
required: false,
additionalValidators: [
(value) {
return regExValidator(value);
}
])
GeneratedFormTextField(
'filterReleaseNotesByRegEx',
label: tr('filterReleaseNotesByRegEx'),
required: false,
additionalValidators: [
(value) {
return regExValidator(value);
},
],
),
],
[GeneratedFormSwitch('verifyLatestTag', label: tr('verifyLatestTag'))],
[
GeneratedFormDropdown(
'sortMethodChoice',
[
MapEntry('date', tr('releaseDate')),
MapEntry('smartname', tr('smartname')),
MapEntry('none', tr('none')),
MapEntry('smartname-datefallback',
'${tr('smartname')} x ${tr('releaseDate')}'),
MapEntry('name', tr('name')),
],
label: tr('sortMethod'),
defaultValue: 'date')
'sortMethodChoice',
[
MapEntry('date', tr('releaseDate')),
MapEntry('smartname', tr('smartname')),
MapEntry('none', tr('none')),
MapEntry(
'smartname-datefallback',
'${tr('smartname')} x ${tr('releaseDate')}',
),
MapEntry('name', tr('name')),
],
label: tr('sortMethod'),
defaultValue: 'date',
),
],
[
GeneratedFormSwitch('useLatestAssetDateAsReleaseDate',
label: tr('useLatestAssetDateAsReleaseDate'), defaultValue: false)
GeneratedFormSwitch(
'useLatestAssetDateAsReleaseDate',
label: tr('useLatestAssetDateAsReleaseDate'),
defaultValue: false,
),
],
[
GeneratedFormSwitch('releaseTitleAsVersion',
label: tr('releaseTitleAsVersion'), defaultValue: false)
]
GeneratedFormSwitch(
'releaseTitleAsVersion',
label: tr('releaseTitleAsVersion'),
defaultValue: false,
),
],
];
canSearch = true;
searchQuerySettingFormItems = [
GeneratedFormTextField('minStarCount',
label: tr('minStarCount'),
defaultValue: '0',
additionalValidators: [
(value) {
try {
int.parse(value ?? '0');
} catch (e) {
return tr('invalidInput');
}
return null;
GeneratedFormTextField(
'minStarCount',
label: tr('minStarCount'),
defaultValue: '0',
additionalValidators: [
(value) {
try {
int.parse(value ?? '0');
} catch (e) {
return tr('invalidInput');
}
])
return null;
},
],
),
];
}
@override
Future<String?> tryInferringAppId(String standardUrl,
{Map<String, dynamic> additionalSettings = const {}}) async {
Future<String?> tryInferringAppId(
String standardUrl, {
Map<String, dynamic> additionalSettings = const {},
}) async {
const possibleBuildGradleLocations = [
'/app/build.gradle',
'android/app/build.gradle',
'src/app/build.gradle'
'src/app/build.gradle',
];
for (var path in possibleBuildGradleLocations) {
try {
var res = await sourceRequest(
'${await convertStandardUrlToAPIUrl(standardUrl, additionalSettings)}/contents/$path',
additionalSettings);
'${await convertStandardUrlToAPIUrl(standardUrl, additionalSettings)}/contents/$path',
additionalSettings,
);
if (res.statusCode == 200) {
try {
var body = jsonDecode(res.body);
var trimmedLines = utf8
.decode(base64
.decode(body['content'].toString().split('\n').join('')))
.decode(
base64.decode(
body['content'].toString().split('\n').join(''),
),
)
.split('\n')
.map((e) => e.trim());
var appIds = trimmedLines.where((l) =>
l.startsWith('applicationId "') ||
l.startsWith('applicationId \''));
appIds = appIds.map((appId) => appId
.split(appId.startsWith('applicationId "') ? '"' : '\'')[1]);
appIds = appIds.map((appId) {
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];
}
return appId;
}).where((appId) => appId.isNotEmpty);
var appIds = trimmedLines.where(
(l) =>
l.startsWith('applicationId "') ||
l.startsWith('applicationId \''),
);
appIds = appIds.map(
(appId) => appId.split(
appId.startsWith('applicationId "') ? '"' : '\'',
)[1],
);
appIds = appIds
.map((appId) {
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];
}
return appId;
})
.where((appId) => appId.isNotEmpty);
if (appIds.length == 1) {
return appIds.first;
}
} catch (err) {
LogsProvider().add(
'Error parsing build.gradle from ${res.request!.url.toString()}: ${err.toString()}');
'Error parsing build.gradle from ${res.request!.url.toString()}: ${err.toString()}',
);
}
}
} catch (err) {
@ -170,8 +210,9 @@ class GitHub extends AppSource {
@override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegEx = RegExp(
'^https?://(www\\.)?${getSourceRegex(hosts)}/[^/]+/[^/]+',
caseSensitive: false);
'^https?://(www\\.)?${getSourceRegex(hosts)}/[^/]+/[^/]+',
caseSensitive: false,
);
RegExpMatch? match = standardUrlRegEx.firstMatch(url);
if (match == null) {
throw InvalidURLError(name);
@ -181,8 +222,9 @@ class GitHub extends AppSource {
@override
Future<Map<String, String>?> getRequestHeaders(
Map<String, dynamic> additionalSettings,
{bool forAPKDownload = false}) async {
Map<String, dynamic> additionalSettings, {
bool forAPKDownload = false,
}) async {
var token = await getTokenIfAny(additionalSettings);
var headers = <String, String>{};
if (token != null && token.isNotEmpty) {
@ -201,14 +243,17 @@ class GitHub extends AppSource {
Future<String?> getTokenIfAny(Map<String, dynamic> additionalSettings) async {
SettingsProvider settingsProvider = SettingsProvider();
await settingsProvider.initializeSettings();
var sourceConfig =
await getSourceConfigValues(additionalSettings, settingsProvider);
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
userNameEndIndex + 1,
); // For old username-included token inputs
}
return creds;
} else {
@ -228,31 +273,36 @@ class GitHub extends AppSource {
'https://api.${hosts[0]}';
Future<String> convertStandardUrlToAPIUrl(
String standardUrl, Map<String, dynamic> additionalSettings) async =>
String standardUrl,
Map<String, dynamic> additionalSettings,
) async =>
'${await getAPIHost(additionalSettings)}/repos${standardUrl.substring('https://${hosts[0]}'.length)}';
@override
String? changeLogPageFromStandardUrl(String standardUrl) =>
'$standardUrl/releases';
Future<APKDetails> getLatestAPKDetailsCommon(String requestUrl,
String standardUrl, Map<String, dynamic> additionalSettings,
{Function(Response)? onHttpErrorCode}) async {
Future<APKDetails> getLatestAPKDetailsCommon(
String requestUrl,
String standardUrl,
Map<String, dynamic> additionalSettings, {
Function(Response)? onHttpErrorCode,
}) async {
bool includePrereleases = additionalSettings['includePrereleases'] == true;
bool fallbackToOlderReleases =
additionalSettings['fallbackToOlderReleases'] == true;
String? regexFilter =
(additionalSettings['filterReleaseTitlesByRegEx'] as String?)
?.isNotEmpty ==
true
? additionalSettings['filterReleaseTitlesByRegEx']
: null;
?.isNotEmpty ==
true
? additionalSettings['filterReleaseTitlesByRegEx']
: null;
String? regexNotesFilter =
(additionalSettings['filterReleaseNotesByRegEx'] as String?)
?.isNotEmpty ==
true
? additionalSettings['filterReleaseNotesByRegEx']
: null;
?.isNotEmpty ==
true
? additionalSettings['filterReleaseNotesByRegEx']
: null;
bool verifyLatestTag = additionalSettings['verifyLatestTag'] == true;
bool useLatestAssetDateAsReleaseDate =
additionalSettings['useLatestAssetDateAsReleaseDate'] == true;
@ -262,8 +312,9 @@ class GitHub extends AppSource {
if (verifyLatestTag) {
var temp = requestUrl.split('?');
Response res = await sourceRequest(
'${temp[0]}/latest${temp.length > 1 ? '?${temp.sublist(1).join('?')}' : ''}',
additionalSettings);
'${temp[0]}/latest${temp.length > 1 ? '?${temp.sublist(1).join('?')}' : ''}',
additionalSettings,
);
if (res.statusCode != 200) {
if (onHttpErrorCode != null) {
onHttpErrorCode(res);
@ -278,8 +329,10 @@ class GitHub extends AppSource {
if (latestRelease != null) {
var latestTag = latestRelease['tag_name'] ?? latestRelease['name'];
if (releases
.where((element) =>
(element['tag_name'] ?? element['name']) == latestTag)
.where(
(element) =>
(element['tag_name'] ?? element['name']) == latestTag,
)
.isEmpty) {
releases = [latestRelease, ...releases];
}
@ -299,10 +352,10 @@ class GitHub extends AppSource {
DateTime? getPublishDateFromRelease(dynamic rel) =>
rel?['published_at'] != null
? DateTime.parse(rel['published_at'])
: rel?['commit']?['created'] != null
? DateTime.parse(rel['commit']['created'])
: null;
? DateTime.parse(rel['published_at'])
: rel?['commit']?['created'] != null
? DateTime.parse(rel['commit']['created'])
: null;
DateTime? getNewestAssetDateFromRelease(dynamic rel) {
var allAssets = rel['assets'] as List<dynamic>?;
var filteredAssets = rel['filteredAssets'] as List<dynamic>?;
@ -323,8 +376,8 @@ class GitHub extends AppSource {
DateTime? getReleaseDateFromRelease(dynamic rel, bool useAssetDate) =>
!useAssetDate
? getPublishDateFromRelease(rel)
: getNewestAssetDateFromRelease(rel);
? getPublishDateFromRelease(rel)
: getNewestAssetDateFromRelease(rel);
if (sortMethod == 'none') {
releases = releases.reversed.toList();
@ -340,29 +393,40 @@ class GitHub extends AppSource {
} else {
var nameA = a['tag_name'] ?? a['name'];
var nameB = b['tag_name'] ?? b['name'];
var stdFormats = findStandardFormatsForVersion(nameA, false)
.intersection(findStandardFormatsForVersion(nameB, false));
var stdFormats = findStandardFormatsForVersion(
nameA,
false,
).intersection(findStandardFormatsForVersion(nameB, false));
if (sortMethod == 'date' ||
(sortMethod == 'smartname-datefallback' &&
stdFormats.isEmpty)) {
return (getReleaseDateFromRelease(
a, useLatestAssetDateAsReleaseDate) ??
a,
useLatestAssetDateAsReleaseDate,
) ??
DateTime(1))
.compareTo(getReleaseDateFromRelease(
b, useLatestAssetDateAsReleaseDate) ??
DateTime(0));
.compareTo(
getReleaseDateFromRelease(
b,
useLatestAssetDateAsReleaseDate,
) ??
DateTime(0),
);
} else {
if (sortMethod != 'name' && stdFormats.isNotEmpty) {
var reg = RegExp(stdFormats.last);
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));
(nameA as String).substring(matchA!.start, matchA.end),
(nameB as String).substring(matchB!.start, matchB.end),
);
} else {
// 'name'
return compareAlphaNumeric(
(nameA as String), (nameB as String));
(nameA as String),
(nameB as String),
);
}
}
}
@ -374,9 +438,11 @@ class GitHub extends AppSource {
latestRelease !=
(releases[releases.length - 1]['tag_name'] ??
releases[0]['name'])) {
var ind = releases.indexWhere((element) =>
(latestRelease['tag_name'] ?? latestRelease['name']) ==
(element['tag_name'] ?? element['name']));
var ind = releases.indexWhere(
(element) =>
(latestRelease['tag_name'] ?? latestRelease['name']) ==
(element['tag_name'] ?? element['name']),
);
if (ind >= 0) {
releases.add(releases.removeAt(ind));
}
@ -404,8 +470,9 @@ class GitHub extends AppSource {
continue;
}
if (regexNotesFilter != null &&
!RegExp(regexNotesFilter)
.hasMatch(((releases[i]['body'] as String?) ?? '').trim())) {
!RegExp(
regexNotesFilter,
).hasMatch(((releases[i]['body'] as String?) ?? '').trim())) {
continue;
}
var allAssetsWithUrls = findReleaseAssetUrls(releases[i]);
@ -413,24 +480,31 @@ class GitHub extends AppSource {
.map((e) => e['final_url'] as MapEntry<String, String>)
.toList();
var apkAssetsWithUrls = allAssetsWithUrls
.where((element) =>
(element['final_url'] as MapEntry<String, String>)
.key
.toLowerCase()
.endsWith('.apk'))
.where(
(element) => (element['final_url'] as MapEntry<String, String>)
.key
.toLowerCase()
.endsWith('.apk'),
)
.toList();
var filteredApkUrls = filterApks(
apkAssetsWithUrls
.map((e) => e['final_url'] as MapEntry<String, String>)
.toList(),
additionalSettings['apkFilterRegEx'],
additionalSettings['invertAPKFilter']);
apkAssetsWithUrls
.map((e) => e['final_url'] as MapEntry<String, String>)
.toList(),
additionalSettings['apkFilterRegEx'],
additionalSettings['invertAPKFilter'],
);
var filteredApks = apkAssetsWithUrls
.where((e) => filteredApkUrls
.where((e2) =>
e2.key == (e['final_url'] as MapEntry<String, String>).key)
.isNotEmpty)
.where(
(e) => filteredApkUrls
.where(
(e2) =>
e2.key ==
(e['final_url'] as MapEntry<String, String>).key,
)
.isNotEmpty,
)
.toList();
if (filteredApks.isEmpty && additionalSettings['trackOnly'] != true) {
@ -441,17 +515,23 @@ class GitHub extends AppSource {
targetRelease['filteredAssets'] = filteredApks;
targetRelease['version'] =
additionalSettings['releaseTitleAsVersion'] == true
? nameToFilter
: targetRelease['tag_name'] ?? targetRelease['name'];
? nameToFilter
: targetRelease['tag_name'] ?? targetRelease['name'];
if (targetRelease['tarball_url'] != null) {
allAssetUrls.add(MapEntry(
allAssetUrls.add(
MapEntry(
(targetRelease['version'] ?? 'source') + '.tar.gz',
targetRelease['tarball_url']));
targetRelease['tarball_url'],
),
);
}
if (targetRelease['zipball_url'] != null) {
allAssetUrls.add(MapEntry(
allAssetUrls.add(
MapEntry(
(targetRelease['version'] ?? 'source') + '.zip',
targetRelease['zipball_url']));
targetRelease['zipball_url'],
),
);
}
targetRelease['allAssetUrls'] = allAssetUrls;
break;
@ -462,19 +542,22 @@ class GitHub extends AppSource {
String? version = targetRelease['version'];
DateTime? releaseDate = getReleaseDateFromRelease(
targetRelease, useLatestAssetDateAsReleaseDate);
targetRelease,
useLatestAssetDateAsReleaseDate,
);
if (version == null) {
throw NoVersionError();
}
var changeLog = (targetRelease['body'] ?? '').toString();
return APKDetails(
version,
targetRelease['apkUrls'] as List<MapEntry<String, String>>,
getAppNames(standardUrl),
releaseDate: releaseDate,
changeLog: changeLog.isEmpty ? null : changeLog,
allAssetUrls:
targetRelease['allAssetUrls'] as List<MapEntry<String, String>>);
version,
targetRelease['apkUrls'] as List<MapEntry<String, String>>,
getAppNames(standardUrl),
releaseDate: releaseDate,
changeLog: changeLog.isEmpty ? null : changeLog,
allAssetUrls:
targetRelease['allAssetUrls'] as List<MapEntry<String, String>>,
);
} else {
if (onHttpErrorCode != null) {
onHttpErrorCode(res);
@ -483,20 +566,27 @@ class GitHub extends AppSource {
}
}
getLatestAPKDetailsCommon2(
String standardUrl,
Map<String, dynamic> additionalSettings,
Future<String> Function(bool) reqUrlGenerator,
dynamic Function(Response)? onHttpErrorCode) async {
Future<APKDetails> getLatestAPKDetailsCommon2(
String standardUrl,
Map<String, dynamic> additionalSettings,
Future<String> Function(bool) reqUrlGenerator,
dynamic Function(Response)? onHttpErrorCode,
) async {
try {
return await getLatestAPKDetailsCommon(
await reqUrlGenerator(false), standardUrl, additionalSettings,
onHttpErrorCode: onHttpErrorCode);
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);
await reqUrlGenerator(true),
standardUrl,
additionalSettings,
onHttpErrorCode: onHttpErrorCode,
);
} else {
rethrow;
}
@ -508,12 +598,16 @@ class GitHub extends AppSource {
String standardUrl,
Map<String, dynamic> 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);
});
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) {
@ -523,9 +617,12 @@ class GitHub extends AppSource {
}
Future<Map<String, List<String>>> searchCommon(
String query, String requestUrl, String rootProp,
{Function(Response)? onHttpErrorCode,
Map<String, dynamic> querySettings = const {}}) async {
String query,
String requestUrl,
String rootProp, {
Function(Response)? onHttpErrorCode,
Map<String, dynamic> querySettings = const {},
}) async {
Response res = await sourceRequest(requestUrl, {});
if (res.statusCode == 200) {
int minStarCount = querySettings['minStarCount'] != null
@ -540,8 +637,8 @@ class GitHub extends AppSource {
((e['archived'] == true ? '[ARCHIVED] ' : '') +
(e['description'] != null
? e['description'] as String
: tr('noDescription')))
]
: tr('noDescription'))),
],
});
}
}
@ -555,22 +652,27 @@ class GitHub extends AppSource {
}
@override
Future<Map<String, List<String>>> search(String query,
{Map<String, dynamic> querySettings = const {}}) async {
Future<Map<String, List<String>>> search(
String query, {
Map<String, dynamic> 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);
query,
'${await getAPIHost({})}/search/repositories?q=${Uri.encodeQueryComponent(query)}&per_page=100',
'items',
onHttpErrorCode: (Response res) {
rateLimitErrorCheck(res);
},
querySettings: querySettings,
);
}
rateLimitErrorCheck(Response res) {
void rateLimitErrorCheck(Response res) {
if (res.headers['x-ratelimit-remaining'] == '0') {
throw RateLimitError(
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
60000000)
.round());
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000)
.round(),
);
}
}
}

View File

@ -18,36 +18,41 @@ class GitLab extends AppSource {
showReleaseDateAsVersionToggle = true;
sourceConfigSettingFormItems = [
GeneratedFormTextField('gitlab-creds',
label: tr('gitlabPATLabel'),
password: true,
required: false,
belowWidgets: [
const SizedBox(
height: 4,
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,
),
),
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,
)
])
),
const SizedBox(height: 4),
],
),
];
additionalSourceAppSpecificSettingFormItems = [
[
GeneratedFormSwitch('fallbackToOlderReleases',
label: tr('fallbackToOlderReleases'), defaultValue: true)
]
GeneratedFormSwitch(
'fallbackToOlderReleases',
label: tr('fallbackToOlderReleases'),
defaultValue: true,
),
],
];
}
@ -55,11 +60,13 @@ class GitLab extends AppSource {
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
var urlSegments = url.split('/');
var cutOffIndex = urlSegments.indexWhere((s) => s == '-');
url =
urlSegments.sublist(0, cutOffIndex <= 0 ? null : cutOffIndex).join('/');
url = urlSegments
.sublist(0, cutOffIndex <= 0 ? null : cutOffIndex)
.join('/');
RegExp standardUrlRegEx = RegExp(
'^https?://(www\\.)?${getSourceRegex(hosts)}/[^/]+(/[^((\b/\b)|(\b/-/\b))]+){1,20}',
caseSensitive: false);
'^https?://(www\\.)?${getSourceRegex(hosts)}/[^/]+(/[^((\b/\b)|(\b/-/\b))]+){1,20}',
caseSensitive: false,
);
RegExpMatch? match = standardUrlRegEx.firstMatch(url);
if (match == null) {
throw InvalidURLError(name);
@ -70,15 +77,19 @@ class GitLab extends AppSource {
Future<String?> getPATIfAny(Map<String, dynamic> additionalSettings) async {
SettingsProvider settingsProvider = SettingsProvider();
await settingsProvider.initializeSettings();
var sourceConfig =
await getSourceConfigValues(additionalSettings, settingsProvider);
var sourceConfig = await getSourceConfigValues(
additionalSettings,
settingsProvider,
);
String? creds = sourceConfig['gitlab-creds'];
return creds != null && creds.isNotEmpty ? creds : null;
}
@override
Future<Map<String, List<String>>> search(String query,
{Map<String, dynamic> querySettings = const {}}) async {
Future<Map<String, List<String>>> search(
String query, {
Map<String, dynamic> querySettings = const {},
}) async {
var url =
'https://${hosts[0]}/api/v4/projects?search=${Uri.encodeQueryComponent(query)}';
var res = await sourceRequest(url, {});
@ -90,7 +101,7 @@ class GitLab extends AppSource {
for (var element in json) {
results['https://${hosts[0]}/${element['path_with_namespace']}'] = [
element['name_with_namespace'],
element['description'] ?? tr('noDescription')
element['description'] ?? tr('noDescription'),
];
}
return results;
@ -102,8 +113,9 @@ class GitLab extends AppSource {
@override
Future<Map<String, String>?> getRequestHeaders(
Map<String, dynamic> additionalSettings,
{bool forAPKDownload = false}) async {
Map<String, dynamic> additionalSettings, {
bool forAPKDownload = false,
}) async {
// Change headers to pacify, e.g. cloudflare protection
// Related to: (#1397, #1389, #1384, #1382, #1381, #1380, #1359, #854, #785, #697)
var headers = <String, String>{};
@ -116,8 +128,11 @@ class GitLab extends AppSource {
}
@override
Future<String> apkUrlPrefetchModifier(String apkUrl, String standardUrl,
Map<String, dynamic> additionalSettings) async {
Future<String> apkUrlPrefetchModifier(
String apkUrl,
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
String? PAT = await getPATIfAny(hostChanged ? additionalSettings : {});
String optionalAuth = (PAT != null) ? 'private_token=$PAT' : '';
return '$apkUrl${(Uri.parse(apkUrl).query.isEmpty ? '?' : '&')}$optionalAuth';
@ -139,8 +154,9 @@ class GitLab extends AppSource {
// Get project ID
Response res0 = await sourceRequest(
'https://${hosts[0]}/api/v4/projects/$projectUriComponent?$optionalAuth',
additionalSettings);
'https://${hosts[0]}/api/v4/projects/$projectUriComponent?$optionalAuth',
additionalSettings,
);
if (res0.statusCode != 200) {
throw getObtainiumHttpError(res0);
}
@ -151,8 +167,9 @@ class GitLab extends AppSource {
// Request data from REST API
Response res = await sourceRequest(
'https://${hosts[0]}/api/v4/projects/$projectUriComponent/${trackOnly ? 'repository/tags' : 'releases'}?$optionalAuth',
additionalSettings);
'https://${hosts[0]}/api/v4/projects/$projectUriComponent/${trackOnly ? 'repository/tags' : 'releases'}?$optionalAuth',
additionalSettings,
);
if (res.statusCode != 200) {
throw getObtainiumHttpError(res);
}
@ -166,11 +183,13 @@ class GitLab extends AppSource {
var url = (e['direct_asset_url'] ?? e['url'] ?? '') as String;
var parsedUrl = url.isNotEmpty ? Uri.parse(url) : null;
return MapEntry(
(e['name'] ??
(parsedUrl != null && parsedUrl.pathSegments.isNotEmpty
? parsedUrl.pathSegments.last
: 'unknown')) as String,
(e['direct_asset_url'] ?? e['url'] ?? '') as String);
(e['name'] ??
(parsedUrl != null && parsedUrl.pathSegments.isNotEmpty
? parsedUrl.pathSegments.last
: 'unknown'))
as String,
(e['direct_asset_url'] ?? e['url'] ?? '') as String,
);
})
.where((s) => s.key.isNotEmpty)
.toList();
@ -193,11 +212,15 @@ class GitLab extends AppSource {
}
var releaseDateString =
e['released_at'] ?? e['created_at'] ?? e['commit']?['created_at'];
DateTime? releaseDate =
releaseDateString != null ? DateTime.parse(releaseDateString) : null;
return APKDetails(e['tag_name'] ?? e['name'], apkUrls.entries.toList(),
AppNames(names.author, names.name.split('/').last),
releaseDate: releaseDate);
DateTime? releaseDate = releaseDateString != null
? DateTime.parse(releaseDateString)
: null;
return APKDetails(
e['tag_name'] ?? e['name'],
apkUrls.entries.toList(),
AppNames(names.author, names.name.split('/').last),
releaseDate: releaseDate,
);
});
if (apkDetailsList.isEmpty) {
throw NoReleasesError();
@ -208,8 +231,9 @@ class GitLab extends AppSource {
bool fallbackToOlderReleases =
additionalSettings['fallbackToOlderReleases'] == true;
if (finalResult.apkUrls.isEmpty && fallbackToOlderReleases && !trackOnly) {
apkDetailsList =
apkDetailsList.where((e) => e.apkUrls.isNotEmpty).toList();
apkDetailsList = apkDetailsList
.where((e) => e.apkUrls.isNotEmpty)
.toList();
finalResult = apkDetailsList.first;
}
@ -218,10 +242,13 @@ class GitLab extends AppSource {
}
finalResult.apkUrls = finalResult.apkUrls.map((apkUrl) {
if (RegExp('^$standardUrl/-/jobs/[0-9]+/artifacts/file/[^/]+')
.hasMatch(apkUrl.value)) {
if (RegExp(
'^$standardUrl/-/jobs/[0-9]+/artifacts/file/[^/]+',
).hasMatch(apkUrl.value)) {
return MapEntry(
apkUrl.key, apkUrl.value.replaceFirst('/file/', '/raw/'));
apkUrl.key,
apkUrl.value.replaceFirst('/file/', '/raw/'),
);
} else {
return apkUrl;
}

View File

@ -9,6 +9,13 @@ import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
String ensureAbsoluteUrl(String ambiguousUrl, Uri referenceAbsoluteUrl) {
try {
if (Uri.parse(ambiguousUrl).isAbsolute) {
return ambiguousUrl; // #2315
}
} catch (e) {
//
}
return referenceAbsoluteUrl.resolve(ambiguousUrl).toString();
}
@ -93,28 +100,37 @@ bool _isNumeric(String s) {
return s.codeUnitAt(0) >= 48 && s.codeUnitAt(0) <= 57;
}
List<MapEntry<String, String>> getLinksInLines(String lines) => RegExp(
r'(http|ftp|https)://([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-])?')
.allMatches(lines)
.map((match) =>
MapEntry(match.group(0)!, match.group(0)?.split('/').last ?? ''))
.toList();
List<MapEntry<String, String>> getLinksInLines(String lines) =>
RegExp(
r'(http|ftp|https)://([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-])?',
)
.allMatches(lines)
.map(
(match) =>
MapEntry(match.group(0)!, match.group(0)?.split('/').last ?? ''),
)
.toList();
// Given an HTTP response, grab some links according to the common additional settings
// (those that apply to intermediate and final steps)
Future<List<MapEntry<String, String>>> grabLinksCommon(
Response res, Map<String, dynamic> additionalSettings) async {
Response res,
Map<String, dynamic> additionalSettings,
) async {
if (res.statusCode != 200) {
throw getObtainiumHttpError(res);
}
var html = parse(res.body);
List<MapEntry<String, String>> allLinks = html
.querySelectorAll('a')
.map((element) => MapEntry(
.map(
(element) => MapEntry(
element.attributes['href'] ?? '',
element.text.isNotEmpty
? element.text
: (element.attributes['href'] ?? '').split('/').last))
: (element.attributes['href'] ?? '').split('/').last,
),
)
.where((element) => element.key.isNotEmpty)
.map((e) => MapEntry(ensureAbsoluteUrl(e.key, res.request!.url), e.value))
.toList();
@ -127,9 +143,13 @@ Future<List<MapEntry<String, String>>> grabLinksCommon(
var jsonStrings = collectAllStringsFromJSONObject(jsonDecode(res.body));
allLinks = getLinksInLines(jsonStrings.join('\n'));
if (allLinks.isEmpty) {
allLinks = getLinksInLines(jsonStrings.map((l) {
return ensureAbsoluteUrl(l, res.request!.url);
}).join('\n'));
allLinks = getLinksInLines(
jsonStrings
.map((l) {
return ensureAbsoluteUrl(l, res.request!.url);
})
.join('\n'),
);
}
} catch (e) {
//
@ -158,17 +178,20 @@ Future<List<MapEntry<String, String>>> grabLinksCommon(
} catch (e) {
// Some links may not have valid encoding
}
return Uri.parse(filterLinkByText ? element.value : link)
.path
.toLowerCase()
.endsWith('.apk');
return Uri.parse(
filterLinkByText ? element.value : link,
).path.toLowerCase().endsWith('.apk');
}).toList();
}
if (!skipSort) {
links.sort((a, b) => additionalSettings['sortByLastLinkSegment'] == true
? compareAlphaNumeric(a.key.split('/').where((e) => e.isNotEmpty).last,
b.key.split('/').where((e) => e.isNotEmpty).last)
: compareAlphaNumeric(a.key, b.key));
links.sort(
(a, b) => additionalSettings['sortByLastLinkSegment'] == true
? compareAlphaNumeric(
a.key.split('/').where((e) => e.isNotEmpty).last,
b.key.split('/').where((e) => e.isNotEmpty).last,
)
: compareAlphaNumeric(a.key, b.key),
);
}
if (additionalSettings['reverseSort'] == true) {
links = links.reversed.toList();
@ -194,102 +217,119 @@ class HTML extends AppSource {
var finalStepFormitems = [
[
GeneratedFormTextField('customLinkFilterRegex',
label: tr('customLinkFilterRegex'),
hint: 'download/(.*/)?(android|apk|mobile)',
required: false,
additionalValidators: [
(value) {
return regExValidator(value);
}
])
GeneratedFormTextField(
'customLinkFilterRegex',
label: tr('customLinkFilterRegex'),
hint: 'download/(.*/)?(android|apk|mobile)',
required: false,
additionalValidators: [
(value) {
return regExValidator(value);
},
],
),
],
[
GeneratedFormSwitch('versionExtractWholePage',
label: tr('versionExtractWholePage'))
]
GeneratedFormSwitch(
'versionExtractWholePage',
label: tr('versionExtractWholePage'),
),
],
];
var commonFormItems = [
[GeneratedFormSwitch('filterByLinkText', label: tr('filterByLinkText'))],
[GeneratedFormSwitch('skipSort', label: tr('skipSort'))],
[GeneratedFormSwitch('reverseSort', label: tr('takeFirstLink'))],
[
GeneratedFormSwitch('sortByLastLinkSegment',
label: tr('sortByLastLinkSegment'))
GeneratedFormSwitch(
'sortByLastLinkSegment',
label: tr('sortByLastLinkSegment'),
),
],
];
var intermediateFormItems = [
[
GeneratedFormTextField('customLinkFilterRegex',
label: tr('intermediateLinkRegex'),
hint: '([0-9]+.)*[0-9]+/\$',
required: true,
additionalValidators: [(value) => regExValidator(value)])
GeneratedFormTextField(
'customLinkFilterRegex',
label: tr('intermediateLinkRegex'),
hint: '([0-9]+.)*[0-9]+/\$',
required: true,
additionalValidators: [(value) => regExValidator(value)],
),
],
[
GeneratedFormSwitch('autoLinkFilterByArch',
label: tr('autoLinkFilterByArch'), defaultValue: false)
GeneratedFormSwitch(
'autoLinkFilterByArch',
label: tr('autoLinkFilterByArch'),
defaultValue: false,
),
],
];
HTML() {
additionalSourceAppSpecificSettingFormItems = [
[
GeneratedFormSubForm(
'intermediateLink', [...intermediateFormItems, ...commonFormItems],
label: tr('intermediateLink'))
GeneratedFormSubForm('intermediateLink', [
...intermediateFormItems,
...commonFormItems,
], label: tr('intermediateLink')),
],
finalStepFormitems[0],
...commonFormItems,
...finalStepFormitems.sublist(1),
[
GeneratedFormSubForm(
'requestHeader',
'requestHeader',
[
[
[
GeneratedFormTextField('requestHeader',
label: tr('requestHeader'),
required: false,
additionalValidators: [
(value) {
if ((value ?? 'empty:valid')
.split(':')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.length <
2) {
return tr('invalidInput');
}
return null;
}
])
]
GeneratedFormTextField(
'requestHeader',
label: tr('requestHeader'),
required: false,
additionalValidators: [
(value) {
if ((value ?? 'empty:valid')
.split(':')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.length <
2) {
return tr('invalidInput');
}
return null;
},
],
),
],
label: tr('requestHeader'),
defaultValue: [
{
'requestHeader':
'User-Agent: Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36'
}
])
],
label: tr('requestHeader'),
defaultValue: [
{
'requestHeader':
'User-Agent: Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36',
},
],
),
],
[
GeneratedFormDropdown(
'defaultPseudoVersioningMethod',
[
MapEntry('partialAPKHash', tr('partialAPKHash')),
MapEntry('APKLinkHash', tr('APKLinkHash')),
MapEntry('ETag', 'ETag')
],
label: tr('defaultPseudoVersioningMethod'),
defaultValue: 'partialAPKHash')
]
'defaultPseudoVersioningMethod',
[
MapEntry('partialAPKHash', tr('partialAPKHash')),
MapEntry('APKLinkHash', tr('APKLinkHash')),
MapEntry('ETag', 'ETag'),
],
label: tr('defaultPseudoVersioningMethod'),
defaultValue: 'partialAPKHash',
),
],
];
}
@override
Future<Map<String, String>?> getRequestHeaders(
Map<String, dynamic> additionalSettings,
{bool forAPKDownload = false}) async {
Map<String, dynamic> additionalSettings, {
bool forAPKDownload = false,
}) async {
if (additionalSettings.isNotEmpty) {
if (additionalSettings['requestHeader']?.isNotEmpty != true) {
additionalSettings['requestHeader'] = [];
@ -329,8 +369,9 @@ class HTML extends AppSource {
.toList();
for (int i = 0; i < (additionalSettings['intermediateLink'].length); i++) {
var intLinks = await grabLinksCommon(
await sourceRequest(currentUrl, additionalSettings),
additionalSettings['intermediateLink'][i]);
await sourceRequest(currentUrl, additionalSettings),
additionalSettings['intermediateLink'][i],
);
if (intLinks.isEmpty) {
throw NoReleasesError(note: currentUrl);
} else {
@ -346,11 +387,17 @@ class HTML extends AppSource {
String versionExtractionWholePageString = currentUrl;
if (additionalSettings['directAPKLink'] != true) {
Response res = await sourceRequest(currentUrl, additionalSettings);
versionExtractionWholePageString =
res.body.split('\r\n').join('\n').split('\n').join('\\n');
versionExtractionWholePageString = res.body
.split('\r\n')
.join('\n')
.split('\n')
.join('\\n');
links = await grabLinksCommon(res, additionalSettings);
links = filterApks(links, additionalSettings['apkFilterRegEx'],
additionalSettings['invertAPKFilter']);
links = filterApks(
links,
additionalSettings['apkFilterRegEx'],
additionalSettings['invertAPKFilter'],
);
if (links.isEmpty) {
throw NoReleasesError(note: currentUrl);
}
@ -366,37 +413,45 @@ class HTML extends AppSource {
}
String? version;
version = extractVersion(
additionalSettings['versionExtractionRegEx'] as String?,
additionalSettings['matchGroupToUse'] as String?,
additionalSettings['versionExtractWholePage'] == true
? versionExtractionWholePageString
: relDecoded);
var apkReqHeaders =
await getRequestHeaders(additionalSettings, forAPKDownload: true);
additionalSettings['versionExtractionRegEx'] as String?,
additionalSettings['matchGroupToUse'] as String?,
additionalSettings['versionExtractWholePage'] == true
? versionExtractionWholePageString
: relDecoded,
);
var apkReqHeaders = await getRequestHeaders(
additionalSettings,
forAPKDownload: true,
);
if (version == null &&
additionalSettings['defaultPseudoVersioningMethod'] == 'ETag') {
version = await checkETagHeader(rel,
headers: apkReqHeaders,
allowInsecure: additionalSettings['allowInsecure'] == true);
version = await checkETagHeader(
rel,
headers: apkReqHeaders,
allowInsecure: additionalSettings['allowInsecure'] == true,
);
if (version == null) {
throw NoVersionError();
}
}
version ??=
additionalSettings['defaultPseudoVersioningMethod'] == 'APKLinkHash'
? rel.hashCode.toString()
: (await checkPartialDownloadHashDynamic(rel,
headers: apkReqHeaders,
allowInsecure: additionalSettings['allowInsecure'] == true))
.toString();
? rel.hashCode.toString()
: (await checkPartialDownloadHashDynamic(
rel,
headers: apkReqHeaders,
allowInsecure: additionalSettings['allowInsecure'] == true,
)).toString();
return APKDetails(
version,
[rel].map((e) {
var uri = Uri.parse(e);
var fileName =
uri.pathSegments.isNotEmpty ? uri.pathSegments.last : uri.origin;
return MapEntry('${e.hashCode}-$fileName', e);
}).toList(),
AppNames(uri.host, tr('app')));
version,
[rel].map((e) {
var uri = Uri.parse(e);
var fileName = uri.pathSegments.isNotEmpty
? uri.pathSegments.last
: uri.origin;
return MapEntry('${e.hashCode}-$fileName', e);
}).toList(),
AppNames(uri.host, tr('app')),
);
}
}

View File

@ -14,8 +14,9 @@ class HuaweiAppGallery extends AppSource {
@override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegEx = RegExp(
'^https?://(www\\.)?${getSourceRegex(hosts)}(/#)?/(app|appdl)/[^/]+',
caseSensitive: false);
'^https?://(www\\.)?${getSourceRegex(hosts)}(/#)?/(app|appdl)/[^/]+',
caseSensitive: false,
);
RegExpMatch? match = standardUrlRegEx.firstMatch(url);
if (match == null) {
throw InvalidURLError(name);
@ -23,13 +24,18 @@ class HuaweiAppGallery extends AppSource {
return match.group(0)!;
}
getDlUrl(String standardUrl) =>
String getDlUrl(String standardUrl) =>
'https://${hosts[0].replaceAll('appgallery.huawei', 'appgallery.cloud.huawei')}/appdl/${standardUrl.split('/').last}';
requestAppdlRedirect(
String dlUrl, Map<String, dynamic> additionalSettings) async {
Response res =
await sourceRequest(dlUrl, additionalSettings, followRedirects: false);
Future<Response> requestAppdlRedirect(
String dlUrl,
Map<String, dynamic> additionalSettings,
) async {
Response res = await sourceRequest(
dlUrl,
additionalSettings,
followRedirects: false,
);
if (res.statusCode == 200 ||
res.statusCode == 302 ||
res.statusCode == 304) {
@ -39,7 +45,7 @@ class HuaweiAppGallery extends AppSource {
}
}
appIdFromRedirectDlUrl(String redirectDlUrl) {
String appIdFromRedirectDlUrl(String redirectDlUrl) {
var parts = redirectDlUrl
.split('?')[0]
.split('/')
@ -53,8 +59,10 @@ class HuaweiAppGallery extends AppSource {
}
@override
Future<String?> tryInferringAppId(String standardUrl,
{Map<String, dynamic> additionalSettings = const {}}) async {
Future<String?> tryInferringAppId(
String standardUrl, {
Map<String, dynamic> additionalSettings = const {},
}) async {
String dlUrl = getDlUrl(standardUrl);
Response res = await requestAppdlRedirect(dlUrl, additionalSettings);
return res.headers['location'] != null
@ -76,8 +84,11 @@ class HuaweiAppGallery extends AppSource {
if (appId.isEmpty) {
throw NoReleasesError();
}
var relDateStr =
res.headers['location']?.split('?')[0].split('.').reversed.toList()[1];
var relDateStr = res.headers['location']
?.split('?')[0]
.split('.')
.reversed
.toList()[1];
if (relDateStr == null || relDateStr.length != 10) {
throw NoVersionError();
}
@ -88,10 +99,15 @@ class HuaweiAppGallery extends AppSource {
relDateStrAdj.insert((i + i ~/ 2 - 1), '-');
i += 2;
}
var relDate =
DateFormat('yy-MM-dd-HH-mm', 'en_US').parse(relDateStrAdj.join(''));
var relDate = DateFormat(
'yy-MM-dd-HH-mm',
'en_US',
).parse(relDateStrAdj.join(''));
return APKDetails(
relDateStr, [MapEntry('$appId.apk', dlUrl)], AppNames(name, appId),
releaseDate: relDate);
relDateStr,
[MapEntry('$appId.apk', dlUrl)],
AppNames(name, appId),
releaseDate: relDate,
);
}
}

View File

@ -16,13 +16,15 @@ class IzzyOnDroid extends AppSource {
@override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegExA = RegExp(
'^https?://android.${getSourceRegex(hosts)}/repo/apk/[^/]+',
caseSensitive: false);
'^https?://android.${getSourceRegex(hosts)}/repo/apk/[^/]+',
caseSensitive: false,
);
RegExpMatch? match = standardUrlRegExA.firstMatch(url);
if (match == null) {
RegExp standardUrlRegExB = RegExp(
'^https?://apt.${getSourceRegex(hosts)}/fdroid/index/apk/[^/]+',
caseSensitive: false);
'^https?://apt.${getSourceRegex(hosts)}/fdroid/index/apk/[^/]+',
caseSensitive: false,
);
match = standardUrlRegExB.firstMatch(url);
}
if (match == null) {
@ -32,8 +34,10 @@ class IzzyOnDroid extends AppSource {
}
@override
Future<String?> tryInferringAppId(String standardUrl,
{Map<String, dynamic> additionalSettings = const {}}) async {
Future<String?> tryInferringAppId(
String standardUrl, {
Map<String, dynamic> additionalSettings = const {},
}) async {
return fd.tryInferringAppId(standardUrl);
}
@ -44,12 +48,14 @@ class IzzyOnDroid extends AppSource {
) async {
String? appId = await tryInferringAppId(standardUrl);
return fd.getAPKUrlsFromFDroidPackagesAPIResponse(
await sourceRequest(
'https://apt.izzysoft.de/fdroid/api/v1/packages/$appId',
additionalSettings),
'https://android.izzysoft.de/frepo/$appId',
standardUrl,
name,
additionalSettings: additionalSettings);
await sourceRequest(
'https://apt.izzysoft.de/fdroid/api/v1/packages/$appId',
additionalSettings,
),
'https://android.izzysoft.de/frepo/$appId',
standardUrl,
name,
additionalSettings: additionalSettings,
);
}
}

View File

@ -31,14 +31,17 @@ class Jenkins extends AppSource {
) async {
standardUrl = trimJobUrl(standardUrl);
Response res = await sourceRequest(
'$standardUrl/lastSuccessfulBuild/api/json', additionalSettings);
'$standardUrl/lastSuccessfulBuild/api/json',
additionalSettings,
);
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();
var version = json['number'] == null
? null
: (json['number'] as int).toString();
if (version == null) {
throw NoVersionError();
}
@ -51,16 +54,21 @@ class Jenkins extends AppSource {
return path == null
? const MapEntry<String, String>('', '')
: MapEntry<String, String>(
(e['fileName'] ?? e['relativePath']) as String, path);
(e['fileName'] ?? e['relativePath']) as String,
path,
);
})
.where((url) =>
url.value.isNotEmpty && url.key.toLowerCase().endsWith('.apk'))
.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));
version,
apkUrls,
releaseDate: releaseDate,
AppNames(Uri.parse(standardUrl).host, standardUrl.split('/').last),
);
} else {
throw getObtainiumHttpError(res);
}

View File

@ -12,8 +12,9 @@ class Mullvad extends AppSource {
@override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegEx = RegExp(
'^https?://(www\\.)?${getSourceRegex(hosts)}',
caseSensitive: false);
'^https?://(www\\.)?${getSourceRegex(hosts)}',
caseSensitive: false,
);
RegExpMatch? match = standardUrlRegEx.firstMatch(url);
if (match == null) {
throw InvalidURLError(name);
@ -31,7 +32,9 @@ class Mullvad extends AppSource {
Map<String, dynamic> additionalSettings,
) async {
Response res = await sourceRequest(
'$standardUrl/en/download/android', additionalSettings);
'$standardUrl/en/download/android',
additionalSettings,
);
if (res.statusCode == 200) {
var versions = parse(res.body)
.querySelectorAll('p')
@ -53,17 +56,18 @@ class Mullvad extends AppSource {
String? changeLog;
try {
changeLog = (await GitHub().getLatestAPKDetails(
'https://github.com/mullvad/mullvadvpn-app',
{'fallbackToOlderReleases': true}))
.changeLog;
'https://github.com/mullvad/mullvadvpn-app',
{'fallbackToOlderReleases': true},
)).changeLog;
} catch (e) {
// Ignore
}
return APKDetails(
versions[0],
getApkUrlsFromUrls(['https://mullvad.net/download/app/apk/latest']),
AppNames(name, 'Mullvad-VPN'),
changeLog: changeLog);
versions[0],
getApkUrlsFromUrls(['https://mullvad.net/download/app/apk/latest']),
AppNames(name, 'Mullvad-VPN'),
changeLog: changeLog,
);
} else {
throw getObtainiumHttpError(res);
}

View File

@ -12,8 +12,9 @@ class NeutronCode extends AppSource {
@override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegEx = RegExp(
'^https?://(www\\.)?${getSourceRegex(hosts)}/downloads/file/[^/]+',
caseSensitive: false);
'^https?://(www\\.)?${getSourceRegex(hosts)}/downloads/file/[^/]+',
caseSensitive: false,
);
RegExpMatch? match = standardUrlRegEx.firstMatch(url);
if (match == null) {
throw InvalidURLError(name);
@ -55,7 +56,7 @@ class NeutronCode extends AppSource {
}
}
customDateParse(String dateString) {
String? customDateParse(String dateString) {
List<String> parts = dateString.split(' ');
if (parts.length != 3) {
return null;
@ -89,24 +90,31 @@ class NeutronCode extends AppSource {
if (filename == null) {
throw NoReleasesError();
}
var version =
http.querySelector('.pd-version-txt')?.nextElementSibling?.innerHtml;
var version = http
.querySelector('.pd-version-txt')
?.nextElementSibling
?.innerHtml;
if (version == null) {
throw NoVersionError();
}
String? apkUrl = 'https://${hosts[0]}/download/$filename';
var dateStringOriginal =
http.querySelector('.pd-date-txt')?.nextElementSibling?.innerHtml;
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);
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);
}

View File

@ -18,8 +18,9 @@ class RuStore extends AppSource {
@override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegEx = RegExp(
'^https?://(www\\.)?${getSourceRegex(hosts)}/catalog/app/+[^/]+',
caseSensitive: false);
'^https?://(www\\.)?${getSourceRegex(hosts)}/catalog/app/+[^/]+',
caseSensitive: false,
);
RegExpMatch? match = standardUrlRegEx.firstMatch(url);
if (match == null) {
throw InvalidURLError(name);
@ -28,16 +29,18 @@ class RuStore extends AppSource {
}
@override
Future<String?> tryInferringAppId(String standardUrl,
{Map<String, dynamic> additionalSettings = const {}}) async {
Future<String?> tryInferringAppId(
String standardUrl, {
Map<String, dynamic> additionalSettings = const {},
}) async {
return Uri.parse(standardUrl).pathSegments.last;
}
Future<String> decodeString(String str) async {
try {
return (await CharsetDetector.autoDecode(
Uint8List.fromList(str.codeUnits)))
.string;
Uint8List.fromList(str.codeUnits),
)).string;
} catch (e) {
return str;
}
@ -50,8 +53,9 @@ class RuStore extends AppSource {
) async {
String? appId = await tryInferringAppId(standardUrl);
Response res0 = await sourceRequest(
'https://backapi.rustore.ru/applicationData/overallInfo/$appId',
additionalSettings);
'https://backapi.rustore.ru/applicationData/overallInfo/$appId',
additionalSettings,
);
if (res0.statusCode != 200) {
throw getObtainiumHttpError(res0);
}
@ -74,10 +78,11 @@ class RuStore extends AppSource {
}
Response res1 = await sourceRequest(
'https://backapi.rustore.ru/applicationData/download-link',
additionalSettings,
followRedirects: false,
postBody: {"appId": appDetails['appId'], "firstInstall": true});
'https://backapi.rustore.ru/applicationData/download-link',
additionalSettings,
followRedirects: false,
postBody: {"appId": appDetails['appId'], "firstInstall": true},
);
var downloadDetails = jsonDecode(res1.body)['body'];
if (res1.statusCode != 200 || downloadDetails['apkUrl'] == null) {
throw NoAPKError();
@ -88,13 +93,16 @@ class RuStore extends AppSource {
changeLog = changeLog != null ? await decodeString(changeLog) : null;
return APKDetails(
version,
getApkUrlsFromUrls([
(downloadDetails['apkUrl'] as String)
.replaceAll(RegExp('\\.zip\$'), '.apk')
]),
AppNames(author, appName),
releaseDate: relDate,
changeLog: changeLog);
version,
getApkUrlsFromUrls([
(downloadDetails['apkUrl'] as String).replaceAll(
RegExp('\\.zip\$'),
'.apk',
),
]),
AppNames(author, appName),
releaseDate: relDate,
changeLog: changeLog,
);
}
}

View File

@ -11,23 +11,27 @@ class SourceForge extends AppSource {
@override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
var sourceRegex = getSourceRegex(hosts);
RegExp standardUrlRegExC =
RegExp('^https?://(www\\.)?$sourceRegex/p/.+', caseSensitive: false);
RegExp standardUrlRegExC = RegExp(
'^https?://(www\\.)?$sourceRegex/p/.+',
caseSensitive: false,
);
RegExpMatch? match = standardUrlRegExC.firstMatch(url);
if (match != null) {
url =
'https://${Uri.parse(match.group(0)!).host}/projects/${url.substring(Uri.parse(match.group(0)!).host.length + '/projects/'.length + 1)}';
}
RegExp standardUrlRegExB = RegExp(
'^https?://(www\\.)?$sourceRegex/projects/[^/]+',
caseSensitive: false);
'^https?://(www\\.)?$sourceRegex/projects/[^/]+',
caseSensitive: false,
);
match = standardUrlRegExB.firstMatch(url);
if (match != null && match.group(0) == url) {
url = '$url/files';
}
RegExp standardUrlRegExA = RegExp(
'^https?://(www\\.)?$sourceRegex/projects/[^/]+/files(/.+)?',
caseSensitive: false);
'^https?://(www\\.)?$sourceRegex/projects/[^/]+/files(/.+)?',
caseSensitive: false,
);
match = standardUrlRegExA.firstMatch(url);
if (match == null) {
throw InvalidURLError(name);
@ -46,8 +50,9 @@ class SourceForge extends AppSource {
standardUri = Uri.parse(standardUrl);
}
Response res = await sourceRequest(
'${standardUri.origin}/${standardUri.pathSegments.sublist(0, 2).join('/')}/rss?path=/',
additionalSettings);
'${standardUri.origin}/${standardUri.pathSegments.sublist(0, 2).join('/')}/rss?path=/',
additionalSettings,
);
if (res.statusCode == 200) {
var parsedHtml = parse(res.body);
var allDownloadLinks = parsedHtml
@ -74,9 +79,10 @@ class SourceForge extends AppSource {
if (version != null) {
try {
var extractedVersion = extractVersion(
additionalSettings['versionExtractionRegEx'] as String?,
additionalSettings['matchGroupToUse'] as String?,
version);
additionalSettings['versionExtractionRegEx'] as String?,
additionalSettings['matchGroupToUse'] as String?,
version,
);
if (extractedVersion != null) {
version = extractedVersion;
}
@ -111,8 +117,11 @@ class SourceForge extends AppSource {
.where((element) => getVersion(element) == version)
.toList();
var segments = standardUrl.split('/');
return APKDetails(version, getApkUrlsFromUrls(apkUrlList),
AppNames(name, segments[segments.indexOf('files') - 1]));
return APKDetails(
version,
getApkUrlsFromUrls(apkUrlList),
AppNames(name, segments[segments.indexOf('files') - 1]),
);
} else {
throw getObtainiumHttpError(res);
}

View File

@ -13,17 +13,21 @@ class SourceHut extends AppSource {
additionalSourceAppSpecificSettingFormItems = [
[
GeneratedFormSwitch('fallbackToOlderReleases',
label: tr('fallbackToOlderReleases'), defaultValue: true)
]
GeneratedFormSwitch(
'fallbackToOlderReleases',
label: tr('fallbackToOlderReleases'),
defaultValue: true,
),
],
];
}
@override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegEx = RegExp(
'^https?://(www\\.)?${getSourceRegex(hosts)}/[^/]+/[^/]+',
caseSensitive: false);
'^https?://(www\\.)?${getSourceRegex(hosts)}/[^/]+/[^/]+',
caseSensitive: false,
);
RegExpMatch? match = standardUrlRegEx.firstMatch(url);
if (match == null) {
throw InvalidURLError(name);
@ -52,8 +56,10 @@ class SourceHut extends AppSource {
String appName = standardUri.pathSegments.last;
bool fallbackToOlderReleases =
additionalSettings['fallbackToOlderReleases'] == true;
Response res =
await sourceRequest('$standardUrl/refs/rss.xml', additionalSettings);
Response res = await sourceRequest(
'$standardUrl/refs/rss.xml',
additionalSettings,
);
if (res.statusCode == 200) {
var parsedHtml = parse(res.body);
List<APKDetails> apkDetailsList = [];
@ -63,10 +69,10 @@ class SourceHut extends AppSource {
ind++;
String releasePage = // querySelector('link') fails for some reason
entry
.querySelector('guid') // Luckily guid is identical
?.innerHtml
.trim() ??
'';
.querySelector('guid') // Luckily guid is identical
?.innerHtml
.trim() ??
'';
if (!releasePage.startsWith('$standardUrl/refs')) {
continue;
}
@ -84,8 +90,9 @@ class SourceHut extends AppSource {
? 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)
? DateFormat(
'EEE, dd MMM yyyy HH:mm:ss Z',
).parse(releaseDateString)
: null;
} catch (e) {
// ignore
@ -93,27 +100,35 @@ class SourceHut extends AppSource {
var res2 = await sourceRequest(releasePage, additionalSettings);
List<MapEntry<String, String>> 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());
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(
apkDetailsList.add(
APKDetails(
version,
apkUrls,
AppNames(entry.querySelector('author')?.innerHtml.trim() ?? appName,
appName),
releaseDate: releaseDate));
AppNames(
entry.querySelector('author')?.innerHtml.trim() ?? appName,
appName,
),
releaseDate: releaseDate,
),
);
}
if (apkDetailsList.isEmpty) {
throw NoReleasesError();
}
if (fallbackToOlderReleases) {
if (additionalSettings['trackOnly'] != true) {
apkDetailsList =
apkDetailsList.where((e) => e.apkUrls.isNotEmpty).toList();
apkDetailsList = apkDetailsList
.where((e) => e.apkUrls.isNotEmpty)
.toList();
}
if (apkDetailsList.isEmpty) {
throw NoReleasesError();

View File

@ -20,12 +20,15 @@ class TelegramApp extends AppSource {
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
Response res =
await sourceRequest('https://t.me/s/TAndroidAPK', additionalSettings);
Response res = await sourceRequest(
'https://t.me/s/TAndroidAPK',
additionalSettings,
);
if (res.statusCode == 200) {
var http = parse(res.body);
var messages =
http.querySelectorAll('.tgme_widget_message_text.js-message_text');
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;
@ -33,10 +36,9 @@ class TelegramApp extends AppSource {
throw NoVersionError();
}
String? apkUrl = 'https://telegram.org/dl/android/apk';
return APKDetails(
version,
[MapEntry<String, String>('telegram-$version.apk', apkUrl)],
AppNames('Telegram', 'Telegram'));
return APKDetails(version, [
MapEntry<String, String>('telegram-$version.apk', apkUrl),
], AppNames('Telegram', 'Telegram'));
} else {
throw getObtainiumHttpError(res);
}

View File

@ -15,8 +15,9 @@ class Tencent extends AppSource {
@override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegEx = RegExp(
'^https?://${getSourceRegex(hosts)}/appdetail/[^/]+',
caseSensitive: false);
'^https?://${getSourceRegex(hosts)}/appdetail/[^/]+',
caseSensitive: false,
);
var match = standardUrlRegEx.firstMatch(url);
if (match == null) {
throw InvalidURLError(name);
@ -25,8 +26,10 @@ class Tencent extends AppSource {
}
@override
Future<String?> tryInferringAppId(String standardUrl,
{Map<String, dynamic> additionalSettings = const {}}) async {
Future<String?> tryInferringAppId(
String standardUrl, {
Map<String, dynamic> additionalSettings = const {},
}) async {
return Uri.parse(standardUrl).pathSegments.last;
}
@ -36,18 +39,16 @@ class Tencent extends AppSource {
Map<String, dynamic> additionalSettings,
) async {
String appId = (await tryInferringAppId(standardUrl))!;
String baseHost = Uri.parse(standardUrl)
.host
.split('.')
.reversed
.toList()
.sublist(0, 2)
.reversed
.join('.');
String baseHost = Uri.parse(
standardUrl,
).host.split('.').reversed.toList().sublist(0, 2).reversed.join('.');
var res = await sourceRequest(
'https://upage.html5.$baseHost/wechat-apkinfo', additionalSettings,
followRedirects: false, postBody: {"packagename": appId});
'https://upage.html5.$baseHost/wechat-apkinfo',
additionalSettings,
followRedirects: false,
postBody: {"packagename": appId},
);
if (res.statusCode == 200) {
var json = jsonDecode(res.body);
@ -64,14 +65,18 @@ class Tencent extends AppSource {
var author = json['app_detail_records'][appId]['app_info']['author'];
var releaseDate =
json['app_detail_records'][appId]['app_info']['update_time'];
var apkName = Uri.parse(apkUrl).queryParameters['fsname'] ??
var apkName =
Uri.parse(apkUrl).queryParameters['fsname'] ??
'${appId}_$version.apk';
return APKDetails(
version, [MapEntry(apkName, apkUrl)], AppNames(author, appName),
releaseDate: releaseDate != null
? DateTime.fromMillisecondsSinceEpoch(releaseDate * 1000)
: null);
version,
[MapEntry(apkName, apkUrl)],
AppNames(author, appName),
releaseDate: releaseDate != null
? DateTime.fromMillisecondsSinceEpoch(releaseDate * 1000)
: null,
);
} else {
throw getObtainiumHttpError(res);
}

View File

@ -1,9 +1,23 @@
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';
DateTime? 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 Uptodown extends AppSource {
Uptodown() {
hosts = ['uptodown.com'];
@ -16,8 +30,9 @@ class Uptodown extends AppSource {
@override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegEx = RegExp(
'^https?://([^\\.]+\\.){2,}${getSourceRegex(hosts)}',
caseSensitive: false);
'^https?://([^\\.]+\\.){2,}${getSourceRegex(hosts)}',
caseSensitive: false,
);
RegExpMatch? match = standardUrlRegEx.firstMatch(url);
if (match == null) {
throw InvalidURLError(name);
@ -26,14 +41,20 @@ class Uptodown extends AppSource {
}
@override
Future<String?> tryInferringAppId(String standardUrl,
{Map<String, dynamic> additionalSettings = const {}}) async {
Future<String?> tryInferringAppId(
String standardUrl, {
Map<String, dynamic> additionalSettings = const {},
}) async {
return (await getAppDetailsFromPage(
standardUrl, additionalSettings))['appId'];
standardUrl,
additionalSettings,
))['appId'];
}
Future<Map<String, String?>> getAppDetailsFromPage(
String standardUrl, Map<String, dynamic> additionalSettings) async {
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
var res = await sourceRequest(standardUrl, additionalSettings);
if (res.statusCode != 200) {
throw getObtainiumHttpError(res);
@ -49,8 +70,9 @@ class Uptodown extends AppSource {
.toList();
String? appId = detailElements.elementAtOrNull(0);
String? dateStr = detailElements.elementAtOrNull(6);
String? fileId =
html.querySelector('#detail-app-name')?.attributes['data-file-id'];
String? fileId = html
.querySelector('#detail-app-name')
?.attributes['data-file-id'];
String? extension = detailElements.elementAtOrNull(7)?.toLowerCase();
return Map.fromEntries([
MapEntry('version', version),
@ -59,7 +81,7 @@ class Uptodown extends AppSource {
MapEntry('author', author),
MapEntry('dateStr', dateStr),
MapEntry('fileId', fileId),
MapEntry('extension', extension)
MapEntry('extension', extension),
]);
}
@ -68,8 +90,10 @@ class Uptodown extends AppSource {
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
var appDetails =
await getAppDetailsFromPage(standardUrl, additionalSettings);
var appDetails = await getAppDetailsFromPage(
standardUrl,
additionalSettings,
);
var version = appDetails['version'];
var appId = appDetails['appId'];
var fileId = appDetails['fileId'];
@ -91,21 +115,28 @@ class Uptodown extends AppSource {
if (dateStr != null) {
relDate = parseDateTimeMMMddCommayyyy(dateStr);
}
return APKDetails(version, [MapEntry('$appId.$extension', apkUrl)],
AppNames(author, appName),
releaseDate: relDate);
return APKDetails(
version,
[MapEntry('$appId.$extension', apkUrl)],
AppNames(author, appName),
releaseDate: relDate,
);
}
@override
Future<String> apkUrlPrefetchModifier(String apkUrl, String standardUrl,
Map<String, dynamic> additionalSettings) async {
Future<String> apkUrlPrefetchModifier(
String apkUrl,
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
var res = await sourceRequest(apkUrl, additionalSettings);
if (res.statusCode != 200) {
throw getObtainiumHttpError(res);
}
var html = parse(res.body);
var finalUrlKey =
html.querySelector('#detail-download-button')?.attributes['data-url'];
var finalUrlKey = html
.querySelector('#detail-download-button')
?.attributes['data-url'];
if (finalUrlKey == null) {
throw NoAPKError();
}

View File

@ -23,15 +23,19 @@ class VivoAppStore extends AppSource {
}
@override
Future<String?> tryInferringAppId(String standardUrl,
{Map<String, dynamic> additionalSettings = const {}}) async {
Future<String?> tryInferringAppId(
String standardUrl, {
Map<String, dynamic> additionalSettings = const {},
}) async {
var json = await getDetailJson(standardUrl, additionalSettings);
return json['package_name'];
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, Map<String, dynamic> additionalSettings) async {
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
var json = await getDetailJson(standardUrl, additionalSettings);
var appName = json['title_zh'].toString();
var packageName = json['package_name'].toString();
@ -42,13 +46,18 @@ class VivoAppStore extends AppSource {
var apkUrl = json['download_url'].toString();
var apkName = '${packageName}_$versionCode.apk';
return APKDetails(
versionName, [MapEntry(apkName, apkUrl)], AppNames(developer, appName),
releaseDate: DateTime.parse(uploadTime));
versionName,
[MapEntry(apkName, apkUrl)],
AppNames(developer, appName),
releaseDate: DateTime.parse(uploadTime),
);
}
@override
Future<Map<String, List<String>>> search(String query,
{Map<String, dynamic> querySettings = const {}}) async {
Future<Map<String, List<String>>> search(
String query, {
Map<String, dynamic> querySettings = const {},
}) async {
var apiBaseUrl =
'https://h5-api.appstore.vivo.com.cn/h5appstore/search/result-list?app_version=2100&page_index=1&apps_per_page=20&target=local&cfrom=2&key=';
var searchUrl = '$apiBaseUrl${Uri.encodeQueryComponent(query)}';
@ -65,14 +74,16 @@ class VivoAppStore extends AppSource {
for (var item in (resultsJson as List<dynamic>)) {
results['$appDetailUrl${item['id']}'] = [
item['title_zh'].toString(),
item['developer'].toString()
item['developer'].toString(),
];
}
return results;
}
Future<Map<String, dynamic>> getDetailJson(
String standardUrl, Map<String, dynamic> additionalSettings) async {
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
var vivoAppId = parseVivoAppId(standardUrl);
var apiBaseUrl = 'https://h5-api.appstore.vivo.com.cn/detail/';
var params = '?frompage=messageh5&app_version=2100';

View File

@ -20,8 +20,9 @@ class _CustomAppBarState extends State<CustomAppBar> {
titlePadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
title: Text(
widget.title,
style:
TextStyle(color: Theme.of(context).textTheme.bodyMedium!.color),
style: TextStyle(
color: Theme.of(context).textTheme.bodyMedium!.color,
),
),
),
);

View File

@ -16,11 +16,13 @@ abstract class GeneratedFormItem {
dynamic ensureType(dynamic val);
GeneratedFormItem clone();
GeneratedFormItem(this.key,
{this.label = 'Input',
this.belowWidgets = const [],
this.defaultValue,
this.additionalValidators = const []});
GeneratedFormItem(
this.key, {
this.label = 'Input',
this.belowWidgets = const [],
this.defaultValue,
this.additionalValidators = const [],
});
}
class GeneratedFormTextField extends GeneratedFormItem {
@ -31,18 +33,19 @@ class GeneratedFormTextField extends GeneratedFormItem {
late TextInputType? textInputType;
late List<String>? autoCompleteOptions;
GeneratedFormTextField(super.key,
{super.label,
super.belowWidgets,
String super.defaultValue = '',
List<String? Function(String? value)> super.additionalValidators =
const [],
this.required = true,
this.max = 1,
this.hint,
this.password = false,
this.textInputType,
this.autoCompleteOptions});
GeneratedFormTextField(
super.key, {
super.label,
super.belowWidgets,
String super.defaultValue = '',
List<String? Function(String? value)> super.additionalValidators = const [],
this.required = true,
this.max = 1,
this.hint,
this.password = false,
this.textInputType,
this.autoCompleteOptions,
});
@override
String ensureType(val) {
@ -51,16 +54,18 @@ class GeneratedFormTextField extends GeneratedFormItem {
@override
GeneratedFormTextField clone() {
return GeneratedFormTextField(key,
label: label,
belowWidgets: belowWidgets,
defaultValue: defaultValue,
additionalValidators: List.from(additionalValidators),
required: required,
max: max,
hint: hint,
password: password,
textInputType: textInputType);
return GeneratedFormTextField(
key,
label: label,
belowWidgets: belowWidgets,
defaultValue: defaultValue,
additionalValidators: List.from(additionalValidators),
required: required,
max: max,
hint: hint,
password: password,
textInputType: textInputType,
);
}
}
@ -91,8 +96,9 @@ class GeneratedFormDropdown extends GeneratedFormItem {
label: label,
belowWidgets: belowWidgets,
defaultValue: defaultValue,
disabledOptKeys:
disabledOptKeys != null ? List.from(disabledOptKeys!) : null,
disabledOptKeys: disabledOptKeys != null
? List.from(disabledOptKeys!)
: null,
additionalValidators: List.from(additionalValidators),
);
}
@ -117,12 +123,14 @@ class GeneratedFormSwitch extends GeneratedFormItem {
@override
GeneratedFormSwitch clone() {
return GeneratedFormSwitch(key,
label: label,
belowWidgets: belowWidgets,
defaultValue: defaultValue,
disabled: false,
additionalValidators: List.from(additionalValidators));
return GeneratedFormSwitch(
key,
label: label,
belowWidgets: belowWidgets,
defaultValue: defaultValue,
disabled: false,
additionalValidators: List.from(additionalValidators),
);
}
}
@ -132,17 +140,20 @@ class GeneratedFormTagInput extends GeneratedFormItem {
late WrapAlignment alignment;
late String emptyMessage;
late bool showLabelWhenNotEmpty;
GeneratedFormTagInput(super.key,
{super.label,
super.belowWidgets,
Map<String, MapEntry<int, bool>> super.defaultValue = const {},
List<String? Function(Map<String, MapEntry<int, bool>> value)>
super.additionalValidators = const [],
this.deleteConfirmationMessage,
this.singleSelect = false,
this.alignment = WrapAlignment.start,
this.emptyMessage = 'Input',
this.showLabelWhenNotEmpty = true});
GeneratedFormTagInput(
super.key, {
super.label,
super.belowWidgets,
Map<String, MapEntry<int, bool>> super.defaultValue = const {},
List<String? Function(Map<String, MapEntry<int, bool>> value)>
super.additionalValidators =
const [],
this.deleteConfirmationMessage,
this.singleSelect = false,
this.alignment = WrapAlignment.start,
this.emptyMessage = 'Input',
this.showLabelWhenNotEmpty = true,
});
@override
Map<String, MapEntry<int, bool>> ensureType(val) {
@ -151,25 +162,30 @@ class GeneratedFormTagInput extends GeneratedFormItem {
@override
GeneratedFormTagInput clone() {
return GeneratedFormTagInput(key,
label: label,
belowWidgets: belowWidgets,
defaultValue: defaultValue,
additionalValidators: List.from(additionalValidators),
deleteConfirmationMessage: deleteConfirmationMessage,
singleSelect: singleSelect,
alignment: alignment,
emptyMessage: emptyMessage,
showLabelWhenNotEmpty: showLabelWhenNotEmpty);
return GeneratedFormTagInput(
key,
label: label,
belowWidgets: belowWidgets,
defaultValue: defaultValue,
additionalValidators: List.from(additionalValidators),
deleteConfirmationMessage: deleteConfirmationMessage,
singleSelect: singleSelect,
alignment: alignment,
emptyMessage: emptyMessage,
showLabelWhenNotEmpty: showLabelWhenNotEmpty,
);
}
}
typedef OnValueChanges = void Function(
Map<String, dynamic> values, bool valid, bool isBuilding);
typedef OnValueChanges =
void Function(Map<String, dynamic> values, bool valid, bool isBuilding);
class GeneratedForm extends StatefulWidget {
const GeneratedForm(
{super.key, required this.items, required this.onValueChanges});
const GeneratedForm({
super.key,
required this.items,
required this.onValueChanges,
});
final List<List<GeneratedFormItem>> items;
final OnValueChanges onValueChanges;
@ -179,7 +195,8 @@ class GeneratedForm extends StatefulWidget {
}
List<List<GeneratedFormItem>> cloneFormItems(
List<List<GeneratedFormItem>> items) {
List<List<GeneratedFormItem>> items,
) {
List<List<GeneratedFormItem>> clonedItems = [];
for (var row in items) {
List<GeneratedFormItem> clonedRow = [];
@ -194,8 +211,13 @@ List<List<GeneratedFormItem>> cloneFormItems(
class GeneratedFormSubForm extends GeneratedFormItem {
final List<List<GeneratedFormItem>> items;
GeneratedFormSubForm(super.key, this.items,
{super.label, super.belowWidgets, super.defaultValue = const []});
GeneratedFormSubForm(
super.key,
this.items, {
super.label,
super.belowWidgets,
super.defaultValue = const [],
});
@override
ensureType(val) {
@ -204,8 +226,13 @@ class GeneratedFormSubForm extends GeneratedFormItem {
@override
GeneratedFormSubForm clone() {
return GeneratedFormSubForm(key, cloneFormItems(items),
label: label, belowWidgets: belowWidgets, defaultValue: defaultValue);
return GeneratedFormSubForm(
key,
cloneFormItems(items),
label: label,
belowWidgets: belowWidgets,
defaultValue: defaultValue,
);
}
}
@ -220,13 +247,18 @@ Color generateRandomLightColor() {
// Map from HPLuv color space to RGB, use constant saturation=100, lightness=70
final List<double> rgbValuesDbl = Hsluv.hpluvToRgb([hue, 100, 70]);
// Map RBG values from 0-1 to 0-255:
final List<int> rgbValues =
rgbValuesDbl.map((rgb) => (rgb * 255).toInt()).toList();
final List<int> rgbValues = rgbValuesDbl
.map((rgb) => (rgb * 255).toInt())
.toList();
return Color.fromARGB(255, rgbValues[0], rgbValues[1], rgbValues[2]);
}
int generateRandomNumber(int seed1,
{int seed2 = 0, int seed3 = 0, max = 10000}) {
int generateRandomNumber(
int seed1, {
int seed2 = 0,
int seed3 = 0,
max = 10000,
}) {
int combinedSeed = seed1.hashCode ^ seed2.hashCode ^ seed3.hashCode;
Random random = Random(combinedSeed);
int randomNumber = random.nextInt(max);
@ -261,7 +293,7 @@ class _GeneratedFormState extends State<GeneratedForm> {
widget.onValueChanges(returnValues, valid, isBuilding);
}
initForm() {
void initForm() {
initKey = widget.key.toString();
// Initialize form values as all empty
values.clear();
@ -297,9 +329,9 @@ class _GeneratedFormState extends State<GeneratedForm> {
});
},
decoration: InputDecoration(
helperText:
formItem.label + (formItem.required ? ' *' : ''),
hintText: formItem.hint),
helperText: formItem.label + (formItem.required ? ' *' : ''),
hintText: formItem.hint,
),
minLines: formItem.max <= 1 ? null : formItem.max,
maxLines: formItem.max <= 1 ? 1 : formItem.max,
validator: (value) {
@ -339,23 +371,26 @@ class _GeneratedFormState extends State<GeneratedForm> {
return Text(tr('dropdownNoOptsError'));
}
return DropdownButtonFormField(
decoration: InputDecoration(labelText: formItem.label),
value: values[formItem.key],
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;
someValueChanged();
});
decoration: InputDecoration(labelText: formItem.label),
value: values[formItem.key],
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;
someValueChanged();
});
},
);
} else if (formItem is GeneratedFormSubForm) {
values[formItem.key] = [];
for (Map<String, dynamic> v
@ -394,33 +429,33 @@ class _GeneratedFormState extends State<GeneratedForm> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(child: Text(widget.items[r][e].label)),
const SizedBox(
width: 8,
),
const SizedBox(width: 8),
Switch(
value: values[fieldKey],
onChanged:
(widget.items[r][e] as GeneratedFormSwitch).disabled
? null
: (value) {
setState(() {
values[fieldKey] = value;
someValueChanged();
});
})
value: values[fieldKey],
onChanged: (widget.items[r][e] as GeneratedFormSwitch).disabled
? null
: (value) {
setState(() {
values[fieldKey] = value;
someValueChanged();
});
},
),
],
);
} else if (widget.items[r][e] is GeneratedFormTagInput) {
onAddPressed() {
showDialog<Map<String, dynamic>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: widget.items[r][e].label,
items: [
[GeneratedFormTextField('label', label: tr('label'))]
]);
}).then((value) {
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: widget.items[r][e].label,
items: [
[GeneratedFormTextField('label', label: tr('label'))],
],
);
},
).then((value) {
String? label = value?['label'];
if (label != null) {
setState(() {
@ -434,8 +469,10 @@ class _GeneratedFormState extends State<GeneratedForm> {
var someSelected = temp.entries
.where((element) => element.value.value)
.isNotEmpty;
temp[label] = MapEntry(generateRandomLightColor().value,
!(someSelected && singleSelect));
temp[label] = MapEntry(
generateRandomLightColor().value,
!(someSelected && singleSelect),
);
values[fieldKey] = temp;
someValueChanged();
}
@ -444,236 +481,274 @@ class _GeneratedFormState extends State<GeneratedForm> {
});
}
formInputs[r][e] =
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
if ((values[fieldKey] as Map<String, MapEntry<int, bool>>?)
?.isNotEmpty ==
true &&
(widget.items[r][e] as GeneratedFormTagInput)
.showLabelWhenNotEmpty)
Column(
crossAxisAlignment:
(widget.items[r][e] as GeneratedFormTagInput).alignment ==
WrapAlignment.center
? CrossAxisAlignment.center
: CrossAxisAlignment.stretch,
formInputs[r][e] = Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if ((values[fieldKey] as Map<String, MapEntry<int, bool>>?)
?.isNotEmpty ==
true &&
(widget.items[r][e] as GeneratedFormTagInput)
.showLabelWhenNotEmpty)
Column(
crossAxisAlignment:
(widget.items[r][e] as GeneratedFormTagInput).alignment ==
WrapAlignment.center
? CrossAxisAlignment.center
: CrossAxisAlignment.stretch,
children: [
Text(widget.items[r][e].label),
const SizedBox(height: 8),
],
),
Wrap(
alignment:
(widget.items[r][e] as GeneratedFormTagInput).alignment,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(widget.items[r][e].label),
const SizedBox(
height: 8,
),
],
),
Wrap(
alignment:
(widget.items[r][e] as GeneratedFormTagInput).alignment,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
// (values[fieldKey] as Map<String, MapEntry<int, bool>>?)
// ?.isEmpty ==
// true
// ? Text(
// (widget.items[r][e] as GeneratedFormTagInput)
// .emptyMessage,
// )
// : const SizedBox.shrink(),
...(values[fieldKey] as Map<String, MapEntry<int, bool>>?)
?.entries
.map((e2) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: ChoiceChip(
label: Text(e2.key),
backgroundColor: Color(e2.value.key).withAlpha(50),
selectedColor: Color(e2.value.key),
visualDensity: VisualDensity.compact,
selected: e2.value.value,
onSelected: (value) {
setState(() {
(values[fieldKey] as Map<String,
MapEntry<int, bool>>)[e2.key] =
MapEntry(
(values[fieldKey] as Map<String,
MapEntry<int, bool>>)[e2.key]!
.key,
value);
if ((widget.items[r][e]
as GeneratedFormTagInput)
.singleSelect &&
value == true) {
for (var key in (values[fieldKey]
as Map<String, MapEntry<int, bool>>)
.keys) {
if (key != e2.key) {
(values[fieldKey] as Map<
String,
MapEntry<int,
bool>>)[key] = MapEntry(
(values[fieldKey] as Map<String,
MapEntry<int, bool>>)[key]!
.key,
false);
// (values[fieldKey] as Map<String, MapEntry<int, bool>>?)
// ?.isEmpty ==
// true
// ? Text(
// (widget.items[r][e] as GeneratedFormTagInput)
// .emptyMessage,
// )
// : const SizedBox.shrink(),
...(values[fieldKey] as Map<String, MapEntry<int, bool>>?)
?.entries
.map((e2) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
),
child: ChoiceChip(
label: Text(e2.key),
backgroundColor: Color(
e2.value.key,
).withAlpha(50),
selectedColor: Color(e2.value.key),
visualDensity: VisualDensity.compact,
selected: e2.value.value,
onSelected: (value) {
setState(() {
(values[fieldKey]
as Map<String, MapEntry<int, bool>>)[e2
.key] = MapEntry(
(values[fieldKey]
as Map<
String,
MapEntry<int, bool>
>)[e2.key]!
.key,
value,
);
if ((widget.items[r][e]
as GeneratedFormTagInput)
.singleSelect &&
value == true) {
for (var key
in (values[fieldKey]
as Map<
String,
MapEntry<int, bool>
>)
.keys) {
if (key != e2.key) {
(values[fieldKey]
as Map<
String,
MapEntry<int, bool>
>)[key] = MapEntry(
(values[fieldKey]
as Map<
String,
MapEntry<int, bool>
>)[key]!
.key,
false,
);
}
}
}
}
}
someValueChanged();
});
},
));
}) ??
[const SizedBox.shrink()],
(values[fieldKey] as Map<String, MapEntry<int, bool>>?)
?.values
.where((e) => e.value)
.length ==
1
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: IconButton(
onPressed: () {
setState(() {
var temp = values[fieldKey]
as Map<String, MapEntry<int, bool>>;
// 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[fieldKey] = temp;
someValueChanged();
});
},
icon: const Icon(Icons.format_color_fill_rounded),
visualDensity: VisualDensity.compact,
tooltip: tr('colour'),
))
: const SizedBox.shrink(),
(values[fieldKey] as Map<String, MapEntry<int, bool>>?)
?.values
.where((e) => e.value)
.isNotEmpty ==
true
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: IconButton(
onPressed: () {
fn() {
someValueChanged();
});
},
),
);
}) ??
[const SizedBox.shrink()],
(values[fieldKey] as Map<String, MapEntry<int, bool>>?)
?.values
.where((e) => e.value)
.length ==
1
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: IconButton(
onPressed: () {
setState(() {
var temp = values[fieldKey]
as Map<String, MapEntry<int, bool>>;
temp.removeWhere((key, value) => value.value);
var temp =
values[fieldKey]
as Map<String, MapEntry<int, bool>>;
// 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[fieldKey] = temp;
someValueChanged();
});
}
},
icon: const Icon(Icons.format_color_fill_rounded),
visualDensity: VisualDensity.compact,
tooltip: tr('colour'),
),
)
: const SizedBox.shrink(),
(values[fieldKey] as Map<String, MapEntry<int, bool>>?)
?.values
.where((e) => e.value)
.isNotEmpty ==
true
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: IconButton(
onPressed: () {
fn() {
setState(() {
var temp =
values[fieldKey]
as Map<String, MapEntry<int, bool>>;
temp.removeWhere((key, value) => value.value);
values[fieldKey] = temp;
someValueChanged();
});
}
if ((widget.items[r][e] as GeneratedFormTagInput)
.deleteConfirmationMessage !=
null) {
var message =
(widget.items[r][e] as GeneratedFormTagInput)
.deleteConfirmationMessage!;
showDialog<Map<String, dynamic>?>(
if ((widget.items[r][e] as GeneratedFormTagInput)
.deleteConfirmationMessage !=
null) {
var message =
(widget.items[r][e]
as GeneratedFormTagInput)
.deleteConfirmationMessage!;
showDialog<Map<String, dynamic>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: message.key,
message: message.value,
items: const []);
}).then((value) {
if (value != null) {
fn();
}
});
} else {
fn();
}
},
icon: const Icon(Icons.remove),
visualDensity: VisualDensity.compact,
tooltip: tr('remove'),
))
: const SizedBox.shrink(),
(values[fieldKey] as Map<String, MapEntry<int, bool>>?)
?.isEmpty ==
true
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: TextButton.icon(
onPressed: onAddPressed,
icon: const Icon(Icons.add),
label: Text(
title: message.key,
message: message.value,
items: const [],
);
},
).then((value) {
if (value != null) {
fn();
}
});
} else {
fn();
}
},
icon: const Icon(Icons.remove),
visualDensity: VisualDensity.compact,
tooltip: tr('remove'),
),
)
: const SizedBox.shrink(),
(values[fieldKey] as Map<String, MapEntry<int, bool>>?)
?.isEmpty ==
true
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: TextButton.icon(
onPressed: onAddPressed,
icon: const Icon(Icons.add),
label: Text(
(widget.items[r][e] as GeneratedFormTagInput)
.label),
))
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: IconButton(
onPressed: onAddPressed,
icon: const Icon(Icons.add),
visualDensity: VisualDensity.compact,
tooltip: tr('add'),
)),
],
)
]);
.label,
),
),
)
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: IconButton(
onPressed: onAddPressed,
icon: const Icon(Icons.add),
visualDensity: VisualDensity.compact,
tooltip: tr('add'),
),
),
],
),
],
);
} else if (widget.items[r][e] is GeneratedFormSubForm) {
List<Widget> subformColumn = [];
var compact = (widget.items[r][e] as GeneratedFormSubForm)
.items
.length ==
1 &&
var compact =
(widget.items[r][e] as GeneratedFormSubForm).items.length == 1 &&
(widget.items[r][e] as GeneratedFormSubForm).items[0].length == 1;
for (int i = 0; i < values[fieldKey].length; i++) {
var internalFormKey = ValueKey(generateRandomNumber(
var internalFormKey = ValueKey(
generateRandomNumber(
values[fieldKey].length,
seed2: i,
seed3: forceUpdateKeyCount));
subformColumn.add(Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!compact)
const SizedBox(
height: 16,
seed3: forceUpdateKeyCount,
),
);
subformColumn.add(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!compact) const SizedBox(height: 16),
if (!compact)
Text(
'${(widget.items[r][e] as GeneratedFormSubForm).label} (${i + 1})',
style: const TextStyle(fontWeight: FontWeight.bold),
),
GeneratedForm(
key: internalFormKey,
items:
cloneFormItems(
(widget.items[r][e] as GeneratedFormSubForm)
.items,
)
.map(
(x) => x.map((y) {
y.defaultValue = values[fieldKey]?[i]?[y.key];
y.key = '${y.key.toString()},$internalFormKey';
return y;
}).toList(),
)
.toList(),
onValueChanges: (values, valid, isBuilding) {
values = values.map(
(key, value) => MapEntry(key.split(',')[0], value),
);
if (valid) {
this.values[fieldKey]?[i] = values;
}
someValueChanged(
isBuilding: isBuilding,
forceInvalid: !valid,
);
},
),
if (!compact)
Text(
'${(widget.items[r][e] as GeneratedFormSubForm).label} (${i + 1})',
style: const TextStyle(fontWeight: FontWeight.bold),
),
GeneratedForm(
key: internalFormKey,
items: cloneFormItems(
(widget.items[r][e] as GeneratedFormSubForm).items)
.map((x) => x.map((y) {
y.defaultValue = values[fieldKey]?[i]?[y.key];
y.key = '${y.key.toString()},$internalFormKey';
return y;
}).toList())
.toList(),
onValueChanges: (values, valid, isBuilding) {
values = values.map(
(key, value) => MapEntry(key.split(',')[0], value));
if (valid) {
this.values[fieldKey]?[i] = values;
}
someValueChanged(
isBuilding: isBuilding, forceInvalid: !valid);
},
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
style: TextButton.styleFrom(
foregroundColor:
Theme.of(context).colorScheme.error),
foregroundColor: Theme.of(context).colorScheme.error,
),
onPressed: (values[fieldKey].length > 0)
? () {
var temp = List.from(values[fieldKey]);
@ -686,33 +761,40 @@ class _GeneratedFormState extends State<GeneratedForm> {
label: Text(
'${(widget.items[r][e] as GeneratedFormSubForm).label} (${i + 1})',
),
icon: const Icon(
Icons.delete_outline_rounded,
))
],
)
],
));
icon: const Icon(Icons.delete_outline_rounded),
),
],
),
],
),
);
}
subformColumn.add(Padding(
padding: const EdgeInsets.only(bottom: 0, top: 8),
child: Row(
children: [
Expanded(
subformColumn.add(
Padding(
padding: const EdgeInsets.only(bottom: 0, top: 8),
child: Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () {
values[fieldKey].add(getDefaultValuesFromFormItems(
(widget.items[r][e] as GeneratedFormSubForm)
.items));
forceUpdateKeyCount++;
someValueChanged();
},
icon: const Icon(Icons.add),
label: Text((widget.items[r][e] as GeneratedFormSubForm)
.label))),
],
onPressed: () {
values[fieldKey].add(
getDefaultValuesFromFormItems(
(widget.items[r][e] as GeneratedFormSubForm).items,
),
);
forceUpdateKeyCount++;
someValueChanged();
},
icon: const Icon(Icons.add),
label: Text(
(widget.items[r][e] as GeneratedFormSubForm).label,
),
),
),
],
),
),
));
);
formInputs[r][e] = Column(children: subformColumn);
}
}
@ -726,38 +808,43 @@ class _GeneratedFormState extends State<GeneratedForm> {
height: widget.items[rowInputs.key - 1][0] is GeneratedFormSwitch
? 8
: 25,
)
),
]);
}
List<Widget> rowItems = [];
rowInputs.value.asMap().entries.forEach((rowInput) {
if (rowInput.key > 0) {
rowItems.add(const SizedBox(
width: 20,
));
rowItems.add(const SizedBox(width: 20));
}
rowItems.add(Expanded(
rowItems.add(
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
rowInput.value,
...widget.items[rowInputs.key][rowInput.key].belowWidgets
])));
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
rowInput.value,
...widget.items[rowInputs.key][rowInput.key].belowWidgets,
],
),
),
);
});
rows.add(rowItems);
});
return Form(
key: _formKey,
child: Column(
children: [
...rows.map((row) => Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [...row.map((e) => e)],
))
],
));
key: _formKey,
child: Column(
children: [
...rows.map(
(row) => Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [...row.map((e) => e)],
),
),
],
),
);
}
}

View File

@ -4,15 +4,16 @@ import 'package:flutter/services.dart';
import 'package:obtainium/components/generated_form.dart';
class GeneratedFormModal extends StatefulWidget {
const GeneratedFormModal(
{super.key,
required this.title,
required this.items,
this.initValid = false,
this.message = '',
this.additionalWidgets = const [],
this.singleNullReturnButton,
this.primaryActionColour});
const GeneratedFormModal({
super.key,
required this.title,
required this.items,
this.initValid = false,
this.message = '',
this.additionalWidgets = const [],
this.singleNullReturnButton,
this.primaryActionColour,
});
final String title;
final String message;
@ -41,14 +42,12 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
return AlertDialog(
scrollable: true,
title: Text(widget.title),
content:
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
if (widget.message.isNotEmpty) Text(widget.message),
if (widget.message.isNotEmpty)
const SizedBox(
height: 16,
),
GeneratedForm(
content: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.message.isNotEmpty) Text(widget.message),
if (widget.message.isNotEmpty) const SizedBox(height: 16),
GeneratedForm(
items: widget.items,
onValueChanges: (values, valid, isBuilding) {
if (isBuilding) {
@ -60,23 +59,29 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
this.valid = valid;
});
}
}),
if (widget.additionalWidgets.isNotEmpty) ...widget.additionalWidgets
]),
},
),
if (widget.additionalWidgets.isNotEmpty) ...widget.additionalWidgets,
],
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(null);
},
child: Text(widget.singleNullReturnButton == null
onPressed: () {
Navigator.of(context).pop(null);
},
child: Text(
widget.singleNullReturnButton == null
? tr('cancel')
: widget.singleNullReturnButton!)),
: widget.singleNullReturnButton!,
),
),
widget.singleNullReturnButton == null
? TextButton(
style: widget.primaryActionColour == null
? null
: TextButton.styleFrom(
foregroundColor: widget.primaryActionColour),
foregroundColor: widget.primaryActionColour,
),
onPressed: !valid
? null
: () {
@ -85,8 +90,9 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
Navigator.of(context).pop(values);
}
},
child: Text(tr('continue')))
: const SizedBox.shrink()
child: Text(tr('continue')),
)
: const SizedBox.shrink(),
],
);
}

View File

@ -20,23 +20,24 @@ class ObtainiumError {
class RateLimitError extends ObtainiumError {
late int remainingMinutes;
RateLimitError(this.remainingMinutes)
: super(plural('tooManyRequestsTryAgainInMinutes', remainingMinutes));
: super(plural('tooManyRequestsTryAgainInMinutes', remainingMinutes));
}
class InvalidURLError extends ObtainiumError {
InvalidURLError(String sourceName)
: super(tr('invalidURLForSource', args: [sourceName]));
: super(tr('invalidURLForSource', args: [sourceName]));
}
class CredsNeededError extends ObtainiumError {
CredsNeededError(String sourceName)
: super(tr('requiresCredentialsInSettings', args: [sourceName]));
: super(tr('requiresCredentialsInSettings', args: [sourceName]));
}
class NoReleasesError extends ObtainiumError {
NoReleasesError({String? note})
: super(
'${tr('noReleaseFound')}${note?.isNotEmpty == true ? '\n\n$note' : ''}');
: super(
'${tr('noReleaseFound')}${note?.isNotEmpty == true ? '\n\n$note' : ''}',
);
}
class NoAPKError extends ObtainiumError {
@ -57,7 +58,7 @@ class DowngradeError extends ObtainiumError {
class InstallError extends ObtainiumError {
InstallError(int code)
: super(PackageInstallerStatus.byCode(code).name.substring(7));
: super(PackageInstallerStatus.byCode(code).name.substring(7));
}
class IDChangedError extends ObtainiumError {
@ -75,7 +76,7 @@ class MultiAppMultiError extends ObtainiumError {
MultiAppMultiError() : super(tr('placeholder'), unexpected: true);
add(String appId, dynamic error, {String? appName}) {
void add(String appId, dynamic error, {String? appName}) {
if (error is SocketException) {
error = error.message;
}
@ -93,8 +94,11 @@ class MultiAppMultiError extends ObtainiumError {
String errorString(String appId, {bool includeIdsWithNames = false}) =>
'${appIdNames.containsKey(appId) ? '${appIdNames[appId]}${includeIdsWithNames ? ' ($appId)' : ''}' : appId}: ${rawErrors[appId].toString()}';
String errorsAppsString(String errString, List<String> appIds,
{bool includeIdsWithNames = false}) =>
String errorsAppsString(
String errString,
List<String> appIds, {
bool includeIdsWithNames = false,
}) =>
'$errString [${list2FriendlyString(appIds.map((id) => appIdNames.containsKey(id) == true ? '${appIdNames[id]}${includeIdsWithNames ? ' ($id)' : ''}' : id).toList())}]';
@override
@ -103,43 +107,50 @@ class MultiAppMultiError extends ObtainiumError {
.join('\n\n');
}
showMessage(dynamic e, BuildContext context, {bool isError = false}) {
Provider.of<LogsProvider>(context, listen: false)
.add(e.toString(), level: isError ? LogLevels.error : LogLevels.info);
void showMessage(dynamic e, BuildContext context, {bool isError = false}) {
Provider.of<LogsProvider>(
context,
listen: false,
).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())),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(e.toString())));
} else {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
scrollable: true,
title: Text(e is MultiAppMultiError
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
scrollable: true,
title: Text(
e is MultiAppMultiError
? 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: () {
Navigator.of(context).pop(null);
},
child: Text(tr('ok'))),
],
);
});
: 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: () {
Navigator.of(context).pop(null);
},
child: Text(tr('ok')),
),
],
);
},
);
}
}
showError(dynamic e, BuildContext context) {
void showError(dynamic e, BuildContext context) {
showMessage(e, context, isError: true);
}
@ -147,14 +158,16 @@ String list2FriendlyString(List<String> list) {
return list.length == 2
? '${list[0]} ${tr('and')} ${list[1]}'
: list
.asMap()
.entries
.map((e) =>
e.value +
(e.key == list.length - 1
? ''
: e.key == list.length - 2
.asMap()
.entries
.map(
(e) =>
e.value +
(e.key == list.length - 1
? ''
: e.key == list.length - 2
? ' and '
: ', '))
.join('');
: ', '),
)
.join('');
}

View File

@ -43,11 +43,14 @@ List<MapEntry<Locale, String>> supportedLocales = const [
MapEntry(Locale('tr'), 'Türkçe'),
MapEntry(Locale('uk'), 'Українська'),
MapEntry(Locale('da'), 'Dansk'),
MapEntry(Locale('en', 'EO'),
'Esperanto'), // https://github.com/aissat/easy_localization/issues/220#issuecomment-846035493
MapEntry(
Locale('en', 'EO'),
'Esperanto',
), // https://github.com/aissat/easy_localization/issues/220#issuecomment-846035493
MapEntry(Locale('in'), 'Bahasa Indonesia'),
MapEntry(Locale('ko'), '한국어'),
MapEntry(Locale('ca'), 'Català'),
MapEntry(Locale('ar'), 'العربية'),
];
const fallbackLocale = Locale('en');
const localeDir = 'assets/translations';
@ -75,9 +78,11 @@ Future<void> loadTranslations() async {
},
);
await controller.loadTranslations();
Localization.load(controller.locale,
translations: controller.translations,
fallbackTranslations: controller.fallbackTranslations);
Localization.load(
controller.locale,
translations: controller.translations,
fallbackTranslations: controller.fallbackTranslations,
);
}
@pragma('vm:entry-point')
@ -96,10 +101,12 @@ void backgroundFetchHeadlessTask(HeadlessTask task) async {
void main() async {
WidgetsFlutterBinding.ensureInitialized();
try {
ByteData data =
await PlatformAssetBundle().load('assets/ca/lets-encrypt-r3.pem');
SecurityContext.defaultContext
.setTrustedCertificatesBytes(data.buffer.asUint8List());
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)
}
@ -112,20 +119,23 @@ void main() async {
}
final np = NotificationsProvider();
await np.initialize();
runApp(MultiProvider(
providers: [
ChangeNotifierProvider(create: (context) => AppsProvider()),
ChangeNotifierProvider(create: (context) => SettingsProvider()),
Provider(create: (context) => np),
Provider(create: (context) => LogsProvider())
],
child: EasyLocalization(
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (context) => AppsProvider()),
ChangeNotifierProvider(create: (context) => SettingsProvider()),
Provider(create: (context) => np),
Provider(create: (context) => LogsProvider()),
],
child: EasyLocalization(
supportedLocales: supportedLocales.map((e) => e.key).toList(),
path: localeDir,
fallbackLocale: fallbackLocale,
useOnlyLangCode: false,
child: const Obtainium()),
));
child: const Obtainium(),
),
),
);
BackgroundFetch.registerHeadlessTask(backgroundFetchHeadlessTask);
}
@ -147,22 +157,26 @@ class _ObtainiumState extends State<Obtainium> {
Future<void> initPlatformState() async {
await BackgroundFetch.configure(
BackgroundFetchConfig(
minimumFetchInterval: 15,
stopOnTerminate: false,
startOnBoot: true,
enableHeadless: true,
requiresBatteryNotLow: false,
requiresCharging: false,
requiresStorageNotLow: false,
requiresDeviceIdle: false,
requiredNetworkType: NetworkType.ANY), (String taskId) async {
await bgUpdateCheck(taskId, null);
BackgroundFetch.finish(taskId);
}, (String taskId) async {
context.read<LogsProvider>().add('BG update task timed out.');
BackgroundFetch.finish(taskId);
});
BackgroundFetchConfig(
minimumFetchInterval: 15,
stopOnTerminate: false,
startOnBoot: true,
enableHeadless: true,
requiresBatteryNotLow: false,
requiresCharging: false,
requiresStorageNotLow: false,
requiresDeviceIdle: false,
requiredNetworkType: NetworkType.ANY,
),
(String taskId) async {
await bgUpdateCheck(taskId, null);
BackgroundFetch.finish(taskId);
},
(String taskId) async {
context.read<LogsProvider>().add('BG update task timed out.');
BackgroundFetch.finish(taskId);
},
);
if (!mounted) return;
}
@ -182,30 +196,33 @@ class _ObtainiumState extends State<Obtainium> {
// If this is the first run, ask for notification permissions and add Obtainium to the Apps list
Permission.notification.request();
if (!fdroid) {
getInstalledInfo(obtainiumId).then((value) {
if (value?.versionName != null) {
appsProvider.saveApps([
App(
obtainiumId,
obtainiumUrl,
'ImranR98',
'Obtainium',
value!.versionName,
value.versionName!,
[],
0,
{
'versionDetection': true,
'apkFilterRegEx': 'fdroid',
'invertAPKFilter': true
},
null,
false)
], onlyIfExists: false);
}
}).catchError((err) {
print(err);
});
getInstalledInfo(obtainiumId)
.then((value) {
if (value?.versionName != null) {
appsProvider.saveApps([
App(
obtainiumId,
obtainiumUrl,
'ImranR98',
'Obtainium',
value!.versionName,
value.versionName!,
[],
0,
{
'versionDetection': true,
'apkFilterRegEx': 'fdroid',
'invertAPKFilter': true,
},
null,
false,
),
], onlyIfExists: false);
}
})
.catchError((err) {
print(err);
});
}
}
if (!supportedLocales.map((e) => e.key).contains(context.locale) ||
@ -220,32 +237,35 @@ class _ObtainiumState extends State<Obtainium> {
});
return DynamicColorBuilder(
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
// Decide on a colour/brightness scheme based on OS and user settings
ColorScheme lightColorScheme;
ColorScheme darkColorScheme;
if (lightDynamic != null &&
darkDynamic != null &&
settingsProvider.useMaterialYou) {
lightColorScheme = lightDynamic.harmonized();
darkColorScheme = darkDynamic.harmonized();
} else {
lightColorScheme =
ColorScheme.fromSeed(seedColor: settingsProvider.themeColor);
darkColorScheme = ColorScheme.fromSeed(
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
// Decide on a colour/brightness scheme based on OS and user settings
ColorScheme lightColorScheme;
ColorScheme darkColorScheme;
if (lightDynamic != null &&
darkDynamic != null &&
settingsProvider.useMaterialYou) {
lightColorScheme = lightDynamic.harmonized();
darkColorScheme = darkDynamic.harmonized();
} else {
lightColorScheme = ColorScheme.fromSeed(
seedColor: settingsProvider.themeColor,
brightness: Brightness.dark);
}
);
darkColorScheme = ColorScheme.fromSeed(
seedColor: settingsProvider.themeColor,
brightness: Brightness.dark,
);
}
// set the background and surface colors to pure black in the amoled theme
if (settingsProvider.useBlackTheme) {
darkColorScheme =
darkColorScheme.copyWith(surface: Colors.black).harmonized();
}
// set the background and surface colors to pure black in the amoled theme
if (settingsProvider.useBlackTheme) {
darkColorScheme = darkColorScheme
.copyWith(surface: Colors.black)
.harmonized();
}
if (settingsProvider.useSystemFont) NativeFeatures.loadSystemFont();
if (settingsProvider.useSystemFont) NativeFeatures.loadSystemFont();
return MaterialApp(
return MaterialApp(
title: 'Obtainium',
localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales,
@ -253,22 +273,31 @@ class _ObtainiumState extends State<Obtainium> {
navigatorKey: globalNavigatorKey,
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorScheme: settingsProvider.theme == ThemeSettings.dark
? darkColorScheme
: lightColorScheme,
fontFamily:
settingsProvider.useSystemFont ? 'SystemFont' : 'Montserrat'),
useMaterial3: true,
colorScheme: settingsProvider.theme == ThemeSettings.dark
? darkColorScheme
: lightColorScheme,
fontFamily: settingsProvider.useSystemFont
? 'SystemFont'
: 'Montserrat',
),
darkTheme: ThemeData(
useMaterial3: true,
colorScheme: settingsProvider.theme == ThemeSettings.light
? lightColorScheme
: darkColorScheme,
fontFamily:
settingsProvider.useSystemFont ? 'SystemFont' : 'Montserrat'),
home: Shortcuts(shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.select): const ActivateIntent(),
}, child: const HomePage()));
});
useMaterial3: true,
colorScheme: settingsProvider.theme == ThemeSettings.light
? lightColorScheme
: darkColorScheme,
fontFamily: settingsProvider.useSystemFont
? 'SystemFont'
: 'Montserrat',
),
home: Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.select): const ActivateIntent(),
},
child: const HomePage(),
),
);
},
);
}
}

View File

@ -3,4 +3,4 @@ import 'main.dart' as m;
void main() async {
m.fdroid = true;
m.main();
}
}

View File

@ -14,11 +14,15 @@ class GitHubStars implements MassAppUrlSource {
late List<String> requiredArgs = [tr('uname')];
Future<Map<String, List<String>>> getOnePageOfUserStarredUrlsWithDescriptions(
String username, int page) async {
String username,
int page,
) async {
Response res = await get(
Uri.parse(
'https://api.github.com/users/$username/starred?per_page=100&page=$page'),
headers: await GitHub().getRequestHeaders({}));
Uri.parse(
'https://api.github.com/users/$username/starred?per_page=100&page=$page',
),
headers: await GitHub().getRequestHeaders({}),
);
if (res.statusCode == 200) {
Map<String, List<String>> urlsWithDescriptions = {};
for (var e in (jsonDecode(res.body) as List<dynamic>)) {
@ -27,8 +31,8 @@ class GitHubStars implements MassAppUrlSource {
e['full_name'] as String,
e['description'] != null
? e['description'] as String
: tr('noDescription')
]
: tr('noDescription'),
],
});
}
return urlsWithDescriptions;
@ -41,15 +45,18 @@ class GitHubStars implements MassAppUrlSource {
@override
Future<Map<String, List<String>>> getUrlsWithDescriptions(
List<String> args) async {
List<String> args,
) async {
if (args.length != requiredArgs.length) {
throw ObtainiumError(tr('wrongArgNum'));
}
Map<String, List<String>> urlsWithDescriptions = {};
var page = 1;
while (true) {
var pageUrls =
await getOnePageOfUserStarredUrlsWithDescriptions(args[0], page++);
var pageUrls = await getOnePageOfUserStarredUrlsWithDescriptions(
args[0],
page++,
);
urlsWithDescriptions.addAll(pageUrls);
if (pageUrls.length < 100) {
break;

File diff suppressed because it is too large Load Diff

View File

@ -40,13 +40,15 @@ class _AppPageState extends State<AppPage> {
onWebResourceError: (WebResourceError error) {
if (error.isForMainFrame == true) {
showError(
ObtainiumError(error.description, unexpected: true), context);
ObtainiumError(error.description, unexpected: true),
context,
);
}
},
onNavigationRequest: (NavigationRequest request) =>
request.url.startsWith("rustore://")
? NavigationDecision.prevent
: NavigationDecision.navigate,
? NavigationDecision.prevent
: NavigationDecision.navigate,
),
);
}
@ -85,8 +87,10 @@ class _AppPageState extends State<AppPage> {
var sourceProvider = SourceProvider();
AppInMemory? app = appsProvider.apps[widget.appId]?.deepCopy();
var source = app != null
? sourceProvider.getSource(app.app.url,
overrideSource: app.app.overrideSource)
? sourceProvider.getSource(
app.app.url,
overrideSource: app.app.overrideSource,
)
: null;
if (!areDownloadsRunning &&
prevApp == null &&
@ -100,9 +104,9 @@ class _AppPageState extends State<AppPage> {
bool isVersionDetectionStandard =
app?.app.additionalSettings['versionDetection'] == true;
bool installedVersionIsEstimate = trackOnly ||
(app?.app.installedVersion != null &&
app?.app.additionalSettings['versionDetection'] != true);
bool installedVersionIsEstimate = app?.app != null
? isVersionPseudo(app!.app)
: false;
if (app != null && !_wasWebViewOpened) {
_wasWebViewOpened = true;
@ -124,11 +128,14 @@ class _AppPageState extends State<AppPage> {
if (!upToDate) {
versionLines += '\n${app?.app.latestVersion} ${tr('latest')}';
}
String infoLines = tr('lastUpdateCheckX', args: [
app?.app.lastUpdateCheck == null
? tr('never')
: '${app?.app.lastUpdateCheck?.toLocal()}'
]);
String infoLines = tr(
'lastUpdateCheckX',
args: [
app?.app.lastUpdateCheck == null
? tr('never')
: '${app?.app.lastUpdateCheck?.toLocal()}',
],
);
if (trackOnly) {
infoLines = '${tr('xIsTrackOnly', args: [tr('app')])}\n$infoLines';
}
@ -148,15 +155,14 @@ class _AppPageState extends State<AppPage> {
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 24),
child: Column(
children: [
const SizedBox(
height: 8,
const SizedBox(height: 8),
Text(
versionLines,
textAlign: TextAlign.start,
style: Theme.of(
context,
).textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.bold),
),
Text(versionLines,
textAlign: TextAlign.start,
style: Theme.of(context)
.textTheme
.bodyLarge!
.copyWith(fontWeight: FontWeight.bold)),
changeLogFn != null || app?.app.releaseDate != null
? GestureDetector(
onTap: changeLogFn,
@ -165,21 +171,19 @@ class _AppPageState extends State<AppPage> {
? tr('changes')
: app!.app.releaseDate!.toLocal().toString(),
textAlign: TextAlign.center,
style:
Theme.of(context).textTheme.labelSmall!.copyWith(
decoration: changeLogFn != null
? TextDecoration.underline
: null,
fontStyle: changeLogFn != null
? FontStyle.italic
: null,
),
style: Theme.of(context).textTheme.labelSmall!
.copyWith(
decoration: changeLogFn != null
? TextDecoration.underline
: null,
fontStyle: changeLogFn != null
? FontStyle.italic
: null,
),
),
)
: const SizedBox.shrink(),
const SizedBox(
height: 8,
),
const SizedBox(height: 8),
],
),
),
@ -191,101 +195,108 @@ class _AppPageState extends State<AppPage> {
if (app?.app.apkUrls.isNotEmpty == true ||
app?.app.otherAssetUrls.isNotEmpty == true)
GestureDetector(
onTap: app?.app == null || updating
? null
: () async {
try {
await appsProvider
.downloadAppAssets([app!.app.id], context);
} catch (e) {
showError(e, context);
}
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: settingsProvider.highlightTouchTargets
? (Theme.of(context).brightness ==
Brightness.light
? Theme.of(context).primaryColor
: Theme.of(context).primaryColorLight)
.withAlpha(Theme.of(context).brightness ==
Brightness.light
? 20
: 40)
: null),
padding: settingsProvider.highlightTouchTargets
? const EdgeInsetsDirectional.fromSTEB(12, 6, 12, 6)
: const EdgeInsetsDirectional.fromSTEB(0, 6, 0, 6),
margin:
const EdgeInsetsDirectional.fromSTEB(0, 6, 0, 0),
child: Text(
tr('downloadX',
args: [tr('releaseAsset').toLowerCase()]),
textAlign: TextAlign.center,
style:
Theme.of(context).textTheme.labelSmall!.copyWith(
decoration: TextDecoration.underline,
fontStyle: FontStyle.italic,
),
))
],
)),
const SizedBox(
height: 48,
),
onTap: app?.app == null || updating
? null
: () async {
try {
await appsProvider.downloadAppAssets([
app!.app.id,
], context);
} catch (e) {
showError(e, context);
}
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: settingsProvider.highlightTouchTargets
? (Theme.of(context).brightness == Brightness.light
? Theme.of(context).primaryColor
: Theme.of(context).primaryColorLight)
.withAlpha(
Theme.of(context).brightness ==
Brightness.light
? 20
: 40,
)
: null,
),
padding: settingsProvider.highlightTouchTargets
? const EdgeInsetsDirectional.fromSTEB(12, 6, 12, 6)
: const EdgeInsetsDirectional.fromSTEB(0, 6, 0, 6),
margin: const EdgeInsetsDirectional.fromSTEB(0, 6, 0, 0),
child: Text(
tr('downloadX', args: [tr('releaseAsset').toLowerCase()]),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelSmall!.copyWith(
decoration: TextDecoration.underline,
fontStyle: FontStyle.italic,
),
),
),
],
),
),
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]);
}
}),
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]);
}
},
),
if (app?.app.additionalSettings['about'] is String &&
app?.app.additionalSettings['about'].isNotEmpty)
Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
height: 48,
),
const SizedBox(height: 48),
GestureDetector(
onLongPress: () {
Clipboard.setData(ClipboardData(
text: app?.app.additionalSettings['about'] ?? ''));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(tr('copiedToClipboard')),
));
},
child: Markdown(
physics: NeverScrollableScrollPhysics(),
shrinkWrap: true,
styleSheet: MarkdownStyleSheet(
blockquoteDecoration:
BoxDecoration(color: Theme.of(context).cardColor),
textAlign: WrapAlignment.center),
data: app?.app.additionalSettings['about'],
onTapLink: (text, href, title) {
if (href != null) {
launchUrlString(href,
mode: LaunchMode.externalApplication);
}
},
extensionSet: md.ExtensionSet(
md.ExtensionSet.gitHubFlavored.blockSyntaxes,
[
md.EmojiSyntax(),
...md.ExtensionSet.gitHubFlavored.inlineSyntaxes
],
onLongPress: () {
Clipboard.setData(
ClipboardData(
text: app?.app.additionalSettings['about'] ?? '',
),
))
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(tr('copiedToClipboard'))),
);
},
child: Markdown(
physics: NeverScrollableScrollPhysics(),
shrinkWrap: true,
styleSheet: MarkdownStyleSheet(
blockquoteDecoration: BoxDecoration(
color: Theme.of(context).cardColor,
),
textAlign: WrapAlignment.center,
),
data: app?.app.additionalSettings['about'],
onTapLink: (text, href, title) {
if (href != null) {
launchUrlString(
href,
mode: LaunchMode.externalApplication,
);
}
},
extensionSet: md.ExtensionSet(
md.ExtensionSet.gitHubFlavored.blockSyntaxes,
[
md.EmojiSyntax(),
...md.ExtensionSet.gitHubFlavored.inlineSyntaxes,
],
),
),
),
],
),
],
@ -293,132 +304,143 @@ class _AppPageState extends State<AppPage> {
}
getFullInfoColumn({bool small = false}) => Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(height: small ? 5 : 20),
FutureBuilder(
future:
appsProvider.updateAppIcon(app?.app.id, ignoreCache: true),
builder: (ctx, val) {
return app?.icon != null
? Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
GestureDetector(
onTap: app == null
? null
: () => pm.openApp(app.app.id),
child: Image.memory(
app!.icon!,
height: small ? 70 : 150,
gaplessPlayback: true,
),
)
])
: Container();
}),
SizedBox(
height: small ? 10 : 25,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(height: small ? 5 : 20),
FutureBuilder(
future: appsProvider.updateAppIcon(app?.app.id, ignoreCache: true),
builder: (ctx, val) {
return app?.icon != null
? Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
GestureDetector(
onTap: app == null
? null
: () => pm.openApp(app.app.id),
child: Image.memory(
app!.icon!,
height: small ? 70 : 150,
gaplessPlayback: true,
),
),
],
)
: Container();
},
),
SizedBox(height: small ? 10 : 25),
Text(
app?.name ?? tr('app'),
textAlign: TextAlign.center,
style: small
? Theme.of(context).textTheme.displaySmall
: Theme.of(context).textTheme.displayLarge,
),
Text(
tr('byX', args: [app?.author ?? tr('unknown')]),
textAlign: TextAlign.center,
style: small
? Theme.of(context).textTheme.headlineSmall
: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 24),
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: Theme.of(context).textTheme.labelSmall!.copyWith(
decoration: TextDecoration.underline,
fontStyle: FontStyle.italic,
),
Text(
app?.name ?? tr('app'),
textAlign: TextAlign.center,
style: small
? Theme.of(context).textTheme.displaySmall
: Theme.of(context).textTheme.displayLarge,
),
Text(tr('byX', args: [app?.author ?? tr('unknown')]),
textAlign: TextAlign.center,
style: small
? Theme.of(context).textTheme.headlineSmall
: Theme.of(context).textTheme.headlineMedium),
const SizedBox(
height: 24,
),
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: Theme.of(context).textTheme.labelSmall!.copyWith(
decoration: TextDecoration.underline,
fontStyle: FontStyle.italic),
)),
Text(
app?.app.id ?? '',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelSmall,
),
getInfoColumn(),
const SizedBox(height: 150)
],
);
),
),
Text(
app?.app.id ?? '',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelSmall,
),
getInfoColumn(),
const SizedBox(height: 150),
],
);
getAppWebView() => app != null
? WebViewWidget(
key: ObjectKey(_webViewController),
controller: _webViewController
..setBackgroundColor(Theme.of(context).colorScheme.surface))
..setBackgroundColor(Theme.of(context).colorScheme.surface),
)
: 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')))
],
);
});
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<Map<String, dynamic>?>(
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;
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();
return GeneratedFormModal(
title: tr('additionalOptions'), items: items);
});
return GeneratedFormModal(
title: tr('additionalOptions'),
items: items,
);
},
);
}
handleAdditionalOptionChanges(Map<String, dynamic>? values) {
@ -432,18 +454,18 @@ class _AppPageState extends State<AppPage> {
}
var versionDetectionEnabled =
app.app.additionalSettings['versionDetection'] == true &&
originalSettings['versionDetection'] != true;
originalSettings['versionDetection'] != true;
var releaseDateVersionEnabled =
app.app.additionalSettings['releaseDateAsVersion'] == true &&
originalSettings['releaseDateAsVersion'] != true;
originalSettings['releaseDateAsVersion'] != true;
var releaseDateVersionDisabled =
app.app.additionalSettings['releaseDateAsVersion'] != true &&
originalSettings['releaseDateAsVersion'] == true;
originalSettings['releaseDateAsVersion'] == true;
if (releaseDateVersionEnabled) {
if (app.app.releaseDate != null) {
bool isUpdated = app.app.installedVersion == app.app.latestVersion;
app.app.latestVersion =
app.app.releaseDate!.microsecondsSinceEpoch.toString();
app.app.latestVersion = app.app.releaseDate!.microsecondsSinceEpoch
.toString();
if (isUpdated) {
app.app.installedVersion = app.app.latestVersion;
}
@ -463,172 +485,195 @@ class _AppPageState extends State<AppPage> {
}
getInstallOrUpdateButton() => TextButton(
onPressed: !updating &&
(app?.app.installedVersion == null ||
app?.app.installedVersion != app?.app.latestVersion) &&
!areDownloadsRunning
? () async {
try {
var successMessage = app?.app.installedVersion == null
? tr('installed')
: tr('appsUpdated');
HapticFeedback.heavyImpact();
var res = await appsProvider.downloadAndInstallLatestApps(
app?.app.id != null ? [app!.app.id] : [],
globalNavigatorKey.currentContext,
);
if (res.isNotEmpty && !trackOnly) {
// ignore: use_build_context_synchronously
showMessage(successMessage, context);
}
if (res.isNotEmpty && mounted) {
Navigator.of(context).pop();
}
} catch (e) {
onPressed:
!updating &&
(app?.app.installedVersion == null ||
app?.app.installedVersion != app?.app.latestVersion) &&
!areDownloadsRunning
? () async {
try {
var successMessage = app?.app.installedVersion == null
? tr('installed')
: tr('appsUpdated');
HapticFeedback.heavyImpact();
var res = await appsProvider.downloadAndInstallLatestApps(
app?.app.id != null ? [app!.app.id] : [],
globalNavigatorKey.currentContext,
);
if (res.isNotEmpty && !trackOnly) {
// ignore: use_build_context_synchronously
showError(e, context);
showMessage(successMessage, 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
}
: null,
child: Text(
app?.app.installedVersion == null
? !trackOnly
? tr('install')
: tr('markInstalled')
? tr('install')
: tr('markInstalled')
: !trackOnly
? tr('update')
: tr('markUpdated')));
? 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 (source != null &&
source.combinedAppSpecificSettingFormItems.isNotEmpty)
IconButton(
onPressed: app?.downloadProgress != null || updating
? 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);
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 (source != null &&
source.combinedAppSpecificSettingFormItems.isNotEmpty)
IconButton(
onPressed: app?.downloadProgress != null || updating
? null
: () async {
var values = await showAdditionalOptionsDialog();
handleAdditionalOptionChanges(values);
},
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: getFullInfoColumn(small: true),
title: Text(app.name),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(tr('continue')))
],
);
});
},
icon: const Icon(Icons.more_horiz),
tooltip: tr('more')),
if (app?.app.installedVersion != null &&
app?.app.installedVersion != app?.app.latestVersion &&
!isVersionDetectionStandard &&
!trackOnly)
IconButton(
onPressed: app?.downloadProgress != null || updating
? null
: showMarkUpdatedDialog,
tooltip: tr('markUpdated'),
icon: const Icon(Icons.done)),
if ((!isVersionDetectionStandard || trackOnly) &&
app?.app.installedVersion != null &&
app?.app.installedVersion == app?.app.latestVersion)
IconButton(
onPressed: app?.app == null || updating
? null
: () {
app!.app.installedVersion = null;
appsProvider.saveApps([app.app]);
},
icon: const Icon(Icons.restore_rounded),
tooltip: tr('resetInstallStatus')),
const SizedBox(width: 16.0),
Expanded(child: getInstallOrUpdateButton()),
const SizedBox(width: 16.0),
IconButton(
onPressed: app?.downloadProgress != null || updating
? 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))
],
));
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: getFullInfoColumn(small: true),
title: Text(app.name),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(tr('continue')),
),
],
);
},
);
},
icon: const Icon(Icons.more_horiz),
tooltip: tr('more'),
),
if (app?.app.installedVersion != null &&
app?.app.installedVersion != app?.app.latestVersion &&
!isVersionDetectionStandard &&
!trackOnly)
IconButton(
onPressed: app?.downloadProgress != null || updating
? null
: showMarkUpdatedDialog,
tooltip: tr('markUpdated'),
icon: const Icon(Icons.done),
),
if ((!isVersionDetectionStandard || trackOnly) &&
app?.app.installedVersion != null &&
app?.app.installedVersion == app?.app.latestVersion)
IconButton(
onPressed: app?.app == null || updating
? null
: () {
app!.app.installedVersion = null;
appsProvider.saveApps([app.app]);
},
icon: const Icon(Icons.restore_rounded),
tooltip: tr('resetInstallStatus'),
),
const SizedBox(width: 16.0),
Expanded(child: getInstallOrUpdateButton()),
const SizedBox(width: 16.0),
IconButton(
onPressed: app?.downloadProgress != null || updating
? 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);
},
),
);
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
Navigator.pop(context);
},
),
);
return Scaffold(
appBar: settingsProvider.showAppWebpage ? AppBar() : appScreenAppBar(),
backgroundColor: Theme.of(context).colorScheme.surface,
body: RefreshIndicator(
child: settingsProvider.showAppWebpage
? getAppWebView()
: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Column(children: [getFullInfoColumn()])),
],
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());
],
),
onRefresh: () async {
if (app != null) {
getUpdate(app.app.id);
}
},
),
bottomSheet: getBottomSheetMenu(),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -43,13 +43,22 @@ class _HomePageState extends State<HomePage> {
bool isLinkActivity = false;
List<NavigationPageItem> pages = [
NavigationPageItem(tr('appsString'), Icons.apps,
AppsPage(key: GlobalKey<AppsPageState>())),
NavigationPageItem(
tr('addApp'), Icons.add, AddAppPage(key: GlobalKey<AddAppPageState>())),
tr('appsString'),
Icons.apps,
AppsPage(key: GlobalKey<AppsPageState>()),
),
NavigationPageItem(
tr('importExport'), Icons.import_export, const ImportExportPage()),
NavigationPageItem(tr('settings'), Icons.settings, const SettingsPage())
tr('addApp'),
Icons.add,
AddAppPage(key: GlobalKey<AddAppPageState>()),
),
NavigationPageItem(
tr('importExport'),
Icons.import_export,
const ImportExportPage(),
),
NavigationPageItem(tr('settings'), Icons.settings, const SettingsPage()),
];
@override
@ -60,63 +69,69 @@ class _HomePageState extends State<HomePage> {
var sp = context.read<SettingsProvider>();
if (!sp.welcomeShown) {
await showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
title: Text(tr('welcome')),
content: Column(
mainAxisSize: MainAxisSize.min,
spacing: 20,
children: [
Text(tr('documentationLinksNote')),
GestureDetector(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
title: Text(tr('welcome')),
content: Column(
mainAxisSize: MainAxisSize.min,
spacing: 20,
children: [
Text(tr('documentationLinksNote')),
GestureDetector(
onTap: () {
launchUrlString(
'https://github.com/ImranR98/Obtainium/blob/main/README.md',
mode: LaunchMode.externalApplication,
);
},
child: Text(
'https://github.com/ImranR98/Obtainium/blob/main/README.md',
style: const TextStyle(
decoration: TextDecoration.underline,
fontWeight: FontWeight.bold,
),
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(tr('batteryOptimizationNote')),
GestureDetector(
onTap: () {
launchUrlString(
'https://github.com/ImranR98/Obtainium/blob/main/README.md',
mode: LaunchMode.externalApplication);
final intent = AndroidIntent(
action:
'android.settings.IGNORE_BATTERY_OPTIMIZATION_SETTINGS',
package:
obtainiumId, // Replace with your app's package name
);
intent.launch();
},
child: Text(
'https://github.com/ImranR98/Obtainium/blob/main/README.md',
tr('settings'),
style: const TextStyle(
decoration: TextDecoration.underline,
fontWeight: FontWeight.bold),
)),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(tr('batteryOptimizationNote')),
GestureDetector(
onTap: () {
final intent = AndroidIntent(
action:
'android.settings.IGNORE_BATTERY_OPTIMIZATION_SETTINGS',
package:
obtainiumId, // Replace with your app's package name
);
intent.launch();
},
child: Text(
tr('settings'),
style: const TextStyle(
decoration: TextDecoration.underline,
fontWeight: FontWeight.bold),
decoration: TextDecoration.underline,
fontWeight: FontWeight.bold,
),
)
],
)
],
),
actions: [
TextButton(
onPressed: () {
sp.welcomeShown = true;
Navigator.of(context).pop(null);
},
child: Text(tr('ok'))),
),
),
],
),
],
);
});
),
actions: [
TextButton(
onPressed: () {
sp.welcomeShown = true;
Navigator.of(context).pop(null);
},
child: Text(tr('ok')),
),
],
);
},
);
}
});
}
@ -126,13 +141,12 @@ class _HomePageState extends State<HomePage> {
goToAddApp(String data) async {
switchToPage(1);
while (
(pages[1].widget.key as GlobalKey<AddAppPageState>?)?.currentState ==
null) {
while ((pages[1].widget.key as GlobalKey<AddAppPageState>?)
?.currentState ==
null) {
await Future.delayed(const Duration(microseconds: 1));
}
(pages[1].widget.key as GlobalKey<AddAppPageState>?)
?.currentState
(pages[1].widget.key as GlobalKey<AddAppPageState>?)?.currentState
?.linkFn(data);
}
@ -146,44 +160,55 @@ class _HomePageState extends State<HomePage> {
} else if (action == 'app' || action == 'apps') {
var dataStr = Uri.decodeComponent(data);
if (await showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr('importX', args: [
action == 'app' ? tr('app') : tr('appsString')
]),
items: const [],
additionalWidgets: [
ExpansionTile(
title: const Text('Raw JSON'),
children: [
Text(
dataStr,
style: const TextStyle(fontFamily: 'monospace'),
)
],
)
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr(
'importX',
args: [
(action == 'app' ? tr('app') : tr('appsString'))
.toLowerCase(),
],
);
}) !=
),
items: const [],
additionalWidgets: [
ExpansionTile(
title: const Text('Raw JSON'),
children: [
Text(
dataStr,
style: const TextStyle(fontFamily: 'monospace'),
),
],
),
],
);
},
) !=
null) {
// ignore: use_build_context_synchronously
var appsProvider = context.read<AppsProvider>();
var result = await appsProvider.import(action == 'app'
? '{ "apps": [$dataStr] }'
: '{ "apps": $dataStr }');
var result = await appsProvider.import(
action == 'app'
? '{ "apps": [$dataStr] }'
: '{ "apps": $dataStr }',
);
// ignore: use_build_context_synchronously
showMessage(
tr('importedX', args: [plural('apps', result.key.length)]),
context);
tr(
'importedX',
args: [plural('apps', result.key.length).toLowerCase()],
),
context,
);
await appsProvider
.checkUpdates(specificIds: result.key.map((e) => e.id).toList())
.catchError((e) {
if (e is Map && e['errors'] is MultiAppMultiError) {
showError(e['errors'].toString(), context);
}
return <App>[];
});
if (e is Map && e['errors'] is MultiAppMultiError) {
showError(e['errors'].toString(), context);
}
return <App>[];
});
}
} else {
throw ObtainiumError(tr('unknown'));
@ -210,15 +235,16 @@ class _HomePageState extends State<HomePage> {
});
}
setIsReversing(int targetIndex) {
bool reversing = selectedIndexHistory.isNotEmpty &&
void setIsReversing(int targetIndex) {
bool reversing =
selectedIndexHistory.isNotEmpty &&
selectedIndexHistory.last > targetIndex;
setState(() {
isReversing = reversing;
});
}
switchToPage(int index) async {
Future<void> switchToPage(int index) async {
setIsReversing(index);
if (index == 0) {
while ((pages[0].widget.key as GlobalKey<AppsPageState>).currentState !=
@ -259,65 +285,71 @@ class _HomePageState extends State<HomePage> {
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<double> animation,
Animation<double> secondaryAnimation,
) {
return SharedAxisTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.horizontal,
child: child,
);
},
child: pages
.elementAt(selectedIndexHistory.isEmpty
? 0
: selectedIndexHistory.last)
.widget,
child: Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: PageTransitionSwitcher(
duration: Duration(
milliseconds: settingsProvider.disablePageTransitions ? 0 : 300,
),
bottomNavigationBar: NavigationBar(
destinations: pages
.map((e) =>
NavigationDestination(icon: Icon(e.icon), label: e.title))
.toList(),
onDestinationSelected: (int index) async {
HapticFeedback.selectionClick();
switchToPage(index);
},
selectedIndex:
reverse: settingsProvider.reversePageTransitions
? !isReversing
: isReversing,
transitionBuilder:
(
Widget child,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
return SharedAxisTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.horizontal,
child: child,
);
},
child: pages
.elementAt(
selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last,
),
)
.widget,
),
onWillPop: () async {
if (isLinkActivity &&
selectedIndexHistory.length == 1 &&
selectedIndexHistory.last == 1) {
return true;
}
setIsReversing(selectedIndexHistory.length >= 2
bottomNavigationBar: NavigationBar(
destinations: pages
.map(
(e) =>
NavigationDestination(icon: Icon(e.icon), label: e.title),
)
.toList(),
onDestinationSelected: (int index) async {
HapticFeedback.selectionClick();
switchToPage(index);
},
selectedIndex: selectedIndexHistory.isEmpty
? 0
: selectedIndexHistory.last,
),
),
onWillPop: () async {
if (isLinkActivity &&
selectedIndexHistory.length == 1 &&
selectedIndexHistory.last == 1) {
return true;
}
setIsReversing(
selectedIndexHistory.length >= 2
? selectedIndexHistory.reversed.toList()[1]
: 0);
if (selectedIndexHistory.isNotEmpty) {
setState(() {
selectedIndexHistory.removeLast();
});
return false;
}
return !(pages[0].widget.key as GlobalKey<AppsPageState>)
.currentState
?.clearSelected();
});
: 0,
);
if (selectedIndexHistory.isNotEmpty) {
setState(() {
selectedIndexHistory.removeLast();
});
return false;
}
return !(pages[0].widget.key as GlobalKey<AppsPageState>).currentState!
.clearSelected();
},
);
}
@override

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -22,7 +22,7 @@ class Log {
idColumn: id,
levelColumn: level.index,
messageColumn: message,
timestampColumn: timestamp.millisecondsSinceEpoch
timestampColumn: timestamp.millisecondsSinceEpoch,
};
return map;
}
@ -33,8 +33,9 @@ class Log {
id = map[idColumn] as int;
level = LogLevels.values.elementAt(map[levelColumn] as int);
message = map[messageColumn] as String;
timestamp =
DateTime.fromMillisecondsSinceEpoch(map[timestampColumn] as int);
timestamp = DateTime.fromMillisecondsSinceEpoch(
map[timestampColumn] as int,
);
}
@override
@ -51,16 +52,19 @@ class LogsProvider {
Database? db;
Future<Database> getDB() async {
db ??= await openDatabase(dbPath, version: 1,
onCreate: (Database db, int version) async {
await db.execute('''
db ??= await openDatabase(
dbPath,
version: 1,
onCreate: (Database db, int version) async {
await db.execute('''
create table if not exists $logTable (
$idColumn integer primary key autoincrement,
$levelColumn integer not null,
$messageColumn text not null,
$timestampColumn integer not null)
''');
});
},
);
return db!;
}
@ -75,27 +79,38 @@ create table if not exists $logTable (
Future<List<Log>> get({DateTime? before, DateTime? after}) async {
var where = getWhereDates(before: before, after: after);
return (await (await getDB())
.query(logTable, where: where.key, whereArgs: where.value))
.map((e) => Log.fromMap(e))
.toList();
return (await (await getDB()).query(
logTable,
where: where.key,
whereArgs: where.value,
)).map((e) => Log.fromMap(e)).toList();
}
Future<int> clear({DateTime? before, DateTime? after}) async {
var where = getWhereDates(before: before, after: after);
var res = await (await getDB())
.delete(logTable, where: where.key, whereArgs: where.value);
var res = await (await getDB()).delete(
logTable,
where: where.key,
whereArgs: where.value,
);
if (res > 0) {
add(plural('clearedNLogsBeforeXAfterY', res,
add(
plural(
'clearedNLogsBeforeXAfterY',
res,
namedArgs: {'before': before.toString(), 'after': after.toString()},
name: 'n'));
name: 'n',
),
);
}
return res;
}
}
MapEntry<String?, List<int>?> getWhereDates(
{DateTime? before, DateTime? after}) {
MapEntry<String?, List<int>?> getWhereDates({
DateTime? before,
DateTime? after,
}) {
List<String> where = [];
List<int> whereArgs = [];
if (before != null) {

View File

@ -20,91 +20,116 @@ class ObtainiumNotification {
bool onlyAlertOnce;
String? payload;
ObtainiumNotification(this.id, this.title, this.message, this.channelCode,
this.channelName, this.channelDescription, this.importance,
{this.onlyAlertOnce = false, this.progPercent, this.payload});
ObtainiumNotification(
this.id,
this.title,
this.message,
this.channelCode,
this.channelName,
this.channelDescription,
this.importance, {
this.onlyAlertOnce = false,
this.progPercent,
this.payload,
});
}
class UpdateNotification extends ObtainiumNotification {
UpdateNotification(List<App> updates, {int? id})
: super(
id ?? 2,
tr('updatesAvailable'),
'',
'UPDATES_AVAILABLE',
tr('updatesAvailableNotifChannel'),
tr('updatesAvailableNotifDescription'),
Importance.max) {
: super(
id ?? 2,
tr('updatesAvailable'),
'',
'UPDATES_AVAILABLE',
tr('updatesAvailableNotifChannel'),
tr('updatesAvailableNotifDescription'),
Importance.max,
) {
message = updates.isEmpty
? tr('noNewUpdates')
: updates.length == 1
? tr('xHasAnUpdate', args: [updates[0].finalName])
: plural('xAndNMoreUpdatesAvailable', updates.length - 1,
args: [updates[0].finalName, (updates.length - 1).toString()]);
? tr('xHasAnUpdate', args: [updates[0].finalName])
: plural(
'xAndNMoreUpdatesAvailable',
updates.length - 1,
args: [updates[0].finalName, (updates.length - 1).toString()],
);
}
}
class SilentUpdateNotification extends ObtainiumNotification {
SilentUpdateNotification(List<App> updates, bool succeeded, {int? id})
: super(
id ?? 3,
succeeded ? tr('appsUpdated') : tr('appsNotUpdated'),
'',
'APPS_UPDATED',
tr('appsUpdatedNotifChannel'),
tr('appsUpdatedNotifDescription'),
Importance.defaultImportance) {
: super(
id ?? 3,
succeeded ? tr('appsUpdated') : tr('appsNotUpdated'),
'',
'APPS_UPDATED',
tr('appsUpdatedNotifChannel'),
tr('appsUpdatedNotifDescription'),
Importance.defaultImportance,
) {
message = updates.length == 1
? tr(succeeded ? 'xWasUpdatedToY' : 'xWasNotUpdatedToY',
args: [updates[0].finalName, updates[0].latestVersion])
? tr(
succeeded ? 'xWasUpdatedToY' : 'xWasNotUpdatedToY',
args: [updates[0].finalName, updates[0].latestVersion],
)
: plural(
succeeded ? 'xAndNMoreUpdatesInstalled' : "xAndNMoreUpdatesFailed",
updates.length - 1,
args: [updates[0].finalName, (updates.length - 1).toString()]);
args: [updates[0].finalName, (updates.length - 1).toString()],
);
}
}
class SilentUpdateAttemptNotification extends ObtainiumNotification {
SilentUpdateAttemptNotification(List<App> updates, {int? id})
: super(
id ?? 3,
tr('appsPossiblyUpdated'),
'',
'APPS_POSSIBLY_UPDATED',
tr('appsPossiblyUpdatedNotifChannel'),
tr('appsPossiblyUpdatedNotifDescription'),
Importance.defaultImportance) {
: 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()]);
? 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, {int? id})
: super(
id ?? 5,
tr('errorCheckingUpdates'),
error,
'BG_UPDATE_CHECK_ERROR',
tr('errorCheckingUpdatesNotifChannel'),
tr('errorCheckingUpdatesNotifDescription'),
Importance.high,
payload: "${tr('errorCheckingUpdates')}\n$error");
: super(
id ?? 5,
tr('errorCheckingUpdates'),
error,
'BG_UPDATE_CHECK_ERROR',
tr('errorCheckingUpdatesNotifChannel'),
tr('errorCheckingUpdatesNotifDescription'),
Importance.high,
payload: "${tr('errorCheckingUpdates')}\n$error",
);
}
class AppsRemovedNotification extends ObtainiumNotification {
AppsRemovedNotification(List<List<String>> namedReasons)
: super(
6,
tr('appsRemoved'),
'',
'APPS_REMOVED',
tr('appsRemovedNotifChannel'),
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';
@ -115,49 +140,53 @@ class AppsRemovedNotification extends ObtainiumNotification {
class DownloadNotification extends ObtainiumNotification {
DownloadNotification(String appName, int progPercent)
: super(
appName.hashCode,
tr('downloadingX', args: [appName]),
'',
'APP_DOWNLOADING',
tr('downloadingXNotifChannel', args: [tr('app')]),
tr('downloadNotifDescription'),
Importance.low,
onlyAlertOnce: true,
progPercent: progPercent);
: super(
appName.hashCode,
tr('downloadingX', args: [appName]),
'',
'APP_DOWNLOADING',
tr('downloadingXNotifChannel', args: [tr('app')]),
tr('downloadNotifDescription'),
Importance.low,
onlyAlertOnce: true,
progPercent: progPercent,
);
}
class DownloadedNotification extends ObtainiumNotification {
DownloadedNotification(String fileName, String downloadUrl)
: super(
downloadUrl.hashCode,
tr('downloadedX', args: [fileName]),
'',
'FILE_DOWNLOADED',
tr('downloadedXNotifChannel', args: [tr('app')]),
tr('downloadedX', args: [tr('app')]),
Importance.defaultImportance);
: super(
downloadUrl.hashCode,
tr('downloadedX', args: [fileName]),
'',
'FILE_DOWNLOADED',
tr('downloadedXNotifChannel', args: [tr('app')]),
tr('downloadedX', args: [tr('app')]),
Importance.defaultImportance,
);
}
final completeInstallationNotification = ObtainiumNotification(
1,
tr('completeAppInstallation'),
tr('obtainiumMustBeOpenToInstallApps'),
'COMPLETE_INSTALL',
tr('completeAppInstallationNotifChannel'),
tr('completeAppInstallationNotifDescription'),
Importance.max);
1,
tr('completeAppInstallation'),
tr('obtainiumMustBeOpenToInstallApps'),
'COMPLETE_INSTALL',
tr('completeAppInstallationNotifChannel'),
tr('completeAppInstallationNotifDescription'),
Importance.max,
);
class CheckingUpdatesNotification extends ObtainiumNotification {
CheckingUpdatesNotification(String appName)
: super(
4,
tr('checkingForUpdates'),
appName,
'BG_UPDATE_CHECK',
tr('checkingForUpdatesNotifChannel'),
tr('checkingForUpdatesNotifDescription'),
Importance.min);
: super(
4,
tr('checkingForUpdates'),
appName,
'BG_UPDATE_CHECK',
tr('checkingForUpdatesNotifChannel'),
tr('checkingForUpdatesNotifDescription'),
Importance.min,
);
}
class NotificationsProvider {
@ -173,13 +202,15 @@ class NotificationsProvider {
Importance.max: Priority.max,
Importance.min: Priority.min,
Importance.none: Priority.min,
Importance.unspecified: Priority.defaultPriority
Importance.unspecified: Priority.defaultPriority,
};
Future<void> initialize() async {
isInitialized = await notifications.initialize(
isInitialized =
await notifications.initialize(
const InitializationSettings(
android: AndroidInitializationSettings('ic_notification')),
android: AndroidInitializationSettings('ic_notification'),
),
onDidReceiveNotificationResponse: (NotificationResponse response) {
_showNotificationPayload(response.payload);
},
@ -187,16 +218,18 @@ class NotificationsProvider {
false;
}
checkLaunchByNotif() async {
final NotificationAppLaunchDetails? launchDetails =
await notifications.getNotificationAppLaunchDetails();
Future<void> checkLaunchByNotif() async {
final NotificationAppLaunchDetails? launchDetails = await notifications
.getNotificationAppLaunchDetails();
if (launchDetails?.didNotificationLaunchApp ?? false) {
_showNotificationPayload(launchDetails!.notificationResponse?.payload,
doublePop: true);
_showNotificationPayload(
launchDetails!.notificationResponse?.payload,
doublePop: true,
);
}
}
_showNotificationPayload(String? payload, {bool doublePop = false}) {
void _showNotificationPayload(String? payload, {bool doublePop = false}) {
if (payload?.isNotEmpty == true) {
var title = (payload ?? '\n\n').split('\n').first;
var content = (payload ?? '\n\n').split('\n').sublist(1).join('\n');
@ -207,13 +240,14 @@ class NotificationsProvider {
content: Text(content),
actions: [
TextButton(
onPressed: () {
onPressed: () {
Navigator.of(context).pop(null);
if (doublePop) {
Navigator.of(context).pop(null);
if (doublePop) {
Navigator.of(context).pop(null);
}
},
child: Text(tr('ok'))),
}
},
child: Text(tr('ok')),
),
],
),
),
@ -229,17 +263,18 @@ class NotificationsProvider {
}
Future<void> notifyRaw(
int id,
String title,
String message,
String channelCode,
String channelName,
String channelDescription,
Importance importance,
{bool cancelExisting = false,
int? progPercent,
bool onlyAlertOnce = false,
String? payload}) async {
int id,
String title,
String message,
String channelCode,
String channelName,
String channelDescription,
Importance importance, {
bool cancelExisting = false,
int? progPercent,
bool onlyAlertOnce = false,
String? payload,
}) async {
if (cancelExisting) {
await cancel(id);
}
@ -247,29 +282,42 @@ class NotificationsProvider {
await initialize();
}
await notifications.show(
id,
title,
message,
NotificationDetails(
android: AndroidNotificationDetails(channelCode, channelName,
channelDescription: channelDescription,
importance: importance,
priority: importanceToPriority[importance]!,
groupKey: '$obtainiumId.$channelCode',
progress: progPercent ?? 0,
maxProgress: 100,
showProgress: progPercent != null,
onlyAlertOnce: onlyAlertOnce,
indeterminate: progPercent != null && progPercent < 0)),
payload: payload);
id,
title,
message,
NotificationDetails(
android: AndroidNotificationDetails(
channelCode,
channelName,
channelDescription: channelDescription,
importance: importance,
priority: importanceToPriority[importance]!,
groupKey: '$obtainiumId.$channelCode',
progress: progPercent ?? 0,
maxProgress: 100,
showProgress: progPercent != null,
onlyAlertOnce: onlyAlertOnce,
indeterminate: progPercent != null && progPercent < 0,
),
),
payload: payload,
);
}
Future<void> notify(ObtainiumNotification notif,
{bool cancelExisting = false}) =>
notifyRaw(notif.id, notif.title, notif.message, notif.channelCode,
notif.channelName, notif.channelDescription, notif.importance,
cancelExisting: cancelExisting,
onlyAlertOnce: notif.onlyAlertOnce,
progPercent: notif.progPercent,
payload: notif.payload);
Future<void> notify(
ObtainiumNotification notif, {
bool cancelExisting = false,
}) => notifyRaw(
notif.id,
notif.title,
notif.message,
notif.channelCode,
notif.channelName,
notif.channelDescription,
notif.importance,
cancelExisting: cancelExisting,
onlyAlertOnce: notif.onlyAlertOnce,
progPercent: notif.progPercent,
payload: notif.payload,
);
}

View File

@ -58,8 +58,8 @@ class SettingsProvider with ChangeNotifier {
}
ThemeSettings get theme {
return ThemeSettings
.values[prefs?.getInt('theme') ?? ThemeSettings.system.index];
return ThemeSettings.values[prefs?.getInt('theme') ??
ThemeSettings.system.index];
}
set theme(ThemeSettings t) {
@ -123,8 +123,8 @@ class SettingsProvider with ChangeNotifier {
}
SortColumnSettings get sortColumn {
return SortColumnSettings.values[
prefs?.getInt('sortColumn') ?? SortColumnSettings.nameAuthor.index];
return SortColumnSettings.values[prefs?.getInt('sortColumn') ??
SortColumnSettings.nameAuthor.index];
}
set sortColumn(SortColumnSettings s) {
@ -133,8 +133,8 @@ class SettingsProvider with ChangeNotifier {
}
SortOrderSettings get sortOrder {
return SortOrderSettings.values[
prefs?.getInt('sortOrder') ?? SortOrderSettings.ascending.index];
return SortOrderSettings.values[prefs?.getInt('sortOrder') ??
SortOrderSettings.ascending.index];
}
set sortOrder(SortOrderSettings s) {
@ -171,7 +171,9 @@ class SettingsProvider with ChangeNotifier {
while (!(await Permission.requestInstallPackages.isGranted)) {
// Explicit request as InstallPlugin request sometimes bugged
Fluttertoast.showToast(
msg: tr('pleaseAllowInstallPerm'), toastLength: Toast.LENGTH_LONG);
msg: tr('pleaseAllowInstallPerm'),
toastLength: Toast.LENGTH_LONG,
);
if ((await Permission.requestInstallPackages.request()) ==
PermissionStatus.granted) {
return true;
@ -470,7 +472,8 @@ class SettingsProvider with ChangeNotifier {
}
List<String> get searchDeselected {
return prefs?.getStringList('searchDeselected') ?? [];
return prefs?.getStringList('searchDeselected') ??
SourceProvider().sources.map((s) => s.name).toList();
}
set searchDeselected(List<String> list) {

File diff suppressed because it is too large Load Diff

View File

@ -76,14 +76,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.4"
archive:
dependency: transitive
description:
name: archive
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
url: "https://pub.dev"
source: hosted
version: "4.0.7"
args:
dependency: transitive
description:
@ -96,10 +88,10 @@ packages:
dependency: transitive
description:
name: async
sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev"
source: hosted
version: "2.12.0"
version: "2.13.0"
background_fetch:
dependency: "direct main"
description:
@ -148,22 +140,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff
url: "https://pub.dev"
source: hosted
version: "2.0.3"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock:
dependency: transitive
description:
@ -288,10 +264,10 @@ packages:
dependency: transitive
description:
name: fake_async
sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc"
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.2"
version: "1.3.3"
ffi:
dependency: transitive
description:
@ -312,10 +288,10 @@ packages:
dependency: "direct main"
description:
name: file_picker
sha256: "77f8e81d22d2a07d0dee2c62e1dda71dc1da73bf43bb2d45af09727406167964"
sha256: ef9908739bdd9c476353d6adff72e88fd00c625f5b959ae23f7567bd5137db0a
url: "https://pub.dev"
source: hosted
version: "10.1.9"
version: "10.2.0"
fixnum:
dependency: transitive
description:
@ -449,22 +425,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: bfa04787c85d80ecb3f8777bde5fc10c3de809240c48fa061a2c2bf15ea5211c
url: "https://pub.dev"
source: hosted
version: "0.14.3"
flutter_lints:
dependency: "direct dev"
dependency: "direct main"
description:
name: flutter_lints
sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "5.0.0"
version: "6.0.0"
flutter_local_notifications:
dependency: "direct main"
description:
@ -511,7 +479,7 @@ packages:
source: hosted
version: "2.0.28"
flutter_test:
dependency: "direct dev"
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
@ -584,38 +552,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
url: "https://pub.dev"
source: hosted
version: "4.5.4"
intl:
dependency: transitive
description:
name: intl
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.19.0"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
version: "0.20.2"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
url: "https://pub.dev"
source: hosted
version: "10.0.8"
version: "10.0.9"
leak_tracker_flutter_testing:
dependency: transitive
description:
@ -636,10 +588,10 @@ packages:
dependency: transitive
description:
name: lints
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0
url: "https://pub.dev"
source: hosted
version: "5.1.1"
version: "6.0.0"
markdown:
dependency: "direct main"
description:
@ -852,18 +804,10 @@ packages:
dependency: transitive
description:
name: pointer_interceptor_web
sha256: "7a7087782110f8c1827170660b09f8aa893e0e9a61431dbbe2ac3fc482e8c044"
sha256: "460b600e71de6fcea2b3d5f662c92293c049c4319e27f0829310e5a953b3ee2a"
url: "https://pub.dev"
source: hosted
version: "0.10.2+1"
posix:
dependency: transitive
description:
name: posix
sha256: f0d7856b6ca1887cfa6d1d394056a296ae33489db914e365e2044fdada449e62
url: "https://pub.dev"
source: hosted
version: "6.0.2"
version: "0.10.3"
provider:
dependency: "direct main"
description:
@ -949,7 +893,7 @@ packages:
description:
path: "."
ref: master
resolved-ref: "8784c39b909324df8913dd30fa416b8a50d55f49"
resolved-ref: "012e22791138958e089f6c1a8d6c4c6943a9f253"
url: "https://github.com/AlexBacich/shared-storage"
source: git
version: "0.7.0"
@ -1179,10 +1123,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14"
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
url: "https://pub.dev"
source: hosted
version: "14.3.1"
version: "15.0.0"
web:
dependency: transitive
description:
@ -1195,42 +1139,42 @@ packages:
dependency: "direct main"
description:
name: webview_flutter
sha256: "62d763c27ce7f6cef04b3bec01c85a28d60149bffd155884aa4b8fd4941ea2e4"
sha256: c3e4fe614b1c814950ad07186007eff2f2e5dd2935eba7b9a9a1af8e5885f1ba
url: "https://pub.dev"
source: hosted
version: "4.12.0"
version: "4.13.0"
webview_flutter_android:
dependency: transitive
description:
name: webview_flutter_android
sha256: "5ece28a317a9f76ad5ee17c78dbacc8a491687cec85ee19c1643761bf8d678ef"
sha256: f6e6afef6e234801da77170f7a1847ded8450778caf2fe13979d140484be3678
url: "https://pub.dev"
source: hosted
version: "4.6.0"
version: "4.7.0"
webview_flutter_platform_interface:
dependency: transitive
description:
name: webview_flutter_platform_interface
sha256: "7cb32b21825bd65569665c32bb00a34ded5779786d6201f5350979d2d529940d"
sha256: f0dc2dc3a2b1e3a6abdd6801b9355ebfeb3b8f6cde6b9dc7c9235909c4a1f147
url: "https://pub.dev"
source: hosted
version: "2.13.0"
version: "2.13.1"
webview_flutter_wkwebview:
dependency: transitive
description:
name: webview_flutter_wkwebview
sha256: "6af7d1908c9c89311c2dffcc2c9b51b88a6f055ba16fa0aa8a04cbb1c3afc9ce"
sha256: a3d461fe3467014e05f3ac4962e5fdde2a4bf44c561cb53e9ae5c586600fdbc3
url: "https://pub.dev"
source: hosted
version: "3.21.0"
version: "3.22.0"
win32:
dependency: transitive
description:
name: win32
sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba"
sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03"
url: "https://pub.dev"
source: hosted
version: "5.13.0"
version: "5.14.0"
win32_registry:
dependency: transitive
description:
@ -1255,14 +1199,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.3.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.7.2 <4.0.0"
dart: ">=3.8.1 <4.0.0"
flutter: ">=3.27.0"

View File

@ -16,10 +16,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: 1.1.54+2311
version: 1.1.58+2315
environment:
sdk: ^3.6.0
sdk: ^3.8.1
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
@ -33,22 +33,22 @@ dependencies:
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.5
path_provider: ^2.0.11
cupertino_icons: ^1.0.8
path_provider: ^2.1.5
flutter_fgbg: ^0.7.1
flutter_local_notifications: ^18.0.0
provider: ^6.0.3
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
flutter_local_notifications: ^18.0.1
provider: ^6.1.5
http: ^1.4.0
webview_flutter: ^4.13.0
dynamic_color: ^1.7.0
html: ^0.15.6
shared_preferences: ^2.5.3
url_launcher: ^6.3.1
permission_handler: ^12.0.0+1
fluttertoast: ^8.0.9
device_info_plus: ^11.0.0
file_picker: ^10.0.0
animations: ^2.0.4
fluttertoast: ^8.2.12
device_info_plus: ^11.4.0
file_picker: ^10.1.9
animations: ^2.0.11
android_package_installer: # TODO: See if PR will be accepted (dev may not be active), else remove this comment
git:
url: https://github.com/ImranR98/android_package_installer
@ -58,23 +58,23 @@ dependencies:
url: https://github.com/ImranR98/android_package_manager
ref: master
share_plus: ^11.0.0
sqflite: ^2.2.0+3
easy_localization: ^3.0.1
android_intent_plus: ^5.0.1
flutter_markdown: ^0.7.1
flutter_archive: ^6.0.0
sqflite: ^2.4.2
easy_localization: ^3.0.7+1
android_intent_plus: ^5.3.0
flutter_markdown: ^0.7.7+1
flutter_archive: ^6.0.3
hsluv: ^1.1.3
connectivity_plus: ^6.0.1
connectivity_plus: ^6.1.4
shared_storage: # TODO: Is this maintained?
git:
url: https://github.com/AlexBacich/shared-storage
ref: master
crypto: ^3.0.3
crypto: ^3.0.6
bcrypt: ^1.1.3
app_links: ^6.0.1
background_fetch: ^1.2.1
app_links: ^6.4.0
background_fetch: ^1.3.8
equations: ^5.0.2
flex_color_picker: ^3.4.1
flex_color_picker: ^3.7.1
android_system_font:
git:
url: https://github.com/re7gog/android_system_font
@ -83,22 +83,17 @@ dependencies:
git:
url: https://github.com/wilver06w/shizuku_apk_installer
ref: master
markdown: any
markdown: ^7.3.0
flutter_typeahead: ^5.2.0
battery_plus: ^6.1.0
battery_plus: ^6.2.1
flutter_charset_detector: ^5.0.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_launcher_icons: ^0.14.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: ^5.0.0
flutter_lints: ^6.0.0
flutter_launcher_icons:
android: "ic_launcher"
@ -154,4 +149,4 @@ flutter:
fonts:
- family: Montserrat
fonts:
- asset: assets/fonts/Montserrat-Regular.ttf
- asset: assets/fonts/Montserrat-Regular.ttf