Compare commits

...

24 Commits

Author SHA1 Message Date
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
85 changed files with 7211 additions and 5663 deletions

View File

@ -20,7 +20,7 @@ jobs:
- uses: actions/setup-java@v4 - uses: actions/setup-java@v4
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '21'
- name: Flutter Doctor - name: Flutter Doctor
id: flutter_doctor id: flutter_doctor
@ -44,7 +44,7 @@ jobs:
- name: Build APKs - name: Build APKs
run: | 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 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 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 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. 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: Verification info:
- Package ID: `dev.imranr.obtainium` - 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/). - 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) - [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]
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 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.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true

View File

@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists 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")

View File

@ -329,6 +329,7 @@
"welcome": "مرحبًا", "welcome": "مرحبًا",
"documentationLinksNote": "تحتوي صفحة Obtainium على GitHub المرتبطة أدناه على روابط لمقاطع فيديو، مقالات، مناقشات وموارد أخرى ستساعدك على فهم كيفية استخدام التطبيق.", "documentationLinksNote": "تحتوي صفحة Obtainium على GitHub المرتبطة أدناه على روابط لمقاطع فيديو، مقالات، مناقشات وموارد أخرى ستساعدك على فهم كيفية استخدام التطبيق.",
"batteryOptimizationNote": "لاحظ أن التنزيلات في الخلفية قد تعمل بشكل أكثر موثوقية إذا قمت بتعطيل تحسينات بطارية النظام لـ Obtainium.", "batteryOptimizationNote": "لاحظ أن التنزيلات في الخلفية قد تعمل بشكل أكثر موثوقية إذا قمت بتعطيل تحسينات بطارية النظام لـ Obtainium.",
"fileDeletionError": "فشل حذف الملف (حاول حذفه يدويًا ثم حاول مرة أخرى): \"{}\"",
"removeAppQuestion": { "removeAppQuestion": {
"one": "إزالة التطبيق؟", "one": "إزالة التطبيق؟",
"other": "إزالة التطبيقات؟" "other": "إزالة التطبيقات؟"

View File

@ -329,6 +329,7 @@
"welcome": "Welcome", "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.", "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.", "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": { "removeAppQuestion": {
"one": "Želite li ukloniti aplikaciju?", "one": "Želite li ukloniti aplikaciju?",
"other": "Želite li ukloniti aplikacije?" "other": "Želite li ukloniti aplikacije?"

View File

@ -329,6 +329,7 @@
"welcome": "Benvinguda", "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ó.", "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.", "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": { "removeAppQuestion": {
"one": "¿Suprimeixo l'aplicació?", "one": "¿Suprimeixo l'aplicació?",
"other": "¿Suprimeixo les aplicacions?" "other": "¿Suprimeixo les aplicacions?"

View File

@ -329,6 +329,7 @@
"welcome": "Vítejte na", "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.", "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": "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.", "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": { "removeAppQuestion": {
"one": "Odstranit Apku?", "one": "Odstranit Apku?",
"other": "Odstranit Apky?" "other": "Odstranit Apky?"

View File

@ -329,6 +329,7 @@
"welcome": "Velkommen", "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.", "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": "Bemærk, at baggrundsdownloads kan fungere mere pålideligt, hvis du deaktiverer OS-batterioptimering 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": { "removeAppQuestion": {
"one": "Fjern app?", "one": "Fjern app?",
"other": "Fjern apps?" "other": "Fjern apps?"

View File

@ -329,6 +329,7 @@
"welcome": "Willkommen", "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.", "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": "Beachten Sie, dass Downloads im Hintergrund möglicherweise zuverlässiger funktionieren, wenn Sie die Batterieoptimierung des Betriebssystems für Obtainium deaktivieren.", "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": { "removeAppQuestion": {
"one": "App entfernen?", "one": "App entfernen?",
"other": "Apps entfernen?" "other": "Apps entfernen?"

View File

@ -329,6 +329,7 @@
"welcome": "Welcome", "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.", "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.", "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": { "removeAppQuestion": {
"one": "Forigi la aplikaĵon?", "one": "Forigi la aplikaĵon?",
"other": "Forigi la aplikaĵojn?" "other": "Forigi la aplikaĵojn?"

View File

@ -329,6 +329,7 @@
"welcome": "Welcome", "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.", "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.", "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": { "removeAppQuestion": {
"one": "Remove app?", "one": "Remove app?",
"other": "Remove apps?" "other": "Remove apps?"

View File

@ -329,6 +329,7 @@
"welcome": "Bienvenido", "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.", "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": "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.", "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": { "removeAppQuestion": {
"one": "¿Eliminar aplicación?", "one": "¿Eliminar aplicación?",
"other": "¿Eliminar aplicaciones?" "other": "¿Eliminar aplicaciones?"

View File

@ -329,6 +329,7 @@
"welcome": "Welcome", "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.", "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.", "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": { "removeAppQuestion": {
"one": "برنامه حذف شود؟", "one": "برنامه حذف شود؟",
"other": "برنامه ها حذف شوند؟" "other": "برنامه ها حذف شوند؟"

View File

@ -329,6 +329,7 @@
"welcome": "Bienvenue", "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.", "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": "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.", "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": { "removeAppQuestion": {
"one": "Supprimer l'application ?", "one": "Supprimer l'application ?",
"other": "Supprimer les applications ?" "other": "Supprimer les applications ?"

View File

@ -318,7 +318,7 @@
"crowdsourcedConfigsShort": "Alkalmazáslista", "crowdsourcedConfigsShort": "Alkalmazáslista",
"allowInsecure": "Nem biztonságos HTTP-kérések engedélyezése", "allowInsecure": "Nem biztonságos HTTP-kérések engedélyezése",
"stayOneVersionBehind": "Maradjon egy verzióval a legújabb mögött", "stayOneVersionBehind": "Maradjon egy verzióval a legújabb mögött",
"useFirstApkOfVersion": "Több APK közül az első automatikus kiválasztása", "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", "refreshBeforeDownload": "Az alkalmazás adatainak frissítése a letöltés előtt",
"tencentAppStore": "Tencent Appstore", "tencentAppStore": "Tencent Appstore",
"coolApk": "CoolApk", "coolApk": "CoolApk",
@ -329,6 +329,7 @@
"welcome": "Üdvözöljük!", "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.", "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.", "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": { "removeAppQuestion": {
"one": "Eltávolítja az alkalmazást?", "one": "Eltávolítja az alkalmazást?",
"other": "Eltávolítja az alkalmazásokat?" "other": "Eltávolítja az alkalmazásokat?"

View File

@ -329,6 +329,7 @@
"welcome": "Selamat datang.", "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.", "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": "Perhatikan bahwa unduhan latar belakang dapat bekerja lebih andal jika Anda menonaktifkan optimasi baterai OS untuk 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": { "removeAppQuestion": {
"one": "Hapus aplikasi?", "one": "Hapus aplikasi?",
"other": "Hapus aplikasi?" "other": "Hapus aplikasi?"

View File

@ -329,6 +329,7 @@
"welcome": "Benvenuti", "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.", "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": "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.", "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": { "removeAppQuestion": {
"one": "Rimuovere l'app?", "one": "Rimuovere l'app?",
"other": "Rimuovere le app?" "other": "Rimuovere le app?"

View File

@ -321,7 +321,7 @@
"useFirstApkOfVersion": "複数のAPKから最初のAPKを自動選択する", "useFirstApkOfVersion": "複数のAPKから最初のAPKを自動選択する",
"refreshBeforeDownload": "ダウンロード前にアプリの詳細を更新する", "refreshBeforeDownload": "ダウンロード前にアプリの詳細を更新する",
"tencentAppStore": "Tencent App Store", "tencentAppStore": "Tencent App Store",
"coolApk": "CoolApk", "coolApk": "クールApk",
"vivoAppStore": "vivo App Store (CN)", "vivoAppStore": "vivo App Store (CN)",
"name": "名称", "name": "名称",
"smartname": "名前(スマート)", "smartname": "名前(スマート)",
@ -329,6 +329,7 @@
"welcome": "ようこそ", "welcome": "ようこそ",
"documentationLinksNote": "以下のリンクにあるObtainium GitHubページには、ビデオ、記事、ディスカッション、その他のリソースへのリンクがあり、アプリの使い方を理解するのに役立ちます。", "documentationLinksNote": "以下のリンクにあるObtainium GitHubページには、ビデオ、記事、ディスカッション、その他のリソースへのリンクがあり、アプリの使い方を理解するのに役立ちます。",
"batteryOptimizationNote": "ObtainiumのOSバッテリー最適化を無効にすると、バックグラウンドダウンロードがより確実に動作するようになります。", "batteryOptimizationNote": "ObtainiumのOSバッテリー最適化を無効にすると、バックグラウンドダウンロードがより確実に動作するようになります。",
"fileDeletionError": "ファイルの削除に失敗しました(手動で削除してから再試行してください):\"{}\"",
"removeAppQuestion": { "removeAppQuestion": {
"one": "アプリを削除しますか?", "one": "アプリを削除しますか?",
"other": "アプリを削除しますか?" "other": "アプリを削除しますか?"

View File

@ -329,6 +329,7 @@
"welcome": "환영", "welcome": "환영",
"documentationLinksNote": "아래에 링크된 Obtainium 깃허브 페이지에는 앱 사용 방법을 이해하는 데 도움이 되는 동영상, 기사, 토론 및 기타 리소스에 대한 링크가 포함되어 있습니다.", "documentationLinksNote": "아래에 링크된 Obtainium 깃허브 페이지에는 앱 사용 방법을 이해하는 데 도움이 되는 동영상, 기사, 토론 및 기타 리소스에 대한 링크가 포함되어 있습니다.",
"batteryOptimizationNote": "Obtainium의 OS 배터리 최적화를 비활성화하면 백그라운드 다운로드가 더 안정적으로 작동할 수 있습니다.", "batteryOptimizationNote": "Obtainium의 OS 배터리 최적화를 비활성화하면 백그라운드 다운로드가 더 안정적으로 작동할 수 있습니다.",
"fileDeletionError": "파일을 삭제하지 못했습니다(수동으로 삭제한 후 다시 시도하세요): \"{}\"",
"removeAppQuestion": { "removeAppQuestion": {
"one": "앱을 제거하시겠습니까?", "one": "앱을 제거하시겠습니까?",
"other": "앱을 제거하시겠습니까?" "other": "앱을 제거하시겠습니까?"

View File

@ -329,6 +329,7 @@
"welcome": "Welkom", "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.", "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": "Merk op dat downloads op de achtergrond mogelijk betrouwbaarder werken als je de batterijoptimalisatie van het besturingssysteem voor Obtainium uitschakelt.", "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": { "removeAppQuestion": {
"one": "App verwijderen?", "one": "App verwijderen?",
"other": "Apps verwijderen?" "other": "Apps verwijderen?"

View File

@ -329,6 +329,7 @@
"welcome": "Witamy", "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.", "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": "Należy pamiętać, że pobieranie w tle może działać bardziej niezawodnie po wyłączeniu optymalizacji baterii systemu operacyjnego dla 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": { "removeAppQuestion": {
"one": "Usunąć aplikację?", "one": "Usunąć aplikację?",
"few": "Usunąć aplikacje?", "few": "Usunąć aplikacje?",

View File

@ -329,6 +329,7 @@
"welcome": "Boas vindas", "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.", "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": "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.", "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": { "removeAppQuestion": {
"one": "Remover app?", "one": "Remover app?",
"other": "Remover apps?" "other": "Remover apps?"

View File

@ -329,6 +329,7 @@
"welcome": "Bem-vindo", "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.", "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 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.", "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": { "removeAppQuestion": {
"one": "Remover aplicativo?", "one": "Remover aplicativo?",
"other": "Remover aplicativos?" "other": "Remover aplicativos?"

View File

@ -329,6 +329,7 @@
"welcome": "Добро пожаловать", "welcome": "Добро пожаловать",
"documentationLinksNote": "На странице Obtainium GitHub, ссылка на которую приведена ниже, содержатся ссылки на видео, статьи, обсуждения и другие ресурсы, которые помогут вам понять, как пользоваться приложением.", "documentationLinksNote": "На странице Obtainium GitHub, ссылка на которую приведена ниже, содержатся ссылки на видео, статьи, обсуждения и другие ресурсы, которые помогут вам понять, как пользоваться приложением.",
"batteryOptimizationNote": "Обратите внимание, что фоновая загрузка может работать более надежно, если отключить оптимизацию батареи ОС для Obtainium.", "batteryOptimizationNote": "Обратите внимание, что фоновая загрузка может работать более надежно, если отключить оптимизацию батареи ОС для Obtainium.",
"fileDeletionError": "Не удалось удалить файл (попробуйте удалить его вручную, а затем повторите попытку): \"{}\"",
"removeAppQuestion": { "removeAppQuestion": {
"one": "Удалить приложение?", "one": "Удалить приложение?",
"other": "Удалить приложения?" "other": "Удалить приложения?"

View File

@ -329,6 +329,7 @@
"welcome": "Välkommen", "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.", "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": "Observera att nedladdningar i bakgrunden kan fungera mer tillförlitligt om du inaktiverar OS-batterioptimeringar för 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": { "removeAppQuestion": {
"one": "Ta Bort App?", "one": "Ta Bort App?",
"other": "Ta Bort Appar?" "other": "Ta Bort Appar?"

View File

@ -329,6 +329,7 @@
"welcome": "Hoş geldiniz", "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.", "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": "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.", "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": { "removeAppQuestion": {
"one": "Uygulamayı Kaldır?", "one": "Uygulamayı Kaldır?",
"other": "Uygulamaları Kaldır?" "other": "Uygulamaları Kaldır?"

View File

@ -329,6 +329,7 @@
"welcome": "Ласкаво просимо.", "welcome": "Ласкаво просимо.",
"documentationLinksNote": "Сторінка Obtainium на GitHub, посилання на яку наведено нижче, містить посилання на відео, статті, дискусії та інші ресурси, які допоможуть вам зрозуміти, як користуватися додатком.", "documentationLinksNote": "Сторінка Obtainium на GitHub, посилання на яку наведено нижче, містить посилання на відео, статті, дискусії та інші ресурси, які допоможуть вам зрозуміти, як користуватися додатком.",
"batteryOptimizationNote": "Зауважте, що фонові завантаження можуть працювати надійніше, якщо ви вимкнете оптимізацію батареї ОС для Obtainium.", "batteryOptimizationNote": "Зауважте, що фонові завантаження можуть працювати надійніше, якщо ви вимкнете оптимізацію батареї ОС для Obtainium.",
"fileDeletionError": "Не вдалося видалити файл (спробуйте видалити його вручну, а потім спробуйте ще раз): \"{}\"",
"removeAppQuestion": { "removeAppQuestion": {
"one": "Видалити застосунок?", "one": "Видалити застосунок?",
"other": "Видалити застосунки?" "other": "Видалити застосунки?"

View File

@ -329,6 +329,7 @@
"welcome": "Welcome", "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.", "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.", "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": { "removeAppQuestion": {
"one": "Gỡ ứng dụng?", "one": "Gỡ ứng dụng?",
"other": "Gỡ ứng dụng?" "other": "Gỡ ứng dụng?"

View File

@ -329,6 +329,7 @@
"welcome": "歡迎", "welcome": "歡迎",
"documentationLinksNote": "下方連結的 Obtainium GitHub 頁面包含影片、文章、討論及其他資源,能幫助你瞭解如何使用這款應用程式。", "documentationLinksNote": "下方連結的 Obtainium GitHub 頁面包含影片、文章、討論及其他資源,能幫助你瞭解如何使用這款應用程式。",
"batteryOptimizationNote": "Note that background downloads may work more reliably if you disable OS battery optimizations for Obtainium.", "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": { "removeAppQuestion": {
"one": "移除應用程式?", "one": "移除應用程式?",
"other": "移除應用程式?" "other": "移除應用程式?"

View File

@ -329,6 +329,7 @@
"welcome": "欢迎光临", "welcome": "欢迎光临",
"documentationLinksNote": "下面链接的 Obtainium GitHub 页面包含视频、文章、讨论和其他资源的链接,可帮助您了解如何使用该应用程序。", "documentationLinksNote": "下面链接的 Obtainium GitHub 页面包含视频、文章、讨论和其他资源的链接,可帮助您了解如何使用该应用程序。",
"batteryOptimizationNote": "请注意,如果为 Obtainium 禁用操作系统电池优化功能,后台下载可能会更稳定。", "batteryOptimizationNote": "请注意,如果为 Obtainium 禁用操作系统电池优化功能,后台下载可能会更稳定。",
"fileDeletionError": "删除文件失败(尝试手动删除,然后再试一次):\"{}\"",
"removeAppQuestion": { "removeAppQuestion": {
"one": "是否删除应用?", "one": "是否删除应用?",
"other": "是否删除应用?" "other": "是否删除应用?"

View File

@ -2,15 +2,35 @@
# Convenience script # Convenience script
CURR_DIR="$(pwd)" 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 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 git fetch && git merge origin/main && git push # Typically run after a PR to main, so bring dev up to date
fi fi
# Update local Flutter
git submodule update --remote
cd .flutter cd .flutter
git fetch 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 .. 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 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) 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 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 && \ mv ${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools ${ANDROID_SDK_ROOT}/cmdline-tools/latest && \
rm -v /tmp/tools.zip && \ rm -v /tmp/tools.zip && \
mkdir -p /root/.android/ && touch /root/.android/repositories.cfg &&\ 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 &&\ yes | sdkmanager --licenses &&\
sdkmanager --update sdkmanager --update

View File

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

View File

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

View File

@ -8,7 +8,7 @@ import 'package:obtainium/providers/source_provider.dart';
extension Unique<E, Id> on List<E> { extension Unique<E, Id> on List<E> {
List<E> unique([Id Function(E element)? id, bool inplace = true]) { List<E> unique([Id Function(E element)? id, bool inplace = true]) {
final ids = Set(); final ids = <dynamic>{};
var list = inplace ? this : List<E>.from(this); var list = inplace ? this : List<E>.from(this);
list.retainWhere((x) => ids.add(id != null ? id(x) : x as Id)); list.retainWhere((x) => ids.add(id != null ? id(x) : x as Id));
return list; return list;
@ -23,33 +23,44 @@ class APKPure extends AppSource {
showReleaseDateAsVersionToggle = true; showReleaseDateAsVersionToggle = true;
additionalSourceAppSpecificSettingFormItems = [ additionalSourceAppSpecificSettingFormItems = [
[ [
GeneratedFormSwitch('fallbackToOlderReleases', GeneratedFormSwitch(
label: tr('fallbackToOlderReleases'), defaultValue: true) 'fallbackToOlderReleases',
label: tr('fallbackToOlderReleases'),
defaultValue: true,
),
], ],
[ [
GeneratedFormSwitch('stayOneVersionBehind', GeneratedFormSwitch(
label: tr('stayOneVersionBehind'), defaultValue: false) 'stayOneVersionBehind',
label: tr('stayOneVersionBehind'),
defaultValue: false,
),
], ],
[ [
GeneratedFormSwitch('useFirstApkOfVersion', GeneratedFormSwitch(
label: tr('useFirstApkOfVersion'), defaultValue: true) 'useFirstApkOfVersion',
] label: tr('useFirstApkOfVersion'),
defaultValue: true,
),
],
]; ];
} }
@override @override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) { String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegExB = RegExp( RegExp standardUrlRegExB = RegExp(
'^https?://m.${getSourceRegex(hosts)}(/+[^/]{2})?/+[^/]+/+[^/]+', '^https?://m.${getSourceRegex(hosts)}(/+[^/]{2})?/+[^/]+/+[^/]+',
caseSensitive: false); caseSensitive: false,
);
RegExpMatch? match = standardUrlRegExB.firstMatch(url); RegExpMatch? match = standardUrlRegExB.firstMatch(url);
if (match != null) { if (match != null) {
var uri = Uri.parse(url); var uri = Uri.parse(url);
url = 'https://${uri.host.substring(2)}${uri.path}'; url = 'https://${uri.host.substring(2)}${uri.path}';
} }
RegExp standardUrlRegExA = RegExp( RegExp standardUrlRegExA = RegExp(
'^https?://(www\\.)?${getSourceRegex(hosts)}(/+[^/]{2})?/+[^/]+/+[^/]+', '^https?://(www\\.)?${getSourceRegex(hosts)}(/+[^/]{2})?/+[^/]+/+[^/]+',
caseSensitive: false); caseSensitive: false,
);
match = standardUrlRegExA.firstMatch(url); match = standardUrlRegExA.firstMatch(url);
if (match == null) { if (match == null) {
throw InvalidURLError(name); throw InvalidURLError(name);
@ -58,15 +69,18 @@ class APKPure extends AppSource {
} }
@override @override
Future<String?> tryInferringAppId(String standardUrl, Future<String?> tryInferringAppId(
{Map<String, dynamic> additionalSettings = const {}}) async { String standardUrl, {
Map<String, dynamic> additionalSettings = const {},
}) async {
return Uri.parse(standardUrl).pathSegments.last; return Uri.parse(standardUrl).pathSegments.last;
} }
getDetailsForVersion( Future<APKDetails> getDetailsForVersion(
List<Map<String, dynamic>> versionVariants, List<Map<String, dynamic>> versionVariants,
List<String> supportedArchs, List<String> supportedArchs,
Map<String, dynamic> additionalSettings) async { Map<String, dynamic> additionalSettings,
) async {
var apkUrls = versionVariants var apkUrls = versionVariants
.map((e) { .map((e) {
String appId = e['package_name']; String appId = e['package_name'];
@ -88,8 +102,9 @@ class APKPure extends AppSource {
String downloadUri = e['asset']['url']; String downloadUri = e['asset']['url'];
return MapEntry( return MapEntry(
'$appId-$versionCode-$architectureString.${type.toLowerCase()}', '$appId-$versionCode-$architectureString.${type.toLowerCase()}',
downloadUri); downloadUri,
);
}) })
.nonNulls .nonNulls
.toList() .toList()
@ -114,14 +129,20 @@ class APKPure extends AppSource {
apkUrls = [apkUrls.first]; apkUrls = [apkUrls.first];
} }
return APKDetails(version, apkUrls, AppNames(author, appName), return APKDetails(
releaseDate: releaseDate, changeLog: changeLog); version,
apkUrls,
AppNames(author, appName),
releaseDate: releaseDate,
changeLog: changeLog,
);
} }
@override @override
Future<Map<String, String>?> getRequestHeaders( Future<Map<String, String>?> getRequestHeaders(
Map<String, dynamic> additionalSettings, Map<String, dynamic> additionalSettings, {
{bool forAPKDownload = false}) async { bool forAPKDownload = false,
}) async {
if (forAPKDownload) { if (forAPKDownload) {
return null; return null;
} else { } else {
@ -145,19 +166,22 @@ class APKPure extends AppSource {
// request versions from API // request versions from API
var res = await sourceRequest( var res = await sourceRequest(
"https://tapi.pureapk.com/v3/get_app_his_version?package_name=$appId&hl=en", "https://tapi.pureapk.com/v3/get_app_his_version?package_name=$appId&hl=en",
additionalSettings); additionalSettings,
);
if (res.statusCode != 200) { if (res.statusCode != 200) {
throw getObtainiumHttpError(res); throw getObtainiumHttpError(res);
} }
List<Map<String, dynamic>> apks = List<Map<String, dynamic>> apks = jsonDecode(
jsonDecode(res.body)['version_list'].cast<Map<String, dynamic>>(); res.body,
)['version_list'].cast<Map<String, dynamic>>();
// group by version // group by version
List<List<Map<String, dynamic>>> versions = apks List<List<Map<String, dynamic>>> versions = apks
.fold<Map<String, List<Map<String, dynamic>>>>({}, .fold<Map<String, List<Map<String, dynamic>>>>({}, (
(Map<String, List<Map<String, dynamic>>> val, Map<String, List<Map<String, dynamic>>> val,
Map<String, dynamic> element) { Map<String, dynamic> element,
) {
String v = element['version_name']; String v = element['version_name'];
if (!val.containsKey(v)) { if (!val.containsKey(v)) {
val[v] = []; val[v] = [];
@ -179,7 +203,10 @@ class APKPure extends AppSource {
throw NoReleasesError(); throw NoReleasesError();
} }
return await getDetailsForVersion( return await getDetailsForVersion(
v, supportedArchs, additionalSettings); v,
supportedArchs,
additionalSettings,
);
} catch (e) { } catch (e) {
if (additionalSettings['fallbackToOlderReleases'] != true || if (additionalSettings['fallbackToOlderReleases'] != true ||
i == versions.length - 1) { i == versions.length - 1) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,8 +12,9 @@ class NeutronCode extends AppSource {
@override @override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) { String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegEx = RegExp( RegExp standardUrlRegEx = RegExp(
'^https?://(www\\.)?${getSourceRegex(hosts)}/downloads/file/[^/]+', '^https?://(www\\.)?${getSourceRegex(hosts)}/downloads/file/[^/]+',
caseSensitive: false); caseSensitive: false,
);
RegExpMatch? match = standardUrlRegEx.firstMatch(url); RegExpMatch? match = standardUrlRegEx.firstMatch(url);
if (match == null) { if (match == null) {
throw InvalidURLError(name); throw InvalidURLError(name);
@ -55,7 +56,7 @@ class NeutronCode extends AppSource {
} }
} }
customDateParse(String dateString) { String? customDateParse(String dateString) {
List<String> parts = dateString.split(' '); List<String> parts = dateString.split(' ');
if (parts.length != 3) { if (parts.length != 3) {
return null; return null;
@ -89,24 +90,31 @@ class NeutronCode extends AppSource {
if (filename == null) { if (filename == null) {
throw NoReleasesError(); throw NoReleasesError();
} }
var version = var version = http
http.querySelector('.pd-version-txt')?.nextElementSibling?.innerHtml; .querySelector('.pd-version-txt')
?.nextElementSibling
?.innerHtml;
if (version == null) { if (version == null) {
throw NoVersionError(); throw NoVersionError();
} }
String? apkUrl = 'https://${hosts[0]}/download/$filename'; String? apkUrl = 'https://${hosts[0]}/download/$filename';
var dateStringOriginal = var dateStringOriginal = http
http.querySelector('.pd-date-txt')?.nextElementSibling?.innerHtml; .querySelector('.pd-date-txt')
?.nextElementSibling
?.innerHtml;
var dateString = dateStringOriginal != null var dateString = dateStringOriginal != null
? (customDateParse(dateStringOriginal)) ? (customDateParse(dateStringOriginal))
: null; : null;
var changeLogElements = http.querySelectorAll('.pd-fdesc p'); var changeLogElements = http.querySelectorAll('.pd-fdesc p');
return APKDetails(version, getApkUrlsFromUrls([apkUrl]), return APKDetails(
AppNames(runtimeType.toString(), name ?? standardUrl.split('/').last), version,
releaseDate: dateString != null ? DateTime.parse(dateString) : null, getApkUrlsFromUrls([apkUrl]),
changeLog: changeLogElements.isNotEmpty AppNames(runtimeType.toString(), name ?? standardUrl.split('/').last),
? changeLogElements.last.innerHtml releaseDate: dateString != null ? DateTime.parse(dateString) : null,
: null); changeLog: changeLogElements.isNotEmpty
? changeLogElements.last.innerHtml
: null,
);
} else { } else {
throw getObtainiumHttpError(res); throw getObtainiumHttpError(res);
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -23,15 +23,19 @@ class VivoAppStore extends AppSource {
} }
@override @override
Future<String?> tryInferringAppId(String standardUrl, Future<String?> tryInferringAppId(
{Map<String, dynamic> additionalSettings = const {}}) async { String standardUrl, {
Map<String, dynamic> additionalSettings = const {},
}) async {
var json = await getDetailJson(standardUrl, additionalSettings); var json = await getDetailJson(standardUrl, additionalSettings);
return json['package_name']; return json['package_name'];
} }
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, Map<String, dynamic> additionalSettings) async { String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
var json = await getDetailJson(standardUrl, additionalSettings); var json = await getDetailJson(standardUrl, additionalSettings);
var appName = json['title_zh'].toString(); var appName = json['title_zh'].toString();
var packageName = json['package_name'].toString(); var packageName = json['package_name'].toString();
@ -42,13 +46,18 @@ class VivoAppStore extends AppSource {
var apkUrl = json['download_url'].toString(); var apkUrl = json['download_url'].toString();
var apkName = '${packageName}_$versionCode.apk'; var apkName = '${packageName}_$versionCode.apk';
return APKDetails( return APKDetails(
versionName, [MapEntry(apkName, apkUrl)], AppNames(developer, appName), versionName,
releaseDate: DateTime.parse(uploadTime)); [MapEntry(apkName, apkUrl)],
AppNames(developer, appName),
releaseDate: DateTime.parse(uploadTime),
);
} }
@override @override
Future<Map<String, List<String>>> search(String query, Future<Map<String, List<String>>> search(
{Map<String, dynamic> querySettings = const {}}) async { String query, {
Map<String, dynamic> querySettings = const {},
}) async {
var apiBaseUrl = 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='; '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)}'; var searchUrl = '$apiBaseUrl${Uri.encodeQueryComponent(query)}';
@ -65,14 +74,16 @@ class VivoAppStore extends AppSource {
for (var item in (resultsJson as List<dynamic>)) { for (var item in (resultsJson as List<dynamic>)) {
results['$appDetailUrl${item['id']}'] = [ results['$appDetailUrl${item['id']}'] = [
item['title_zh'].toString(), item['title_zh'].toString(),
item['developer'].toString() item['developer'].toString(),
]; ];
} }
return results; return results;
} }
Future<Map<String, dynamic>> getDetailJson( Future<Map<String, dynamic>> getDetailJson(
String standardUrl, Map<String, dynamic> additionalSettings) async { String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
var vivoAppId = parseVivoAppId(standardUrl); var vivoAppId = parseVivoAppId(standardUrl);
var apiBaseUrl = 'https://h5-api.appstore.vivo.com.cn/detail/'; var apiBaseUrl = 'https://h5-api.appstore.vivo.com.cn/detail/';
var params = '?frompage=messageh5&app_version=2100'; 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), titlePadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
title: Text( title: Text(
widget.title, widget.title,
style: style: TextStyle(
TextStyle(color: Theme.of(context).textTheme.bodyMedium!.color), color: Theme.of(context).textTheme.bodyMedium!.color,
),
), ),
), ),
); );

View File

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

View File

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

View File

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

View File

@ -14,11 +14,15 @@ class GitHubStars implements MassAppUrlSource {
late List<String> requiredArgs = [tr('uname')]; late List<String> requiredArgs = [tr('uname')];
Future<Map<String, List<String>>> getOnePageOfUserStarredUrlsWithDescriptions( Future<Map<String, List<String>>> getOnePageOfUserStarredUrlsWithDescriptions(
String username, int page) async { String username,
int page,
) async {
Response res = await get( Response res = await get(
Uri.parse( Uri.parse(
'https://api.github.com/users/$username/starred?per_page=100&page=$page'), 'https://api.github.com/users/$username/starred?per_page=100&page=$page',
headers: await GitHub().getRequestHeaders({})); ),
headers: await GitHub().getRequestHeaders({}),
);
if (res.statusCode == 200) { if (res.statusCode == 200) {
Map<String, List<String>> urlsWithDescriptions = {}; Map<String, List<String>> urlsWithDescriptions = {};
for (var e in (jsonDecode(res.body) as List<dynamic>)) { for (var e in (jsonDecode(res.body) as List<dynamic>)) {
@ -27,8 +31,8 @@ class GitHubStars implements MassAppUrlSource {
e['full_name'] as String, e['full_name'] as String,
e['description'] != null e['description'] != null
? e['description'] as String ? e['description'] as String
: tr('noDescription') : tr('noDescription'),
] ],
}); });
} }
return urlsWithDescriptions; return urlsWithDescriptions;
@ -41,15 +45,18 @@ class GitHubStars implements MassAppUrlSource {
@override @override
Future<Map<String, List<String>>> getUrlsWithDescriptions( Future<Map<String, List<String>>> getUrlsWithDescriptions(
List<String> args) async { List<String> args,
) async {
if (args.length != requiredArgs.length) { if (args.length != requiredArgs.length) {
throw ObtainiumError(tr('wrongArgNum')); throw ObtainiumError(tr('wrongArgNum'));
} }
Map<String, List<String>> urlsWithDescriptions = {}; Map<String, List<String>> urlsWithDescriptions = {};
var page = 1; var page = 1;
while (true) { while (true) {
var pageUrls = var pageUrls = await getOnePageOfUserStarredUrlsWithDescriptions(
await getOnePageOfUserStarredUrlsWithDescriptions(args[0], page++); args[0],
page++,
);
urlsWithDescriptions.addAll(pageUrls); urlsWithDescriptions.addAll(pageUrls);
if (pageUrls.length < 100) { if (pageUrls.length < 100) {
break; 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) { onWebResourceError: (WebResourceError error) {
if (error.isForMainFrame == true) { if (error.isForMainFrame == true) {
showError( showError(
ObtainiumError(error.description, unexpected: true), context); ObtainiumError(error.description, unexpected: true),
context,
);
} }
}, },
onNavigationRequest: (NavigationRequest request) => onNavigationRequest: (NavigationRequest request) =>
request.url.startsWith("rustore://") request.url.startsWith("rustore://")
? NavigationDecision.prevent ? NavigationDecision.prevent
: NavigationDecision.navigate, : NavigationDecision.navigate,
), ),
); );
} }
@ -85,8 +87,10 @@ class _AppPageState extends State<AppPage> {
var sourceProvider = SourceProvider(); var sourceProvider = SourceProvider();
AppInMemory? app = appsProvider.apps[widget.appId]?.deepCopy(); AppInMemory? app = appsProvider.apps[widget.appId]?.deepCopy();
var source = app != null var source = app != null
? sourceProvider.getSource(app.app.url, ? sourceProvider.getSource(
overrideSource: app.app.overrideSource) app.app.url,
overrideSource: app.app.overrideSource,
)
: null; : null;
if (!areDownloadsRunning && if (!areDownloadsRunning &&
prevApp == null && prevApp == null &&
@ -100,9 +104,9 @@ class _AppPageState extends State<AppPage> {
bool isVersionDetectionStandard = bool isVersionDetectionStandard =
app?.app.additionalSettings['versionDetection'] == true; app?.app.additionalSettings['versionDetection'] == true;
bool installedVersionIsEstimate = trackOnly || bool installedVersionIsEstimate = app?.app != null
(app?.app.installedVersion != null && ? isVersionPseudo(app!.app)
app?.app.additionalSettings['versionDetection'] != true); : false;
if (app != null && !_wasWebViewOpened) { if (app != null && !_wasWebViewOpened) {
_wasWebViewOpened = true; _wasWebViewOpened = true;
@ -124,11 +128,14 @@ class _AppPageState extends State<AppPage> {
if (!upToDate) { if (!upToDate) {
versionLines += '\n${app?.app.latestVersion} ${tr('latest')}'; versionLines += '\n${app?.app.latestVersion} ${tr('latest')}';
} }
String infoLines = tr('lastUpdateCheckX', args: [ String infoLines = tr(
app?.app.lastUpdateCheck == null 'lastUpdateCheckX',
? tr('never') args: [
: '${app?.app.lastUpdateCheck?.toLocal()}' app?.app.lastUpdateCheck == null
]); ? tr('never')
: '${app?.app.lastUpdateCheck?.toLocal()}',
],
);
if (trackOnly) { if (trackOnly) {
infoLines = '${tr('xIsTrackOnly', args: [tr('app')])}\n$infoLines'; 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), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 24),
child: Column( child: Column(
children: [ children: [
const SizedBox( const SizedBox(height: 8),
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 changeLogFn != null || app?.app.releaseDate != null
? GestureDetector( ? GestureDetector(
onTap: changeLogFn, onTap: changeLogFn,
@ -165,21 +171,19 @@ class _AppPageState extends State<AppPage> {
? tr('changes') ? tr('changes')
: app!.app.releaseDate!.toLocal().toString(), : app!.app.releaseDate!.toLocal().toString(),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: style: Theme.of(context).textTheme.labelSmall!
Theme.of(context).textTheme.labelSmall!.copyWith( .copyWith(
decoration: changeLogFn != null decoration: changeLogFn != null
? TextDecoration.underline ? TextDecoration.underline
: null, : null,
fontStyle: changeLogFn != null fontStyle: changeLogFn != null
? FontStyle.italic ? FontStyle.italic
: null, : null,
), ),
), ),
) )
: const SizedBox.shrink(), : const SizedBox.shrink(),
const SizedBox( const SizedBox(height: 8),
height: 8,
),
], ],
), ),
), ),
@ -191,101 +195,108 @@ class _AppPageState extends State<AppPage> {
if (app?.app.apkUrls.isNotEmpty == true || if (app?.app.apkUrls.isNotEmpty == true ||
app?.app.otherAssetUrls.isNotEmpty == true) app?.app.otherAssetUrls.isNotEmpty == true)
GestureDetector( GestureDetector(
onTap: app?.app == null || updating onTap: app?.app == null || updating
? null ? null
: () async { : () async {
try { try {
await appsProvider await appsProvider.downloadAppAssets([
.downloadAppAssets([app!.app.id], context); app!.app.id,
} catch (e) { ], context);
showError(e, context); } catch (e) {
} showError(e, context);
}, }
child: Row( },
mainAxisAlignment: MainAxisAlignment.center, child: Row(
children: [ mainAxisAlignment: MainAxisAlignment.center,
Container( children: [
decoration: BoxDecoration( Container(
borderRadius: BorderRadius.circular(12), decoration: BoxDecoration(
color: settingsProvider.highlightTouchTargets borderRadius: BorderRadius.circular(12),
? (Theme.of(context).brightness == color: settingsProvider.highlightTouchTargets
Brightness.light ? (Theme.of(context).brightness == Brightness.light
? Theme.of(context).primaryColor ? Theme.of(context).primaryColor
: Theme.of(context).primaryColorLight) : Theme.of(context).primaryColorLight)
.withAlpha(Theme.of(context).brightness == .withAlpha(
Brightness.light Theme.of(context).brightness ==
? 20 Brightness.light
: 40) ? 20
: null), : 40,
padding: settingsProvider.highlightTouchTargets )
? const EdgeInsetsDirectional.fromSTEB(12, 6, 12, 6) : null,
: const EdgeInsetsDirectional.fromSTEB(0, 6, 0, 6), ),
margin: padding: settingsProvider.highlightTouchTargets
const EdgeInsetsDirectional.fromSTEB(0, 6, 0, 0), ? const EdgeInsetsDirectional.fromSTEB(12, 6, 12, 6)
child: Text( : const EdgeInsetsDirectional.fromSTEB(0, 6, 0, 6),
tr('downloadX', margin: const EdgeInsetsDirectional.fromSTEB(0, 6, 0, 0),
args: [tr('releaseAsset').toLowerCase()]), child: Text(
textAlign: TextAlign.center, tr('downloadX', args: [tr('releaseAsset').toLowerCase()]),
style: textAlign: TextAlign.center,
Theme.of(context).textTheme.labelSmall!.copyWith( style: Theme.of(context).textTheme.labelSmall!.copyWith(
decoration: TextDecoration.underline, decoration: TextDecoration.underline,
fontStyle: FontStyle.italic, fontStyle: FontStyle.italic,
), ),
)) ),
], ),
)), ],
const SizedBox( ),
height: 48, ),
), const SizedBox(height: 48),
CategoryEditorSelector( CategoryEditorSelector(
alignment: WrapAlignment.center, alignment: WrapAlignment.center,
preselected: app?.app.categories != null preselected: app?.app.categories != null
? app!.app.categories.toSet() ? app!.app.categories.toSet()
: {}, : {},
onSelected: (categories) { onSelected: (categories) {
if (app != null) { if (app != null) {
app.app.categories = categories; app.app.categories = categories;
appsProvider.saveApps([app.app]); appsProvider.saveApps([app.app]);
} }
}), },
),
if (app?.app.additionalSettings['about'] is String && if (app?.app.additionalSettings['about'] is String &&
app?.app.additionalSettings['about'].isNotEmpty) app?.app.additionalSettings['about'].isNotEmpty)
Column( Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const SizedBox( const SizedBox(height: 48),
height: 48,
),
GestureDetector( GestureDetector(
onLongPress: () { onLongPress: () {
Clipboard.setData(ClipboardData( Clipboard.setData(
text: app?.app.additionalSettings['about'] ?? '')); ClipboardData(
ScaffoldMessenger.of(context).showSnackBar(SnackBar( text: app?.app.additionalSettings['about'] ?? '',
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
],
), ),
)) );
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( getFullInfoColumn({bool small = false}) => Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
SizedBox(height: small ? 5 : 20), SizedBox(height: small ? 5 : 20),
FutureBuilder( FutureBuilder(
future: future: appsProvider.updateAppIcon(app?.app.id, ignoreCache: true),
appsProvider.updateAppIcon(app?.app.id, ignoreCache: true), builder: (ctx, val) {
builder: (ctx, val) { return app?.icon != null
return app?.icon != null ? Row(
? Row( mainAxisAlignment: MainAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center, children: [
children: [ GestureDetector(
GestureDetector( onTap: app == null
onTap: app == null ? null
? null : () => pm.openApp(app.app.id),
: () => pm.openApp(app.app.id), child: Image.memory(
child: Image.memory( app!.icon!,
app!.icon!, height: small ? 70 : 150,
height: small ? 70 : 150, gaplessPlayback: true,
gaplessPlayback: true, ),
), ),
) ],
]) )
: Container(); : Container();
}), },
SizedBox( ),
height: small ? 10 : 25, 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, Text(
style: small app?.app.id ?? '',
? Theme.of(context).textTheme.displaySmall textAlign: TextAlign.center,
: Theme.of(context).textTheme.displayLarge, style: Theme.of(context).textTheme.labelSmall,
), ),
Text(tr('byX', args: [app?.author ?? tr('unknown')]), getInfoColumn(),
textAlign: TextAlign.center, const SizedBox(height: 150),
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)
],
);
getAppWebView() => app != null getAppWebView() => app != null
? WebViewWidget( ? WebViewWidget(
key: ObjectKey(_webViewController), key: ObjectKey(_webViewController),
controller: _webViewController controller: _webViewController
..setBackgroundColor(Theme.of(context).colorScheme.surface)) ..setBackgroundColor(Theme.of(context).colorScheme.surface),
)
: Container(); : Container();
showMarkUpdatedDialog() { showMarkUpdatedDialog() {
return showDialog( return showDialog(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
return AlertDialog( return AlertDialog(
title: Text(tr('alreadyUpToDateQuestion')), title: Text(tr('alreadyUpToDateQuestion')),
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
child: Text(tr('no'))), child: Text(tr('no')),
TextButton( ),
onPressed: () { TextButton(
HapticFeedback.selectionClick(); onPressed: () {
var updatedApp = app?.app; HapticFeedback.selectionClick();
if (updatedApp != null) { var updatedApp = app?.app;
updatedApp.installedVersion = updatedApp.latestVersion; if (updatedApp != null) {
appsProvider.saveApps([updatedApp]); updatedApp.installedVersion = updatedApp.latestVersion;
} appsProvider.saveApps([updatedApp]);
Navigator.of(context).pop(); }
}, Navigator.of(context).pop();
child: Text(tr('yesMarkUpdated'))) },
], child: Text(tr('yesMarkUpdated')),
); ),
}); ],
);
},
);
} }
showAdditionalOptionsDialog() async { showAdditionalOptionsDialog() async {
return await showDialog<Map<String, dynamic>?>( return await showDialog<Map<String, dynamic>?>(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
var items = var items = (source?.combinedAppSpecificSettingFormItems ?? []).map((
(source?.combinedAppSpecificSettingFormItems ?? []).map((row) { row,
row = row.map((e) { ) {
if (app?.app.additionalSettings[e.key] != null) { row = row.map((e) {
e.defaultValue = app?.app.additionalSettings[e.key]; if (app?.app.additionalSettings[e.key] != null) {
} e.defaultValue = app?.app.additionalSettings[e.key];
return e; }
}).toList(); return e;
return row;
}).toList(); }).toList();
return row;
}).toList();
return GeneratedFormModal( return GeneratedFormModal(
title: tr('additionalOptions'), items: items); title: tr('additionalOptions'),
}); items: items,
);
},
);
} }
handleAdditionalOptionChanges(Map<String, dynamic>? values) { handleAdditionalOptionChanges(Map<String, dynamic>? values) {
@ -432,18 +454,18 @@ class _AppPageState extends State<AppPage> {
} }
var versionDetectionEnabled = var versionDetectionEnabled =
app.app.additionalSettings['versionDetection'] == true && app.app.additionalSettings['versionDetection'] == true &&
originalSettings['versionDetection'] != true; originalSettings['versionDetection'] != true;
var releaseDateVersionEnabled = var releaseDateVersionEnabled =
app.app.additionalSettings['releaseDateAsVersion'] == true && app.app.additionalSettings['releaseDateAsVersion'] == true &&
originalSettings['releaseDateAsVersion'] != true; originalSettings['releaseDateAsVersion'] != true;
var releaseDateVersionDisabled = var releaseDateVersionDisabled =
app.app.additionalSettings['releaseDateAsVersion'] != true && app.app.additionalSettings['releaseDateAsVersion'] != true &&
originalSettings['releaseDateAsVersion'] == true; originalSettings['releaseDateAsVersion'] == true;
if (releaseDateVersionEnabled) { if (releaseDateVersionEnabled) {
if (app.app.releaseDate != null) { if (app.app.releaseDate != null) {
bool isUpdated = app.app.installedVersion == app.app.latestVersion; bool isUpdated = app.app.installedVersion == app.app.latestVersion;
app.app.latestVersion = app.app.latestVersion = app.app.releaseDate!.microsecondsSinceEpoch
app.app.releaseDate!.microsecondsSinceEpoch.toString(); .toString();
if (isUpdated) { if (isUpdated) {
app.app.installedVersion = app.app.latestVersion; app.app.installedVersion = app.app.latestVersion;
} }
@ -463,172 +485,195 @@ class _AppPageState extends State<AppPage> {
} }
getInstallOrUpdateButton() => TextButton( getInstallOrUpdateButton() => TextButton(
onPressed: !updating && onPressed:
(app?.app.installedVersion == null || !updating &&
app?.app.installedVersion != app?.app.latestVersion) && (app?.app.installedVersion == null ||
!areDownloadsRunning app?.app.installedVersion != app?.app.latestVersion) &&
? () async { !areDownloadsRunning
try { ? () async {
var successMessage = app?.app.installedVersion == null try {
? tr('installed') var successMessage = app?.app.installedVersion == null
: tr('appsUpdated'); ? tr('installed')
HapticFeedback.heavyImpact(); : tr('appsUpdated');
var res = await appsProvider.downloadAndInstallLatestApps( HapticFeedback.heavyImpact();
app?.app.id != null ? [app!.app.id] : [], var res = await appsProvider.downloadAndInstallLatestApps(
globalNavigatorKey.currentContext, app?.app.id != null ? [app!.app.id] : [],
); globalNavigatorKey.currentContext,
if (res.isNotEmpty && !trackOnly) { );
// ignore: use_build_context_synchronously if (res.isNotEmpty && !trackOnly) {
showMessage(successMessage, context);
}
if (res.isNotEmpty && mounted) {
Navigator.of(context).pop();
}
} catch (e) {
// ignore: use_build_context_synchronously // 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 ? !trackOnly
? tr('install') ? tr('install')
: tr('markInstalled') : tr('markInstalled')
: !trackOnly : !trackOnly
? tr('update') ? tr('update')
: tr('markUpdated'))); : tr('markUpdated'),
),
);
getBottomSheetMenu() => Padding( getBottomSheetMenu() => Padding(
padding: padding: EdgeInsets.fromLTRB(
EdgeInsets.fromLTRB(0, 0, 0, MediaQuery.of(context).padding.bottom), 0,
child: Column( 0,
mainAxisSize: MainAxisSize.min, 0,
children: [ MediaQuery.of(context).padding.bottom,
Padding( ),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: Column(
child: Row( mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [
children: [ Padding(
if (source != null && padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
source.combinedAppSpecificSettingFormItems.isNotEmpty) child: Row(
IconButton( mainAxisAlignment: MainAxisAlignment.spaceEvenly,
onPressed: app?.downloadProgress != null || updating children: [
? null if (source != null &&
: () async { source.combinedAppSpecificSettingFormItems.isNotEmpty)
var values = IconButton(
await showAdditionalOptionsDialog(); onPressed: app?.downloadProgress != null || updating
handleAdditionalOptionChanges(values); ? null
}, : () async {
tooltip: tr('additionalOptions'), var values = await showAdditionalOptionsDialog();
icon: const Icon(Icons.edit)), handleAdditionalOptionChanges(values);
if (app != null && app.installedInfo != null)
IconButton(
onPressed: () {
appsProvider.openAppSettings(app.app.id);
}, },
icon: const Icon(Icons.settings), tooltip: tr('additionalOptions'),
tooltip: tr('settings'), icon: const Icon(Icons.edit),
), ),
if (app != null && settingsProvider.showAppWebpage) if (app != null && app.installedInfo != null)
IconButton( IconButton(
onPressed: () { onPressed: () {
showDialog( appsProvider.openAppSettings(app.app.id);
context: context, },
builder: (BuildContext ctx) { icon: const Icon(Icons.settings),
return AlertDialog( tooltip: tr('settings'),
scrollable: true, ),
content: getFullInfoColumn(small: true), if (app != null && settingsProvider.showAppWebpage)
title: Text(app.name), IconButton(
actions: [ onPressed: () {
TextButton( showDialog(
onPressed: () { context: context,
Navigator.of(context).pop(); builder: (BuildContext ctx) {
}, return AlertDialog(
child: Text(tr('continue'))) scrollable: true,
], content: getFullInfoColumn(small: true),
); title: Text(app.name),
}); actions: [
}, TextButton(
icon: const Icon(Icons.more_horiz), onPressed: () {
tooltip: tr('more')), Navigator.of(context).pop();
if (app?.app.installedVersion != null && },
app?.app.installedVersion != app?.app.latestVersion && child: Text(tr('continue')),
!isVersionDetectionStandard && ),
!trackOnly) ],
IconButton( );
onPressed: app?.downloadProgress != null || updating },
? null );
: showMarkUpdatedDialog, },
tooltip: tr('markUpdated'), icon: const Icon(Icons.more_horiz),
icon: const Icon(Icons.done)), tooltip: tr('more'),
if ((!isVersionDetectionStandard || trackOnly) && ),
app?.app.installedVersion != null && if (app?.app.installedVersion != null &&
app?.app.installedVersion == app?.app.latestVersion) app?.app.installedVersion != app?.app.latestVersion &&
IconButton( !isVersionDetectionStandard &&
onPressed: app?.app == null || updating !trackOnly)
? null IconButton(
: () { onPressed: app?.downloadProgress != null || updating
app!.app.installedVersion = null; ? null
appsProvider.saveApps([app.app]); : showMarkUpdatedDialog,
}, tooltip: tr('markUpdated'),
icon: const Icon(Icons.restore_rounded), icon: const Icon(Icons.done),
tooltip: tr('resetInstallStatus')), ),
const SizedBox(width: 16.0), if ((!isVersionDetectionStandard || trackOnly) &&
Expanded(child: getInstallOrUpdateButton()), app?.app.installedVersion != null &&
const SizedBox(width: 16.0), app?.app.installedVersion == app?.app.latestVersion)
IconButton( IconButton(
onPressed: app?.downloadProgress != null || updating onPressed: app?.app == null || updating
? null ? null
: () { : () {
appsProvider app!.app.installedVersion = null;
.removeAppsWithModal( appsProvider.saveApps([app.app]);
context, app != null ? [app.app] : []) },
.then((value) { icon: const Icon(Icons.restore_rounded),
if (value == true) { tooltip: tr('resetInstallStatus'),
Navigator.of(context).pop(); ),
} const SizedBox(width: 16.0),
}); Expanded(child: getInstallOrUpdateButton()),
}, const SizedBox(width: 16.0),
tooltip: tr('remove'), IconButton(
icon: const Icon(Icons.delete_outline), onPressed: app?.downloadProgress != null || updating
), ? null
])), : () {
if (app?.downloadProgress != null) appsProvider
Padding( .removeAppsWithModal(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 0), context,
child: LinearProgressIndicator( app != null ? [app.app] : [],
value: app!.downloadProgress! >= 0 )
? app.downloadProgress! / 100 .then((value) {
: null)) 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( appScreenAppBar() => AppBar(
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () { onPressed: () {
Navigator.pop(context); Navigator.pop(context);
}, },
), ),
); );
return Scaffold( return Scaffold(
appBar: settingsProvider.showAppWebpage ? AppBar() : appScreenAppBar(), appBar: settingsProvider.showAppWebpage ? AppBar() : appScreenAppBar(),
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
body: RefreshIndicator( body: RefreshIndicator(
child: settingsProvider.showAppWebpage child: settingsProvider.showAppWebpage
? getAppWebView() ? getAppWebView()
: CustomScrollView( : CustomScrollView(
slivers: [ slivers: [
SliverToBoxAdapter( SliverToBoxAdapter(
child: Column(children: [getFullInfoColumn()])), child: Column(children: [getFullInfoColumn()]),
],
), ),
onRefresh: () async { ],
if (app != null) { ),
getUpdate(app.app.id); onRefresh: () async {
} if (app != null) {
}), getUpdate(app.app.id);
bottomSheet: getBottomSheetMenu()); }
},
),
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; bool isLinkActivity = false;
List<NavigationPageItem> pages = [ List<NavigationPageItem> pages = [
NavigationPageItem(tr('appsString'), Icons.apps,
AppsPage(key: GlobalKey<AppsPageState>())),
NavigationPageItem( NavigationPageItem(
tr('addApp'), Icons.add, AddAppPage(key: GlobalKey<AddAppPageState>())), tr('appsString'),
Icons.apps,
AppsPage(key: GlobalKey<AppsPageState>()),
),
NavigationPageItem( NavigationPageItem(
tr('importExport'), Icons.import_export, const ImportExportPage()), tr('addApp'),
NavigationPageItem(tr('settings'), Icons.settings, const SettingsPage()) Icons.add,
AddAppPage(key: GlobalKey<AddAppPageState>()),
),
NavigationPageItem(
tr('importExport'),
Icons.import_export,
const ImportExportPage(),
),
NavigationPageItem(tr('settings'), Icons.settings, const SettingsPage()),
]; ];
@override @override
@ -60,63 +69,69 @@ class _HomePageState extends State<HomePage> {
var sp = context.read<SettingsProvider>(); var sp = context.read<SettingsProvider>();
if (!sp.welcomeShown) { if (!sp.welcomeShown) {
await showDialog( await showDialog(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
return AlertDialog( return AlertDialog(
title: Text(tr('welcome')), title: Text(tr('welcome')),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
spacing: 20, spacing: 20,
children: [ children: [
Text(tr('documentationLinksNote')), Text(tr('documentationLinksNote')),
GestureDetector( 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: () { onTap: () {
launchUrlString( final intent = AndroidIntent(
'https://github.com/ImranR98/Obtainium/blob/main/README.md', action:
mode: LaunchMode.externalApplication); 'android.settings.IGNORE_BATTERY_OPTIMIZATION_SETTINGS',
package:
obtainiumId, // Replace with your app's package name
);
intent.launch();
}, },
child: Text( child: Text(
'https://github.com/ImranR98/Obtainium/blob/main/README.md', tr('settings'),
style: const TextStyle( style: const TextStyle(
decoration: TextDecoration.underline, decoration: TextDecoration.underline,
fontWeight: FontWeight.bold), 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),
), ),
) ),
], ),
) ],
], ),
),
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 { goToAddApp(String data) async {
switchToPage(1); switchToPage(1);
while ( while ((pages[1].widget.key as GlobalKey<AddAppPageState>?)
(pages[1].widget.key as GlobalKey<AddAppPageState>?)?.currentState == ?.currentState ==
null) { null) {
await Future.delayed(const Duration(microseconds: 1)); await Future.delayed(const Duration(microseconds: 1));
} }
(pages[1].widget.key as GlobalKey<AddAppPageState>?) (pages[1].widget.key as GlobalKey<AddAppPageState>?)?.currentState
?.currentState
?.linkFn(data); ?.linkFn(data);
} }
@ -146,44 +160,55 @@ class _HomePageState extends State<HomePage> {
} else if (action == 'app' || action == 'apps') { } else if (action == 'app' || action == 'apps') {
var dataStr = Uri.decodeComponent(data); var dataStr = Uri.decodeComponent(data);
if (await showDialog( if (await showDialog(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
return GeneratedFormModal( return GeneratedFormModal(
title: tr('importX', args: [ title: tr(
action == 'app' ? tr('app') : tr('appsString') 'importX',
]), args: [
items: const [], (action == 'app' ? tr('app') : tr('appsString'))
additionalWidgets: [ .toLowerCase(),
ExpansionTile(
title: const Text('Raw JSON'),
children: [
Text(
dataStr,
style: const TextStyle(fontFamily: 'monospace'),
)
],
)
], ],
); ),
}) != items: const [],
additionalWidgets: [
ExpansionTile(
title: const Text('Raw JSON'),
children: [
Text(
dataStr,
style: const TextStyle(fontFamily: 'monospace'),
),
],
),
],
);
},
) !=
null) { null) {
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
var appsProvider = context.read<AppsProvider>(); var appsProvider = context.read<AppsProvider>();
var result = await appsProvider.import(action == 'app' var result = await appsProvider.import(
? '{ "apps": [$dataStr] }' action == 'app'
: '{ "apps": $dataStr }'); ? '{ "apps": [$dataStr] }'
: '{ "apps": $dataStr }',
);
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
showMessage( showMessage(
tr('importedX', args: [plural('apps', result.key.length)]), tr(
context); 'importedX',
args: [plural('apps', result.key.length).toLowerCase()],
),
context,
);
await appsProvider await appsProvider
.checkUpdates(specificIds: result.key.map((e) => e.id).toList()) .checkUpdates(specificIds: result.key.map((e) => e.id).toList())
.catchError((e) { .catchError((e) {
if (e is Map && e['errors'] is MultiAppMultiError) { if (e is Map && e['errors'] is MultiAppMultiError) {
showError(e['errors'].toString(), context); showError(e['errors'].toString(), context);
} }
return <App>[]; return <App>[];
}); });
} }
} else { } else {
throw ObtainiumError(tr('unknown')); throw ObtainiumError(tr('unknown'));
@ -210,15 +235,16 @@ class _HomePageState extends State<HomePage> {
}); });
} }
setIsReversing(int targetIndex) { void setIsReversing(int targetIndex) {
bool reversing = selectedIndexHistory.isNotEmpty && bool reversing =
selectedIndexHistory.isNotEmpty &&
selectedIndexHistory.last > targetIndex; selectedIndexHistory.last > targetIndex;
setState(() { setState(() {
isReversing = reversing; isReversing = reversing;
}); });
} }
switchToPage(int index) async { Future<void> switchToPage(int index) async {
setIsReversing(index); setIsReversing(index);
if (index == 0) { if (index == 0) {
while ((pages[0].widget.key as GlobalKey<AppsPageState>).currentState != while ((pages[0].widget.key as GlobalKey<AppsPageState>).currentState !=
@ -259,65 +285,71 @@ class _HomePageState extends State<HomePage> {
prevIsLoading = appsProvider.loadingApps; prevIsLoading = appsProvider.loadingApps;
return WillPopScope( return WillPopScope(
child: Scaffold( child: Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
body: PageTransitionSwitcher( body: PageTransitionSwitcher(
duration: Duration( duration: Duration(
milliseconds: milliseconds: settingsProvider.disablePageTransitions ? 0 : 300,
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,
), ),
bottomNavigationBar: NavigationBar( reverse: settingsProvider.reversePageTransitions
destinations: pages ? !isReversing
.map((e) => : isReversing,
NavigationDestination(icon: Icon(e.icon), label: e.title)) transitionBuilder:
.toList(), (
onDestinationSelected: (int index) async { Widget child,
HapticFeedback.selectionClick(); Animation<double> animation,
switchToPage(index); Animation<double> secondaryAnimation,
}, ) {
selectedIndex: return SharedAxisTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.horizontal,
child: child,
);
},
child: pages
.elementAt(
selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last, selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last,
), )
.widget,
), ),
onWillPop: () async { bottomNavigationBar: NavigationBar(
if (isLinkActivity && destinations: pages
selectedIndexHistory.length == 1 && .map(
selectedIndexHistory.last == 1) { (e) =>
return true; NavigationDestination(icon: Icon(e.icon), label: e.title),
} )
setIsReversing(selectedIndexHistory.length >= 2 .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] ? selectedIndexHistory.reversed.toList()[1]
: 0); : 0,
if (selectedIndexHistory.isNotEmpty) { );
setState(() { if (selectedIndexHistory.isNotEmpty) {
selectedIndexHistory.removeLast(); setState(() {
}); selectedIndexHistory.removeLast();
return false; });
} return false;
return !(pages[0].widget.key as GlobalKey<AppsPageState>) }
.currentState return !(pages[0].widget.key as GlobalKey<AppsPageState>).currentState!
?.clearSelected(); .clearSelected();
}); },
);
} }
@override @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, idColumn: id,
levelColumn: level.index, levelColumn: level.index,
messageColumn: message, messageColumn: message,
timestampColumn: timestamp.millisecondsSinceEpoch timestampColumn: timestamp.millisecondsSinceEpoch,
}; };
return map; return map;
} }
@ -33,8 +33,9 @@ class Log {
id = map[idColumn] as int; id = map[idColumn] as int;
level = LogLevels.values.elementAt(map[levelColumn] as int); level = LogLevels.values.elementAt(map[levelColumn] as int);
message = map[messageColumn] as String; message = map[messageColumn] as String;
timestamp = timestamp = DateTime.fromMillisecondsSinceEpoch(
DateTime.fromMillisecondsSinceEpoch(map[timestampColumn] as int); map[timestampColumn] as int,
);
} }
@override @override
@ -51,16 +52,19 @@ class LogsProvider {
Database? db; Database? db;
Future<Database> getDB() async { Future<Database> getDB() async {
db ??= await openDatabase(dbPath, version: 1, db ??= await openDatabase(
onCreate: (Database db, int version) async { dbPath,
await db.execute(''' version: 1,
onCreate: (Database db, int version) async {
await db.execute('''
create table if not exists $logTable ( create table if not exists $logTable (
$idColumn integer primary key autoincrement, $idColumn integer primary key autoincrement,
$levelColumn integer not null, $levelColumn integer not null,
$messageColumn text not null, $messageColumn text not null,
$timestampColumn integer not null) $timestampColumn integer not null)
'''); ''');
}); },
);
return db!; return db!;
} }
@ -75,27 +79,38 @@ create table if not exists $logTable (
Future<List<Log>> get({DateTime? before, DateTime? after}) async { Future<List<Log>> get({DateTime? before, DateTime? after}) async {
var where = getWhereDates(before: before, after: after); var where = getWhereDates(before: before, after: after);
return (await (await getDB()) return (await (await getDB()).query(
.query(logTable, where: where.key, whereArgs: where.value)) logTable,
.map((e) => Log.fromMap(e)) where: where.key,
.toList(); whereArgs: where.value,
)).map((e) => Log.fromMap(e)).toList();
} }
Future<int> clear({DateTime? before, DateTime? after}) async { Future<int> clear({DateTime? before, DateTime? after}) async {
var where = getWhereDates(before: before, after: after); var where = getWhereDates(before: before, after: after);
var res = await (await getDB()) var res = await (await getDB()).delete(
.delete(logTable, where: where.key, whereArgs: where.value); logTable,
where: where.key,
whereArgs: where.value,
);
if (res > 0) { if (res > 0) {
add(plural('clearedNLogsBeforeXAfterY', res, add(
plural(
'clearedNLogsBeforeXAfterY',
res,
namedArgs: {'before': before.toString(), 'after': after.toString()}, namedArgs: {'before': before.toString(), 'after': after.toString()},
name: 'n')); name: 'n',
),
);
} }
return res; return res;
} }
} }
MapEntry<String?, List<int>?> getWhereDates( MapEntry<String?, List<int>?> getWhereDates({
{DateTime? before, DateTime? after}) { DateTime? before,
DateTime? after,
}) {
List<String> where = []; List<String> where = [];
List<int> whereArgs = []; List<int> whereArgs = [];
if (before != null) { if (before != null) {

View File

@ -20,91 +20,116 @@ class ObtainiumNotification {
bool onlyAlertOnce; bool onlyAlertOnce;
String? payload; String? payload;
ObtainiumNotification(this.id, this.title, this.message, this.channelCode, ObtainiumNotification(
this.channelName, this.channelDescription, this.importance, this.id,
{this.onlyAlertOnce = false, this.progPercent, this.payload}); this.title,
this.message,
this.channelCode,
this.channelName,
this.channelDescription,
this.importance, {
this.onlyAlertOnce = false,
this.progPercent,
this.payload,
});
} }
class UpdateNotification extends ObtainiumNotification { class UpdateNotification extends ObtainiumNotification {
UpdateNotification(List<App> updates, {int? id}) UpdateNotification(List<App> updates, {int? id})
: super( : super(
id ?? 2, id ?? 2,
tr('updatesAvailable'), tr('updatesAvailable'),
'', '',
'UPDATES_AVAILABLE', 'UPDATES_AVAILABLE',
tr('updatesAvailableNotifChannel'), tr('updatesAvailableNotifChannel'),
tr('updatesAvailableNotifDescription'), tr('updatesAvailableNotifDescription'),
Importance.max) { Importance.max,
) {
message = updates.isEmpty message = updates.isEmpty
? tr('noNewUpdates') ? tr('noNewUpdates')
: updates.length == 1 : updates.length == 1
? tr('xHasAnUpdate', args: [updates[0].finalName]) ? tr('xHasAnUpdate', args: [updates[0].finalName])
: plural('xAndNMoreUpdatesAvailable', updates.length - 1, : plural(
args: [updates[0].finalName, (updates.length - 1).toString()]); 'xAndNMoreUpdatesAvailable',
updates.length - 1,
args: [updates[0].finalName, (updates.length - 1).toString()],
);
} }
} }
class SilentUpdateNotification extends ObtainiumNotification { class SilentUpdateNotification extends ObtainiumNotification {
SilentUpdateNotification(List<App> updates, bool succeeded, {int? id}) SilentUpdateNotification(List<App> updates, bool succeeded, {int? id})
: super( : super(
id ?? 3, id ?? 3,
succeeded ? tr('appsUpdated') : tr('appsNotUpdated'), succeeded ? tr('appsUpdated') : tr('appsNotUpdated'),
'', '',
'APPS_UPDATED', 'APPS_UPDATED',
tr('appsUpdatedNotifChannel'), tr('appsUpdatedNotifChannel'),
tr('appsUpdatedNotifDescription'), tr('appsUpdatedNotifDescription'),
Importance.defaultImportance) { Importance.defaultImportance,
) {
message = updates.length == 1 message = updates.length == 1
? tr(succeeded ? 'xWasUpdatedToY' : 'xWasNotUpdatedToY', ? tr(
args: [updates[0].finalName, updates[0].latestVersion]) succeeded ? 'xWasUpdatedToY' : 'xWasNotUpdatedToY',
args: [updates[0].finalName, updates[0].latestVersion],
)
: plural( : plural(
succeeded ? 'xAndNMoreUpdatesInstalled' : "xAndNMoreUpdatesFailed", succeeded ? 'xAndNMoreUpdatesInstalled' : "xAndNMoreUpdatesFailed",
updates.length - 1, updates.length - 1,
args: [updates[0].finalName, (updates.length - 1).toString()]); args: [updates[0].finalName, (updates.length - 1).toString()],
);
} }
} }
class SilentUpdateAttemptNotification extends ObtainiumNotification { class SilentUpdateAttemptNotification extends ObtainiumNotification {
SilentUpdateAttemptNotification(List<App> updates, {int? id}) SilentUpdateAttemptNotification(List<App> updates, {int? id})
: super( : super(
id ?? 3, id ?? 3,
tr('appsPossiblyUpdated'), tr('appsPossiblyUpdated'),
'', '',
'APPS_POSSIBLY_UPDATED', 'APPS_POSSIBLY_UPDATED',
tr('appsPossiblyUpdatedNotifChannel'), tr('appsPossiblyUpdatedNotifChannel'),
tr('appsPossiblyUpdatedNotifDescription'), tr('appsPossiblyUpdatedNotifDescription'),
Importance.defaultImportance) { Importance.defaultImportance,
) {
message = updates.length == 1 message = updates.length == 1
? tr('xWasPossiblyUpdatedToY', ? tr(
args: [updates[0].finalName, updates[0].latestVersion]) 'xWasPossiblyUpdatedToY',
: plural('xAndNMoreUpdatesPossiblyInstalled', updates.length - 1, args: [updates[0].finalName, updates[0].latestVersion],
args: [updates[0].finalName, (updates.length - 1).toString()]); )
: plural(
'xAndNMoreUpdatesPossiblyInstalled',
updates.length - 1,
args: [updates[0].finalName, (updates.length - 1).toString()],
);
} }
} }
class ErrorCheckingUpdatesNotification extends ObtainiumNotification { class ErrorCheckingUpdatesNotification extends ObtainiumNotification {
ErrorCheckingUpdatesNotification(String error, {int? id}) ErrorCheckingUpdatesNotification(String error, {int? id})
: super( : super(
id ?? 5, id ?? 5,
tr('errorCheckingUpdates'), tr('errorCheckingUpdates'),
error, error,
'BG_UPDATE_CHECK_ERROR', 'BG_UPDATE_CHECK_ERROR',
tr('errorCheckingUpdatesNotifChannel'), tr('errorCheckingUpdatesNotifChannel'),
tr('errorCheckingUpdatesNotifDescription'), tr('errorCheckingUpdatesNotifDescription'),
Importance.high, Importance.high,
payload: "${tr('errorCheckingUpdates')}\n$error"); payload: "${tr('errorCheckingUpdates')}\n$error",
);
} }
class AppsRemovedNotification extends ObtainiumNotification { class AppsRemovedNotification extends ObtainiumNotification {
AppsRemovedNotification(List<List<String>> namedReasons) AppsRemovedNotification(List<List<String>> namedReasons)
: super( : super(
6, 6,
tr('appsRemoved'), tr('appsRemoved'),
'', '',
'APPS_REMOVED', 'APPS_REMOVED',
tr('appsRemovedNotifChannel'), tr('appsRemovedNotifChannel'),
tr('appsRemovedNotifDescription'), tr('appsRemovedNotifDescription'),
Importance.max) { Importance.max,
) {
message = ''; message = '';
for (var r in namedReasons) { for (var r in namedReasons) {
message += '${tr('xWasRemovedDueToErrorY', args: [r[0], r[1]])} \n'; message += '${tr('xWasRemovedDueToErrorY', args: [r[0], r[1]])} \n';
@ -115,49 +140,53 @@ class AppsRemovedNotification extends ObtainiumNotification {
class DownloadNotification extends ObtainiumNotification { class DownloadNotification extends ObtainiumNotification {
DownloadNotification(String appName, int progPercent) DownloadNotification(String appName, int progPercent)
: super( : super(
appName.hashCode, appName.hashCode,
tr('downloadingX', args: [appName]), tr('downloadingX', args: [appName]),
'', '',
'APP_DOWNLOADING', 'APP_DOWNLOADING',
tr('downloadingXNotifChannel', args: [tr('app')]), tr('downloadingXNotifChannel', args: [tr('app')]),
tr('downloadNotifDescription'), tr('downloadNotifDescription'),
Importance.low, Importance.low,
onlyAlertOnce: true, onlyAlertOnce: true,
progPercent: progPercent); progPercent: progPercent,
);
} }
class DownloadedNotification extends ObtainiumNotification { class DownloadedNotification extends ObtainiumNotification {
DownloadedNotification(String fileName, String downloadUrl) DownloadedNotification(String fileName, String downloadUrl)
: super( : super(
downloadUrl.hashCode, downloadUrl.hashCode,
tr('downloadedX', args: [fileName]), tr('downloadedX', args: [fileName]),
'', '',
'FILE_DOWNLOADED', 'FILE_DOWNLOADED',
tr('downloadedXNotifChannel', args: [tr('app')]), tr('downloadedXNotifChannel', args: [tr('app')]),
tr('downloadedX', args: [tr('app')]), tr('downloadedX', args: [tr('app')]),
Importance.defaultImportance); Importance.defaultImportance,
);
} }
final completeInstallationNotification = ObtainiumNotification( final completeInstallationNotification = ObtainiumNotification(
1, 1,
tr('completeAppInstallation'), tr('completeAppInstallation'),
tr('obtainiumMustBeOpenToInstallApps'), tr('obtainiumMustBeOpenToInstallApps'),
'COMPLETE_INSTALL', 'COMPLETE_INSTALL',
tr('completeAppInstallationNotifChannel'), tr('completeAppInstallationNotifChannel'),
tr('completeAppInstallationNotifDescription'), tr('completeAppInstallationNotifDescription'),
Importance.max); Importance.max,
);
class CheckingUpdatesNotification extends ObtainiumNotification { class CheckingUpdatesNotification extends ObtainiumNotification {
CheckingUpdatesNotification(String appName) CheckingUpdatesNotification(String appName)
: super( : super(
4, 4,
tr('checkingForUpdates'), tr('checkingForUpdates'),
appName, appName,
'BG_UPDATE_CHECK', 'BG_UPDATE_CHECK',
tr('checkingForUpdatesNotifChannel'), tr('checkingForUpdatesNotifChannel'),
tr('checkingForUpdatesNotifDescription'), tr('checkingForUpdatesNotifDescription'),
Importance.min); Importance.min,
);
} }
class NotificationsProvider { class NotificationsProvider {
@ -173,13 +202,15 @@ class NotificationsProvider {
Importance.max: Priority.max, Importance.max: Priority.max,
Importance.min: Priority.min, Importance.min: Priority.min,
Importance.none: Priority.min, Importance.none: Priority.min,
Importance.unspecified: Priority.defaultPriority Importance.unspecified: Priority.defaultPriority,
}; };
Future<void> initialize() async { Future<void> initialize() async {
isInitialized = await notifications.initialize( isInitialized =
await notifications.initialize(
const InitializationSettings( const InitializationSettings(
android: AndroidInitializationSettings('ic_notification')), android: AndroidInitializationSettings('ic_notification'),
),
onDidReceiveNotificationResponse: (NotificationResponse response) { onDidReceiveNotificationResponse: (NotificationResponse response) {
_showNotificationPayload(response.payload); _showNotificationPayload(response.payload);
}, },
@ -187,16 +218,18 @@ class NotificationsProvider {
false; false;
} }
checkLaunchByNotif() async { Future<void> checkLaunchByNotif() async {
final NotificationAppLaunchDetails? launchDetails = final NotificationAppLaunchDetails? launchDetails = await notifications
await notifications.getNotificationAppLaunchDetails(); .getNotificationAppLaunchDetails();
if (launchDetails?.didNotificationLaunchApp ?? false) { if (launchDetails?.didNotificationLaunchApp ?? false) {
_showNotificationPayload(launchDetails!.notificationResponse?.payload, _showNotificationPayload(
doublePop: true); launchDetails!.notificationResponse?.payload,
doublePop: true,
);
} }
} }
_showNotificationPayload(String? payload, {bool doublePop = false}) { void _showNotificationPayload(String? payload, {bool doublePop = false}) {
if (payload?.isNotEmpty == true) { if (payload?.isNotEmpty == true) {
var title = (payload ?? '\n\n').split('\n').first; var title = (payload ?? '\n\n').split('\n').first;
var content = (payload ?? '\n\n').split('\n').sublist(1).join('\n'); var content = (payload ?? '\n\n').split('\n').sublist(1).join('\n');
@ -207,13 +240,14 @@ class NotificationsProvider {
content: Text(content), content: Text(content),
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
Navigator.of(context).pop(null);
if (doublePop) {
Navigator.of(context).pop(null); 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( Future<void> notifyRaw(
int id, int id,
String title, String title,
String message, String message,
String channelCode, String channelCode,
String channelName, String channelName,
String channelDescription, String channelDescription,
Importance importance, Importance importance, {
{bool cancelExisting = false, bool cancelExisting = false,
int? progPercent, int? progPercent,
bool onlyAlertOnce = false, bool onlyAlertOnce = false,
String? payload}) async { String? payload,
}) async {
if (cancelExisting) { if (cancelExisting) {
await cancel(id); await cancel(id);
} }
@ -247,29 +282,42 @@ class NotificationsProvider {
await initialize(); await initialize();
} }
await notifications.show( await notifications.show(
id, id,
title, title,
message, message,
NotificationDetails( NotificationDetails(
android: AndroidNotificationDetails(channelCode, channelName, android: AndroidNotificationDetails(
channelDescription: channelDescription, channelCode,
importance: importance, channelName,
priority: importanceToPriority[importance]!, channelDescription: channelDescription,
groupKey: '$obtainiumId.$channelCode', importance: importance,
progress: progPercent ?? 0, priority: importanceToPriority[importance]!,
maxProgress: 100, groupKey: '$obtainiumId.$channelCode',
showProgress: progPercent != null, progress: progPercent ?? 0,
onlyAlertOnce: onlyAlertOnce, maxProgress: 100,
indeterminate: progPercent != null && progPercent < 0)), showProgress: progPercent != null,
payload: payload); onlyAlertOnce: onlyAlertOnce,
indeterminate: progPercent != null && progPercent < 0,
),
),
payload: payload,
);
} }
Future<void> notify(ObtainiumNotification notif, Future<void> notify(
{bool cancelExisting = false}) => ObtainiumNotification notif, {
notifyRaw(notif.id, notif.title, notif.message, notif.channelCode, bool cancelExisting = false,
notif.channelName, notif.channelDescription, notif.importance, }) => notifyRaw(
cancelExisting: cancelExisting, notif.id,
onlyAlertOnce: notif.onlyAlertOnce, notif.title,
progPercent: notif.progPercent, notif.message,
payload: notif.payload); 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 { ThemeSettings get theme {
return ThemeSettings return ThemeSettings.values[prefs?.getInt('theme') ??
.values[prefs?.getInt('theme') ?? ThemeSettings.system.index]; ThemeSettings.system.index];
} }
set theme(ThemeSettings t) { set theme(ThemeSettings t) {
@ -123,8 +123,8 @@ class SettingsProvider with ChangeNotifier {
} }
SortColumnSettings get sortColumn { SortColumnSettings get sortColumn {
return SortColumnSettings.values[ return SortColumnSettings.values[prefs?.getInt('sortColumn') ??
prefs?.getInt('sortColumn') ?? SortColumnSettings.nameAuthor.index]; SortColumnSettings.nameAuthor.index];
} }
set sortColumn(SortColumnSettings s) { set sortColumn(SortColumnSettings s) {
@ -133,8 +133,8 @@ class SettingsProvider with ChangeNotifier {
} }
SortOrderSettings get sortOrder { SortOrderSettings get sortOrder {
return SortOrderSettings.values[ return SortOrderSettings.values[prefs?.getInt('sortOrder') ??
prefs?.getInt('sortOrder') ?? SortOrderSettings.ascending.index]; SortOrderSettings.ascending.index];
} }
set sortOrder(SortOrderSettings s) { set sortOrder(SortOrderSettings s) {
@ -171,7 +171,9 @@ class SettingsProvider with ChangeNotifier {
while (!(await Permission.requestInstallPackages.isGranted)) { while (!(await Permission.requestInstallPackages.isGranted)) {
// Explicit request as InstallPlugin request sometimes bugged // Explicit request as InstallPlugin request sometimes bugged
Fluttertoast.showToast( Fluttertoast.showToast(
msg: tr('pleaseAllowInstallPerm'), toastLength: Toast.LENGTH_LONG); msg: tr('pleaseAllowInstallPerm'),
toastLength: Toast.LENGTH_LONG,
);
if ((await Permission.requestInstallPackages.request()) == if ((await Permission.requestInstallPackages.request()) ==
PermissionStatus.granted) { PermissionStatus.granted) {
return true; return true;
@ -470,7 +472,8 @@ class SettingsProvider with ChangeNotifier {
} }
List<String> get searchDeselected { List<String> get searchDeselected {
return prefs?.getStringList('searchDeselected') ?? []; return prefs?.getStringList('searchDeselected') ??
SourceProvider().sources.map((s) => s.name).toList();
} }
set searchDeselected(List<String> list) { 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" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.4" version: "1.0.4"
archive:
dependency: transitive
description:
name: archive
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
url: "https://pub.dev"
source: hosted
version: "4.0.7"
args: args:
dependency: transitive dependency: transitive
description: description:
@ -96,10 +88,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: async name: async
sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.12.0" version: "2.13.0"
background_fetch: background_fetch:
dependency: "direct main" dependency: "direct main"
description: description:
@ -148,22 +140,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" 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: clock:
dependency: transitive dependency: transitive
description: description:
@ -288,10 +264,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: fake_async name: fake_async
sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.2" version: "1.3.3"
ffi: ffi:
dependency: transitive dependency: transitive
description: description:
@ -312,10 +288,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: file_picker name: file_picker
sha256: "77f8e81d22d2a07d0dee2c62e1dda71dc1da73bf43bb2d45af09727406167964" sha256: ef9908739bdd9c476353d6adff72e88fd00c625f5b959ae23f7567bd5137db0a
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.1.9" version: "10.2.0"
fixnum: fixnum:
dependency: transitive dependency: transitive
description: description:
@ -449,22 +425,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" 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: flutter_lints:
dependency: "direct dev" dependency: "direct main"
description: description:
name: flutter_lints name: flutter_lints
sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.0" version: "6.0.0"
flutter_local_notifications: flutter_local_notifications:
dependency: "direct main" dependency: "direct main"
description: description:
@ -511,7 +479,7 @@ packages:
source: hosted source: hosted
version: "2.0.28" version: "2.0.28"
flutter_test: flutter_test:
dependency: "direct dev" dependency: transitive
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
@ -584,38 +552,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
url: "https://pub.dev"
source: hosted
version: "4.5.4"
intl: intl:
dependency: transitive dependency: transitive
description: description:
name: intl name: intl
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.19.0" version: "0.20.2"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.8" version: "10.0.9"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
@ -636,10 +588,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: lints name: lints
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.1.1" version: "6.0.0"
markdown: markdown:
dependency: "direct main" dependency: "direct main"
description: description:
@ -852,18 +804,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: pointer_interceptor_web name: pointer_interceptor_web
sha256: "7a7087782110f8c1827170660b09f8aa893e0e9a61431dbbe2ac3fc482e8c044" sha256: "460b600e71de6fcea2b3d5f662c92293c049c4319e27f0829310e5a953b3ee2a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.10.2+1" version: "0.10.3"
posix:
dependency: transitive
description:
name: posix
sha256: f0d7856b6ca1887cfa6d1d394056a296ae33489db914e365e2044fdada449e62
url: "https://pub.dev"
source: hosted
version: "6.0.2"
provider: provider:
dependency: "direct main" dependency: "direct main"
description: description:
@ -949,7 +893,7 @@ packages:
description: description:
path: "." path: "."
ref: master ref: master
resolved-ref: "8784c39b909324df8913dd30fa416b8a50d55f49" resolved-ref: "012e22791138958e089f6c1a8d6c4c6943a9f253"
url: "https://github.com/AlexBacich/shared-storage" url: "https://github.com/AlexBacich/shared-storage"
source: git source: git
version: "0.7.0" version: "0.7.0"
@ -1179,10 +1123,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.3.1" version: "15.0.0"
web: web:
dependency: transitive dependency: transitive
description: description:
@ -1195,10 +1139,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: webview_flutter name: webview_flutter
sha256: "62d763c27ce7f6cef04b3bec01c85a28d60149bffd155884aa4b8fd4941ea2e4" sha256: c3e4fe614b1c814950ad07186007eff2f2e5dd2935eba7b9a9a1af8e5885f1ba
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.12.0" version: "4.13.0"
webview_flutter_android: webview_flutter_android:
dependency: transitive dependency: transitive
description: description:
@ -1211,10 +1155,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: webview_flutter_platform_interface name: webview_flutter_platform_interface
sha256: "7cb32b21825bd65569665c32bb00a34ded5779786d6201f5350979d2d529940d" sha256: f0dc2dc3a2b1e3a6abdd6801b9355ebfeb3b8f6cde6b9dc7c9235909c4a1f147
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.0" version: "2.13.1"
webview_flutter_wkwebview: webview_flutter_wkwebview:
dependency: transitive dependency: transitive
description: description:
@ -1227,10 +1171,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: win32 name: win32
sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba" sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.13.0" version: "5.14.0"
win32_registry: win32_registry:
dependency: transitive dependency: transitive
description: description:
@ -1255,14 +1199,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.0" version: "6.3.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks: sdks:
dart: ">=3.7.2 <4.0.0" dart: ">=3.8.1 <4.0.0"
flutter: ">=3.27.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 # 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 # 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. # of the product and file versions while build-number is used as the build suffix.
version: 1.1.55+2312 version: 1.1.57+2314
environment: environment:
sdk: ^3.6.0 sdk: ^3.8.1
# Dependencies specify other packages that your package needs in order to work. # Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions # 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. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.5 cupertino_icons: ^1.0.8
path_provider: ^2.0.11 path_provider: ^2.1.5
flutter_fgbg: ^0.7.1 flutter_fgbg: ^0.7.1
flutter_local_notifications: ^18.0.0 flutter_local_notifications: ^18.0.1
provider: ^6.0.3 provider: ^6.1.5
http: ^1.0.0 http: ^1.4.0
webview_flutter: ^4.0.0 webview_flutter: ^4.13.0
dynamic_color: ^1.5.4 dynamic_color: ^1.7.0
html: ^0.15.0 html: ^0.15.6
shared_preferences: ^2.0.15 shared_preferences: ^2.5.3
url_launcher: ^6.1.5 url_launcher: ^6.3.1
permission_handler: ^12.0.0+1 permission_handler: ^12.0.0+1
fluttertoast: ^8.0.9 fluttertoast: ^8.2.12
device_info_plus: ^11.0.0 device_info_plus: ^11.4.0
file_picker: ^10.0.0 file_picker: ^10.1.9
animations: ^2.0.4 animations: ^2.0.11
android_package_installer: # TODO: See if PR will be accepted (dev may not be active), else remove this comment android_package_installer: # TODO: See if PR will be accepted (dev may not be active), else remove this comment
git: git:
url: https://github.com/ImranR98/android_package_installer url: https://github.com/ImranR98/android_package_installer
@ -58,23 +58,23 @@ dependencies:
url: https://github.com/ImranR98/android_package_manager url: https://github.com/ImranR98/android_package_manager
ref: master ref: master
share_plus: ^11.0.0 share_plus: ^11.0.0
sqflite: ^2.2.0+3 sqflite: ^2.4.2
easy_localization: ^3.0.1 easy_localization: ^3.0.7+1
android_intent_plus: ^5.0.1 android_intent_plus: ^5.3.0
flutter_markdown: ^0.7.1 flutter_markdown: ^0.7.7+1
flutter_archive: ^6.0.0 flutter_archive: ^6.0.3
hsluv: ^1.1.3 hsluv: ^1.1.3
connectivity_plus: ^6.0.1 connectivity_plus: ^6.1.4
shared_storage: # TODO: Is this maintained? shared_storage: # TODO: Is this maintained?
git: git:
url: https://github.com/AlexBacich/shared-storage url: https://github.com/AlexBacich/shared-storage
ref: master ref: master
crypto: ^3.0.3 crypto: ^3.0.6
bcrypt: ^1.1.3 bcrypt: ^1.1.3
app_links: ^6.0.1 app_links: ^6.4.0
background_fetch: ^1.2.1 background_fetch: ^1.3.8
equations: ^5.0.2 equations: ^5.0.2
flex_color_picker: ^3.4.1 flex_color_picker: ^3.7.1
android_system_font: android_system_font:
git: git:
url: https://github.com/re7gog/android_system_font url: https://github.com/re7gog/android_system_font
@ -83,22 +83,17 @@ dependencies:
git: git:
url: https://github.com/wilver06w/shizuku_apk_installer url: https://github.com/wilver06w/shizuku_apk_installer
ref: master ref: master
markdown: ^7.3.0
markdown: any
flutter_typeahead: ^5.2.0 flutter_typeahead: ^5.2.0
battery_plus: ^6.1.0 battery_plus: ^6.2.1
flutter_charset_detector: ^5.0.0 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 # The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is # 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 # activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint # package. See that file for information about deactivating specific lint
# rules and activating additional ones. # rules and activating additional ones.
flutter_lints: ^5.0.0 flutter_lints: ^6.0.0
flutter_launcher_icons: flutter_launcher_icons:
android: "ic_launcher" android: "ic_launcher"
@ -154,4 +149,4 @@ flutter:
fonts: fonts:
- family: Montserrat - family: Montserrat
fonts: fonts:
- asset: assets/fonts/Montserrat-Regular.ttf - asset: assets/fonts/Montserrat-Regular.ttf