mirror of
				https://github.com/ImranR98/Obtainium.git
				synced 2025-10-31 05:23:28 +01:00 
			
		
		
		
	Compare commits
	
		
			114 Commits
		
	
	
		
			v0.14.33-b
			...
			v0.15.1-be
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 6e13457eb2 | ||
|  | 3b319ee19b | ||
|  | 8ea8daa811 | ||
|  | eb29b908c2 | ||
|  | 9935cb482e | ||
|  | 0d6e7181cf | ||
|  | d225650e15 | ||
|  | bfe09791d5 | ||
|  | 85d103f3f6 | ||
|  | 9e6dbe2465 | ||
|  | 355e5ccda6 | ||
|  | a5f6f05e10 | ||
|  | db0d35d80b | ||
|  | 6fca2a3931 | ||
|  | 0305a42b02 | ||
|  | 77d81716ed | ||
|  | 3e54e80eb6 | ||
|  | 3c9bb63d32 | ||
|  | 617ab9efab | ||
|  | bc574097e2 | ||
|  | 4cc64dc233 | ||
|  | 45fa0a165a | ||
|  | 0e5c07a078 | ||
|  | 601a742c71 | ||
|  | c972401b6e | ||
|  | 024e81cf01 | ||
|  | 975ed402d5 | ||
|  | b9e8083744 | ||
|  | bb859708bc | ||
|  | 3cf2c221ac | ||
|  | 6edd7edcd2 | ||
|  | 4e26a02d78 | ||
|  | bb36a57053 | ||
|  | b291c800f1 | ||
|  | b63a798d86 | ||
|  | eacf3777a4 | ||
|  | a5a7436bb1 | ||
|  | 2a4cc35df7 | ||
|  | cdccf58b76 | ||
|  | 27300383a1 | ||
|  | 375b9bce30 | ||
|  | b6b8db48df | ||
|  | 36e6c267b9 | ||
|  | 13066b3b4a | ||
|  | ccbe9d00c8 | ||
|  | ce291582cb | ||
|  | bb37bc3b51 | ||
|  | 5a7747acd1 | ||
|  | 1bc2ec9461 | ||
|  | 2b977fc2b0 | ||
|  | de60c4ee9e | ||
|  | de67e40c00 | ||
|  | cc4b016c64 | ||
|  | f64f561d6f | ||
|  | 80bddf8a6b | ||
|  | cbaaec961c | ||
|  | 5477b3f936 | ||
|  | fd59a93ede | ||
|  | cd316b7138 | ||
|  | d1955192ed | ||
|  | 9beb839bf4 | ||
|  | 29ea303093 | ||
|  | feff6751ca | ||
|  | ca33fdf752 | ||
|  | fdcdfe89d6 | ||
|  | 48ed2115a7 | ||
|  | 65988f4e08 | ||
|  | ede65eda6c | ||
|  | 5da56acac8 | ||
|  | 5720c55301 | ||
|  | ffefa4b30e | ||
|  | 80e4986b23 | ||
|  | dc92ccda0a | ||
|  | f9bab18076 | ||
|  | 2dec52e221 | ||
|  | 7413f693d7 | ||
|  | 415460df75 | ||
|  | 125a194468 | ||
|  | 32e9afbf36 | ||
|  | 3eca704f4a | ||
|  | 9c95129311 | ||
|  | bf34c1bcdb | ||
|  | 284c687d77 | ||
|  | 09afb5a3f5 | ||
|  | 0138721451 | ||
|  | 2d5f610941 | ||
|  | 864fa7762b | ||
|  | 4fde38ee6a | ||
|  | 6cdf0f10d4 | ||
|  | b66592c25f | ||
|  | 43616c566d | ||
|  | 62f1dc17a0 | ||
|  | 0e9a8a937a | ||
|  | 9a86b245ce | ||
|  | 64533f7a3f | ||
|  | 0b7de8d387 | ||
|  | 8eba4860fe | ||
|  | b53e2f57e6 | ||
|  | e1e834297b | ||
|  | e37dc6e341 | ||
|  | c91c896854 | ||
|  | 7e5dfa03d6 | ||
|  | 1a4ec3f049 | ||
|  | 756763fcbe | ||
|  | 93036c4e67 | ||
|  | 15bf972ef6 | ||
|  | bcb4567382 | ||
|  | 3890c4ffb9 | ||
|  | c8d82f24a8 | ||
|  | 367e740a9c | ||
|  | 5b5a51bdad | ||
|  | 7fc030a53e | ||
|  | 65087fb5f8 | ||
|  | d5e789af3c | 
							
								
								
									
										1
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
								
							| @@ -9,6 +9,7 @@ assignees: '' | ||||
|  | ||||
| **Prerequisites** | ||||
| <!-- Please ensure your request is not part of an existing issue. --> | ||||
| <!-- Please ensure you have checked the Obtainium Wiki. --> | ||||
|  | ||||
| **Describe the bug** | ||||
| <!-- A clear and concise description of what the bug is. --> | ||||
|   | ||||
							
								
								
									
										1
									
								
								.github/ISSUE_TEMPLATE/feature_request.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/ISSUE_TEMPLATE/feature_request.md
									
									
									
									
										vendored
									
									
								
							| @@ -9,6 +9,7 @@ assignees: '' | ||||
|  | ||||
| **Prerequisites** | ||||
| <!-- Please ensure your request is not part of an existing issue. --> | ||||
| <!-- Please ensure you have checked the Obtainium Wiki. --> | ||||
|  | ||||
| **Describe the feature** | ||||
| <!-- A clear and concise description of what you want to happen. | ||||
|   | ||||
							
								
								
									
										1
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							| @@ -21,6 +21,7 @@ jobs: | ||||
|  | ||||
|       - name: Build APKs | ||||
|         run: | | ||||
|           sed -i 's/signingConfig signingConfigs.release//g' android/app/build.gradle | ||||
|           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 | ||||
|           rm ./build/app/outputs/flutter-apk/*.sha1 | ||||
|   | ||||
| @@ -6,6 +6,8 @@ Obtainium allows you to install and update Apps directly from their releases pag | ||||
|  | ||||
| Motivation: [Side Of Burritos - You should use this instead of F-Droid | How to use app RSS feed](https://youtu.be/FFz57zNR_M0) | ||||
|  | ||||
| Wiki: [https://github.com/ImranR98/Obtainium/wiki](https://github.com/ImranR98/Obtainium/wiki) | ||||
|  | ||||
| Currently supported App sources: | ||||
| - Open Source - General: | ||||
|   - [GitHub](https://github.com/) | ||||
|   | ||||
| @@ -23,6 +23,7 @@ if (flutterVersionName == null) { | ||||
|  | ||||
| apply plugin: 'com.android.application' | ||||
| apply plugin: 'kotlin-android' | ||||
| apply plugin: 'dev.rikka.tools.refine' | ||||
| apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" | ||||
|  | ||||
| def keystoreProperties = new Properties() | ||||
| @@ -32,7 +33,7 @@ if (keystorePropertiesFile.exists()) { | ||||
| } | ||||
|  | ||||
| android { | ||||
|     compileSdkVersion 33 | ||||
|     compileSdkVersion rootProject.ext.compileSdkVersion | ||||
|     ndkVersion flutter.ndkVersion | ||||
|  | ||||
|     compileOptions { | ||||
| @@ -52,8 +53,8 @@ android { | ||||
|         applicationId "dev.imranr.obtainium" | ||||
|         // You can update the following values to match your application needs. | ||||
|         // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration. | ||||
|         minSdkVersion 23 | ||||
|         targetSdkVersion 33 | ||||
|         minSdkVersion 24 | ||||
|         targetSdkVersion rootProject.ext.targetSdkVersion | ||||
|         versionCode flutterVersionCode.toInteger() | ||||
|         versionName flutterVersionName | ||||
|     } | ||||
| @@ -71,9 +72,17 @@ android { | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     signingConfigs { | ||||
|         release { | ||||
|             keyAlias keystoreProperties['keyAlias'] | ||||
|             keyPassword keystoreProperties['keyPassword'] | ||||
|             storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null | ||||
|             storePassword keystoreProperties['storePassword'] | ||||
|         } | ||||
|     } | ||||
|     buildTypes { | ||||
|         release { | ||||
|              | ||||
|             signingConfig signingConfigs.release | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -82,6 +91,24 @@ flutter { | ||||
|     source '../..' | ||||
| } | ||||
|  | ||||
| repositories { | ||||
|     maven { url 'https://jitpack.io' } | ||||
| } | ||||
|  | ||||
| dependencies { | ||||
|     implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" | ||||
|  | ||||
|     def shizuku_version = '13.1.5' | ||||
|     implementation "dev.rikka.shizuku:api:$shizuku_version" | ||||
|     implementation "dev.rikka.shizuku:provider:$shizuku_version" | ||||
|  | ||||
|     def hidden_api_version = '4.1.0' | ||||
|     // DO NOT UPDATE Hidden API without updating the Android tools | ||||
|     // and do not update Android tools without updating the whole Flutter | ||||
|     // (also in android/build.gradle) | ||||
|     implementation "dev.rikka.tools.refine:runtime:$hidden_api_version" | ||||
|     implementation "dev.rikka.hidden:compat:$hidden_api_version" | ||||
|     compileOnly "dev.rikka.hidden:stub:$hidden_api_version" | ||||
|  | ||||
|     implementation "com.github.topjohnwu.libsu:core:5.2.2" | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| <manifest xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     xmlns:tools="http://schemas.android.com/tools" | ||||
|     package="dev.imranr.obtainium"> | ||||
|     <application | ||||
|         android:label="Obtainium" | ||||
| @@ -28,8 +29,15 @@ | ||||
|             <intent-filter> | ||||
|                 <action | ||||
|                     android:name="com.android_package_installer.content.SESSION_API_PACKAGE_INSTALLED" | ||||
|                     android:exported="false"/> | ||||
|                     android:exported="false" /> | ||||
|             </intent-filter> | ||||
|             <intent-filter> | ||||
|                 <action android:name="android.intent.action.VIEW" /> | ||||
|                 <category android:name="android.intent.category.DEFAULT" /> | ||||
|                 <category android:name="android.intent.category.BROWSABLE" /> | ||||
|                 <data android:scheme="obtainium" /> | ||||
|             </intent-filter> | ||||
|  | ||||
|         </activity> | ||||
|         <!-- Don't delete the meta-data below. | ||||
|              This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> | ||||
| @@ -39,10 +47,10 @@ | ||||
|         <service | ||||
|             android:name="dev.fluttercommunity.plus.androidalarmmanager.AlarmService" | ||||
|             android:permission="android.permission.BIND_JOB_SERVICE" | ||||
|             android:exported="false"/> | ||||
|             android:exported="false" /> | ||||
|         <receiver | ||||
|             android:name="dev.fluttercommunity.plus.androidalarmmanager.AlarmBroadcastReceiver" | ||||
|             android:exported="false"/> | ||||
|             android:exported="false" /> | ||||
|         <receiver | ||||
|             android:name="dev.fluttercommunity.plus.androidalarmmanager.RebootBroadcastReceiver" | ||||
|             android:enabled="false" | ||||
| @@ -52,24 +60,31 @@ | ||||
|             </intent-filter> | ||||
|         </receiver> | ||||
|         <provider | ||||
|         android:name="androidx.core.content.FileProvider" | ||||
|         android:authorities="dev.imranr.obtainium" | ||||
|         android:grantUriPermissions="true"> | ||||
|         <meta-data | ||||
|             android:name="android.support.FILE_PROVIDER_PATHS" | ||||
|             android:resource="@xml/file_paths"/> | ||||
|             android:name="androidx.core.content.FileProvider" | ||||
|             android:authorities="dev.imranr.obtainium" | ||||
|             android:grantUriPermissions="true"> | ||||
|             <meta-data | ||||
|                 android:name="android.support.FILE_PROVIDER_PATHS" | ||||
|                 android:resource="@xml/file_paths" /> | ||||
|         </provider> | ||||
|         <provider | ||||
|             android:name="rikka.shizuku.ShizukuProvider" | ||||
|             android:authorities="${applicationId}.shizuku" | ||||
|             android:multiprocess="false" | ||||
|             android:enabled="true" | ||||
|             android:exported="true" | ||||
|             android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" /> | ||||
|     </application> | ||||
|     <uses-permission android:name="android.permission.INTERNET" /> | ||||
|     <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> | ||||
|     <uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" /> | ||||
|     <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> | ||||
|     <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> | ||||
|     <uses-permission android:name="android.permission.WAKE_LOCK"/> | ||||
|     <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/> | ||||
|     <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> | ||||
|     <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> | ||||
|     <uses-permission android:name="android.permission.WAKE_LOCK" /> | ||||
|     <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" /> | ||||
|     <uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" /> | ||||
|     <uses-permission | ||||
|         android:name="android.permission.WRITE_EXTERNAL_STORAGE" | ||||
|         android:maxSdkVersion="29"/> | ||||
|         android:maxSdkVersion="29" /> | ||||
|     <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" /> | ||||
| </manifest> | ||||
| @@ -1,6 +0,0 @@ | ||||
| package dev.imranr.obtainium | ||||
|  | ||||
| import io.flutter.embedding.android.FlutterActivity | ||||
|  | ||||
| class MainActivity: FlutterActivity() { | ||||
| } | ||||
							
								
								
									
										171
									
								
								android/app/src/main/kotlin/dev/imranr/obtainium/MainActivity.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										171
									
								
								android/app/src/main/kotlin/dev/imranr/obtainium/MainActivity.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,171 @@ | ||||
| package dev.imranr.obtainium | ||||
|  | ||||
| import android.content.Intent | ||||
| import android.content.IntentSender | ||||
| import android.content.pm.IPackageInstaller | ||||
| import android.content.pm.IPackageInstallerSession | ||||
| import android.content.pm.PackageInstaller | ||||
| import android.content.pm.PackageManager | ||||
| import android.net.Uri | ||||
| import android.os.Build | ||||
| import android.os.Bundle | ||||
| import android.os.Process | ||||
| import androidx.annotation.NonNull | ||||
| import com.topjohnwu.superuser.Shell | ||||
| import dev.imranr.obtainium.util.IIntentSenderAdaptor | ||||
| import dev.imranr.obtainium.util.IntentSenderUtils | ||||
| import dev.imranr.obtainium.util.PackageInstallerUtils | ||||
| import dev.imranr.obtainium.util.ShizukuSystemServerApi | ||||
| import io.flutter.embedding.android.FlutterActivity | ||||
| import io.flutter.embedding.engine.FlutterEngine | ||||
| import io.flutter.plugin.common.MethodChannel | ||||
| import io.flutter.plugin.common.MethodChannel.Result | ||||
| import java.io.IOException | ||||
| import java.util.concurrent.CountDownLatch | ||||
| import rikka.shizuku.Shizuku | ||||
| import rikka.shizuku.Shizuku.OnRequestPermissionResultListener | ||||
| import rikka.shizuku.ShizukuBinderWrapper | ||||
|  | ||||
| class MainActivity: FlutterActivity() { | ||||
|     private var installersChannel: MethodChannel? = null | ||||
|     private val SHIZUKU_PERMISSION_REQUEST_CODE = (10..200).random() | ||||
|  | ||||
|     private fun shizukuCheckPermission(result: Result) { | ||||
|         try { | ||||
|             if (Shizuku.isPreV11()) {  // Unsupported | ||||
|                 result.success(-1) | ||||
|             } else if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) { | ||||
|                 result.success(1) | ||||
|             } else if (Shizuku.shouldShowRequestPermissionRationale()) {  // Deny and don't ask again | ||||
|                 result.success(0) | ||||
|             } else { | ||||
|                 Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE) | ||||
|                 result.success(-2) | ||||
|             } | ||||
|         } catch (_: Exception) {  // If shizuku not running | ||||
|             result.success(-1) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private val shizukuRequestPermissionResultListener = OnRequestPermissionResultListener { | ||||
|             requestCode: Int, grantResult: Int -> | ||||
|         if (requestCode == SHIZUKU_PERMISSION_REQUEST_CODE) { | ||||
|             val res = if (grantResult == PackageManager.PERMISSION_GRANTED) 1 else 0 | ||||
|             installersChannel!!.invokeMethod("resPermShizuku", mapOf("res" to res)) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun shizukuInstallApk(apkFileUri: String, result: Result) { | ||||
|         val uri = Uri.parse(apkFileUri) | ||||
|         var res = false | ||||
|         var session: PackageInstaller.Session? = null | ||||
|         try { | ||||
|             val iPackageInstaller: IPackageInstaller = | ||||
|                 ShizukuSystemServerApi.PackageManager_getPackageInstaller() | ||||
|             val isRoot = Shizuku.getUid() == 0 | ||||
|             // The reason for use "com.android.shell" as installer package under adb | ||||
|             // is that getMySessions will check installer package's owner | ||||
|             val installerPackageName = if (isRoot) packageName else "com.android.shell" | ||||
|             var installerAttributionTag: String? = null | ||||
|             if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { | ||||
|                 installerAttributionTag = attributionTag | ||||
|             } | ||||
|             val userId = if (isRoot) Process.myUserHandle().hashCode() else 0 | ||||
|             val packageInstaller = PackageInstallerUtils.createPackageInstaller( | ||||
|                 iPackageInstaller, installerPackageName, installerAttributionTag, userId) | ||||
|             val params = | ||||
|                 PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) | ||||
|             var installFlags: Int = PackageInstallerUtils.getInstallFlags(params) | ||||
|             installFlags = installFlags or 0x00000004  // PackageManager.INSTALL_ALLOW_TEST | ||||
|             PackageInstallerUtils.setInstallFlags(params, installFlags) | ||||
|             val sessionId = packageInstaller.createSession(params) | ||||
|             val iSession = IPackageInstallerSession.Stub.asInterface( | ||||
|                 ShizukuBinderWrapper(iPackageInstaller.openSession(sessionId).asBinder())) | ||||
|             session = PackageInstallerUtils.createSession(iSession) | ||||
|             val inputStream = contentResolver.openInputStream(uri) | ||||
|             val openedSession = session.openWrite("apk.apk", 0, -1) | ||||
|             val buffer = ByteArray(8192) | ||||
|             var length: Int | ||||
|             try { | ||||
|                 while (inputStream!!.read(buffer).also { length = it } > 0) { | ||||
|                     openedSession.write(buffer, 0, length) | ||||
|                     openedSession.flush() | ||||
|                     session.fsync(openedSession) | ||||
|                 } | ||||
|             } finally { | ||||
|                 try { | ||||
|                     inputStream!!.close() | ||||
|                     openedSession.close() | ||||
|                 } catch (e: IOException) { | ||||
|                     e.printStackTrace() | ||||
|                 } | ||||
|             } | ||||
|             val results = arrayOf<Intent?>(null) | ||||
|             val countDownLatch = CountDownLatch(1) | ||||
|             val intentSender: IntentSender = | ||||
|                 IntentSenderUtils.newInstance(object : IIntentSenderAdaptor() { | ||||
|                     override fun send(intent: Intent?) { | ||||
|                         results[0] = intent | ||||
|                         countDownLatch.countDown() | ||||
|                     } | ||||
|                 }) | ||||
|             session.commit(intentSender) | ||||
|             countDownLatch.await() | ||||
|             res = results[0]!!.getIntExtra( | ||||
|                 PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) == 0 | ||||
|         } catch (_: Exception) { | ||||
|             res = false | ||||
|         } finally { | ||||
|             if (session != null) { | ||||
|                 try { | ||||
|                     session.close() | ||||
|                 } catch (_: Exception) { | ||||
|                     res = false | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         result.success(res) | ||||
|     } | ||||
|  | ||||
|     private fun rootCheckPermission(result: Result) { | ||||
|         Shell.getShell(Shell.GetShellCallback( | ||||
|             fun(shell: Shell) { | ||||
|                 result.success(shell.isRoot) | ||||
|             } | ||||
|         )) | ||||
|     } | ||||
|  | ||||
|     private fun rootInstallApk(apkFilePath: String, result: Result) { | ||||
|         Shell.sh("pm install -R -t " + apkFilePath).submit { out -> | ||||
|             val builder = StringBuilder() | ||||
|             for (data in out.getOut()) { builder.append(data) } | ||||
|             result.success(builder.toString().endsWith("Success")) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { | ||||
|         super.configureFlutterEngine(flutterEngine) | ||||
|         Shizuku.addRequestPermissionResultListener(shizukuRequestPermissionResultListener) | ||||
|         installersChannel = MethodChannel( | ||||
|             flutterEngine.dartExecutor.binaryMessenger, "installers") | ||||
|         installersChannel!!.setMethodCallHandler { | ||||
|             call, result -> | ||||
|             if (call.method == "checkPermissionShizuku") { | ||||
|                 shizukuCheckPermission(result) | ||||
|             } else if (call.method == "checkPermissionRoot") { | ||||
|                 rootCheckPermission(result) | ||||
|             } else if (call.method == "installWithShizuku") { | ||||
|                 val apkFileUri: String? = call.argument("apkFileUri") | ||||
|                 shizukuInstallApk(apkFileUri!!, result) | ||||
|             } else if (call.method == "installWithRoot") { | ||||
|                 val apkFilePath: String? = call.argument("apkFilePath") | ||||
|                 rootInstallApk(apkFilePath!!, result) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onDestroy() { | ||||
|         super.onDestroy() | ||||
|         Shizuku.removeRequestPermissionResultListener(shizukuRequestPermissionResultListener) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,37 @@ | ||||
| package dev.imranr.obtainium.util; | ||||
|  | ||||
| import android.annotation.SuppressLint; | ||||
| import android.app.Application; | ||||
| import android.os.Build; | ||||
|  | ||||
| import java.lang.reflect.InvocationTargetException; | ||||
| import java.lang.reflect.Method; | ||||
|  | ||||
| public class ApplicationUtils { | ||||
|  | ||||
|     private static Application application; | ||||
|  | ||||
|     public static Application getApplication() { | ||||
|         return application; | ||||
|     } | ||||
|  | ||||
|     public static void setApplication(Application application) { | ||||
|         ApplicationUtils.application = application; | ||||
|     } | ||||
|  | ||||
|     public static String getProcessName() { | ||||
|         if (Build.VERSION.SDK_INT >= 28) | ||||
|             return Application.getProcessName(); | ||||
|         else { | ||||
|             try { | ||||
|                 @SuppressLint("PrivateApi") | ||||
|                 Class<?> activityThread = Class.forName("android.app.ActivityThread"); | ||||
|                 @SuppressLint("DiscouragedPrivateApi") | ||||
|                 Method method = activityThread.getDeclaredMethod("currentProcessName"); | ||||
|                 return (String) method.invoke(null); | ||||
|             } catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { | ||||
|                 throw new RuntimeException(e); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,23 @@ | ||||
| package dev.imranr.obtainium.util; | ||||
|  | ||||
| import android.content.IIntentReceiver; | ||||
| import android.content.IIntentSender; | ||||
| import android.content.Intent; | ||||
| import android.os.Bundle; | ||||
| import android.os.IBinder; | ||||
|  | ||||
| public abstract class IIntentSenderAdaptor extends IIntentSender.Stub { | ||||
|  | ||||
|     public abstract void send(Intent intent); | ||||
|  | ||||
|     @Override | ||||
|     public int send(int code, Intent intent, String resolvedType, IIntentReceiver finishedReceiver, String requiredPermission, Bundle options) { | ||||
|         send(intent); | ||||
|         return 0; | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void send(int code, Intent intent, String resolvedType, IBinder whitelistToken, IIntentReceiver finishedReceiver, String requiredPermission, Bundle options) { | ||||
|         send(intent); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,14 @@ | ||||
| package dev.imranr.obtainium.util; | ||||
|  | ||||
| import android.content.IIntentSender; | ||||
| import android.content.IntentSender; | ||||
|  | ||||
| import java.lang.reflect.InvocationTargetException; | ||||
|  | ||||
| public class IntentSenderUtils { | ||||
|  | ||||
|     public static IntentSender newInstance(IIntentSender binder) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException { | ||||
|         //noinspection JavaReflectionMemberAccess | ||||
|         return IntentSender.class.getConstructor(IIntentSender.class).newInstance(binder); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,41 @@ | ||||
| package dev.imranr.obtainium.util; | ||||
|  | ||||
| import android.content.Context; | ||||
| import android.content.pm.IPackageInstaller; | ||||
| import android.content.pm.IPackageInstallerSession; | ||||
| import android.content.pm.PackageInstaller; | ||||
| import android.content.pm.PackageManager; | ||||
| import android.os.Build; | ||||
|  | ||||
| import java.lang.reflect.InvocationTargetException; | ||||
|  | ||||
| @SuppressWarnings({"JavaReflectionMemberAccess"}) | ||||
| public class PackageInstallerUtils { | ||||
|  | ||||
|     public static PackageInstaller createPackageInstaller(IPackageInstaller installer, String installerPackageName, String installerAttributionTag, int userId) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException { | ||||
|         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { | ||||
|             return PackageInstaller.class.getConstructor(IPackageInstaller.class, String.class, String.class, int.class) | ||||
|                     .newInstance(installer, installerPackageName, installerAttributionTag, userId); | ||||
|         } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { | ||||
|             return PackageInstaller.class.getConstructor(IPackageInstaller.class, String.class, int.class) | ||||
|                     .newInstance(installer, installerPackageName, userId); | ||||
|         } else { | ||||
|             return PackageInstaller.class.getConstructor(Context.class, PackageManager.class, IPackageInstaller.class, String.class, int.class) | ||||
|                     .newInstance(ApplicationUtils.getApplication(), ApplicationUtils.getApplication().getPackageManager(), installer, installerPackageName, userId); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public static PackageInstaller.Session createSession(IPackageInstallerSession session) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException { | ||||
|         return PackageInstaller.Session.class.getConstructor(IPackageInstallerSession.class) | ||||
|                 .newInstance(session); | ||||
|  | ||||
|     } | ||||
|  | ||||
|     public static int getInstallFlags(PackageInstaller.SessionParams params) throws NoSuchFieldException, IllegalAccessException { | ||||
|         return (int) PackageInstaller.SessionParams.class.getDeclaredField("installFlags").get(params); | ||||
|     } | ||||
|  | ||||
|     public static void setInstallFlags(PackageInstaller.SessionParams params, int newValue) throws NoSuchFieldException, IllegalAccessException { | ||||
|         PackageInstaller.SessionParams.class.getDeclaredField("installFlags").set(params, newValue); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,68 @@ | ||||
| package dev.imranr.obtainium.util; | ||||
|  | ||||
| import android.content.Context; | ||||
| import android.content.pm.IPackageInstaller; | ||||
| import android.content.pm.IPackageManager; | ||||
| import android.content.pm.UserInfo; | ||||
| import android.os.Build; | ||||
| import android.os.IUserManager; | ||||
| import android.os.RemoteException; | ||||
|  | ||||
| import java.util.List; | ||||
|  | ||||
| import rikka.shizuku.ShizukuBinderWrapper; | ||||
| import rikka.shizuku.SystemServiceHelper; | ||||
|  | ||||
| public class ShizukuSystemServerApi { | ||||
|  | ||||
|     private static final Singleton<IPackageManager> PACKAGE_MANAGER = new Singleton<IPackageManager>() { | ||||
|         @Override | ||||
|         protected IPackageManager create() { | ||||
|             return IPackageManager.Stub.asInterface(new ShizukuBinderWrapper(SystemServiceHelper.getSystemService("package"))); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     private static final Singleton<IUserManager> USER_MANAGER = new Singleton<IUserManager>() { | ||||
|         @Override | ||||
|         protected IUserManager create() { | ||||
|             return IUserManager.Stub.asInterface(new ShizukuBinderWrapper(SystemServiceHelper.getSystemService(Context.USER_SERVICE))); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     public static IPackageInstaller PackageManager_getPackageInstaller() throws RemoteException { | ||||
|         IPackageInstaller packageInstaller = PACKAGE_MANAGER.get().getPackageInstaller(); | ||||
|         return IPackageInstaller.Stub.asInterface(new ShizukuBinderWrapper(packageInstaller.asBinder())); | ||||
|     } | ||||
|  | ||||
|     public static List<UserInfo> UserManager_getUsers(boolean excludePartial, boolean excludeDying, boolean excludePreCreated) throws RemoteException { | ||||
|         if (Build.VERSION.SDK_INT >= 30) { | ||||
|             return USER_MANAGER.get().getUsers(excludePartial, excludeDying, excludePreCreated); | ||||
|         } else { | ||||
|             try { | ||||
|                 return USER_MANAGER.get().getUsers(excludeDying); | ||||
|             } catch (NoSuchFieldError e) { | ||||
|                 return USER_MANAGER.get().getUsers(excludePartial, excludeDying, excludePreCreated); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // method 2: use transactRemote directly | ||||
|     /*public static List<UserInfo> UserManager_getUsers(boolean excludeDying) { | ||||
|         Parcel data = SystemServiceHelper.obtainParcel(Context.USER_SERVICE, "android.os.IUserManager", "getUsers"); | ||||
|         Parcel reply = Parcel.obtain(); | ||||
|         data.writeInt(excludeDying ? 1 : 0); | ||||
|  | ||||
|         List<UserInfo> res = null; | ||||
|         try { | ||||
|             ShizukuService.transactRemote(data, reply, 0); | ||||
|             reply.readException(); | ||||
|             res = reply.createTypedArrayList(UserInfo.CREATOR); | ||||
|         } catch (RemoteException e) { | ||||
|             Log.e("ShizukuSample", "UserManager#getUsers", e); | ||||
|         } finally { | ||||
|             data.recycle(); | ||||
|             reply.recycle(); | ||||
|         } | ||||
|         return res; | ||||
|     }*/ | ||||
| } | ||||
| @@ -0,0 +1,17 @@ | ||||
| package dev.imranr.obtainium.util; | ||||
|  | ||||
| public abstract class Singleton<T> { | ||||
|  | ||||
|     private T mInstance; | ||||
|  | ||||
|     protected abstract T create(); | ||||
|  | ||||
|     public final T get() { | ||||
|         synchronized (this) { | ||||
|             if (mInstance == null) { | ||||
|                 mInstance = create(); | ||||
|             } | ||||
|             return mInstance; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,8 +1,8 @@ | ||||
| <vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt" | ||||
|     android:viewportWidth="142.129" | ||||
|     android:viewportHeight="142.129" | ||||
|     android:width="503.6066dp" | ||||
|     android:height="503.6066dp"> | ||||
|     android:width="108dp" | ||||
|     android:height="108dp"> | ||||
|     <group | ||||
|         android:translateX="-30.39437" | ||||
|         android:translateY="-54.68043"> | ||||
|   | ||||
| @@ -1,5 +1,10 @@ | ||||
| buildscript { | ||||
|     ext.kotlin_version = '1.7.10' | ||||
|     ext { | ||||
|         compileSdkVersion   = 34                // or latest | ||||
|         targetSdkVersion    = 34                // or latest | ||||
|         appCompatVersion    = "1.4.2"           // or latest | ||||
|     } | ||||
|     repositories { | ||||
|         google() | ||||
|         mavenCentral() | ||||
| @@ -8,6 +13,7 @@ buildscript { | ||||
|     dependencies { | ||||
|         classpath 'com.android.tools.build:gradle:7.2.0' | ||||
|         classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" | ||||
|         classpath 'dev.rikka.tools.refine:gradle-plugin:4.1.0'  // Do not update! | ||||
|     } | ||||
| } | ||||
|  | ||||
| @@ -15,6 +21,10 @@ allprojects { | ||||
|     repositories { | ||||
|         google() | ||||
|         mavenCentral() | ||||
|         maven { | ||||
|             // [required] background_fetch | ||||
|             url "${project(':background_fetch').projectDir}/libs" | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -55,7 +55,7 @@ | ||||
|     "notInstalled": "Nije instalirano", | ||||
|     "estimateInBrackets": "(Procjena)", | ||||
|     "selectAll": "Označi sve", | ||||
|     "deselectN": "Poništi odabir {}", | ||||
|     "deselectX": "Poništi odabir {}", | ||||
|     "xWillBeRemovedButRemainInstalled": "{} će biti uklonjen iz Obtainiuma, ali će ostati instaliran na uređaju.", | ||||
|     "removeSelectedAppsQuestion": "Želite li ukloniti odabrane aplikacije?", | ||||
|     "removeSelectedApps": "Ukloni odabrane aplikacije", | ||||
| @@ -88,7 +88,7 @@ | ||||
|     "importExport": "Uvoz/izvoz", | ||||
|     "settings": "Postavke", | ||||
|     "exportedTo": "Izvezeno u {}", | ||||
|     "obtainiumExport": "Obtainium Export", | ||||
|     "obtainiumExport": "Obtainium izvoz", | ||||
|     "invalidInput": "Neispravan unos.", | ||||
|     "importedX": "Uvezeno {}", | ||||
|     "obtainiumImport": "Obtainium uvoz", | ||||
| @@ -134,7 +134,7 @@ | ||||
|     "close": "Zatvori", | ||||
|     "share": "Podijeli", | ||||
|     "appNotFound": "Aplikacija nije pronađena", | ||||
|     "obtainiumExportHyphenatedLowercase": "obtainium-export", | ||||
|     "obtainiumExportHyphenatedLowercase": "obtainium-izvoz", | ||||
|     "pickAnAPK": "Odaberite APK", | ||||
|     "appHasMoreThanOnePackage": "{} ima više od jednog paketa:", | ||||
|     "deviceSupportsXArch": "Vaš uređaj podržava {} arhitekturu procesora.", | ||||
| @@ -223,7 +223,7 @@ | ||||
|     "moveNonInstalledAppsToBottom": "Premjesti neinstalirane aplikacije na dno prikaza aplikacija", | ||||
|     "gitlabPATLabel": "GitLab token za lični pristup\n(Omogućava pretraživanje i bolje otkrivanje APK-a)", | ||||
|     "about": "O nama", | ||||
|     "requiresCredentialsInSettings": "Za ovo su potrebni dodatni akreditivi (u Postavkama)", | ||||
|     "requiresCredentialsInSettings": "{}: Za ovo su potrebni dodatni akreditivi (u Postavkama)", | ||||
|     "checkOnStart": "Provjerite ima li novosti pri pokretanju", | ||||
|     "tryInferAppIdFromCode": "Pokušati otkriti ID aplikacije iz izvornog koda", | ||||
|     "removeOnExternalUninstall": "Automatski ukloni eksterno deinstalirane aplikacije", | ||||
| @@ -231,50 +231,58 @@ | ||||
|     "checkUpdateOnDetailPage": "Provjerite ima li novosti pri otvaranju stranice s detaljima aplikacije", | ||||
|     "disablePageTransitions": "Ugasite animaciju prijelaza stranice", | ||||
|     "reversePageTransitions": "Reverzne animacije prijelaza stranice", | ||||
|     "minStarCount": "Minimum Star Count", | ||||
|     "addInfoBelow": "Add this info below.", | ||||
|     "addInfoInSettings": "Add this info in the Settings.", | ||||
|     "githubSourceNote": "GitHub rate limiting can be avoided using an API key.", | ||||
|     "gitlabSourceNote": "GitLab APK extraction may not work without an API key.", | ||||
|     "sortByFileNamesNotLinks": "Sort by file names instead of full links", | ||||
|     "filterReleaseNotesByRegEx": "Filter Release Notes by Regular Expression", | ||||
|     "customLinkFilterRegex": "Custom APK Link Filter by Regular Expression (Default '.apk$')", | ||||
|     "appsPossiblyUpdated": "App Updates Attempted", | ||||
|     "appsPossiblyUpdatedNotifDescription": "Notifies the user that updates to one or more Apps were potentially applied in the background", | ||||
|     "xWasPossiblyUpdatedToY": "{} may have been updated to {}.", | ||||
|     "enableBackgroundUpdates": "Enable background updates", | ||||
|     "backgroundUpdateReqsExplanation": "Background updates may not be possible for all apps.", | ||||
|     "backgroundUpdateLimitsExplanation": "The success of a background install can only be determined when Obtainium is opened.", | ||||
|     "verifyLatestTag": "Verify the 'latest' tag", | ||||
|     "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit First", | ||||
|     "intermediateLinkNotFound": "Intermediate link not found", | ||||
|     "exemptFromBackgroundUpdates": "Exempt from background updates (if enabled)", | ||||
|     "bgUpdatesOnWiFiOnly": "Disable background updates when not on WiFi", | ||||
|     "autoSelectHighestVersionCode": "Auto-select highest versionCode APK", | ||||
|     "versionExtractionRegEx": "Version Extraction RegEx", | ||||
|     "matchGroupToUse": "Match Group to Use", | ||||
|     "highlightTouchTargets": "Highlight less obvious touch targets", | ||||
|     "pickExportDir": "Pick Export Directory", | ||||
|     "autoExportOnChanges": "Auto-export on changes", | ||||
|     "filterVersionsByRegEx": "Filter Versions by Regular Expression", | ||||
|     "trySelectingSuggestedVersionCode": "Try selecting suggested versionCode APK", | ||||
|     "dontSortReleasesList": "Retain release order from API", | ||||
|     "reverseSort": "Reverse sorting", | ||||
|     "debugMenu": "Debug Menu", | ||||
|     "bgTaskStarted": "Background task started - check logs.", | ||||
|     "runBgCheckNow": "Run Background Update Check Now", | ||||
|     "versionExtractWholePage": "Apply Version Extraction Regex to Entire Page", | ||||
|     "installing": "Installing", | ||||
|     "skipUpdateNotifications": "Skip update notifications", | ||||
|     "minStarCount": "Najmanji broj zvjezdica", | ||||
|     "addInfoBelow": "Dodajte ove informacije ispod.", | ||||
|     "addInfoInSettings": "Dodajte ove informacije u Postavkama.", | ||||
|     "githubSourceNote": "GitHub ograničavanje se može izbjeći korišćenjem tokena za lični pristup.", | ||||
|     "gitlabSourceNote": "GitLab APK preuzimanje možda neće raditi bez tokena za lični pristup.", | ||||
|     "sortByLastLinkSegment": "Sort by only the last segment of the link", | ||||
|     "filterReleaseNotesByRegEx": "Filtirajte promjene u izdanju po regularnom izrazu", | ||||
|     "customLinkFilterRegex": "Prilagođeni APK link filtrira se po regularnom izrazu (Zadano '.apk$')", | ||||
|     "appsPossiblyUpdated": "Pokušano ažuriranje aplikacija", | ||||
|     "appsPossiblyUpdatedNotifDescription": "Obavještava korisnika da je ažuriranje jedne ili više aplikacija potencijalno izvršeno u pozadini", | ||||
|     "xWasPossiblyUpdatedToY": "{} aplikacija bi trebala biti ažurirana na {}.", | ||||
|     "enableBackgroundUpdates": "Dozvolite ažuriranja u pozadini", | ||||
|     "backgroundUpdateReqsExplanation": "Ažuriranja u pozadini možda neće raditi za sve aplikacije.", | ||||
|     "backgroundUpdateLimitsExplanation": "Uspjeh ažuriranja u pozadini se može provjeriti tek kada otvorite Obtainium.", | ||||
|     "verifyLatestTag": "Provjerite 'posljednu' ('latest') oznaku", | ||||
|     "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit", | ||||
|     "filterByLinkText": "Filter links by link text", | ||||
|     "intermediateLinkNotFound": "Intermediate link nije nađen", | ||||
|     "intermediateLink": "Intermediate link", | ||||
|     "exemptFromBackgroundUpdates": "Izuzmi iz ažuriranja u pozadini (ako su uključeni)", | ||||
|     "bgUpdatesOnWiFiOnly": "Isključite ažuriranje u pozadini kada niste na WiFi-ju", | ||||
|     "autoSelectHighestVersionCode": "Automatski izaberite najveću (verziju) versionCode APK-a", | ||||
|     "versionExtractionRegEx": "RegEx ekstrakcija verzije", | ||||
|     "matchGroupToUse": "Podjesite grupu za upotebu", | ||||
|     "highlightTouchTargets": "Istaknite manje vidljive touch mete", | ||||
|     "pickExportDir": "Izaberite datoteku za izvoz", | ||||
|     "autoExportOnChanges": "Automatski izvezite pri promjenama", | ||||
|     "includeSettings": "Include settings", | ||||
|     "filterVersionsByRegEx": "Filtrirajte verzije po regulatnom izrazu", | ||||
|     "trySelectingSuggestedVersionCode": "Probajte izabrati preloženu (verziju) versionCode APK-a", | ||||
|     "dontSortReleasesList": "Zadrži redosled izdanja iz API-a", | ||||
|     "reverseSort": "Obrni redosled", | ||||
|     "takeFirstLink": "Take first link", | ||||
|     "skipSort": "Skip sorting", | ||||
|     "debugMenu": "Meni za otkrivanje grešaka", | ||||
|     "bgTaskStarted": "Rad u pozadini pokrenut - provjerite log-ove.", | ||||
|     "runBgCheckNow": "Pokrenite pozadinsku provjeru ažuriranja sad", | ||||
|     "versionExtractWholePage": "Primjenite Regex ekstrakciju verzije na cijelu stranicu", | ||||
|     "installing": "Instaliranje", | ||||
|     "skipUpdateNotifications": "Ne prikazujte obavještenja ažuriranja", | ||||
|     "updatesAvailableNotifChannel": "Dostupna ažuriranja", | ||||
|     "appsUpdatedNotifChannel": "Aplikacije su ažurirane", | ||||
|     "appsPossiblyUpdatedNotifChannel": "App Updates Attempted", | ||||
|     "appsPossiblyUpdatedNotifChannel": "Pokušano ažuriranje aplikacija", | ||||
|     "errorCheckingUpdatesNotifChannel": "Greška pri provjeri ažuriranja", | ||||
|     "appsRemovedNotifChannel": "Aplikacije su uklonjene", | ||||
|     "downloadingXNotifChannel": "Preuzimanje {}", | ||||
|     "completeAppInstallationNotifChannel": "Dovršite instalaciju aplikacije", | ||||
|     "checkingForUpdatesNotifChannel": "Tražim moguće nadogradnje", | ||||
|     "onlyCheckInstalledOrTrackOnlyApps": "Only check installed and Track-Only apps for updates", | ||||
|     "onlyCheckInstalledOrTrackOnlyApps": "Isključivo provjerite ažuriranje za instalirane i aplikacije 'samo za praćenje'", | ||||
|     "supportFixedAPKURL": "Podržite fiksne APK URL-ove", | ||||
|     "selectX": "Izaberite {}", | ||||
|     "parallelDownloads": "Allow parallel downloads", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "Želite li ukloniti aplikaciju?", | ||||
|         "other": "Želite li ukloniti aplikacije?" | ||||
| @@ -324,7 +332,7 @@ | ||||
|         "other": "{} i još {} aplikacija je ažurirano." | ||||
|     }, | ||||
|     "xAndNMoreUpdatesPossiblyInstalled": { | ||||
|         "one": "{} and 1 more app may have been updated.", | ||||
|         "other": "{} and {} more apps may have been updated." | ||||
|         "one": "{} i još jedna aplikacija je vjerovatno ažurirana.", | ||||
|         "other": "{} i još {} aplikacija su vjerovatno ažurirane." | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -55,7 +55,7 @@ | ||||
|     "notInstalled": "Není nainstalováno", | ||||
|     "estimateInBrackets": "(přibližně)", | ||||
|     "selectAll": "Vybrat Vše", | ||||
|     "deselectN": "{} deselected", | ||||
|     "deselectX": "{} deselected", | ||||
|     "xWillBeRemovedButRemainInstalled": "{} bude odstraněn z Obtainium, ale zůstane nainstalován v zařízení.", | ||||
|     "removeSelectedAppsQuestion": "Odebrat vybrané aplikace?", | ||||
|     "removeSelectedApps": "Odebrat vybrané aplikace", | ||||
| @@ -223,7 +223,7 @@ | ||||
|     "moveNonInstalledAppsToBottom": "Přesunout nenainstalované aplikace na konec zobrazení Aplikace", | ||||
|     "gitlabPATLabel": "GitLab Personal Access Token\n(Umožňuje vyhledávání a lepší zjišťování APK)", | ||||
|     "about": "About", | ||||
|     "requiresCredentialsInSettings": "Vyžaduje další pověření (v nastavení)", | ||||
|     "requiresCredentialsInSettings": "{}: Vyžaduje další pověření (v nastavení)", | ||||
|     "checkOnStart": "Zkontrolovat jednou při spuštění", | ||||
|     "tryInferAppIdFromCode": "Pokusit se určit ID aplikace ze zdrojového kódu", | ||||
|     "removeOnExternalUninstall": "Automaticky odstranit externě odinstalované aplikace", | ||||
| @@ -236,7 +236,7 @@ | ||||
|     "addInfoInSettings": "Přidat tuto informaci do nastavení.", | ||||
|     "githubSourceNote": "Omezení rychlosti GitHub lze obejít pomocí klíče API.", | ||||
|     "gitlabSourceNote": "Extrakce GitLab APK nemusí fungovat bez klíče API", | ||||
|     "sortByFileNamesNotLinks": "Řadit podle názvů souborů místo celých odkazů", | ||||
|     "sortByLastLinkSegment": "Sort by only the last segment of the link", | ||||
|     "filterReleaseNotesByRegEx": "Filtrovat poznámky k vydání podle regulárního výrazu", | ||||
|     "customLinkFilterRegex": "Vlastní filtr odkazů APK podle regulárního výrazu (výchozí '.apk$')", | ||||
|     "appsPossiblyUpdated": "Byly provedeny pokusy o aktualizaci aplikací", | ||||
| @@ -246,8 +246,10 @@ | ||||
|     "backgroundUpdateReqsExplanation": "Aktualizace na pozadí nemusí být možné pro všechny aplikace.", | ||||
|     "backgroundUpdateLimitsExplanation": "Úspěšnost instalace na pozadí lze určit pouze v případě, že je otevřen Obtainium.", | ||||
|     "verifyLatestTag": "Ověřit značku 'latest'", | ||||
|     "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit First", | ||||
|     "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit", | ||||
|     "filterByLinkText": "Filter links by link text", | ||||
|     "intermediateLinkNotFound": "Intermediate link not found", | ||||
|     "intermediateLink": "Intermediate link", | ||||
|     "exemptFromBackgroundUpdates": "Vyloučit aktualizace na pozadí (pokud jsou povoleny)", | ||||
|     "bgUpdatesOnWiFiOnly": "Zakázat aktualizace na pozadí, pokud není přítomna Wi-Fi", | ||||
|     "autoSelectHighestVersionCode": "Automatický výběr nejvyššího kódu verze APK", | ||||
| @@ -256,10 +258,13 @@ | ||||
|     "highlightTouchTargets": "Zvýraznit méně zjevné cíle dotyku", | ||||
|     "pickExportDir": "Vybrat adresář pro export", | ||||
|     "autoExportOnChanges": "Automatický export při změnách", | ||||
|     "includeSettings": "Include settings", | ||||
|     "filterVersionsByRegEx": "Filtrovat verze podle regulárního výrazu", | ||||
|     "trySelectingSuggestedVersionCode": "Zkusit vybrat navrhovaný kód verze APK", | ||||
|     "dontSortReleasesList": "Retain release order from API", | ||||
|     "reverseSort": "Reverse sorting", | ||||
|     "takeFirstLink": "Take first link", | ||||
|     "skipSort": "Skip sorting", | ||||
|     "debugMenu": "Debug Menu", | ||||
|     "bgTaskStarted": "Background task started - check logs.", | ||||
|     "runBgCheckNow": "Run Background Update Check Now", | ||||
| @@ -275,6 +280,9 @@ | ||||
|     "completeAppInstallationNotifChannel": "Dokončit instalaci aplikace", | ||||
|     "checkingForUpdatesNotifChannel": "Zkontrolovat aktualizace", | ||||
|     "onlyCheckInstalledOrTrackOnlyApps": "Only check installed and Track-Only apps for updates", | ||||
|     "supportFixedAPKURL": "Support fixed APK URLs", | ||||
|     "selectX": "Select {}", | ||||
|     "parallelDownloads": "Allow parallel downloads", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "Odstranit Apku?", | ||||
|         "other": "Odstranit Apky?" | ||||
|   | ||||
| @@ -24,7 +24,7 @@ | ||||
|     "colour": "Farbe", | ||||
|     "githubStarredRepos": "GitHub Starred Repos", | ||||
|     "uname": "Benutzername", | ||||
|     "wrongArgNum": "Falsche Anzahl von Argumenten übermittelt", | ||||
|     "wrongArgNum": "Falsche Anzahl von Argumenten (Parametern) übermittelt", | ||||
|     "xIsTrackOnly": "{} ist nur zur Nachverfolgung", | ||||
|     "source": "Quelle", | ||||
|     "app": "App", | ||||
| @@ -55,7 +55,7 @@ | ||||
|     "notInstalled": "Nicht installiert", | ||||
|     "estimateInBrackets": "(Ungefähr)", | ||||
|     "selectAll": "Alle auswählen", | ||||
|     "deselectN": "{} abgewählt", | ||||
|     "deselectX": "{} abgewählt", | ||||
|     "xWillBeRemovedButRemainInstalled": "{} wird aus Obtainium entfernt, bleibt aber auf dem Gerät installiert.", | ||||
|     "removeSelectedAppsQuestion": "Ausgewählte Apps entfernen?", | ||||
|     "removeSelectedApps": "Ausgewählte Apps entfernen", | ||||
| @@ -76,7 +76,7 @@ | ||||
|     "shareSelectedAppURLs": "Ausgewählte App-URLs teilen", | ||||
|     "resetInstallStatus": "Installationsstatus zurücksetzen", | ||||
|     "more": "Mehr", | ||||
|     "removeOutdatedFilter": "App-Filter 'Nicht aktuell' entfernen", | ||||
|     "removeOutdatedFilter": "App-Filter ‚Nicht aktuell‘ entfernen", | ||||
|     "showOutdatedOnly": "Nur nicht aktuelle Apps anzeigen", | ||||
|     "filter": "Filter", | ||||
|     "filterActive": "Filter *", | ||||
| @@ -223,7 +223,7 @@ | ||||
|     "moveNonInstalledAppsToBottom": "Nicht installierte Apps ans Ende der Apps Ansicht verschieben", | ||||
|     "gitlabPATLabel": "GitLab Personal Access Token\n(Aktiviert Suche und bessere APK Entdeckung)", | ||||
|     "about": "Über", | ||||
|     "requiresCredentialsInSettings": "Benötigt zusätzliche Anmeldedaten (in den Einstellungen)", | ||||
|     "requiresCredentialsInSettings": "{}: Benötigt zusätzliche Anmeldedaten (in den Einstellungen)", | ||||
|     "checkOnStart": "Überprüfe einmalig beim Start", | ||||
|     "tryInferAppIdFromCode": "Versuche, die App-ID aus dem Quellcode zu ermitteln", | ||||
|     "removeOnExternalUninstall": "Automatisches Entfernen von extern deinstallierten Apps", | ||||
| @@ -236,7 +236,7 @@ | ||||
|     "addInfoInSettings": "Fügen Sie diese Info in den Einstellungen hinzu.", | ||||
|     "githubSourceNote": "Die GitHub-Ratenbegrenzung kann mit einem API-Schlüssel umgangen werden.", | ||||
|     "gitlabSourceNote": "GitLab APK-Extraktion funktioniert möglicherweise nicht ohne API-Schlüssel", | ||||
|     "sortByFileNamesNotLinks": "Sortiere nach Dateinamen, anstelle von ganzen Links", | ||||
|     "sortByLastLinkSegment": "Sortiere nur nach dem letzten Teil des Links", | ||||
|     "filterReleaseNotesByRegEx": "Versionshinweise nach regulärem Ausdruck filtern", | ||||
|     "customLinkFilterRegex": "Benutzerdefinierter APK Link Filter nach Regulärem Ausdruck (Standard '.apk$')", | ||||
|     "appsPossiblyUpdated": "App Aktualisierungen wurden versucht", | ||||
| @@ -247,7 +247,9 @@ | ||||
|     "backgroundUpdateLimitsExplanation": "Der Erfolg einer Hintergrundinstallation kann nur festgestellt werden, wenn Obtainium geöffnet wird.", | ||||
|     "verifyLatestTag": "Überprüfe das „latest“ Tag", | ||||
|     "intermediateLinkRegex": "Filter für einen „Zwischen“-Link, der zuerst besucht werden soll", | ||||
|     "intermediateLinkNotFound": "„Zwischen“link nicht gefunden", | ||||
|     "filterByLinkText": "Filtere Links durch Linktext", | ||||
|     "intermediateLinkNotFound": "„Zwischen“-Link nicht gefunden", | ||||
|     "intermediateLink": "„Zwischen“-Link", | ||||
|     "exemptFromBackgroundUpdates": "Ausschluss von Hintergrundaktualisierungen (falls aktiviert)", | ||||
|     "bgUpdatesOnWiFiOnly": "Hintergrundaktualisierungen deaktivieren, wenn kein WLAN vorhanden ist", | ||||
|     "autoSelectHighestVersionCode": "Automatisch höchste APK-Version auswählen", | ||||
| @@ -256,10 +258,13 @@ | ||||
|     "highlightTouchTargets": "Weniger offensichtliche Touch-Ziele hervorheben", | ||||
|     "pickExportDir": "Export-Verzeichnis wählen", | ||||
|     "autoExportOnChanges": "Automatischer Export bei Änderung(en)", | ||||
|     "includeSettings": "Einstellungen einbeziehen", | ||||
|     "filterVersionsByRegEx": "Versionen nach regulären Ausdrücken filtern", | ||||
|     "trySelectingSuggestedVersionCode": "Versuchen, den vorgeschlagenen APK-Versionscode auszuwählen", | ||||
|     "dontSortReleasesList": "Freigaberelease von der API ordern", | ||||
|     "reverseSort": "Umgekehrtes Sortieren", | ||||
|     "takeFirstLink": "Verwende den ersten Link", | ||||
|     "skipSort": "Überspringe Sortieren", | ||||
|     "debugMenu": "Debug-Menü", | ||||
|     "bgTaskStarted": "Hintergrundaufgabe gestartet – Logs prüfen.", | ||||
|     "runBgCheckNow": "Hintergrundaktualisierungsprüfung jetzt durchführen", | ||||
| @@ -274,7 +279,10 @@ | ||||
|     "downloadingXNotifChannel": "Lade {} herunter", | ||||
|     "completeAppInstallationNotifChannel": "App Installation abschließen", | ||||
|     "checkingForUpdatesNotifChannel": "Nach Aktualisierungen suchen", | ||||
|     "onlyCheckInstalledOrTrackOnlyApps": "Überprüfe nur installierte und mit „nur Nachverfolgen“ markierte Apps nach Aktualisierungen", | ||||
|     "onlyCheckInstalledOrTrackOnlyApps": "Überprüfe nur installierte und mit „nur Nachverfolgen“ markierte Apps auf Aktualisierungen", | ||||
|     "supportFixedAPKURL": "neuere Version anhand der ersten dreißig Zahlen der Checksumme der APK URL erraten, wenn anderweitig nicht unterstützt", | ||||
|     "selectX": "Wähle {}", | ||||
|     "parallelDownloads": "Erlaube parallele Downloads", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "App entfernen?", | ||||
|         "other": "Apps entfernen?" | ||||
|   | ||||
| @@ -55,7 +55,7 @@ | ||||
|     "notInstalled": "Not Installed", | ||||
|     "estimateInBrackets": "(Estimate)", | ||||
|     "selectAll": "Select All", | ||||
|     "deselectN": "Deselect {}", | ||||
|     "deselectX": "Deselect {}", | ||||
|     "xWillBeRemovedButRemainInstalled": "{} will be removed from Obtainium but remain installed on device.", | ||||
|     "removeSelectedAppsQuestion": "Remove Selected Apps?", | ||||
|     "removeSelectedApps": "Remove Selected Apps", | ||||
| @@ -223,7 +223,7 @@ | ||||
|     "moveNonInstalledAppsToBottom": "Move non-installed Apps to bottom of Apps view", | ||||
|     "gitlabPATLabel": "GitLab Personal Access Token\n(Enables Search and Better APK Discovery)", | ||||
|     "about": "About", | ||||
|     "requiresCredentialsInSettings": "This needs additional credentials (in Settings)", | ||||
|     "requiresCredentialsInSettings": "{} needs additional credentials (in Settings)", | ||||
|     "checkOnStart": "Check for updates on startup", | ||||
|     "tryInferAppIdFromCode": "Try inferring App ID from source code", | ||||
|     "removeOnExternalUninstall": "Automatically remove externally uninstalled Apps", | ||||
| @@ -236,7 +236,7 @@ | ||||
|     "addInfoInSettings": "Add this info in the Settings.", | ||||
|     "githubSourceNote": "GitHub rate limiting can be avoided using an API key.", | ||||
|     "gitlabSourceNote": "GitLab APK extraction may not work without an API key.", | ||||
|     "sortByFileNamesNotLinks": "Sort by file names instead of full links", | ||||
|     "sortByLastLinkSegment": "Sort by only the last segment of the link", | ||||
|     "filterReleaseNotesByRegEx": "Filter Release Notes by Regular Expression", | ||||
|     "customLinkFilterRegex": "Custom APK Link Filter by Regular Expression (Default '.apk$')", | ||||
|     "appsPossiblyUpdated": "App Updates Attempted", | ||||
| @@ -246,8 +246,10 @@ | ||||
|     "backgroundUpdateReqsExplanation": "Background updates may not be possible for all apps.", | ||||
|     "backgroundUpdateLimitsExplanation": "The success of a background install can only be determined when Obtainium is opened.", | ||||
|     "verifyLatestTag": "Verify the 'latest' tag", | ||||
|     "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit First", | ||||
|     "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit", | ||||
|     "filterByLinkText": "Filter links by link text", | ||||
|     "intermediateLinkNotFound": "Intermediate link not found", | ||||
|     "intermediateLink": "Intermediate link", | ||||
|     "exemptFromBackgroundUpdates": "Exempt from background updates (if enabled)", | ||||
|     "bgUpdatesOnWiFiOnly": "Disable background updates when not on WiFi", | ||||
|     "autoSelectHighestVersionCode": "Auto-select highest versionCode APK", | ||||
| @@ -256,10 +258,13 @@ | ||||
|     "highlightTouchTargets": "Highlight less obvious touch targets", | ||||
|     "pickExportDir": "Pick Export Directory", | ||||
|     "autoExportOnChanges": "Auto-export on changes", | ||||
|     "includeSettings": "Include settings", | ||||
|     "filterVersionsByRegEx": "Filter Versions by Regular Expression", | ||||
|     "trySelectingSuggestedVersionCode": "Try selecting suggested versionCode APK", | ||||
|     "dontSortReleasesList": "Retain release order from API", | ||||
|     "reverseSort": "Reverse sorting", | ||||
|     "takeFirstLink": "Take first link", | ||||
|     "skipSort": "Skip sorting", | ||||
|     "debugMenu": "Debug Menu", | ||||
|     "bgTaskStarted": "Background task started - check logs.", | ||||
|     "runBgCheckNow": "Run Background Update Check Now", | ||||
| @@ -275,6 +280,14 @@ | ||||
|     "completeAppInstallationNotifChannel": "Complete App Installation", | ||||
|     "checkingForUpdatesNotifChannel": "Checking for Updates", | ||||
|     "onlyCheckInstalledOrTrackOnlyApps": "Only check installed and Track-Only apps for updates", | ||||
|     "supportFixedAPKURL": "Support fixed APK URLs", | ||||
|     "selectX": "Select {}", | ||||
|     "parallelDownloads": "Allow parallel downloads", | ||||
|     "installMethod": "Installation method", | ||||
|     "normal": "Normal", | ||||
|     "shizuku": "Shizuku", | ||||
|     "root": "Root", | ||||
|     "shizukuBinderNotFound": "Shizuku is not running", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "Remove App?", | ||||
|         "other": "Remove Apps?" | ||||
|   | ||||
| @@ -9,28 +9,28 @@ | ||||
|     "placeholder": "Espacio reservado", | ||||
|     "someErrors": "Han ocurrido algunos errores", | ||||
|     "unexpectedError": "Error Inesperado", | ||||
|     "ok": "Correcto", | ||||
|     "ok": "OK", | ||||
|     "and": "y", | ||||
|     "githubPATLabel": "Token de Acceso Personal de GitHub (Reduce tiempos de espera)", | ||||
|     "githubPATLabel": "Token Github de Acceso Personal\n(Reduce tiempos de espera)", | ||||
|     "includePrereleases": "Incluir versiones preliminares", | ||||
|     "fallbackToOlderReleases": "Retorceder a versiones previas", | ||||
|     "filterReleaseTitlesByRegEx": "Filtra Títulos de Versiones mediantes Expresiones Regulares", | ||||
|     "invalidRegEx": "Expresión regular inválida", | ||||
|     "fallbackToOlderReleases": "Retroceder a versiones previas", | ||||
|     "filterReleaseTitlesByRegEx": "Filtrar por título de version", | ||||
|     "invalidRegEx": "Expresión inválida", | ||||
|     "noDescription": "Sin descripción", | ||||
|     "cancel": "Cancelar", | ||||
|     "continue": "Continuar", | ||||
|     "requiredInBrackets": "(Requerido)", | ||||
|     "dropdownNoOptsError": "ERROR: EL DESPLEGABLE DEBE TENER AL MENOS UNA OPCIÓN", | ||||
|     "colour": "Color", | ||||
|     "githubStarredRepos": "Repositorios favoritos de GitHub", | ||||
|     "githubStarredRepos": "Repositorios favoritos GitHub", | ||||
|     "uname": "Nombre de usuario", | ||||
|     "wrongArgNum": "Número de argumentos provistos inválido", | ||||
|     "xIsTrackOnly": "{} es de 'Solo Seguimiento'", | ||||
|     "source": "Origen", | ||||
|     "app": "Aplicación", | ||||
|     "appsFromSourceAreTrackOnly": "Las aplicaciones de este origen son de 'Solo Seguimiento'.", | ||||
|     "youPickedTrackOnly": "Debes seleccionar la opción de 'Solo Seguimiento'.", | ||||
|     "trackOnlyAppDescription": "Se monitorizará la aplicación en busca de actualizaciones, pero Obtainium no será capaz de descargarla o acutalizarla.", | ||||
|     "youPickedTrackOnly": "Debe seleccionar la opción de 'Solo Seguimiento'.", | ||||
|     "trackOnlyAppDescription": "Se hará el seguimiento de actualizaciones para la aplicación, pero Obtainium no será capaz de descargarla o actalizarla.", | ||||
|     "cancelled": "Cancelado", | ||||
|     "appAlreadyAdded": "Aplicación ya añadida", | ||||
|     "alreadyUpToDateQuestion": "¿Aplicación ya actualizada?", | ||||
| @@ -38,16 +38,16 @@ | ||||
|     "appSourceURL": "URL de Origen de la Aplicación", | ||||
|     "error": "Error", | ||||
|     "add": "Añadir", | ||||
|     "searchSomeSourcesLabel": "Buscar (Solo Algunas Fuentes)", | ||||
|     "searchSomeSourcesLabel": "Buscar (solo algunas fuentes)", | ||||
|     "search": "Buscar", | ||||
|     "additionalOptsFor": "Opciones Adicionales para {}", | ||||
|     "supportedSources": "Fuentes Soportadas", | ||||
|     "trackOnlyInBrackets": "(Solo Seguimiento)", | ||||
|     "searchableInBrackets": "(Soporta Búsquedas)", | ||||
|     "searchableInBrackets": "(soporta búsqueda)", | ||||
|     "appsString": "Aplicaciones", | ||||
|     "noApps": "Sin Aplicaciones", | ||||
|     "noAppsForFilter": "Sin Aplicaciones para Filtrar", | ||||
|     "byX": "Por {}", | ||||
|     "noAppsForFilter": "Sin aplicaciones para filtrar", | ||||
|     "byX": "por: {}", | ||||
|     "percentProgress": "Progreso: {}%", | ||||
|     "pleaseWait": "Por favor, espere", | ||||
|     "updateAvailable": "Actualización Disponible", | ||||
| @@ -55,13 +55,13 @@ | ||||
|     "notInstalled": "No Instalado", | ||||
|     "estimateInBrackets": "(Aproximado)", | ||||
|     "selectAll": "Seleccionar Todo", | ||||
|     "deselectN": "Deseleccionar {}", | ||||
|     "xWillBeRemovedButRemainInstalled": "{} será borrada de Obtainium pero continuará instalada en el dispositivo.", | ||||
|     "removeSelectedAppsQuestion": "¿Borrar aplicaciones seleccionadas?", | ||||
|     "removeSelectedApps": "Borrar Aplicaciones Seleccionadas", | ||||
|     "deselectX": "Deseleccionar {}", | ||||
|     "xWillBeRemovedButRemainInstalled": "{} será eliminada de Obtainium pero continuará instalada en el dispositivo.", | ||||
|     "removeSelectedAppsQuestion": "¿Eliminar aplicaciones seleccionadas?", | ||||
|     "removeSelectedApps": "Eliminar Aplicaciones Seleccionadas", | ||||
|     "updateX": "Actualizar {}", | ||||
|     "installX": "Instalar {}", | ||||
|     "markXTrackOnlyAsUpdated": "Marcar {}\n(Solo Seguimient)\ncomo Actualizada", | ||||
|     "markXTrackOnlyAsUpdated": "Marcar {}\n(Solo Seguimiento)\ncomo Actualizada", | ||||
|     "changeX": "Cambiar {}", | ||||
|     "installUpdateApps": "Instalar/Actualizar Aplicaciones", | ||||
|     "installUpdateSelectedApps": "Instalar/Actualizar Aplicaciones Seleccionadas", | ||||
| @@ -72,7 +72,7 @@ | ||||
|     "pinToTop": "Fijar arriba", | ||||
|     "unpinFromTop": "Desfijar de arriba", | ||||
|     "resetInstallStatusForSelectedAppsQuestion": "¿Restuarar Estado de Instalación para las Aplicaciones Seleccionadas?", | ||||
|     "installStatusOfXWillBeResetExplanation": "El estado de instalación de las aplicaciones seleccionadas será restaurado.\n\nEsto puede ser de utilidad cuando la versión de la aplicación mostrada en Obtainium es incorrecta por actualizaciones fallidas u otros motivos.", | ||||
|     "installStatusOfXWillBeResetExplanation": "El estado de instalación de las aplicaciones seleccionadas será restaurado.\n\nEsto puede ser de útil cuando la versión de la aplicación mostrada en Obtainium es incorrecta por actualizaciones fallidas u otros motivos.", | ||||
|     "shareSelectedAppURLs": "Compartir URLs de las Aplicaciones Seleccionadas", | ||||
|     "resetInstallStatus": "Restaurar Estado de Instalación", | ||||
|     "more": "Más", | ||||
| @@ -98,12 +98,12 @@ | ||||
|     "line": "Línea", | ||||
|     "searchX": "Buscar {}", | ||||
|     "noResults": "Resultados no encontrados", | ||||
|     "importX": "Importar {}", | ||||
|     "importedAppsIdDisclaimer": "Las Aplicaciones Importadas pueden mostrarse incorrectamente como \"No Instalada\".\nPara arreglar esto, reinstálalas a través de Obtainium.\nEsto no debería afectar a los datos de las aplicaciones.\n\nSolo afecta a las URLs y a los métodos de importación mediante terceros.", | ||||
|     "importErrors": "Import Errors", | ||||
|     "importX": "Importar desde {}", | ||||
|     "importedAppsIdDisclaimer": "Las aplicaciones importadas podrían mostrarse incorrectamente como \"No Instalada\".\nPara solucionarlo, reinstálalas a través de Obtainium.\nEsto no debería afectar a los datos de las aplicaciones.\n\nSolo afecta a las URLs y a los métodos de importación mediante terceros.", | ||||
|     "importErrors": "Errores de Importación", | ||||
|     "importedXOfYApps": "{} de {} Aplicaciones importadas.", | ||||
|     "followingURLsHadErrors": "Las siguientes URLs tuvieron problemas:", | ||||
|     "okay": "Correcto", | ||||
|     "followingURLsHadErrors": "Las siguientes URLs han tenido problemas:", | ||||
|     "okay": "Aceptar", | ||||
|     "selectURL": "Seleccionar URL", | ||||
|     "selectURLs": "Seleccionar URLs", | ||||
|     "pick": "Escoger", | ||||
| @@ -113,12 +113,12 @@ | ||||
|     "followSystem": "Seguir al Sistema", | ||||
|     "obtainium": "Obtainium", | ||||
|     "materialYou": "Material You", | ||||
|     "useBlackTheme": "Usar tema oscuro con negros puros", | ||||
|     "appSortBy": "Ordenar Aplicaciones Por", | ||||
|     "useBlackTheme": "Negro puro en tema Oscuro", | ||||
|     "appSortBy": "Ordenar Apps Por", | ||||
|     "authorName": "Autor/Nombre", | ||||
|     "nameAuthor": "Nombre/Autor", | ||||
|     "asAdded": "Según se Añadieron", | ||||
|     "appSortOrder": "Orden de Clasificación de Aplicaciones", | ||||
|     "appSortOrder": "Orden de Clasificación", | ||||
|     "ascending": "Ascendente", | ||||
|     "descending": "Descendente", | ||||
|     "bgUpdateCheckInterval": "Intervalo de Comprobación de Actualizaciones en Segundo Plano", | ||||
| @@ -135,10 +135,10 @@ | ||||
|     "share": "Compartir", | ||||
|     "appNotFound": "Aplicación no encontrada", | ||||
|     "obtainiumExportHyphenatedLowercase": "obtainium-export", | ||||
|     "pickAnAPK": "Elige una APK", | ||||
|     "pickAnAPK": "Seleccione una APK", | ||||
|     "appHasMoreThanOnePackage": "{} tiene más de un paquete:", | ||||
|     "deviceSupportsXArch": "Tu dispositivo soporta las siguientes arquitecturas de procesador: {}.", | ||||
|     "deviceSupportsFollowingArchs": "Tu dispositivo soporta las siguientes arquitecturas de procesador:", | ||||
|     "deviceSupportsXArch": "Su dispositivo soporta las siguientes arquitecturas de procesador: {}.", | ||||
|     "deviceSupportsFollowingArchs": "Su dispositivo soporta las siguientes arquitecturas de procesador:", | ||||
|     "warning": "Aviso", | ||||
|     "sourceIsXButPackageFromYPrompt": "La fuente de la aplicación es '{}' pero el paquete de la actualización viene de '{}'. ¿Desea continuar?", | ||||
|     "updatesAvailable": "Actualizaciones Disponibles", | ||||
| @@ -154,11 +154,11 @@ | ||||
|     "appsRemovedNotifDescription": "Notifica al usuario que una o más aplicaciones fueron eliminadas por problemas al cargarlas", | ||||
|     "xWasRemovedDueToErrorY": "{} ha sido eliminada por: {}", | ||||
|     "completeAppInstallation": "Instalación Completa de la Aplicación", | ||||
|     "obtainiumMustBeOpenToInstallApps": "Obtainium debe estar abierta para instalar aplicaciones", | ||||
|     "completeAppInstallationNotifDescription": "Pide al usuario volver a Obtainium para teminar de instalar una aplicación", | ||||
|     "obtainiumMustBeOpenToInstallApps": "Obtainium debe estar abierto para instalar aplicaciones", | ||||
|     "completeAppInstallationNotifDescription": "Pide al usuario volver a Obtainium para terminar de instalar una aplicación", | ||||
|     "checkingForUpdates": "Buscando Actualizaciones", | ||||
|     "checkingForUpdatesNotifDescription": "Notificación temporal que aparece al buscar actualizaciones", | ||||
|     "pleaseAllowInstallPerm": "Por favor, permite a Obtainium instalar aplicaciones", | ||||
|     "pleaseAllowInstallPerm": "Por favor, permita que Obtainium instale aplicaciones", | ||||
|     "trackOnly": "Solo Seguimiento", | ||||
|     "errorWithHttpStatusCode": "Error {}", | ||||
|     "versionCorrectionDisabled": "Corrección de versiones desactivada (el plugin parece no funcionar)", | ||||
| @@ -170,24 +170,24 @@ | ||||
|     "lastUpdateCheckX": "Última Comprobación: {}", | ||||
|     "remove": "Eliminar", | ||||
|     "yesMarkUpdated": "Sí, Marcar como Actualizada", | ||||
|     "fdroid": "Repositorio oficial de F-Droid", | ||||
|     "fdroid": "Repositorio oficial F-Droid", | ||||
|     "appIdOrName": "ID o Nombre de la Aplicación", | ||||
|     "appId": "ID de la Aplicación", | ||||
|     "appWithIdOrNameNotFound": "No se han encontrado aplicaciones con esa ID o nombre", | ||||
|     "reposHaveMultipleApps": "Los repositorios pueden contener varias aplicaciones", | ||||
|     "fdroidThirdPartyRepo": "Rpositorios de terceros de F-Droid", | ||||
|     "fdroidThirdPartyRepo": "Rpositorios de terceros F-Droid", | ||||
|     "steam": "Steam", | ||||
|     "steamMobile": "Steam Mobile", | ||||
|     "steamChat": "Steam Chat", | ||||
|     "install": "Instalar", | ||||
|     "markInstalled": "Marcar como Instalda", | ||||
|     "markInstalled": "Marcar como Instalada", | ||||
|     "update": "Actualizar", | ||||
|     "markUpdated": "Marcar como Actualizada", | ||||
|     "additionalOptions": "Opciones Adicionales", | ||||
|     "disableVersionDetection": "Descativar Detección de Versiones", | ||||
|     "noVersionDetectionExplanation": "Esta opción solo se debe usar en aplicaciones en las que la deteción de versiones pueda no funcionar correctamente.", | ||||
|     "downloadingX": "Descargando {}", | ||||
|     "downloadNotifDescription": "Notifica al usuario de progreso de descarga de una aplicación", | ||||
|     "downloadNotifDescription": "Notifica al usuario del progreso de descarga de una aplicación", | ||||
|     "noAPKFound": "APK no encontrada", | ||||
|     "noVersionDetection": "Sin detección de versiones", | ||||
|     "categorize": "Catogorizar", | ||||
| @@ -195,86 +195,94 @@ | ||||
|     "category": "Categoría", | ||||
|     "noCategory": "Sin Categoría", | ||||
|     "noCategories": "Sin Categorías", | ||||
|     "deleteCategoriesQuestion": "¿Borrar Categorías?", | ||||
|     "categoryDeleteWarning": "Todas las aplicaciones en las categorías borradas serán margadas como 'Sin Categoría'.", | ||||
|     "deleteCategoriesQuestion": "¿Eliminar Categorías?", | ||||
|     "categoryDeleteWarning": "Todas las aplicaciones en las categorías eliminadas serán marcadas como 'Sin Categoría'.", | ||||
|     "addCategory": "Añadir Categoría", | ||||
|     "label": "Nombre", | ||||
|     "language": "Idioma", | ||||
|     "copiedToClipboard": "Copiado al Portapapeles", | ||||
|     "storagePermissionDenied": "Permiso de Almacenamiento rechazado", | ||||
|     "selectedCategorizeWarning": "Esto reemplazará cualquier ajuste de categoría para las aplicaicones seleccionadas.", | ||||
|     "filterAPKsByRegEx": "Filtrar APKs mediante Expresiones Regulares", | ||||
|     "selectedCategorizeWarning": "Esto reemplazará cualquier ajuste de categoría para las aplicaciones seleccionadas.", | ||||
|     "filterAPKsByRegEx": "Filtrar por APKs", | ||||
|     "removeFromObtainium": "Eliminar de Obtainium", | ||||
|     "uninstallFromDevice": "Desinstalar del Dispositivo", | ||||
|     "onlyWorksWithNonVersionDetectApps": "Solo funciona para aplicaciones con la detección de versiones desactivada.", | ||||
|     "releaseDateAsVersion": "Usar Fecha de Publicación como Versión", | ||||
|     "releaseDateAsVersion": "Por fecha de publicación", | ||||
|     "releaseDateAsVersionExplanation": "Esta opción solo se debería usar con aplicaciones en las que la detección de versiones no funciona pero hay disponible una fecha de publicación.", | ||||
|     "changes": "Cambios", | ||||
|     "releaseDate": "Fecha de Publicación", | ||||
|     "importFromURLsInFile": "Importar de URls en un Archivo (como OPML)", | ||||
|     "importFromURLsInFile": "Importar URLs desde archivo (como OPML)", | ||||
|     "versionDetection": "Detección de Versiones", | ||||
|     "standardVersionDetection": "Detección de versiones estándar", | ||||
|     "standardVersionDetection": "Por versión", | ||||
|     "groupByCategory": "Agrupar por Categoría", | ||||
|     "autoApkFilterByArch": "Tratar de filtrar las APKs mediante arquitecturas de procesador si es posible", | ||||
|     "autoApkFilterByArch": "Filtrar APKs por arquitectura del procesador, si es posible", | ||||
|     "overrideSource": "Sobrescribir Fuente", | ||||
|     "dontShowAgain": "No mostrar de nuevo", | ||||
|     "dontShowTrackOnlyWarnings": "No mostrar avisos de 'Solo Seguimiento'", | ||||
|     "dontShowAPKOriginWarnings": "No mostrar avisos de las fuentes de las APks", | ||||
|     "moveNonInstalledAppsToBottom": "Move non-installed Apps to bottom of Apps view", | ||||
|     "gitlabPATLabel": "GitLab Personal Access Token\n(Enables Search and Better APK Discovery)", | ||||
|     "about": "About", | ||||
|     "requiresCredentialsInSettings": "This needs additional credentials (in Settings)", | ||||
|     "checkOnStart": "Check for updates on startup", | ||||
|     "tryInferAppIdFromCode": "Try inferring App ID from source code", | ||||
|     "removeOnExternalUninstall": "Automatically remove externally uninstalled Apps", | ||||
|     "pickHighestVersionCode": "Auto-select highest version code APK", | ||||
|     "checkUpdateOnDetailPage": "Check for updates on opening an App detail page", | ||||
|     "disablePageTransitions": "Disable page transition animations", | ||||
|     "reversePageTransitions": "Reverse page transition animations", | ||||
|     "minStarCount": "Minimum Star Count", | ||||
|     "addInfoBelow": "Add this info below.", | ||||
|     "addInfoInSettings": "Add this info in the Settings.", | ||||
|     "githubSourceNote": "GitHub rate limiting can be avoided using an API key.", | ||||
|     "gitlabSourceNote": "GitLab APK extraction may not work without an API key.", | ||||
|     "sortByFileNamesNotLinks": "Sort by file names instead of full links", | ||||
|     "filterReleaseNotesByRegEx": "Filter Release Notes by Regular Expression", | ||||
|     "customLinkFilterRegex": "Custom APK Link Filter by Regular Expression (Default '.apk$')", | ||||
|     "appsPossiblyUpdated": "App Updates Attempted", | ||||
|     "appsPossiblyUpdatedNotifDescription": "Notifies the user that updates to one or more Apps were potentially applied in the background", | ||||
|     "xWasPossiblyUpdatedToY": "{} may have been updated to {}.", | ||||
|     "enableBackgroundUpdates": "Enable background updates", | ||||
|     "backgroundUpdateReqsExplanation": "Background updates may not be possible for all apps.", | ||||
|     "backgroundUpdateLimitsExplanation": "The success of a background install can only be determined when Obtainium is opened.", | ||||
|     "verifyLatestTag": "Verify the 'latest' tag", | ||||
|     "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit First", | ||||
|     "intermediateLinkNotFound": "Intermediate link not found", | ||||
|     "exemptFromBackgroundUpdates": "Exempt from background updates (if enabled)", | ||||
|     "bgUpdatesOnWiFiOnly": "Disable background updates when not on WiFi", | ||||
|     "autoSelectHighestVersionCode": "Auto-select highest versionCode APK", | ||||
|     "versionExtractionRegEx": "Version Extraction RegEx", | ||||
|     "matchGroupToUse": "Match Group to Use", | ||||
|     "highlightTouchTargets": "Highlight less obvious touch targets", | ||||
|     "pickExportDir": "Pick Export Directory", | ||||
|     "autoExportOnChanges": "Auto-export on changes", | ||||
|     "filterVersionsByRegEx": "Filter Versions by Regular Expression", | ||||
|     "trySelectingSuggestedVersionCode": "Try selecting suggested versionCode APK", | ||||
|     "dontSortReleasesList": "Retain release order from API", | ||||
|     "reverseSort": "Reverse sorting", | ||||
|     "debugMenu": "Debug Menu", | ||||
|     "bgTaskStarted": "Background task started - check logs.", | ||||
|     "runBgCheckNow": "Run Background Update Check Now", | ||||
|     "versionExtractWholePage": "Apply Version Extraction Regex to Entire Page", | ||||
|     "installing": "Installing", | ||||
|     "skipUpdateNotifications": "Skip update notifications", | ||||
|     "dontShowAPKOriginWarnings": "No mostrar avisos de las fuentes de las APKs", | ||||
|     "moveNonInstalledAppsToBottom": "Mover Apps no instaladas en la Parte Inferior de la Vista de Aplicaciones", | ||||
|     "gitlabPATLabel": "Token GitLab de Acceso Personal\n(Habilita la Búsqueda y Mejor Detección de APKs)", | ||||
|     "about": "Acerca", | ||||
|     "requiresCredentialsInSettings": "{}: Esto requiere credenciales adicionales (en Ajustes)", | ||||
|     "checkOnStart": "Comprobar actualizaciones durante el inicio", | ||||
|     "tryInferAppIdFromCode": "Intentar deducir la ID de la APP por el código fuente", | ||||
|     "removeOnExternalUninstall": "Auto eliminar Apps desinstaladas externamente", | ||||
|     "pickHighestVersionCode": "Auto selección versión superior del código APK", | ||||
|     "checkUpdateOnDetailPage": "Comprobar actualizaciones al abrir detalles de la App", | ||||
|     "disablePageTransitions": "Deshabilitar animaciones de transición de la página", | ||||
|     "reversePageTransitions": "Invertir animaciones de transición de la página", | ||||
|     "minStarCount": "Número Mínimo de Estrellas", | ||||
|     "addInfoBelow": "Añadir esta información debajo.", | ||||
|     "addInfoInSettings": "Puede añadir esta información en Ajustes.", | ||||
|     "githubSourceNote": "La limitación de velocidad de GitHub puede evitarse con una clave API.", | ||||
|     "gitlabSourceNote": "La extracción de APK de GitLab podría no funcionar sin una clave API.", | ||||
|     "sortByLastLinkSegment": "Sort by only the last segment of the link", | ||||
|     "filterReleaseNotesByRegEx": "Filtrar por notas de nersión (release notes)", | ||||
|     "customLinkFilterRegex": "Filtro personalizado de Enlace APK (por defecto '.apk$')", | ||||
|     "appsPossiblyUpdated": "Actualización de Apps intentada", | ||||
|     "appsPossiblyUpdatedNotifDescription": "Notifica al usuario que las actualizaciones en segundo plano podrían haberse realizado para una o más aplicaciones", | ||||
|     "xWasPossiblyUpdatedToY": "{} podría estar actualizada a {}.", | ||||
|     "enableBackgroundUpdates": "Habilitar actualizaciones en segundo plano", | ||||
|     "backgroundUpdateReqsExplanation": "Las actualizaciones en segundo plano pueden no estar disponibles para todas las aplicaciones.", | ||||
|     "backgroundUpdateLimitsExplanation": "El éxito de las instalaciones en segundo plano solo se puede comprobar con Obtainium abierto.", | ||||
|     "verifyLatestTag": "Comprueba la etiqueta 'Latest'", | ||||
|     "intermediateLinkRegex": "Filtrar por enlace 'intermedio' para visitar primero", | ||||
|     "filterByLinkText": "Filter links by link text", | ||||
|     "intermediateLinkNotFound": "Enlace intermedio no encontrado", | ||||
|     "intermediateLink": "Intermediate link", | ||||
|     "exemptFromBackgroundUpdates": "Exenta de actualizciones en segundo plano (si están habilitadas)", | ||||
|     "bgUpdatesOnWiFiOnly": "Deshabilitar las actualizaciones en segundo plano sin WiFi", | ||||
|     "autoSelectHighestVersionCode": "Auto Selección de la versionCode APK superior", | ||||
|     "versionExtractionRegEx": "Versión de Extracción de RegEx", | ||||
|     "matchGroupToUse": "Coincidir en Grupo a Usar", | ||||
|     "highlightTouchTargets": "Resaltar objetivos menos obvios", | ||||
|     "pickExportDir": "Selecciona el Directorio para Exportar", | ||||
|     "autoExportOnChanges": "Auto Exportar cuando haya cambios", | ||||
|     "includeSettings": "Incluir ajustes", | ||||
|     "filterVersionsByRegEx": "Filtrar por Versiones", | ||||
|     "trySelectingSuggestedVersionCode": "Pruebe seleccionando la versionCode APK sugerida", | ||||
|     "dontSortReleasesList": "Mantener el order de publicación desde API", | ||||
|     "reverseSort": "Orden inverso", | ||||
|     "takeFirstLink": "Usar primer enlace", | ||||
|     "skipSort": "Omitir orden", | ||||
|     "debugMenu": "Menu Depurar", | ||||
|     "bgTaskStarted": "Iniciada tarea en segundo plano - revisa los logs.", | ||||
|     "runBgCheckNow": "Ejecutar verficiación de actualizaciones en segundo plano", | ||||
|     "versionExtractWholePage": "Aplicar la Versión de Extracción Regex a la Página Entera", | ||||
|     "installing": "Instalando", | ||||
|     "skipUpdateNotifications": "Omitir de notificaciones sobre actualizaciones", | ||||
|     "updatesAvailableNotifChannel": "Actualizaciones Disponibles", | ||||
|     "appsUpdatedNotifChannel": "Aplicaciones Actualizadas", | ||||
|     "appsPossiblyUpdatedNotifChannel": "App Updates Attempted", | ||||
|     "appsPossiblyUpdatedNotifChannel": "Se ha Intentado Actualizar la Aplicación", | ||||
|     "errorCheckingUpdatesNotifChannel": "Error Buscando Actualizaciones", | ||||
|     "appsRemovedNotifChannel": "Aplicaciones Eliminadas", | ||||
|     "downloadingXNotifChannel": "Descargando {}", | ||||
|     "completeAppInstallationNotifChannel": "Instalación Completa de la Aplicación", | ||||
|     "checkingForUpdatesNotifChannel": "Buscando Actualizaciones", | ||||
|     "onlyCheckInstalledOrTrackOnlyApps": "Only check installed and Track-Only apps for updates", | ||||
|     "onlyCheckInstalledOrTrackOnlyApps": "Comprobar actualizaciones solo para apps instaladas y en seguimiento", | ||||
|     "supportFixedAPKURL": "Soporte para URLs fijas de APK", | ||||
|     "selectX": "Selecciona {}", | ||||
|     "parallelDownloads": "Permitir descargas paralelas", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "¿Eliminar Aplicación?", | ||||
|         "other": "¿Eliminar Aplicaciones?" | ||||
| @@ -312,19 +320,19 @@ | ||||
|         "other": "{} Días" | ||||
|     }, | ||||
|     "clearedNLogsBeforeXAfterY": { | ||||
|         "one": "Borrado {n} log (previo a = {before}, posterior a = {after})", | ||||
|         "other": "Borrados {n} logs (previos a = {before}, posteriores a = {after})" | ||||
|         "one": "Eliminado {n} log (previo a = {before}, posterior a = {after})", | ||||
|         "other": "Eliminados {n} logs (previos a = {before}, posteriores a = {after})" | ||||
|     }, | ||||
|     "xAndNMoreUpdatesAvailable": { | ||||
|         "one": "{} y 1 aplicación más tiene actualizaciones.", | ||||
|         "other": "{} y {} aplicaciones más tiene actualizaciones." | ||||
|         "other": "{} y {} aplicaciones más tienen actualizaciones." | ||||
|     }, | ||||
|     "xAndNMoreUpdatesInstalled": { | ||||
|         "one": "{} y 1 aplicación más han sido actualizadas.", | ||||
|         "other": "{} y {} aplicaciones más han sido actualizadas." | ||||
|     }, | ||||
|     "xAndNMoreUpdatesPossiblyInstalled": { | ||||
|         "one": "{} and 1 more app may have been updated.", | ||||
|         "other": "{} and {} more apps may have been updated." | ||||
|         "one": "{} y 1 aplicación más podría haber sido actualizada.", | ||||
|         "other": "{} y {} aplicaciones más podrían haber sido actualizadas." | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -55,7 +55,7 @@ | ||||
|     "notInstalled": "نصب نشده", | ||||
|     "estimateInBrackets": "(تخمین زدن)", | ||||
|     "selectAll": "انتخاب همه", | ||||
|     "deselectN": "لغو انتخاب {}", | ||||
|     "deselectX": "لغو انتخاب {}", | ||||
|     "xWillBeRemovedButRemainInstalled": "{} از Obtainium حذف میشود اما روی دستگاه نصب میماند.", | ||||
|     "removeSelectedAppsQuestion": "برنامه های انتخابی حذف شود؟", | ||||
|     "removeSelectedApps": "حذف برنامه های انتخاب شده", | ||||
| @@ -223,7 +223,7 @@ | ||||
|     "moveNonInstalledAppsToBottom": "برنامه های نصب نشده را به نمای پایین برنامه ها منتقل کنید", | ||||
|     "gitlabPATLabel": "رمز دسترسی شخصی GitLab\n(جستجو و کشف بهتر APK را فعال میکند)", | ||||
|     "about": "درباره", | ||||
|     "requiresCredentialsInSettings": "این به اعتبارنامه های اضافی نیاز دارد (در تنظیمات)", | ||||
|     "requiresCredentialsInSettings": "{}: این به اعتبارنامه های اضافی نیاز دارد (در تنظیمات)", | ||||
|     "checkOnStart": "بررسی در شروع", | ||||
|     "tryInferAppIdFromCode": "شناسه برنامه را از کد منبع استنباط کنید", | ||||
|     "removeOnExternalUninstall": "حذف خودکار برنامه های حذف نصب شده خارجی", | ||||
| @@ -236,45 +236,53 @@ | ||||
|     "addInfoInSettings": "این اطلاعات را در تنظیمات اضافه کنید.", | ||||
|     "githubSourceNote": "با استفاده از کلید API می توان از محدودیت نرخ GitHub جلوگیری کرد.", | ||||
|     "gitlabSourceNote": "استخراج APK GitLab ممکن است بدون کلید API کار نکند.", | ||||
|     "sortByFileNamesNotLinks": "مرتب سازی بر اساس نام فایل به جای پیوندهای کامل", | ||||
|     "sortByLastLinkSegment": "Sort by only the last segment of the link", | ||||
|     "filterReleaseNotesByRegEx": "یادداشت های انتشار را با بیان منظم فیلتر کنید", | ||||
|     "customLinkFilterRegex": "فیلتر پیوند سفارشی بر اساس عبارت منظم (پیشفرض '.apk$')", | ||||
|     "appsPossiblyUpdated": "App Updates Attempted", | ||||
|     "appsPossiblyUpdatedNotifDescription": "Notifies the user that updates to one or more Apps were potentially applied in the background", | ||||
|     "xWasPossiblyUpdatedToY": "{} may have been updated to {}.", | ||||
|     "enableBackgroundUpdates": "Enable background updates", | ||||
|     "backgroundUpdateReqsExplanation": "Background updates may not be possible for all apps.", | ||||
|     "backgroundUpdateLimitsExplanation": "The success of a background install can only be determined when Obtainium is opened.", | ||||
|     "verifyLatestTag": "Verify the 'latest' tag", | ||||
|     "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit First", | ||||
|     "intermediateLinkNotFound": "Intermediate link not found", | ||||
|     "exemptFromBackgroundUpdates": "Exempt from background updates (if enabled)", | ||||
|     "bgUpdatesOnWiFiOnly": "Disable background updates when not on WiFi", | ||||
|     "autoSelectHighestVersionCode": "Auto-select highest versionCode APK", | ||||
|     "versionExtractionRegEx": "Version Extraction RegEx", | ||||
|     "matchGroupToUse": "Match Group to Use", | ||||
|     "highlightTouchTargets": "Highlight less obvious touch targets", | ||||
|     "pickExportDir": "Pick Export Directory", | ||||
|     "autoExportOnChanges": "Auto-export on changes", | ||||
|     "filterVersionsByRegEx": "Filter Versions by Regular Expression", | ||||
|     "trySelectingSuggestedVersionCode": "Try selecting suggested versionCode APK", | ||||
|     "dontSortReleasesList": "Retain release order from API", | ||||
|     "reverseSort": "Reverse sorting", | ||||
|     "debugMenu": "Debug Menu", | ||||
|     "bgTaskStarted": "Background task started - check logs.", | ||||
|     "runBgCheckNow": "Run Background Update Check Now", | ||||
|     "versionExtractWholePage": "Apply Version Extraction Regex to Entire Page", | ||||
|     "installing": "Installing", | ||||
|     "skipUpdateNotifications": "Skip update notifications", | ||||
|     "appsPossiblyUpdated": "بهروزرسانی برنامه انجام شد", | ||||
|     "appsPossiblyUpdatedNotifDescription": "به کاربر اطلاع میدهد که بهروزرسانیهای یک یا چند برنامه به طور بالقوه در پسزمینه اعمال شده است", | ||||
|     "xWasPossiblyUpdatedToY": "ممکن است {} به {} به روز شده باشد.", | ||||
|     "enableBackgroundUpdates": "به روز رسانی پس زمینه را فعال کنید", | ||||
|     "backgroundUpdateReqsExplanation": "به روز رسانی پس زمینه ممکن است برای همه برنامه ها امکان پذیر نباشد.", | ||||
|     "backgroundUpdateLimitsExplanation": "موفقیت نصب پسزمینه تنها زمانی مشخص میشود که Obtainium باز شود.", | ||||
|     "verifyLatestTag": "برچسب \"آخرین\" را تأیید کنید", | ||||
|     "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit", | ||||
|     "filterByLinkText": "Filter links by link text", | ||||
|     "intermediateLinkNotFound": "لینک میانی پیدا نشد", | ||||
|     "intermediateLink": "Intermediate link", | ||||
|     "exemptFromBackgroundUpdates": "معاف از بهروزرسانیهای پسزمینه (در صورت فعال بودن)", | ||||
|     "bgUpdatesOnWiFiOnly": "بهروزرسانیهای پسزمینه را در صورت عدم اتصال به WiFi غیرفعال کنید", | ||||
|     "autoSelectHighestVersionCode": "انتخاب خودکار بالاترین نسخه کد APK", | ||||
|     "versionExtractionRegEx": "نسخه استخراج RegEx", | ||||
|     "matchGroupToUse": "گروه مورد استفاده را مطابقت دهید", | ||||
|     "highlightTouchTargets": "اهداف لمسی کمتر واضح را برجسته کنید", | ||||
|     "pickExportDir": "فهرست صادرات را انتخاب کنید", | ||||
|     "autoExportOnChanges": "صادرات خودکار تغییرات", | ||||
|     "includeSettings": "Include settings", | ||||
|     "filterVersionsByRegEx": "فیلتر کردن نسخه ها با RegEx", | ||||
|     "trySelectingSuggestedVersionCode": "نسخه پیشنهادی APK نسخه کد را انتخاب کنید", | ||||
|     "dontSortReleasesList": "حفظ سفارش انتشار از API", | ||||
|     "reverseSort": "مرتب سازی معکوس", | ||||
|     "takeFirstLink": "Take first link", | ||||
|     "skipSort": "Skip sorting", | ||||
|     "debugMenu": "منوی اشکال زدایی", | ||||
|     "bgTaskStarted": "کار پس زمینه شروع شد - لاگ های مربوط را بررسی کنید.", | ||||
|     "runBgCheckNow": "اکنون بهروزرسانی پسزمینه را بررسی کنید", | ||||
|     "versionExtractWholePage": "نسخه Extraction Regex را در کل صفحه اعمال کنید", | ||||
|     "installing": "در حال نصب", | ||||
|     "skipUpdateNotifications": "رد شدن از اعلان های به روز رسانی", | ||||
|     "updatesAvailableNotifChannel": "بروزرسانی در دسترس ", | ||||
|     "appsUpdatedNotifChannel": "برنامه ها به روز شدند", | ||||
|     "appsPossiblyUpdatedNotifChannel": "App Updates Attempted", | ||||
|     "appsPossiblyUpdatedNotifChannel": "بهروزرسانی برنامه انجام شد", | ||||
|     "errorCheckingUpdatesNotifChannel": "خطا در بررسی بهروزرسانیها", | ||||
|     "appsRemovedNotifChannel": "برنامه ها حذف شدند", | ||||
|     "downloadingXNotifChannel": "در حال دانلود {}", | ||||
|     "completeAppInstallationNotifChannel": "نصب کامل برنامه", | ||||
|     "checkingForUpdatesNotifChannel": "بررسی بهروزرسانیها", | ||||
|     "onlyCheckInstalledOrTrackOnlyApps": "Only check installed and Track-Only apps for updates", | ||||
|     "onlyCheckInstalledOrTrackOnlyApps": "فقط برنامه های نصب شده و فقط ردیابی را برای به روز رسانی بررسی کنید", | ||||
|     "supportFixedAPKURL": "پشتیبانی از URL های APK ثابت", | ||||
|     "selectX": "انتخاب کنید {}", | ||||
|     "parallelDownloads": "Allow parallel downloads", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "برنامه حذف شود؟", | ||||
|         "other": "برنامه ها حذف شوند؟" | ||||
| @@ -324,7 +332,7 @@ | ||||
|         "other": "{} و {} برنامه دیگر به روز شدند." | ||||
|     }, | ||||
|     "xAndNMoreUpdatesPossiblyInstalled": { | ||||
|         "one": "{} and 1 more app may have been updated.", | ||||
|         "other": "{} and {} more apps may have been updated." | ||||
|         "one": "{} و 1 برنامه دیگر ممکن است به روز شده باشند.", | ||||
|         "other": "ممکن است {} و {} برنامه های دیگر به روز شده باشند." | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -55,7 +55,7 @@ | ||||
|     "notInstalled": "Pas installé", | ||||
|     "estimateInBrackets": "(Estimation)", | ||||
|     "selectAll": "Tout sélectionner", | ||||
|     "deselectN": "Déselectionner {}", | ||||
|     "deselectX": "Déselectionner {}", | ||||
|     "xWillBeRemovedButRemainInstalled": "{} sera supprimé d'Obtainium mais restera installé sur l'appareil.", | ||||
|     "removeSelectedAppsQuestion": "Supprimer les applications sélectionnées ?", | ||||
|     "removeSelectedApps": "Supprimer les applications sélectionnées", | ||||
| @@ -223,7 +223,7 @@ | ||||
|     "moveNonInstalledAppsToBottom": "Move non-installed Apps to bottom of Apps view", | ||||
|     "gitlabPATLabel": "GitLab Personal Access Token\n(Enables Search and Better APK Discovery)", | ||||
|     "about": "About", | ||||
|     "requiresCredentialsInSettings": "This needs additional credentials (in Settings)", | ||||
|     "requiresCredentialsInSettings": "{}: This needs additional credentials (in Settings)", | ||||
|     "checkOnStart": "Check for updates on startup", | ||||
|     "tryInferAppIdFromCode": "Try inferring App ID from source code", | ||||
|     "removeOnExternalUninstall": "Automatically remove externally uninstalled Apps", | ||||
| @@ -236,7 +236,7 @@ | ||||
|     "addInfoInSettings": "Add this info in the Settings.", | ||||
|     "githubSourceNote": "GitHub rate limiting can be avoided using an API key.", | ||||
|     "gitlabSourceNote": "GitLab APK extraction may not work without an API key.", | ||||
|     "sortByFileNamesNotLinks": "Sort by file names instead of full links", | ||||
|     "sortByLastLinkSegment": "Sort by only the last segment of the link", | ||||
|     "filterReleaseNotesByRegEx": "Filter Release Notes by Regular Expression", | ||||
|     "customLinkFilterRegex": "Custom APK Link Filter by Regular Expression (Default '.apk$')", | ||||
|     "appsPossiblyUpdated": "App Updates Attempted", | ||||
| @@ -246,8 +246,10 @@ | ||||
|     "backgroundUpdateReqsExplanation": "Background updates may not be possible for all apps.", | ||||
|     "backgroundUpdateLimitsExplanation": "The success of a background install can only be determined when Obtainium is opened.", | ||||
|     "verifyLatestTag": "Verify the 'latest' tag", | ||||
|     "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit First", | ||||
|     "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit", | ||||
|     "filterByLinkText": "Filter links by link text", | ||||
|     "intermediateLinkNotFound": "Intermediate link not found", | ||||
|     "intermediateLink": "Intermediate link", | ||||
|     "exemptFromBackgroundUpdates": "Exempt from background updates (if enabled)", | ||||
|     "bgUpdatesOnWiFiOnly": "Disable background updates when not on WiFi", | ||||
|     "autoSelectHighestVersionCode": "Auto-select highest versionCode APK", | ||||
| @@ -256,10 +258,13 @@ | ||||
|     "highlightTouchTargets": "Highlight less obvious touch targets", | ||||
|     "pickExportDir": "Pick Export Directory", | ||||
|     "autoExportOnChanges": "Auto-export on changes", | ||||
|     "includeSettings": "Include settings", | ||||
|     "filterVersionsByRegEx": "Filter Versions by Regular Expression", | ||||
|     "trySelectingSuggestedVersionCode": "Try selecting suggested versionCode APK", | ||||
|     "dontSortReleasesList": "Retain release order from API", | ||||
|     "reverseSort": "Reverse sorting", | ||||
|     "takeFirstLink": "Take first link", | ||||
|     "skipSort": "Skip sorting", | ||||
|     "debugMenu": "Debug Menu", | ||||
|     "bgTaskStarted": "Background task started - check logs.", | ||||
|     "runBgCheckNow": "Run Background Update Check Now", | ||||
| @@ -275,6 +280,9 @@ | ||||
|     "completeAppInstallationNotifChannel": "Installation complète de l'application", | ||||
|     "checkingForUpdatesNotifChannel": "Vérification des mises à jour", | ||||
|     "onlyCheckInstalledOrTrackOnlyApps": "Only check installed and Track-Only apps for updates", | ||||
|     "supportFixedAPKURL": "Support fixed APK URLs", | ||||
|     "selectX": "Select {}", | ||||
|     "parallelDownloads": "Allow parallel downloads", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "Supprimer l'application ?", | ||||
|         "other": "Supprimer les applications ?" | ||||
|   | ||||
| @@ -55,7 +55,7 @@ | ||||
|     "notInstalled": "Nem telepített", | ||||
|     "estimateInBrackets": "(Becslés)", | ||||
|     "selectAll": "Mindet kiválaszt", | ||||
|     "deselectN": "Törölje {} kijelölését", | ||||
|     "deselectX": "Törölje {} kijelölését", | ||||
|     "xWillBeRemovedButRemainInstalled": "A(z) {} el lesz távolítva az Obtainiumból, de továbbra is telepítve marad az eszközön.", | ||||
|     "removeSelectedAppsQuestion": "Eltávolítja a kiválasztott appokat?", | ||||
|     "removeSelectedApps": "Távolítsa el a kiválasztott appokat", | ||||
| @@ -113,7 +113,7 @@ | ||||
|     "followSystem": "Rendszer szerint", | ||||
|     "obtainium": "Obtainium", | ||||
|     "materialYou": "Material You", | ||||
|     "useBlackTheme": "Használjon tiszta fekete sötét témát", | ||||
|     "useBlackTheme": "Használjon teljesen fekete sötét témát", | ||||
|     "appSortBy": "App rendezés...", | ||||
|     "authorName": "Szerző/Név", | ||||
|     "nameAuthor": "Név/Szerző", | ||||
| @@ -194,7 +194,7 @@ | ||||
|     "categories": "Kategóriák", | ||||
|     "category": "Kategória", | ||||
|     "noCategory": "Nincs kategória", | ||||
|     "noCategories": "No Categories", | ||||
|     "noCategories": "Nincsenek kategóriák", | ||||
|     "deleteCategoryQuestion": "Törli a kategóriát?", | ||||
|     "categoryDeleteWarning": "A(z) {} összes app kategorizálatlan állapotba kerül.", | ||||
|     "addCategory": "Új kategória", | ||||
| @@ -215,7 +215,7 @@ | ||||
|     "versionDetection": "Verzió érzékelés", | ||||
|     "standardVersionDetection": "Alapért. verzió érzékelés", | ||||
|     "groupByCategory": "Csoportosítás Kategória alapján", | ||||
|     "autoApkFilterByArch": "Ha lehetséges, próbálja CPU architektúra szerint szűrni az APK-okat", | ||||
|     "autoApkFilterByArch": "Ha lehetséges, próbálja CPU architektúra szerint szűrni az APK-kat", | ||||
|     "overrideSource": "Forrás felülbírálása", | ||||
|     "dontShowAgain": "Ne mutassa ezt újra", | ||||
|     "dontShowTrackOnlyWarnings": "Ne jelenítsen meg 'Csak nyomon követés' figyelmeztetést", | ||||
| @@ -223,7 +223,7 @@ | ||||
|     "moveNonInstalledAppsToBottom": "Helyezze át a nem telepített appokat az App nézet aljára", | ||||
|     "gitlabPATLabel": "GitLab Personal Access Token\n(Engedélyezi a Keresést és jobb APK felfedezés)", | ||||
|     "about": "Rólunk", | ||||
|     "requiresCredentialsInSettings": "Ehhez további hitelesítő adatokra van szükség (a Beállításokban)", | ||||
|     "requiresCredentialsInSettings": "{}: Ehhez további hitelesítő adatokra van szükség (a Beállításokban)", | ||||
|     "checkOnStart": "Egyszer az alkalmazás indításakor is", | ||||
|     "tryInferAppIdFromCode": "Próbálja kikövetkeztetni az app azonosítót a forráskódból", | ||||
|     "removeOnExternalUninstall": "A külsőleg eltávolított appok auto. eltávolítása", | ||||
| @@ -236,7 +236,7 @@ | ||||
|     "addInfoInSettings": "Adja hozzá ezt az infót a Beállításokban.", | ||||
|     "githubSourceNote": "A GitHub sebességkorlátozás elkerülhető API-kulcs használatával.", | ||||
|     "gitlabSourceNote": "Előfordulhat, hogy a GitLab APK kibontása nem működik API-kulcs nélkül.", | ||||
|     "sortByFileNamesNotLinks": "Fájlnevek szerinti elrendezés teljes linkek helyett", | ||||
|     "sortByLastLinkSegment": "Rendezés csak a link utolsó szegmense szerint", | ||||
|     "filterReleaseNotesByRegEx": "Kiadási megjegyzések szűrése reguláris kifejezéssel", | ||||
|     "customLinkFilterRegex": "Egyéni APK hivatkozásszűrő reguláris kifejezéssel (Alapérték '.apk$')", | ||||
|     "appsPossiblyUpdated": "App frissítési kísérlet", | ||||
| @@ -245,8 +245,10 @@ | ||||
|     "backgroundUpdateReqsExplanation": "Előfordulhat, hogy nem minden appnál lehetséges a háttérbeli frissítés.", | ||||
|     "backgroundUpdateLimitsExplanation": "A háttérben történő telepítés sikeressége csak az Obtainium megnyitásakor állapítható meg.", | ||||
|     "verifyLatestTag": "Ellenőrizze a „legújabb” címkét", | ||||
|     "intermediateLinkRegex": "Szűrés egy 'közvetítő' linkre, amelyet először meg kell látogatni", | ||||
|     "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit", | ||||
|     "filterByLinkText": "Filter links by link text", | ||||
|     "intermediateLinkNotFound": "Közvetítő link nem található", | ||||
|     "intermediateLink": "Intermediate link", | ||||
|     "exemptFromBackgroundUpdates": "Mentes a háttérben történő frissítések alól (ha engedélyezett)", | ||||
|     "bgUpdatesOnWiFiOnly": "Tiltsa le a háttérben frissítéseket, ha nincs Wi-Fi-n", | ||||
|     "autoSelectHighestVersionCode": "A legmagasabb verziószámú APK auto. kiválasztása", | ||||
| @@ -255,10 +257,13 @@ | ||||
|     "highlightTouchTargets": "Emelje ki a kevésbé nyilvánvaló érintési célokat", | ||||
|     "pickExportDir": "Válassza az Exportálási könyvtárat", | ||||
|     "autoExportOnChanges": "Auto-exportálás a változások után", | ||||
|     "includeSettings": "Include settings", | ||||
|     "filterVersionsByRegEx": "Verziók szűrése reguláris kifejezéssel", | ||||
|     "trySelectingSuggestedVersionCode": "Próbálja ki a javasolt verziókódú APK-t", | ||||
|     "dontSortReleasesList": "Az API-ból származó kiadási sorrend megőrzése", | ||||
|     "reverseSort": "Fordított rendezés", | ||||
|     "takeFirstLink": "Take first link", | ||||
|     "skipSort": "Skip sorting", | ||||
|     "debugMenu": "Hibakereső menü", | ||||
|     "bgTaskStarted": "A háttérfeladat elindult – ellenőrizze a naplókat.", | ||||
|     "enableBackgroundUpdates": "Frissítések a háttérben", | ||||
| @@ -275,6 +280,9 @@ | ||||
|     "completeAppInstallationNotifChannel": "Teljes app telepítés", | ||||
|     "checkingForUpdatesNotifChannel": "Frissítések keresése", | ||||
|     "onlyCheckInstalledOrTrackOnlyApps": "Csak a telepített és a csak követhető appokat ellenőrizze frissítésekért", | ||||
|     "supportFixedAPKURL": "Támogatja a rögzített APK URL-eket", | ||||
|     "selectX": "Kiválaszt {}", | ||||
|     "parallelDownloads": "Allow parallel downloads", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "Eltávolítja az alkalmazást?", | ||||
|         "other": "Eltávolítja az alkalmazást?" | ||||
|   | ||||
| @@ -55,7 +55,7 @@ | ||||
|     "notInstalled": "Non installato", | ||||
|     "estimateInBrackets": "(stimato)", | ||||
|     "selectAll": "Seleziona tutto", | ||||
|     "deselectN": "Deseleziona {}", | ||||
|     "deselectX": "Deseleziona {}", | ||||
|     "xWillBeRemovedButRemainInstalled": "Verà effettuata la rimozione di {}, ma non la disinstallazione.", | ||||
|     "removeSelectedAppsQuestion": "Rimuovere le app selezionate?", | ||||
|     "removeSelectedApps": "Rimuovi le app selezionate", | ||||
| @@ -223,7 +223,7 @@ | ||||
|     "moveNonInstalledAppsToBottom": "Sposta le app non installate in fondo alla lista", | ||||
|     "gitlabPATLabel": "GitLab Personal Access Token\n(attiva la ricerca e migliora la rilevazione di apk)", | ||||
|     "about": "Informazioni", | ||||
|     "requiresCredentialsInSettings": "Servono credenziali aggiuntive (in Impostazioni)", | ||||
|     "requiresCredentialsInSettings": "{}: Servono credenziali aggiuntive (in Impostazioni)", | ||||
|     "checkOnStart": "Controlla una volta all'avvio", | ||||
|     "tryInferAppIdFromCode": "Prova a dedurre l'ID dell'app dal codice sorgente", | ||||
|     "removeOnExternalUninstall": "Rimuovi automaticamente app disinstallate esternamente", | ||||
| @@ -236,7 +236,7 @@ | ||||
|     "addInfoInSettings": "Aggiungi questa info nelle impostazioni.", | ||||
|     "githubSourceNote": "Il limite di ricerca GitHub può essere evitato usando una chiave API.", | ||||
|     "gitlabSourceNote": "L'estrazione di APK da GitLab potrebbe non funzionare senza chiave API.", | ||||
|     "sortByFileNamesNotLinks": "Ordina per nome del file invece dei link completi", | ||||
|     "sortByLastLinkSegment": "Sort by only the last segment of the link", | ||||
|     "filterReleaseNotesByRegEx": "Filtra le note di rilascio con espressione regolare", | ||||
|     "customLinkFilterRegex": "Filtra link APK personalizzato con espressione regolare (predefinito '.apk$')", | ||||
|     "appsPossiblyUpdated": "Aggiornamenti app tentati", | ||||
| @@ -246,8 +246,10 @@ | ||||
|     "backgroundUpdateReqsExplanation": "Gli aggiornamenti in secondo piano potrebbero non essere possibili per tutte le app.", | ||||
|     "backgroundUpdateLimitsExplanation": "La riuscita di un'installazione in secondo piano può essere determinata solo quando viene aperto Obtainium.", | ||||
|     "verifyLatestTag": "Verifica l'etichetta 'Latest'", | ||||
|     "intermediateLinkRegex": "Filtra un link 'Intermedio' da visitare prima", | ||||
|     "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit", | ||||
|     "filterByLinkText": "Filter links by link text", | ||||
|     "intermediateLinkNotFound": "Link intermedio non trovato", | ||||
|     "intermediateLink": "Intermediate link", | ||||
|     "exemptFromBackgroundUpdates": "Esente da aggiornamenti in secondo piano (se attivo)", | ||||
|     "bgUpdatesOnWiFiOnly": "Disattiva aggiornamenti in secondo piano quando non si usa il WiFi", | ||||
|     "autoSelectHighestVersionCode": "Auto-seleziona APK con versionCode più alto", | ||||
| @@ -256,10 +258,13 @@ | ||||
|     "highlightTouchTargets": "Evidenzia elementi toccabili meno ovvi", | ||||
|     "pickExportDir": "Scegli cartella esp.", | ||||
|     "autoExportOnChanges": "Auto-esporta dopo modifiche", | ||||
|     "includeSettings": "Includi impostazioni", | ||||
|     "filterVersionsByRegEx": "Filtra versioni con espressione regolare", | ||||
|     "trySelectingSuggestedVersionCode": "Prova a selezionare APK con versionCode suggerito", | ||||
|     "dontSortReleasesList": "Conserva l'ordine di release da API", | ||||
|     "reverseSort": "Ordine inverso", | ||||
|     "takeFirstLink": "Prendi il primo link", | ||||
|     "skipSort": "Salta ordinamento", | ||||
|     "debugMenu": "Menu di debug", | ||||
|     "bgTaskStarted": "Attività in secondo piano iniziata - controllo log.", | ||||
|     "runBgCheckNow": "Inizia aggiornamento in secondo piano ora", | ||||
| @@ -275,6 +280,14 @@ | ||||
|     "completeAppInstallationNotifChannel": "Completa l'installazione dell'app", | ||||
|     "checkingForUpdatesNotifChannel": "Controllo degli aggiornamenti in corso", | ||||
|     "onlyCheckInstalledOrTrackOnlyApps": "Cerca aggiornamenti solo per app installate e app in Solo-Monitoraggio", | ||||
|     "supportFixedAPKURL": "Supporta URL fissi di APK", | ||||
|     "selectX": "Seleziona {}", | ||||
|     "parallelDownloads": "Permetti download paralleli", | ||||
|     "installMethod": "Metodo d'installazione", | ||||
|     "normal": "Normale", | ||||
|     "shizuku": "Shizuku", | ||||
|     "root": "Root", | ||||
|     "shizukuBinderNotFound": "Shizuku non è in esecuzione", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "Rimuovere l'app?", | ||||
|         "other": "Rimuovere le app?" | ||||
|   | ||||
| @@ -55,7 +55,7 @@ | ||||
|     "notInstalled": "未インストール", | ||||
|     "estimateInBrackets": "(推定)", | ||||
|     "selectAll": "すべて選択", | ||||
|     "deselectN": "{}件の選択を解除", | ||||
|     "deselectX": "{}件の選択を解除", | ||||
|     "xWillBeRemovedButRemainInstalled": "{} はObtainiumから削除されますが、デバイスにはインストールされたままです。", | ||||
|     "removeSelectedAppsQuestion": "選択したアプリを削除しますか?", | ||||
|     "removeSelectedApps": "選択したアプリを削除する", | ||||
| @@ -223,7 +223,7 @@ | ||||
|     "moveNonInstalledAppsToBottom": "未インストールのアプリをアプリ一覧の下部に移動させる", | ||||
|     "gitlabPATLabel": "GitLab パーソナルアクセストークン\n(検索とより良いAPK検出の有効化)", | ||||
|     "about": "概要", | ||||
|     "requiresCredentialsInSettings": "これには追加の認証が必要です (設定にて)", | ||||
|     "requiresCredentialsInSettings": "{}: これには追加の認証が必要です (設定にて)", | ||||
|     "checkOnStart": "起動時にアップデートを確認する", | ||||
|     "tryInferAppIdFromCode": "ソースコードからApp IDを推測する", | ||||
|     "removeOnExternalUninstall": "外部でアンインストールされたアプリを自動的に削除する", | ||||
| @@ -236,7 +236,7 @@ | ||||
|     "addInfoInSettings": "設定でこの情報を追加してください。", | ||||
|     "githubSourceNote": "GitHubのレート制限はAPIキーを使うことで回避できます。", | ||||
|     "gitlabSourceNote": "GitLabのAPK抽出はAPIキーがないと動作しない場合があります。", | ||||
|     "sortByFileNamesNotLinks": "フルのリンクではなくファイル名でソートする", | ||||
|     "sortByLastLinkSegment": "Sort by only the last segment of the link", | ||||
|     "filterReleaseNotesByRegEx": "正規表現でリリースノートをフィルタリングする", | ||||
|     "customLinkFilterRegex": "正規表現によるカスタムリンクフィルター (デフォルト '.apk$')", | ||||
|     "appsPossiblyUpdated": "アプリのアップデートを試行", | ||||
| @@ -246,8 +246,10 @@ | ||||
|     "backgroundUpdateReqsExplanation": "バックグラウンドアップデートは、すべてのアプリで可能とは限りません。", | ||||
|     "backgroundUpdateLimitsExplanation": "バックグラウンドアップデートが成功したかどうかは、Obtainiumを起動したときにしか判断できません。", | ||||
|     "verifyLatestTag": "'latest'タグを確認する", | ||||
|     "intermediateLinkRegex": "最初にアクセスする「中間」リンクをフィルタリングする", | ||||
|     "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit", | ||||
|     "filterByLinkText": "Filter links by link text", | ||||
|     "intermediateLinkNotFound": "中間リンクが見つかりませんでした", | ||||
|     "intermediateLink": "Intermediate link", | ||||
|     "exemptFromBackgroundUpdates": "バックグラウンドアップデートを行わない (有効な場合)", | ||||
|     "bgUpdatesOnWiFiOnly": "WiFiを使用していない場合,バックグラウンドアップデートを無効にする", | ||||
|     "autoSelectHighestVersionCode": "最も高いバージョンコードのAPKを自動で選択する", | ||||
| @@ -256,10 +258,13 @@ | ||||
|     "highlightTouchTargets": "目立たないタップ可能な対象をハイライトする", | ||||
|     "pickExportDir": "エクスポートディレクトリを選択", | ||||
|     "autoExportOnChanges": "変更があった際に自動でエクスポートする", | ||||
|     "includeSettings": "Include settings", | ||||
|     "filterVersionsByRegEx": "正規表現でバージョンをフィルタリングする", | ||||
|     "trySelectingSuggestedVersionCode": "提案されたバージョンコードのAPKを選択する", | ||||
|     "dontSortReleasesList": "APIからのリリース順を保持する", | ||||
|     "reverseSort": "逆順ソート", | ||||
|     "takeFirstLink": "Take first link", | ||||
|     "skipSort": "Skip sorting", | ||||
|     "debugMenu": "デバッグメニュー", | ||||
|     "bgTaskStarted": "バックグラウンドタスクが開始されました - ログを確認してください。", | ||||
|     "runBgCheckNow": "今すぐバックグラウンドでのアップデート確認を開始する", | ||||
| @@ -275,6 +280,9 @@ | ||||
|     "completeAppInstallationNotifChannel": "アプリのインストールを完了する", | ||||
|     "checkingForUpdatesNotifChannel": "アップデートを確認中", | ||||
|     "onlyCheckInstalledOrTrackOnlyApps": "インストール済みのアプリと「追跡のみ」のアプリのアップデートのみを確認する", | ||||
|     "supportFixedAPKURL": "Support fixed APK URLs", | ||||
|     "selectX": "Select {}", | ||||
|     "parallelDownloads": "Allow parallel downloads", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "アプリを削除しますか?", | ||||
|         "other": "アプリを削除しますか?" | ||||
|   | ||||
| @@ -55,7 +55,7 @@ | ||||
|     "notInstalled": "Niet geinstalleerd", | ||||
|     "estimateInBrackets": "(Ongeveer)", | ||||
|     "selectAll": "Selecteer alles", | ||||
|     "deselectN": "Deselecteer {}", | ||||
|     "deselectX": "Deselecteer {}", | ||||
|     "xWillBeRemovedButRemainInstalled": "{} zal worden verwijderd uit Obtainium, maar blijft geïnstalleerd op het apparaat.", | ||||
|     "removeSelectedAppsQuestion": "Geselecteerde apps verwijderen??", | ||||
|     "removeSelectedApps": "Geselecteerde apps verwijderen", | ||||
| @@ -223,7 +223,7 @@ | ||||
|     "moveNonInstalledAppsToBottom": "Verplaats niet-geïnstalleerde apps naar de onderkant van de apps-weergave", | ||||
|     "gitlabPATLabel": "GitLab Personal Access Token\n(Maakt het mogelijk beter te zoeken naar APK's)", | ||||
|     "about": "Over", | ||||
|     "requiresCredentialsInSettings": "Dit vereist aanvullende referenties (in Instellingen)", | ||||
|     "requiresCredentialsInSettings": "{}: Dit vereist aanvullende referenties (in Instellingen)", | ||||
|     "checkOnStart": "Controleren op updates bij opstarten", | ||||
|     "tryInferAppIdFromCode": "Probeer de app-ID af te leiden uit de broncode", | ||||
|     "removeOnExternalUninstall": "Automatisch extern verwijderde apps verwijderen", | ||||
| @@ -236,7 +236,7 @@ | ||||
|     "addInfoInSettings": "Voeg deze informatie toe in de instellingen.", | ||||
|     "githubSourceNote": "Beperkingen van GitHub kunnen worden vermeden door het gebruik van een API-sleutel.", | ||||
|     "gitlabSourceNote": "GitLab APK-extractie werkt mogelijk niet zonder een API-sleutel.", | ||||
|     "sortByFileNamesNotLinks": "Sorteren op bestandsnamen in plaats van volledige links.", | ||||
|     "sortByLastLinkSegment": "Sort by only the last segment of the link", | ||||
|     "filterReleaseNotesByRegEx": "Filter release-opmerkingen met een reguliere expressie.", | ||||
|     "customLinkFilterRegex": "Aangepaste APK-linkfilter met een reguliere expressie (Standaard '.apk$').", | ||||
|     "appsPossiblyUpdated": "Poging tot app-updates", | ||||
| @@ -246,8 +246,10 @@ | ||||
|     "backgroundUpdateReqsExplanation": "Achtergrondupdates zijn mogelijk niet voor alle apps mogelijk.", | ||||
|     "backgroundUpdateLimitsExplanation": "Het succes van een installatie in de achtergrond kan alleen worden bepaald wanneer Obtainium is geopend.", | ||||
|     "verifyLatestTag": "Verifieer de 'Laatste'-tag", | ||||
|     "intermediateLinkRegex": "Filter voor een 'tussenliggende' link om eerst te bezoeken", | ||||
|     "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit", | ||||
|     "filterByLinkText": "Filter links by link text", | ||||
|     "intermediateLinkNotFound": "Tussenliggende link niet gevonden", | ||||
|     "intermediateLink": "Intermediate link", | ||||
|     "exemptFromBackgroundUpdates": "Vrijgesteld van achtergrondupdates (indien ingeschakeld)", | ||||
|     "bgUpdatesOnWiFiOnly": "Achtergrondupdates uitschakelen wanneer niet verbonden met WiFi", | ||||
|     "autoSelectHighestVersionCode": "Automatisch de APK met de hoogste versiecode selecteren", | ||||
| @@ -256,10 +258,13 @@ | ||||
|     "highlightTouchTargets": "Markeer minder voor de hand liggende aanraakdoelen.", | ||||
|     "pickExportDir": "Kies de exportmap", | ||||
|     "autoExportOnChanges": "Automatisch exporteren bij wijzigingen", | ||||
|     "includeSettings": "Include settings", | ||||
|     "filterVersionsByRegEx": "Filter versies met een reguliere expressie", | ||||
|     "trySelectingSuggestedVersionCode": "Probeer de voorgestelde versiecode APK te selecteren", | ||||
|     "dontSortReleasesList": "Volgorde van releases behouden vanuit de API", | ||||
|     "reverseSort": "Sortering omkeren", | ||||
|     "takeFirstLink": "Take first link", | ||||
|     "skipSort": "Skip sorting", | ||||
|     "debugMenu": "Debug menu", | ||||
|     "bgTaskStarted": "Achtergrondtaak gestart - controleer de logs.", | ||||
|     "runBgCheckNow": "Voer nu een achtergrondupdatecontrole uit", | ||||
| @@ -275,6 +280,9 @@ | ||||
|     "completeAppInstallationNotifChannel": "Voltooien van de app-installatie", | ||||
|     "checkingForUpdatesNotifChannel": "Controleren op updates", | ||||
|     "onlyCheckInstalledOrTrackOnlyApps": "Alleen geïnstalleerde en Track-Only apps controleren op updates", | ||||
|     "supportFixedAPKURL": "Support fixed APK URLs", | ||||
|     "selectX": "Select {}", | ||||
|     "parallelDownloads": "Allow parallel downloads", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "App verwijderen?", | ||||
|         "other": "Apps verwijderen?" | ||||
|   | ||||
| @@ -55,7 +55,7 @@ | ||||
|     "notInstalled": "Nie zainstalowano", | ||||
|     "estimateInBrackets": "(Szacunkowo)", | ||||
|     "selectAll": "Zaznacz wszystkie", | ||||
|     "deselectN": "Odznacz {}", | ||||
|     "deselectX": "Odznacz {}", | ||||
|     "xWillBeRemovedButRemainInstalled": "{} zostanie usunięty z Obtainium, ale pozostanie zainstalowany na urządzeniu.", | ||||
|     "removeSelectedAppsQuestion": "Usunąć wybrane aplikacje?", | ||||
|     "removeSelectedApps": "Usuń wybrane aplikacje", | ||||
| @@ -223,7 +223,7 @@ | ||||
|     "moveNonInstalledAppsToBottom": "Przenieś niezainstalowane aplikacje na dół widoku aplikacji", | ||||
|     "gitlabPATLabel": "Osobisty token dostępu GitLab\n(Umożliwia wyszukiwanie i lepsze wykrywanie APK)", | ||||
|     "about": "Więcej informacji", | ||||
|     "requiresCredentialsInSettings": "Wymaga to dodatkowych poświadczeń (w Ustawieniach)", | ||||
|     "requiresCredentialsInSettings": "{}: Wymaga to dodatkowych poświadczeń (w Ustawieniach)", | ||||
|     "checkOnStart": "Sprawdź aktualizacje przy uruchomieniu", | ||||
|     "tryInferAppIdFromCode": "Spróbuj wywnioskować identyfikator aplikacji z kodu źródłowego", | ||||
|     "removeOnExternalUninstall": "Automatyczne usuń odinstalowane zewnętrznie aplikacje", | ||||
| @@ -236,7 +236,7 @@ | ||||
|     "addInfoInSettings": "Dodaj tę informację w Ustawieniach.", | ||||
|     "githubSourceNote": "Limit żądań GitHub można ominąć za pomocą klucza API.", | ||||
|     "gitlabSourceNote": "Pozyskiwanie pliku APK z GitLab może nie działać bez klucza API.", | ||||
|     "sortByFileNamesNotLinks": "Sortuj wg nazw plików zamiast pełnych linków", | ||||
|     "sortByLastLinkSegment": "Sort by only the last segment of the link", | ||||
|     "filterReleaseNotesByRegEx": "Filtruj informacje o wersji według wyrażenia regularnego", | ||||
|     "customLinkFilterRegex": "Filtruj linki APK według wyrażenia regularnego (domyślnie \".apk$\")", | ||||
|     "appsPossiblyUpdated": "Aplikacje mogły zostać zaktualizowane", | ||||
| @@ -246,8 +246,10 @@ | ||||
|     "backgroundUpdateReqsExplanation": "Aktualizacje w tle mogą nie być możliwe dla wszystkich aplikacji.", | ||||
|     "backgroundUpdateLimitsExplanation": "Powodzenie instalacji w tle można określić dopiero po otwarciu Obtainium.", | ||||
|     "verifyLatestTag": "Zweryfikuj najnowszy tag", | ||||
|     "intermediateLinkRegex": "Filtr linków \"pośrednich\" do odwiedzenia w pierwszej kolejności", | ||||
|     "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit", | ||||
|     "filterByLinkText": "Filter links by link text", | ||||
|     "intermediateLinkNotFound": "Nie znaleziono linku pośredniego", | ||||
|     "intermediateLink": "Intermediate link", | ||||
|     "exemptFromBackgroundUpdates": "Wyklucz z uaktualnień w tle (jeśli są włączone)", | ||||
|     "bgUpdatesOnWiFiOnly": "Wyłącz aktualizacje w tle, gdy nie ma połączenia z Wi-Fi", | ||||
|     "autoSelectHighestVersionCode": "Automatycznie wybierz najwyższy kod wersji APK", | ||||
| @@ -256,10 +258,13 @@ | ||||
|     "highlightTouchTargets": "Wyróżnij mniej oczywiste elementy dotykowe", | ||||
|     "pickExportDir": "Wybierz katalog eksportu", | ||||
|     "autoExportOnChanges": "Automatyczny eksport po wprowadzeniu zmian", | ||||
|     "includeSettings": "Include settings", | ||||
|     "filterVersionsByRegEx": "Filtruj wersje według wyrażenia regularnego", | ||||
|     "trySelectingSuggestedVersionCode": "Spróbuj wybierać sugerowany kod wersji APK", | ||||
|     "dontSortReleasesList": "Utrzymaj kolejność wydań z interfejsu API", | ||||
|     "reverseSort": "Odwrotne sortowanie", | ||||
|     "takeFirstLink": "Take first link", | ||||
|     "skipSort": "Skip sorting", | ||||
|     "debugMenu": "Menu debugowania", | ||||
|     "bgTaskStarted": "Uruchomiono zadanie w tle - sprawdź logi.", | ||||
|     "runBgCheckNow": "Wymuś sprawdzenie aktualizacji w tle", | ||||
| @@ -275,6 +280,9 @@ | ||||
|     "completeAppInstallationNotifChannel": "Ukończenie instalacji aplikacji", | ||||
|     "checkingForUpdatesNotifChannel": "Sprawdzanie dostępności aktualizacji", | ||||
|     "onlyCheckInstalledOrTrackOnlyApps": "Sprawdzaj tylko zainstalowane i obserwowane aplikacje pod kątem aktualizacji", | ||||
|     "supportFixedAPKURL": "Obsługuj stałe adresy URL APK", | ||||
|     "selectX": "Wybierz {}", | ||||
|     "parallelDownloads": "Allow parallel downloads", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "Usunąć aplikację?", | ||||
|         "few": "Usunąć aplikacje?", | ||||
|   | ||||
| @@ -55,7 +55,7 @@ | ||||
|     "notInstalled": "Não Instalado", | ||||
|     "estimateInBrackets": "(Aproximado)", | ||||
|     "selectAll": "Selecionar All", | ||||
|     "deselectN": "Deselecionar {}", | ||||
|     "deselectX": "Deselecionar {}", | ||||
|     "xWillBeRemovedButRemainInstalled": "{} sera removido do Obtainium mais permanecerá instalado no dispositivo.", | ||||
|     "removeSelectedAppsQuestion": "Remover Apps Selecionados?", | ||||
|     "removeSelectedApps": "Remover Apps Selecionados", | ||||
| @@ -223,7 +223,7 @@ | ||||
|     "moveNonInstalledAppsToBottom": "Mover Apps não instalados para o fundo da visão de Apps", | ||||
|     "gitlabPATLabel": "Token de Acceso Pessoal do Gitlab\n(Ativa Pesquisa e Melhor Descoberta de APKs)", | ||||
|     "about": "Sobre", | ||||
|     "requiresCredentialsInSettings": "Isso requer credenciais adicionais (em Configurações)", | ||||
|     "requiresCredentialsInSettings": "{}: Isso requer credenciais adicionais (em Configurações)", | ||||
|     "checkOnStart": "Checar por atualizações ao iniciar ", | ||||
|     "tryInferAppIdFromCode": "Tente inferir o ID do App pelo código fonte", | ||||
|     "removeOnExternalUninstall": "Remover automaticamente Apps desinstalados externamente", | ||||
| @@ -236,7 +236,7 @@ | ||||
|     "addInfoInSettings": "Adicionar essa informação nas configurações.", | ||||
|     "githubSourceNote": "A limitação de taxa do GitHub pode ser evitada usando uma chave de API.", | ||||
|     "gitlabSourceNote": "A extração de APK do GitLab pode não funcionar sem uma chave de API.", | ||||
|     "sortByFileNamesNotLinks": "Classifique por nomes de arquivos em vez de links completos", | ||||
|     "sortByLastLinkSegment": "Sort by only the last segment of the link", | ||||
|     "filterReleaseNotesByRegEx": "Filtrar Notas de Lançamento por Expressão Regular", | ||||
|     "customLinkFilterRegex": "Filtro de Link Personalizado por Expressão Regular (Padrão '.apk$')", | ||||
|     "appsPossiblyUpdated": "Tentativas de atualização de Apps", | ||||
| @@ -246,8 +246,10 @@ | ||||
|     "backgroundUpdateReqsExplanation": "Atualizações em segundo plano podem não ser possíveis para todos os Apps.", | ||||
|     "backgroundUpdateLimitsExplanation": "O sucesso de uma instalação em segundo plano só pode ser determinado quando o Obtainium é aberto.", | ||||
|     "verifyLatestTag": "Verifique a 'ultima' etiqueta", | ||||
|     "intermediateLinkRegex": "Filtre por um Link 'Intermediário' para Visitar Primeiro", | ||||
|     "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit", | ||||
|     "filterByLinkText": "Filter links by link text", | ||||
|     "intermediateLinkNotFound": "Link intermediário não encontrado", | ||||
|     "intermediateLink": "Intermediate link", | ||||
|     "exemptFromBackgroundUpdates": "Isento de atualizações em segundo plano (se ativadas)", | ||||
|     "bgUpdatesOnWiFiOnly": "Desative atualizações em segundo plano quando não estiver em WiFi", | ||||
|     "autoSelectHighestVersionCode": "Auto-selecionar o maior codigo de versão", | ||||
| @@ -256,10 +258,13 @@ | ||||
|     "highlightTouchTargets": "Destaque areas de toque menos óbvias", | ||||
|     "pickExportDir": "Escolher Diretorio de Exportação", | ||||
|     "autoExportOnChanges": "Auto-exportar em mudanças", | ||||
|     "includeSettings": "Include settings", | ||||
|     "filterVersionsByRegEx": "Filtrar Versões por Expressão Regular", | ||||
|     "trySelectingSuggestedVersionCode": "Tente selecionar a versão sugerida", | ||||
|     "dontSortReleasesList": "Reter a ordem de lançamento da API", | ||||
|     "reverseSort": "Ordenação reversa", | ||||
|     "takeFirstLink": "Take first link", | ||||
|     "skipSort": "Skip sorting", | ||||
|     "debugMenu": "Menu Debug", | ||||
|     "bgTaskStarted": "Tarefa em segundo plano iniciada - verifique os logs.", | ||||
|     "runBgCheckNow": "Execute a verificação de atualização em segundo plano agora", | ||||
| @@ -274,7 +279,15 @@ | ||||
|     "downloadingXNotifChannel": "Baixando {}", | ||||
|     "completeAppInstallationNotifChannel": "Instalação completa do App", | ||||
|     "checkingForUpdatesNotifChannel": "Checando por Atualizações", | ||||
|     "onlyCheckInstalledOrTrackOnlyApps": "Only check installed and Track-Only apps for updates", | ||||
|     "onlyCheckInstalledOrTrackOnlyApps": "Apenas checar apps instalados e 'Apenas Seguir' por updates", | ||||
|     "supportFixedAPKURL": "Suporte APK com URLs fixas", | ||||
|     "selectX": "Selecionar {}", | ||||
|     "parallelDownloads": "Permitir downloads paralelos", | ||||
|     "installMethod": "Método de instalação", | ||||
|     "normal": "Normal", | ||||
|     "shizuku": "Shizuku", | ||||
|     "root": "Root", | ||||
|     "shizukuBinderNotFound": "Shizuku não esta rodando", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "Remover App?", | ||||
|         "other": "Remover Apps?" | ||||
|   | ||||
| @@ -55,7 +55,7 @@ | ||||
|     "notInstalled": "Не установлено", | ||||
|     "estimateInBrackets": "(Оценка)", | ||||
|     "selectAll": "Выбрать всё", | ||||
|     "deselectN": "Отменить выбор {}", | ||||
|     "deselectX": "Отменить выбор {}", | ||||
|     "xWillBeRemovedButRemainInstalled": "{} будет удалено из Obtainium, но останется на устройстве", | ||||
|     "removeSelectedAppsQuestion": "Удалить выбранные приложения?", | ||||
|     "removeSelectedApps": "Удалить выбранные приложения", | ||||
| @@ -223,7 +223,7 @@ | ||||
|     "moveNonInstalledAppsToBottom": "Отображать неустановленные приложения внизу списка", | ||||
|     "gitlabPATLabel": "Персональный токен доступа GitLab\n(включает поиск и улучшает обнаружение APK)", | ||||
|     "about": "Описание", | ||||
|     "requiresCredentialsInSettings": "Для этого требуются дополнительные учетные данные (в настройках)", | ||||
|     "requiresCredentialsInSettings": "{}: Для этого требуются дополнительные учетные данные (в настройках)", | ||||
|     "checkOnStart": "Проверять наличие обновлений при запуске", | ||||
|     "tryInferAppIdFromCode": "Попытаться определить ID приложения из исходного кода", | ||||
|     "removeOnExternalUninstall": "Автоматически убирать из списка удаленные извне приложения", | ||||
| @@ -236,7 +236,7 @@ | ||||
|     "addInfoInSettings": "Добавьте эту информацию в Настройки", | ||||
|     "githubSourceNote": "Используя ключ API можно обойти лимит запросов GitHub", | ||||
|     "gitlabSourceNote": "Без ключа API может не работать извлечение APK с GitLab", | ||||
|     "sortByFileNamesNotLinks": "Сортировать по именам файлов, а не ссылкам целиком", | ||||
|     "sortByLastLinkSegment": "Sort by only the last segment of the link", | ||||
|     "filterReleaseNotesByRegEx": "Фильтровать примечания к выпуску\n(регулярное выражение)", | ||||
|     "customLinkFilterRegex": "Пользовательский фильтр ссылок APK\n(регулярное выражение, по умолчанию: '.apk$')", | ||||
|     "appsPossiblyUpdated": "Попытки обновления приложений", | ||||
| @@ -246,8 +246,10 @@ | ||||
|     "backgroundUpdateReqsExplanation": "Фоновые обновления могут быть возможны не для всех приложений", | ||||
|     "backgroundUpdateLimitsExplanation": "Успешность фоновой установки можно определить только после открытия Obtainium", | ||||
|     "verifyLatestTag": "Проверять тег 'latest'", | ||||
|     "intermediateLinkRegex": "Фильтр промежуточных ссылок для первоочередного посещения\n(регулярное выражение)", | ||||
|     "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit", | ||||
|     "filterByLinkText": "Filter links by link text", | ||||
|     "intermediateLinkNotFound": "Промежуточная ссылка не найдена", | ||||
|     "intermediateLink": "Intermediate link", | ||||
|     "exemptFromBackgroundUpdates": "Исключить из фоновых обновлений (если включено)", | ||||
|     "bgUpdatesOnWiFiOnly": "Отключить фоновые обновления, если нет соединения с Wi-Fi", | ||||
|     "autoSelectHighestVersionCode": "Автоматически выбирать APK с актуальной версией кода", | ||||
| @@ -256,10 +258,13 @@ | ||||
|     "highlightTouchTargets": "Выделить менее очевидные элементы управления касанием", | ||||
|     "pickExportDir": "Выбрать каталог для экспорта", | ||||
|     "autoExportOnChanges": "Автоэкспорт при изменениях", | ||||
|     "includeSettings": "Include settings", | ||||
|     "filterVersionsByRegEx": "Фильтровать версии по регулярному выражению", | ||||
|     "trySelectingSuggestedVersionCode": "Попробуйте выбрать предложенный код версии APK", | ||||
|     "dontSortReleasesList": "Сохранить порядок релизов от API", | ||||
|     "reverseSort": "Обратная сортировка", | ||||
|     "takeFirstLink": "Take first link", | ||||
|     "skipSort": "Skip sorting", | ||||
|     "debugMenu": "Меню отладки", | ||||
|     "bgTaskStarted": "Фоновая задача начата — проверьте журналы", | ||||
|     "runBgCheckNow": "Запустить проверку фонового обновления сейчас", | ||||
| @@ -274,7 +279,15 @@ | ||||
|     "downloadingXNotifChannel": "Загрузка {}", | ||||
|     "completeAppInstallationNotifChannel": "Завершение установки приложения", | ||||
|     "checkingForUpdatesNotifChannel": "Проверка обновлений", | ||||
|     "onlyCheckInstalledOrTrackOnlyApps": "Only check installed and Track-Only apps for updates", | ||||
|     "onlyCheckInstalledOrTrackOnlyApps": "Проверять обновления только у установленных или отслеживаемых приложений", | ||||
|     "supportFixedAPKURL": "Поддержка фиксированных URL-адресов APK", | ||||
|     "selectX": "Выбрать {}", | ||||
|     "parallelDownloads": "Разрешить параллельные загрузки", | ||||
|     "installMethod": "Метод установки", | ||||
|     "normal": "Нормальный", | ||||
|     "shizuku": "Shizuku", | ||||
|     "root": "Суперпользователь", | ||||
|     "shizukuBinderNotFound": "Shizuku не запущен", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "Удалить приложение?", | ||||
|         "other": "Удалить приложения?" | ||||
|   | ||||
| @@ -55,7 +55,7 @@ | ||||
|     "notInstalled": "Inte Installerad", | ||||
|     "estimateInBrackets": "(Uppskattning)", | ||||
|     "selectAll": "Välj Alla", | ||||
|     "deselectN": "Avmarkera {}", | ||||
|     "deselectX": "Avmarkera {}", | ||||
|     "xWillBeRemovedButRemainInstalled": "{} kommer tas bort från Obtainium men kommer vara fortsatt installerad på enheten.", | ||||
|     "removeSelectedAppsQuestion": "Ta bort markerade Appar?", | ||||
|     "removeSelectedApps": "Ta bort markerade Appar", | ||||
| @@ -223,7 +223,7 @@ | ||||
|     "moveNonInstalledAppsToBottom": "Move non-installed Apps to bottom of Apps view", | ||||
|     "gitlabPATLabel": "GitLab Personal Access Token\n(Enables Search and Better APK Discovery)", | ||||
|     "about": "Om", | ||||
|     "requiresCredentialsInSettings": "This needs additional credentials (in Settings)", | ||||
|     "requiresCredentialsInSettings": "{}: This needs additional credentials (in Settings)", | ||||
|     "checkOnStart": "Kolla efter uppdateringar vid start", | ||||
|     "tryInferAppIdFromCode": "Try inferring App ID from source code", | ||||
|     "removeOnExternalUninstall": "Automatically remove externally uninstalled Apps", | ||||
| @@ -236,7 +236,7 @@ | ||||
|     "addInfoInSettings": "Lägg till denna information i Inställningar.", | ||||
|     "githubSourceNote": "GitHub rate limiting can be avoided using an API key.", | ||||
|     "gitlabSourceNote": "GitLab APK extraction may not work without an API key.", | ||||
|     "sortByFileNamesNotLinks": "Sort by file names instead of full links", | ||||
|     "sortByLastLinkSegment": "Sort by only the last segment of the link", | ||||
|     "filterReleaseNotesByRegEx": "Filter Release Notes by Regular Expression", | ||||
|     "customLinkFilterRegex": "Custom APK Link Filter by Regular Expression (Default '.apk$')", | ||||
|     "appsPossiblyUpdated": "App Updates Attempted", | ||||
| @@ -246,8 +246,10 @@ | ||||
|     "backgroundUpdateReqsExplanation": "Bakgrundsuppdateringar är inte möjligt för alla appar.", | ||||
|     "backgroundUpdateLimitsExplanation": "The success of a background install can only be determined when Obtainium is opened.", | ||||
|     "verifyLatestTag": "Verifiera 'senaste'-taggen", | ||||
|     "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit First", | ||||
|     "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit", | ||||
|     "filterByLinkText": "Filter links by link text", | ||||
|     "intermediateLinkNotFound": "Intermediate link not found", | ||||
|     "intermediateLink": "Intermediate link", | ||||
|     "exemptFromBackgroundUpdates": "Undta från bakgrundsuppdateringar (om aktiverad)", | ||||
|     "bgUpdatesOnWiFiOnly": "Inaktivera Bakgrundsuppdateringar utan WiFi", | ||||
|     "autoSelectHighestVersionCode": "Auto-select highest versionCode APK", | ||||
| @@ -256,13 +258,17 @@ | ||||
|     "highlightTouchTargets": "Highlight less obvious touch targets", | ||||
|     "pickExportDir": "Välj Exportsökväg", | ||||
|     "autoExportOnChanges": "Automatisk export vid ändringar", | ||||
|     "includeSettings": "Include settings", | ||||
|     "filterVersionsByRegEx": "Filter Versions by Regular Expression", | ||||
|     "trySelectingSuggestedVersionCode": "Try selecting suggested versionCode APK", | ||||
|     "dontSortReleasesList": "Retain release order from API", | ||||
|     "reverseSort": "Omvänd sortering", | ||||
|     "takeFirstLink": "Take first link", | ||||
|     "skipSort": "Skip sorting", | ||||
|     "debugMenu": "Felsökningsmeny", | ||||
|     "bgTaskStarted": "Background task started - check logs.", | ||||
|     "runBgCheckNow": "Kör Bakgrundsuppdateringskoll Nu", | ||||
|     "parallelDownloads": "Allow parallel downloads", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "Ta Bort App?", | ||||
|         "other": "Ta Bort Appar?" | ||||
|   | ||||
| @@ -55,7 +55,7 @@ | ||||
|     "notInstalled": "Yüklenmedi", | ||||
|     "estimateInBrackets": "(Tahmini)", | ||||
|     "selectAll": "Hepsini Seç", | ||||
|     "deselectN": "{}'yi Seçimden Kaldır", | ||||
|     "deselectX": "{}'yi Seçimden Kaldır", | ||||
|     "xWillBeRemovedButRemainInstalled": "{} Obtainium'dan kaldırılacak ancak cihazınızda yüklü kalacaktır.", | ||||
|     "removeSelectedAppsQuestion": "Seçilen Uygulamaları Kaldırmak İstiyor musunuz?", | ||||
|     "removeSelectedApps": "Seçilen Uygulamaları Kaldır", | ||||
| @@ -223,7 +223,7 @@ | ||||
|     "moveNonInstalledAppsToBottom": "Yüklenmemiş Uygulamaları Uygulamalar Görünümünün Altına Taşı", | ||||
|     "gitlabPATLabel": "GitLab Kişisel Erişim Belirteci\n(Arama ve Daha İyi APK Keşfi İçin)", | ||||
|     "about": "Hakkında", | ||||
|     "requiresCredentialsInSettings": "Bu, ek kimlik bilgilerine ihtiyaç duyar (Ayarlar'da)", | ||||
|     "requiresCredentialsInSettings": "{}: Bu, ek kimlik bilgilerine ihtiyaç duyar (Ayarlar'da)", | ||||
|     "checkOnStart": "Başlangıçta güncellemeleri kontrol et", | ||||
|     "tryInferAppIdFromCode": "Uygulama kimliğini kaynak kodundan çıkarma girişimi", | ||||
|     "removeOnExternalUninstall": "Harici kaldırmada otomatik olarak kaldırılan uygulamalar", | ||||
| @@ -236,7 +236,7 @@ | ||||
|     "addInfoInSettings": "Bu bilgiyi Ayarlar'da ekleyin.", | ||||
|     "githubSourceNote": "GitHub hız sınırlaması bir API anahtarı kullanılarak atlanabilir.", | ||||
|     "gitlabSourceNote": "GitLab APK çıkarma işlemi bir API anahtarı olmadan çalışmayabilir.", | ||||
|     "sortByFileNamesNotLinks": "Bağlantılar yerine dosya adlarına göre sırala", | ||||
|     "sortByLastLinkSegment": "Sort by only the last segment of the link", | ||||
|     "filterReleaseNotesByRegEx": "Sürüm Notlarını Düzenli İfade ile Filtrele", | ||||
|     "customLinkFilterRegex": "Özel APK Bağlantı Filtresi Düzenli İfade ile (Varsayılan '.apk$')", | ||||
|     "appsPossiblyUpdated": "Uygulama Güncellemeleri Denendi", | ||||
| @@ -246,8 +246,10 @@ | ||||
|     "backgroundUpdateReqsExplanation": "Arka plan güncellemeleri tüm uygulamalar için mümkün olmayabilir.", | ||||
|     "backgroundUpdateLimitsExplanation": "Arka plan kurulumunun başarısı, Obtainium'un açıldığında ancak belirlenebilir.", | ||||
|     "verifyLatestTag": "'latest' etiketini doğrula", | ||||
|     "intermediateLinkRegex": "İlk Ziyaret Edilecek 'Ara' Bağlantısını Filtrele", | ||||
|     "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit", | ||||
|     "filterByLinkText": "Filter links by link text", | ||||
|     "intermediateLinkNotFound": "Ara bağlantı bulunamadı", | ||||
|     "intermediateLink": "Intermediate link", | ||||
|     "exemptFromBackgroundUpdates": "Arka plan güncellemelerinden muaf tut (etkinse)", | ||||
|     "bgUpdatesOnWiFiOnly": "WiFi olmadığında arka plan güncellemelerini devre dışı bırak", | ||||
|     "autoSelectHighestVersionCode": "Otomatik olarak en yüksek sürüm kodunu seç", | ||||
| @@ -256,10 +258,13 @@ | ||||
|     "highlightTouchTargets": "Daha az belirgin dokunma hedeflerini vurgula", | ||||
|     "pickExportDir": "Dışa Aktarılacak Klasörü Seç", | ||||
|     "autoExportOnChanges": "Değişikliklerde otomatik olarak dışa aktar", | ||||
|     "includeSettings": "Include settings", | ||||
|     "filterVersionsByRegEx": "Sürümleri Düzenli İfade ile Filtrele", | ||||
|     "trySelectingSuggestedVersionCode": "Önerilen sürüm kodunu seçmeyi dene", | ||||
|     "dontSortReleasesList": "API'den sıralama düzenini koru", | ||||
|     "reverseSort": "Ters sıralama", | ||||
|     "takeFirstLink": "Take first link", | ||||
|     "skipSort": "Skip sorting", | ||||
|     "debugMenu": "Hata Ayıklama Menüsü", | ||||
|     "bgTaskStarted": "Arka plan görevi başladı - günlükleri kontrol et.", | ||||
|     "runBgCheckNow": "Arka Plan Güncelleme Kontrolünü Şimdi Çalıştır", | ||||
| @@ -275,6 +280,9 @@ | ||||
|     "completeAppInstallationNotifChannel": "Uygulama Kurulumu Tamamlandı", | ||||
|     "checkingForUpdatesNotifChannel": "Güncellemeler Kontrol Ediliyor", | ||||
|     "onlyCheckInstalledOrTrackOnlyApps": "Yalnızca yüklü ve Yalnızca İzleme Uygulamalarını güncelleme", | ||||
|     "supportFixedAPKURL": "Support fixed APK URLs", | ||||
|     "selectX": "Select {}", | ||||
|     "parallelDownloads": "Allow parallel downloads", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "Uygulamayı Kaldır?", | ||||
|         "other": "Uygulamaları Kaldır?" | ||||
|   | ||||
| @@ -55,7 +55,7 @@ | ||||
|     "notInstalled": "Chưa cài đặt", | ||||
|     "estimateInBrackets": "(Ước lượng)", | ||||
|     "selectAll": "Chọn tất cả", | ||||
|     "deselectN": "Bỏ chọn {}", | ||||
|     "deselectX": "Bỏ chọn {}", | ||||
|     "xWillBeRemovedButRemainInstalled": "{} sẽ bị xóa khỏi Obtainium nhưng vẫn còn cài đặt trên thiết bị.", | ||||
|     "removeSelectedAppsQuestion": "Xóa ứng dụng đã chọn?", | ||||
|     "removeSelectedApps": "Xóa ứng dụng đã chọn", | ||||
| @@ -223,7 +223,7 @@ | ||||
|     "moveNonInstalledAppsToBottom": "Di chuyển Ứng dụng chưa được cài đặt xuống cuối chế độ xem Ứng dụng", | ||||
|     "gitlabPATLabel": "Mã thông báo truy cập cá nhân GitLab\n(Cho phép tìm kiếm và khám phá APK tốt hơn)", | ||||
|     "about": "Giới thiệu", | ||||
|     "requiresCredentialsInSettings": "Điều này cần thông tin xác thực bổ sung (trong Cài đặt)", | ||||
|     "requiresCredentialsInSettings": "{}: Điều này cần thông tin xác thực bổ sung (trong Cài đặt)", | ||||
|     "checkOnStart": "Kiểm tra các bản cập nhật khi khởi động", | ||||
|     "tryInferAppIdFromCode": "Thử suy ra ID ứng dụng từ mã nguồn", | ||||
|     "removeOnExternalUninstall": "Tự động xóa ứng dụng đã gỡ cài đặt bên ngoài", | ||||
| @@ -236,7 +236,7 @@ | ||||
|     "addInfoInSettings": "Thêm thông tin này vào Cài đặt.", | ||||
|     "githubSourceNote": "Có thể tránh được việc giới hạn tốc độ GitHub bằng cách sử dụng khóa API.", | ||||
|     "gitlabSourceNote": "Trích xuất APK GitLab có thể không hoạt động nếu không có khóa API.", | ||||
|     "sortByFileNamesNotLinks": "Sắp xếp theo tên tệp thay vì liên kết đầy đủ", | ||||
|     "sortByLastLinkSegment": "Sort by only the last segment of the link", | ||||
|     "filterReleaseNotesByRegEx": "Lọc ghi chú phát hành theo biểu thức chính quy", | ||||
|     "customLinkFilterRegex": "Bộ lọc liên kết APK tùy chỉnh theo biểu thức chính quy (Mặc định '.apk$')", | ||||
|     "appsPossiblyUpdated": "Đã cố gắng cập nhật ứng dụng", | ||||
| @@ -246,8 +246,10 @@ | ||||
|     "backgroundUpdateReqsExplanation": "Có thể không thực hiện được cập nhật nền cho tất cả ứng dụng.", | ||||
|     "backgroundUpdateLimitsExplanation": "Sự thành công của cài đặt nền chỉ có thể được xác định khi mở Obtainium.", | ||||
|     "verifyLatestTag": "Xác minh thẻ 'mới nhất'", | ||||
|     "intermediateLinkRegex": "Lọc tìm liên kết 'Trung gian' để truy cập trước", | ||||
|     "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit", | ||||
|     "filterByLinkText": "Filter links by link text", | ||||
|     "intermediateLinkNotFound": "Không tìm thấy liên kết trung gian", | ||||
|     "intermediateLink": "Intermediate link", | ||||
|     "exemptFromBackgroundUpdates": "Miễn cập nhật nền (nếu được bật)", | ||||
|     "bgUpdatesOnWiFiOnly": "Tắt cập nhật nền khi không có WiFi", | ||||
|     "autoSelectHighestVersionCode": "Tự động chọn APK mã phiên bản cao nhất", | ||||
| @@ -256,10 +258,13 @@ | ||||
|     "highlightTouchTargets": "Đánh dấu các mục tiêu cảm ứng ít rõ ràng hơn", | ||||
|     "pickExportDir": "Chọn thư mục xuất", | ||||
|     "autoExportOnChanges": "Tự động xuất khi thay đổi", | ||||
|     "includeSettings": "Include settings", | ||||
|     "filterVersionsByRegEx": "Lọc phiên bản theo biểu thức chính quy", | ||||
|     "trySelectingSuggestedVersionCode": "Thử chọn APK Mã phiên bản được đề xuất", | ||||
|     "dontSortReleasesList": "Giữ lại thứ tự phát hành từ API", | ||||
|     "reverseSort": "Sắp xếp ngược", | ||||
|     "takeFirstLink": "Take first link", | ||||
|     "skipSort": "Skip sorting", | ||||
|     "debugMenu": "Danh sách gỡ lỗi", | ||||
|     "bgTaskStarted": "Tác vụ nền đã bắt đầu - kiểm tra nhật ký.", | ||||
|     "runBgCheckNow": "Chạy kiểm tra cập nhật nền ngay bây giờ", | ||||
| @@ -275,6 +280,9 @@ | ||||
|     "completeAppInstallationNotifChannel": "Hoàn tất cài đặt ứng dụng", | ||||
|     "checkingForUpdatesNotifChannel": "Đang kiểm tra cập nhật", | ||||
|     "onlyCheckInstalledOrTrackOnlyApps": "Chỉ kiểm tra các ứng dụng đã cài đặt và Chỉ-Theo dõi để biết các bản cập nhật", | ||||
|     "supportFixedAPKURL": "Support fixed APK URLs", | ||||
|     "selectX": "Select {}", | ||||
|     "parallelDownloads": "Allow parallel downloads", | ||||
|     "removeAppQuestion":{ | ||||
|         "one": "Gỡ ứng dụng?", | ||||
|         "other": "Gỡ ứng dụng?" | ||||
|   | ||||
| @@ -55,7 +55,7 @@ | ||||
|     "notInstalled": "未安装", | ||||
|     "estimateInBrackets": "(推测)", | ||||
|     "selectAll": "全选", | ||||
|     "deselectN": "取消选择 {}", | ||||
|     "deselectX": "取消选择 {}", | ||||
|     "xWillBeRemovedButRemainInstalled": "{} 将从 Obtainium 中删除,但仍安装在您的设备中。", | ||||
|     "removeSelectedAppsQuestion": "是否删除选中的应用?", | ||||
|     "removeSelectedApps": "删除选中的应用", | ||||
| @@ -223,7 +223,7 @@ | ||||
|     "moveNonInstalledAppsToBottom": "将未安装应用置底", | ||||
|     "gitlabPATLabel": "GitLab 个人访问令牌(启用搜索功能并增强 APK 发现)", | ||||
|     "about": "相关文档", | ||||
|     "requiresCredentialsInSettings": "此功能需要额外的凭据(在“设置”中添加)", | ||||
|     "requiresCredentialsInSettings": "{}: 此功能需要额外的凭据(在“设置”中添加)", | ||||
|     "checkOnStart": "启动时进行一次检查", | ||||
|     "tryInferAppIdFromCode": "尝试从源代码推断应用 ID", | ||||
|     "removeOnExternalUninstall": "自动删除已卸载的外部应用", | ||||
| @@ -236,7 +236,7 @@ | ||||
|     "addInfoInSettings": "在“设置”中添加此凭据。", | ||||
|     "githubSourceNote": "使用访问令牌可避免触发 GitHub 的 API 请求限制。", | ||||
|     "gitlabSourceNote": "未使用访问令牌时可能无法从 GitLab 获取 APK 文件。", | ||||
|     "sortByFileNamesNotLinks": "使用文件名代替链接进行排序", | ||||
|     "sortByLastLinkSegment": "Sort by only the last segment of the link", | ||||
|     "filterReleaseNotesByRegEx": "筛选发行说明(正则表达式)", | ||||
|     "customLinkFilterRegex": "筛选自定义来源 APK 文件链接\n(正则表达式,默认匹配模式为“.apk$”)", | ||||
|     "appsPossiblyUpdated": "已尝试更新应用", | ||||
| @@ -246,8 +246,10 @@ | ||||
|     "backgroundUpdateReqsExplanation": "后台更新未必适用于所有的应用。", | ||||
|     "backgroundUpdateLimitsExplanation": "只有在启动 Obtainium 时才能确认安装是否成功。", | ||||
|     "verifyLatestTag": "验证“Latest”标签", | ||||
|     "intermediateLinkRegex": "筛选首先访问的“中转”链接(正则表达式)", | ||||
|     "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit", | ||||
|     "filterByLinkText": "Filter links by link text", | ||||
|     "intermediateLinkNotFound": "未找到“中转”链接", | ||||
|     "intermediateLink": "Intermediate link", | ||||
|     "exemptFromBackgroundUpdates": "禁用后台更新\n(如果已经全局启用)", | ||||
|     "bgUpdatesOnWiFiOnly": "未连接 Wi-Fi 时禁用后台更新", | ||||
|     "autoSelectHighestVersionCode": "自动选择版本号最高的 APK 文件", | ||||
| @@ -256,10 +258,13 @@ | ||||
|     "highlightTouchTargets": "突出展示不明显的触摸区域", | ||||
|     "pickExportDir": "选择导出文件夹", | ||||
|     "autoExportOnChanges": "数据变更时自动导出", | ||||
|     "includeSettings": "Include settings", | ||||
|     "filterVersionsByRegEx": "筛选版本号(正则表达式)", | ||||
|     "trySelectingSuggestedVersionCode": "尝试选择推荐版本的 APK 文件", | ||||
|     "dontSortReleasesList": "保持来自 API 的发行顺序", | ||||
|     "reverseSort": "反转排序", | ||||
|     "takeFirstLink": "Take first link", | ||||
|     "skipSort": "Skip sorting", | ||||
|     "debugMenu": "调试选项", | ||||
|     "bgTaskStarted": "后台任务已启动 - 详见日志", | ||||
|     "runBgCheckNow": "立即进行后台更新检查", | ||||
| @@ -275,6 +280,9 @@ | ||||
|     "completeAppInstallationNotifChannel": "完成应用安装", | ||||
|     "checkingForUpdatesNotifChannel": "正在检查更新", | ||||
|     "onlyCheckInstalledOrTrackOnlyApps": "只对已安装和“仅追踪”的应用进行更新检查", | ||||
|     "supportFixedAPKURL": "Support fixed APK URLs", | ||||
|     "selectX": "Select {}", | ||||
|     "parallelDownloads": "Allow parallel downloads", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "是否删除应用?", | ||||
|         "other": "是否删除应用?" | ||||
|   | ||||
| @@ -32,7 +32,8 @@ class APKMirror extends AppSource { | ||||
|  | ||||
|   @override | ||||
|   String sourceSpecificStandardizeURL(String url) { | ||||
|     RegExp standardUrlRegEx = RegExp('^https?://$host/apk/[^/]+/[^/]+'); | ||||
|     RegExp standardUrlRegEx = | ||||
|         RegExp('^https?://(www\\.)?$host/apk/[^/]+/[^/]+'); | ||||
|     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); | ||||
|     if (match == null) { | ||||
|       throw InvalidURLError(name); | ||||
|   | ||||
| @@ -7,7 +7,7 @@ import 'package:obtainium/providers/source_provider.dart'; | ||||
| class Aptoide extends AppSource { | ||||
|   Aptoide() { | ||||
|     host = 'aptoide.com'; | ||||
|     name = tr('Aptoide'); | ||||
|     name = 'Aptoide'; | ||||
|     allowSubDomains = true; | ||||
|     naiveStandardVersionDetection = true; | ||||
|   } | ||||
|   | ||||
| @@ -65,7 +65,7 @@ class FDroid extends AppSource { | ||||
|   ) async { | ||||
|     String? appId = await tryInferringAppId(standardUrl); | ||||
|     String host = Uri.parse(standardUrl).host; | ||||
|     return getAPKUrlsFromFDroidPackagesAPIResponse( | ||||
|     var details = getAPKUrlsFromFDroidPackagesAPIResponse( | ||||
|         await sourceRequest('https://$host/api/v1/packages/$appId'), | ||||
|         'https://$host/repo/$appId', | ||||
|         standardUrl, | ||||
| @@ -80,6 +80,23 @@ class FDroid extends AppSource { | ||||
|                     true | ||||
|                 ? additionalSettings['filterVersionsByRegEx'] | ||||
|                 : null); | ||||
|     if (!hostChanged) { | ||||
|       try { | ||||
|         var res = await sourceRequest( | ||||
|             'https://gitlab.com/fdroid/fdroiddata/-/raw/master/metadata/$appId.yml'); | ||||
|         String author = res.body | ||||
|             .split('\n') | ||||
|             .where((l) => l.startsWith('AuthorName: ')) | ||||
|             .first | ||||
|             .split(': ') | ||||
|             .sublist(1) | ||||
|             .join(': '); | ||||
|         details.names.author = author; | ||||
|       } catch (e) { | ||||
|         // Fail silently | ||||
|       } | ||||
|     } | ||||
|     return details; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
| @@ -111,79 +128,79 @@ class FDroid extends AppSource { | ||||
|       throw getObtainiumHttpError(res); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| APKDetails getAPKUrlsFromFDroidPackagesAPIResponse( | ||||
|     Response res, String apkUrlPrefix, String standardUrl, String sourceName, | ||||
|     {bool autoSelectHighestVersionCode = false, | ||||
|     bool trySelectingSuggestedVersionCode = false, | ||||
|     String? filterVersionsByRegEx}) { | ||||
|   if (res.statusCode == 200) { | ||||
|     var response = jsonDecode(res.body); | ||||
|     List<dynamic> releases = response['packages'] ?? []; | ||||
|     if (releases.isEmpty) { | ||||
|       throw NoReleasesError(); | ||||
|     } | ||||
|     String? version; | ||||
|     Iterable<dynamic> releaseChoices = []; | ||||
|     // Grab the versionCode suggested if the user chose to do that | ||||
|     // Only do so at this stage if the user has no release filter | ||||
|     if (trySelectingSuggestedVersionCode && | ||||
|         response['suggestedVersionCode'] != null && | ||||
|         filterVersionsByRegEx == null) { | ||||
|       var suggestedReleases = releases.where((element) => | ||||
|           element['versionCode'] == response['suggestedVersionCode']); | ||||
|       if (suggestedReleases.isNotEmpty) { | ||||
|         releaseChoices = suggestedReleases; | ||||
|         version = suggestedReleases.first['versionName']; | ||||
|   APKDetails getAPKUrlsFromFDroidPackagesAPIResponse( | ||||
|       Response res, String apkUrlPrefix, String standardUrl, String sourceName, | ||||
|       {bool autoSelectHighestVersionCode = false, | ||||
|       bool trySelectingSuggestedVersionCode = false, | ||||
|       String? filterVersionsByRegEx}) { | ||||
|     if (res.statusCode == 200) { | ||||
|       var response = jsonDecode(res.body); | ||||
|       List<dynamic> releases = response['packages'] ?? []; | ||||
|       if (releases.isEmpty) { | ||||
|         throw NoReleasesError(); | ||||
|       } | ||||
|     } | ||||
|     // Apply the release filter if any | ||||
|     if (filterVersionsByRegEx != null) { | ||||
|       version = null; | ||||
|       releaseChoices = []; | ||||
|       for (var i = 0; i < releases.length; i++) { | ||||
|         if (RegExp(filterVersionsByRegEx) | ||||
|             .hasMatch(releases[i]['versionName'])) { | ||||
|           version = releases[i]['versionName']; | ||||
|         } | ||||
|       } | ||||
|       if (version == null) { | ||||
|         throw NoVersionError(); | ||||
|       } | ||||
|     } | ||||
|     // Default to the highest version | ||||
|     version ??= releases[0]['versionName']; | ||||
|     if (version == null) { | ||||
|       throw NoVersionError(); | ||||
|     } | ||||
|     // If a suggested release was not already picked, pick all those with the selected version | ||||
|     if (releaseChoices.isEmpty) { | ||||
|       releaseChoices = | ||||
|           releases.where((element) => element['versionName'] == version); | ||||
|     } | ||||
|     // For the remaining releases, use the toggles to auto-select one if possible | ||||
|     if (releaseChoices.length > 1) { | ||||
|       if (autoSelectHighestVersionCode) { | ||||
|         releaseChoices = [releaseChoices.first]; | ||||
|       } else if (trySelectingSuggestedVersionCode && | ||||
|           response['suggestedVersionCode'] != null) { | ||||
|         var suggestedReleases = releaseChoices.where((element) => | ||||
|       String? version; | ||||
|       Iterable<dynamic> releaseChoices = []; | ||||
|       // Grab the versionCode suggested if the user chose to do that | ||||
|       // Only do so at this stage if the user has no release filter | ||||
|       if (trySelectingSuggestedVersionCode && | ||||
|           response['suggestedVersionCode'] != null && | ||||
|           filterVersionsByRegEx == null) { | ||||
|         var suggestedReleases = releases.where((element) => | ||||
|             element['versionCode'] == response['suggestedVersionCode']); | ||||
|         if (suggestedReleases.isNotEmpty) { | ||||
|           releaseChoices = suggestedReleases; | ||||
|           version = suggestedReleases.first['versionName']; | ||||
|         } | ||||
|       } | ||||
|       // Apply the release filter if any | ||||
|       if (filterVersionsByRegEx?.isNotEmpty == true) { | ||||
|         version = null; | ||||
|         releaseChoices = []; | ||||
|         for (var i = 0; i < releases.length; i++) { | ||||
|           if (RegExp(filterVersionsByRegEx!) | ||||
|               .hasMatch(releases[i]['versionName'])) { | ||||
|             version = releases[i]['versionName']; | ||||
|           } | ||||
|         } | ||||
|         if (version == null) { | ||||
|           throw NoVersionError(); | ||||
|         } | ||||
|       } | ||||
|       // Default to the highest version | ||||
|       version ??= releases[0]['versionName']; | ||||
|       if (version == null) { | ||||
|         throw NoVersionError(); | ||||
|       } | ||||
|       // If a suggested release was not already picked, pick all those with the selected version | ||||
|       if (releaseChoices.isEmpty) { | ||||
|         releaseChoices = | ||||
|             releases.where((element) => element['versionName'] == version); | ||||
|       } | ||||
|       // For the remaining releases, use the toggles to auto-select one if possible | ||||
|       if (releaseChoices.length > 1) { | ||||
|         if (autoSelectHighestVersionCode) { | ||||
|           releaseChoices = [releaseChoices.first]; | ||||
|         } else if (trySelectingSuggestedVersionCode && | ||||
|             response['suggestedVersionCode'] != null) { | ||||
|           var suggestedReleases = releaseChoices.where((element) => | ||||
|               element['versionCode'] == response['suggestedVersionCode']); | ||||
|           if (suggestedReleases.isNotEmpty) { | ||||
|             releaseChoices = suggestedReleases; | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|       if (releaseChoices.isEmpty) { | ||||
|         throw NoReleasesError(); | ||||
|       } | ||||
|       List<String> apkUrls = releaseChoices | ||||
|           .map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk') | ||||
|           .toList(); | ||||
|       return APKDetails(version, getApkUrlsFromUrls(apkUrls.toSet().toList()), | ||||
|           AppNames(sourceName, Uri.parse(standardUrl).pathSegments.last)); | ||||
|     } else { | ||||
|       throw getObtainiumHttpError(res); | ||||
|     } | ||||
|     if (releaseChoices.isEmpty) { | ||||
|       throw NoReleasesError(); | ||||
|     } | ||||
|     List<String> apkUrls = releaseChoices | ||||
|         .map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk') | ||||
|         .toList(); | ||||
|     return APKDetails(version, getApkUrlsFromUrls(apkUrls.toSet().toList()), | ||||
|         AppNames(sourceName, Uri.parse(standardUrl).pathSegments.last)); | ||||
|   } else { | ||||
|     throw getObtainiumHttpError(res); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -54,17 +54,25 @@ class FDroidRepo extends AppSource { | ||||
|   @override | ||||
|   Future<Map<String, List<String>>> search(String query, | ||||
|       {Map<String, dynamic> querySettings = const {}}) async { | ||||
|     query = removeQueryParamsFromUrl(standardizeUrl(query)); | ||||
|     var res = await sourceRequest('$query/index.xml'); | ||||
|     String? url = querySettings['url']; | ||||
|     if (url == null) { | ||||
|       throw NoReleasesError(); | ||||
|     } | ||||
|     url = removeQueryParamsFromUrl(standardizeUrl(url)); | ||||
|     var res = await sourceRequest('$url/index.xml'); | ||||
|     if (res.statusCode == 200) { | ||||
|       var body = parse(res.body); | ||||
|       Map<String, List<String>> results = {}; | ||||
|       body.querySelectorAll('application').toList().forEach((app) { | ||||
|         String appId = app.attributes['id']!; | ||||
|         results['$query?appId=$appId'] = [ | ||||
|           app.querySelector('name')?.innerHtml ?? appId, | ||||
|           app.querySelector('desc')?.innerHtml ?? '' | ||||
|         ]; | ||||
|         String appName = app.querySelector('name')?.innerHtml ?? appId; | ||||
|         String appDesc = app.querySelector('desc')?.innerHtml ?? ''; | ||||
|         if (query.isEmpty || | ||||
|             appId.contains(query) || | ||||
|             appName.contains(query) || | ||||
|             appDesc.contains(query)) { | ||||
|           results['$url?appId=$appId'] = [appName, appDesc]; | ||||
|         } | ||||
|       }); | ||||
|       return results; | ||||
|     } else { | ||||
|   | ||||
| @@ -117,22 +117,23 @@ class GitHub extends AppSource { | ||||
|                     .decode(body['content'].toString().split('\n').join(''))) | ||||
|                 .split('\n') | ||||
|                 .map((e) => e.trim()); | ||||
|             var appId = trimmedLines | ||||
|                 .where((l) => | ||||
|                     l.startsWith('applicationId "') || | ||||
|                     l.startsWith('applicationId \'')) | ||||
|                 .first; | ||||
|             appId = appId | ||||
|                 .split(appId.startsWith('applicationId "') ? '"' : '\'')[1]; | ||||
|             if (appId.startsWith('\${') && appId.endsWith('}')) { | ||||
|               appId = trimmedLines | ||||
|                   .where((l) => l.startsWith( | ||||
|                       'def ${appId.substring(2, appId.length - 1)}')) | ||||
|                   .first; | ||||
|               appId = appId.split(appId.contains('"') ? '"' : '\'')[1]; | ||||
|             } | ||||
|             if (appId.isNotEmpty) { | ||||
|             var appIds = trimmedLines.where((l) => | ||||
|                 l.startsWith('applicationId "') || | ||||
|                 l.startsWith('applicationId \'')); | ||||
|             appIds = appIds.map((appId) => appId | ||||
|                 .split(appId.startsWith('applicationId "') ? '"' : '\'')[1]); | ||||
|             appIds = appIds.map((appId) { | ||||
|               if (appId.startsWith('\${') && appId.endsWith('}')) { | ||||
|                 appId = trimmedLines | ||||
|                     .where((l) => l.startsWith( | ||||
|                         'def ${appId.substring(2, appId.length - 1)}')) | ||||
|                     .first; | ||||
|                 appId = appId.split(appId.contains('"') ? '"' : '\'')[1]; | ||||
|               } | ||||
|               return appId; | ||||
|             }).where((appId) => appId.isNotEmpty); | ||||
|             if (appIds.length == 1) { | ||||
|               return appIds.first; | ||||
|             } | ||||
|           } catch (err) { | ||||
|             LogsProvider().add( | ||||
| @@ -233,7 +234,7 @@ class GitHub extends AppSource { | ||||
|     bool verifyLatestTag = additionalSettings['verifyLatestTag'] == true; | ||||
|     bool dontSortReleasesList = | ||||
|         additionalSettings['dontSortReleasesList'] == true; | ||||
|     String? latestTag; | ||||
|     dynamic latestRelease; | ||||
|     if (verifyLatestTag) { | ||||
|       var temp = requestUrl.split('?'); | ||||
|       Response res = await sourceRequest( | ||||
| @@ -244,12 +245,20 @@ class GitHub extends AppSource { | ||||
|         } | ||||
|         throw getObtainiumHttpError(res); | ||||
|       } | ||||
|       var jsres = jsonDecode(res.body); | ||||
|       latestTag = jsres['tag_name'] ?? jsres['name']; | ||||
|       latestRelease = jsonDecode(res.body); | ||||
|     } | ||||
|     Response res = await sourceRequest(requestUrl); | ||||
|     if (res.statusCode == 200) { | ||||
|       var releases = jsonDecode(res.body) as List<dynamic>; | ||||
|       if (latestRelease != null) { | ||||
|         var latestTag = latestRelease['tag_name'] ?? latestRelease['name']; | ||||
|         if (releases | ||||
|             .where((element) => | ||||
|                 (element['tag_name'] ?? element['name']) == latestTag) | ||||
|             .isEmpty) { | ||||
|           releases = [latestRelease, ...releases]; | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       List<MapEntry<String, String>> getReleaseAPKUrls(dynamic release) => | ||||
|           (release['assets'] as List<dynamic>?) | ||||
| @@ -298,13 +307,13 @@ class GitHub extends AppSource { | ||||
|           } | ||||
|         }); | ||||
|       } | ||||
|       if (latestTag != null && | ||||
|       if (latestRelease != null && | ||||
|           releases.isNotEmpty && | ||||
|           latestTag != | ||||
|           latestRelease != | ||||
|               (releases[releases.length - 1]['tag_name'] ?? | ||||
|                   releases[0]['name'])) { | ||||
|         var ind = releases.indexWhere( | ||||
|             (element) => latestTag == (element['tag_name'] ?? element['name'])); | ||||
|         var ind = releases.indexWhere((element) => | ||||
|             latestRelease == (element['tag_name'] ?? element['name'])); | ||||
|         if (ind >= 0) { | ||||
|           releases.add(releases.removeAt(ind)); | ||||
|         } | ||||
|   | ||||
| @@ -80,12 +80,8 @@ class GitLab extends AppSource { | ||||
|   @override | ||||
|   Future<Map<String, List<String>>> search(String query, | ||||
|       {Map<String, dynamic> querySettings = const {}}) async { | ||||
|     String? PAT = await getPATIfAny({}); | ||||
|     if (PAT == null) { | ||||
|       throw CredsNeededError(name); | ||||
|     } | ||||
|     var url = | ||||
|         'https://$host/api/v4/search?private_token=$PAT&scope=projects&search=${Uri.encodeQueryComponent(query)}'; | ||||
|         'https://$host/api/v4/projects?search=${Uri.encodeQueryComponent(query)}'; | ||||
|     var res = await sourceRequest(url); | ||||
|     if (res.statusCode != 200) { | ||||
|       throw getObtainiumHttpError(res); | ||||
| @@ -174,7 +170,6 @@ class GitLab extends AppSource { | ||||
|           ...getLinksFromParsedHTML(entryContent, | ||||
|                   RegExp('/[^/]+\\.apk\$', caseSensitive: false), '') | ||||
|               .where((element) => Uri.parse(element).host != '') | ||||
|               .toList() | ||||
|         ]; | ||||
|         var entryId = entry.querySelector('id')?.innerHtml; | ||||
|         var version = | ||||
| @@ -192,7 +187,7 @@ class GitLab extends AppSource { | ||||
|       }); | ||||
|     } | ||||
|     if (apkDetailsList.isEmpty) { | ||||
|       throw NoReleasesError(); | ||||
|       throw NoReleasesError(note: tr('gitlabSourceNote')); | ||||
|     } | ||||
|     if (fallbackToOlderReleases) { | ||||
|       if (additionalSettings['trackOnly'] != true) { | ||||
| @@ -200,7 +195,7 @@ class GitLab extends AppSource { | ||||
|             apkDetailsList.where((e) => e.apkUrls.isNotEmpty).toList(); | ||||
|       } | ||||
|       if (apkDetailsList.isEmpty) { | ||||
|         throw NoReleasesError(); | ||||
|         throw NoReleasesError(note: tr('gitlabSourceNote')); | ||||
|       } | ||||
|     } | ||||
|     return apkDetailsList.first; | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import 'package:html/parser.dart'; | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/providers/apps_provider.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
|  | ||||
| String ensureAbsoluteUrl(String ambiguousUrl, Uri referenceAbsoluteUrl) { | ||||
| @@ -87,57 +88,77 @@ bool _isNumeric(String s) { | ||||
| } | ||||
|  | ||||
| class HTML extends AppSource { | ||||
|   var finalStepFormitems = [ | ||||
|     [ | ||||
|       GeneratedFormTextField('customLinkFilterRegex', | ||||
|           label: tr('customLinkFilterRegex'), | ||||
|           hint: 'download/(.*/)?(android|apk|mobile)', | ||||
|           required: false, | ||||
|           additionalValidators: [ | ||||
|             (value) { | ||||
|               return regExValidator(value); | ||||
|             } | ||||
|           ]) | ||||
|     ], | ||||
|     [ | ||||
|       GeneratedFormTextField('versionExtractionRegEx', | ||||
|           label: tr('versionExtractionRegEx'), | ||||
|           required: false, | ||||
|           additionalValidators: [(value) => regExValidator(value)]), | ||||
|     ], | ||||
|     [ | ||||
|       GeneratedFormTextField('matchGroupToUse', | ||||
|           label: tr('matchGroupToUse'), | ||||
|           required: false, | ||||
|           hint: '0', | ||||
|           textInputType: const TextInputType.numberWithOptions(), | ||||
|           additionalValidators: [ | ||||
|             (value) { | ||||
|               if (value?.isEmpty == true) { | ||||
|                 value = null; | ||||
|               } | ||||
|               value ??= '0'; | ||||
|               return intValidator(value); | ||||
|             } | ||||
|           ]) | ||||
|     ], | ||||
|     [ | ||||
|       GeneratedFormSwitch('versionExtractWholePage', | ||||
|           label: tr('versionExtractWholePage')) | ||||
|     ], | ||||
|     [ | ||||
|       GeneratedFormSwitch('supportFixedAPKURL', | ||||
|           defaultValue: true, label: tr('supportFixedAPKURL')), | ||||
|     ], | ||||
|   ]; | ||||
|   var commonFormItems = [ | ||||
|     [GeneratedFormSwitch('filterByLinkText', label: tr('filterByLinkText'))], | ||||
|     [GeneratedFormSwitch('skipSort', label: tr('skipSort'))], | ||||
|     [GeneratedFormSwitch('reverseSort', label: tr('takeFirstLink'))], | ||||
|     [ | ||||
|       GeneratedFormSwitch('sortByLastLinkSegment', | ||||
|           label: tr('sortByLastLinkSegment')) | ||||
|     ], | ||||
|   ]; | ||||
|   var intermediateFormItems = [ | ||||
|     [ | ||||
|       GeneratedFormTextField('customLinkFilterRegex', | ||||
|           label: tr('intermediateLinkRegex'), | ||||
|           hint: '([0-9]+.)*[0-9]+/\$', | ||||
|           required: true, | ||||
|           additionalValidators: [(value) => regExValidator(value)]) | ||||
|     ], | ||||
|   ]; | ||||
|   HTML() { | ||||
|     additionalSourceAppSpecificSettingFormItems = [ | ||||
|       [ | ||||
|         GeneratedFormSwitch('sortByFileNamesNotLinks', | ||||
|             label: tr('sortByFileNamesNotLinks')) | ||||
|         GeneratedFormSubForm( | ||||
|             'intermediateLink', [...intermediateFormItems, ...commonFormItems], | ||||
|             label: tr('intermediateLink')) | ||||
|       ], | ||||
|       [GeneratedFormSwitch('reverseSort', label: tr('reverseSort'))], | ||||
|       [ | ||||
|         GeneratedFormTextField('customLinkFilterRegex', | ||||
|             label: tr('customLinkFilterRegex'), | ||||
|             hint: 'download/(.*/)?(android|apk|mobile)', | ||||
|             required: false, | ||||
|             additionalValidators: [ | ||||
|               (value) { | ||||
|                 return regExValidator(value); | ||||
|               } | ||||
|             ]) | ||||
|       ], | ||||
|       [ | ||||
|         GeneratedFormTextField('intermediateLinkRegex', | ||||
|             label: tr('intermediateLinkRegex'), | ||||
|             hint: '([0-9]+.)*[0-9]+/\$', | ||||
|             required: false, | ||||
|             additionalValidators: [(value) => regExValidator(value)]) | ||||
|       ], | ||||
|       [ | ||||
|         GeneratedFormTextField('versionExtractionRegEx', | ||||
|             label: tr('versionExtractionRegEx'), | ||||
|             required: false, | ||||
|             additionalValidators: [(value) => regExValidator(value)]), | ||||
|       ], | ||||
|       [ | ||||
|         GeneratedFormTextField('matchGroupToUse', | ||||
|             label: tr('matchGroupToUse'), | ||||
|             required: false, | ||||
|             hint: '0', | ||||
|             textInputType: const TextInputType.numberWithOptions(), | ||||
|             additionalValidators: [ | ||||
|               (value) { | ||||
|                 if (value?.isEmpty == true) { | ||||
|                   value = null; | ||||
|                 } | ||||
|                 value ??= '0'; | ||||
|                 return intValidator(value); | ||||
|               } | ||||
|             ]) | ||||
|       ], | ||||
|       [ | ||||
|         GeneratedFormSwitch('versionExtractWholePage', | ||||
|             label: tr('versionExtractWholePage')) | ||||
|       ] | ||||
|       finalStepFormitems[0], | ||||
|       ...commonFormItems, | ||||
|       ...finalStepFormitems.sublist(1) | ||||
|     ]; | ||||
|     overrideVersionDetectionFormDefault('noVersionDetection', | ||||
|         disableStandard: false, disableRelDate: true); | ||||
| @@ -158,97 +179,120 @@ class HTML extends AppSource { | ||||
|     return url; | ||||
|   } | ||||
|  | ||||
|   // Given an HTTP response, grab some links according to the common additional settings | ||||
|   // (those that apply to intermediate and final steps) | ||||
|   Future<List<MapEntry<String, String>>> grabLinksCommon( | ||||
|       Response res, Map<String, dynamic> additionalSettings) async { | ||||
|     if (res.statusCode != 200) { | ||||
|       throw getObtainiumHttpError(res); | ||||
|     } | ||||
|     var html = parse(res.body); | ||||
|     List<MapEntry<String, String>> allLinks = html | ||||
|         .querySelectorAll('a') | ||||
|         .map((element) => MapEntry( | ||||
|             element.attributes['href'] ?? '', | ||||
|             element.text.isNotEmpty | ||||
|                 ? element.text | ||||
|                 : (element.attributes['href'] ?? '').split('/').last)) | ||||
|         .where((element) => element.key.isNotEmpty) | ||||
|         .toList(); | ||||
|     if (allLinks.isEmpty) { | ||||
|       allLinks = RegExp( | ||||
|               r'(http|ftp|https)://([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-])?') | ||||
|           .allMatches(res.body) | ||||
|           .map((match) => | ||||
|               MapEntry(match.group(0)!, match.group(0)?.split('/').last ?? '')) | ||||
|           .toList(); | ||||
|     } | ||||
|     List<MapEntry<String, String>> links = []; | ||||
|     bool skipSort = additionalSettings['skipSort'] == true; | ||||
|     bool filterLinkByText = additionalSettings['filterByLinkText'] == true; | ||||
|     if ((additionalSettings['customLinkFilterRegex'] as String?)?.isNotEmpty == | ||||
|         true) { | ||||
|       var reg = RegExp(additionalSettings['customLinkFilterRegex']); | ||||
|       links = allLinks | ||||
|           .where((element) => | ||||
|               reg.hasMatch(filterLinkByText ? element.value : element.key)) | ||||
|           .toList(); | ||||
|     } else { | ||||
|       links = allLinks | ||||
|           .where((element) => | ||||
|               Uri.parse(filterLinkByText ? element.value : element.key) | ||||
|                   .path | ||||
|                   .toLowerCase() | ||||
|                   .endsWith('.apk')) | ||||
|           .toList(); | ||||
|     } | ||||
|     if (!skipSort) { | ||||
|       links.sort((a, b) => additionalSettings['sortByLastLinkSegment'] == true | ||||
|           ? compareAlphaNumeric( | ||||
|               a.key.split('/').where((e) => e.isNotEmpty).last, | ||||
|               b.key.split('/').where((e) => e.isNotEmpty).last) | ||||
|           : compareAlphaNumeric(a.key, b.key)); | ||||
|     } | ||||
|     if (additionalSettings['reverseSort'] == true) { | ||||
|       links = links.reversed.toList(); | ||||
|     } | ||||
|     return links; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<APKDetails> getLatestAPKDetails( | ||||
|     String standardUrl, | ||||
|     Map<String, dynamic> additionalSettings, | ||||
|   ) async { | ||||
|     var uri = Uri.parse(standardUrl); | ||||
|     Response res = await sourceRequest(standardUrl); | ||||
|     if (res.statusCode == 200) { | ||||
|       var html = parse(res.body); | ||||
|       List<String> allLinks = html | ||||
|           .querySelectorAll('a') | ||||
|           .map((element) => element.attributes['href'] ?? '') | ||||
|           .where((element) => element.isNotEmpty) | ||||
|           .toList(); | ||||
|       if (allLinks.isEmpty) { | ||||
|         allLinks = RegExp( | ||||
|                 r'(http|ftp|https)://([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-])?') | ||||
|             .allMatches(res.body) | ||||
|             .map((match) => match.group(0)!) | ||||
|             .toList(); | ||||
|       } | ||||
|       List<String> links = []; | ||||
|       if ((additionalSettings['intermediateLinkRegex'] as String?) | ||||
|               ?.isNotEmpty == | ||||
|           true) { | ||||
|         var reg = RegExp(additionalSettings['intermediateLinkRegex']); | ||||
|         links = allLinks.where((element) => reg.hasMatch(element)).toList(); | ||||
|         links.sort((a, b) => compareAlphaNumeric(a, b)); | ||||
|         if (links.isEmpty) { | ||||
|           throw ObtainiumError(tr('intermediateLinkNotFound')); | ||||
|         } | ||||
|         Map<String, dynamic> additionalSettingsTemp = | ||||
|             Map.from(additionalSettings); | ||||
|         additionalSettingsTemp['intermediateLinkRegex'] = null; | ||||
|         return getLatestAPKDetails( | ||||
|             ensureAbsoluteUrl(links.last, uri), additionalSettingsTemp); | ||||
|       } | ||||
|       if ((additionalSettings['customLinkFilterRegex'] as String?) | ||||
|               ?.isNotEmpty == | ||||
|           true) { | ||||
|         var reg = RegExp(additionalSettings['customLinkFilterRegex']); | ||||
|         links = allLinks.where((element) => reg.hasMatch(element)).toList(); | ||||
|       } else { | ||||
|         links = allLinks | ||||
|             .where((element) => | ||||
|                 Uri.parse(element).path.toLowerCase().endsWith('.apk')) | ||||
|             .toList(); | ||||
|       } | ||||
|       links.sort((a, b) => additionalSettings['sortByFileNamesNotLinks'] == true | ||||
|           ? compareAlphaNumeric(a.split('/').where((e) => e.isNotEmpty).last, | ||||
|               b.split('/').where((e) => e.isNotEmpty).last) | ||||
|           : compareAlphaNumeric(a, b)); | ||||
|       if (additionalSettings['reverseSort'] == true) { | ||||
|         links = links.reversed.toList(); | ||||
|       } | ||||
|       if ((additionalSettings['apkFilterRegEx'] as String?)?.isNotEmpty == | ||||
|           true) { | ||||
|         var reg = RegExp(additionalSettings['apkFilterRegEx']); | ||||
|         links = links.where((element) => reg.hasMatch(element)).toList(); | ||||
|       } | ||||
|       if (links.isEmpty) { | ||||
|     var currentUrl = standardUrl; | ||||
|     for (int i = 0; | ||||
|         i < (additionalSettings['intermediateLink']?.length ?? 0); | ||||
|         i++) { | ||||
|       var intLinks = await grabLinksCommon(await sourceRequest(currentUrl), | ||||
|           additionalSettings['intermediateLink'][i]); | ||||
|       if (intLinks.isEmpty) { | ||||
|         throw NoReleasesError(); | ||||
|       } else { | ||||
|         currentUrl = intLinks.last.key; | ||||
|       } | ||||
|       var rel = links.last; | ||||
|       String? version = rel.hashCode.toString(); | ||||
|       var versionExtractionRegEx = | ||||
|           additionalSettings['versionExtractionRegEx'] as String?; | ||||
|       if (versionExtractionRegEx?.isNotEmpty == true) { | ||||
|         var match = RegExp(versionExtractionRegEx!).allMatches( | ||||
|             additionalSettings['versionExtractWholePage'] == true | ||||
|                 ? res.body.split('\r\n').join('\n').split('\n').join('\\n') | ||||
|                 : rel); | ||||
|         if (match.isEmpty) { | ||||
|           throw NoVersionError(); | ||||
|         } | ||||
|         String matchGroupString = | ||||
|             (additionalSettings['matchGroupToUse'] as String).trim(); | ||||
|         if (matchGroupString.isEmpty) { | ||||
|           matchGroupString = "0"; | ||||
|         } | ||||
|         version = match.last.group(int.parse(matchGroupString)); | ||||
|         if (version?.isEmpty == true) { | ||||
|           throw NoVersionError(); | ||||
|         } | ||||
|       } | ||||
|       List<String> apkUrls = | ||||
|           [rel].map((e) => ensureAbsoluteUrl(e, uri)).toList(); | ||||
|       return APKDetails(version!, apkUrls.map((e) => MapEntry(e, e)).toList(), | ||||
|           AppNames(uri.host, tr('app'))); | ||||
|     } else { | ||||
|       throw getObtainiumHttpError(res); | ||||
|     } | ||||
|  | ||||
|     var uri = Uri.parse(currentUrl); | ||||
|     Response res = await sourceRequest(currentUrl); | ||||
|     var links = await grabLinksCommon(res, additionalSettings); | ||||
|  | ||||
|     if ((additionalSettings['apkFilterRegEx'] as String?)?.isNotEmpty == true) { | ||||
|       var reg = RegExp(additionalSettings['apkFilterRegEx']); | ||||
|       links = links.where((element) => reg.hasMatch(element.key)).toList(); | ||||
|     } | ||||
|     if (links.isEmpty) { | ||||
|       throw NoReleasesError(); | ||||
|     } | ||||
|     var rel = links.last.key; | ||||
|     String? version; | ||||
|     if (additionalSettings['supportFixedAPKURL'] != true) { | ||||
|       version = rel.hashCode.toString(); | ||||
|     } | ||||
|     var versionExtractionRegEx = | ||||
|         additionalSettings['versionExtractionRegEx'] as String?; | ||||
|     if (versionExtractionRegEx?.isNotEmpty == true) { | ||||
|       var match = RegExp(versionExtractionRegEx!).allMatches( | ||||
|           additionalSettings['versionExtractWholePage'] == true | ||||
|               ? res.body.split('\r\n').join('\n').split('\n').join('\\n') | ||||
|               : rel); | ||||
|       if (match.isEmpty) { | ||||
|         throw NoVersionError(); | ||||
|       } | ||||
|       String matchGroupString = | ||||
|           (additionalSettings['matchGroupToUse'] as String).trim(); | ||||
|       if (matchGroupString.isEmpty) { | ||||
|         matchGroupString = "0"; | ||||
|       } | ||||
|       version = match.last.group(int.parse(matchGroupString)); | ||||
|       if (version?.isEmpty == true) { | ||||
|         throw NoVersionError(); | ||||
|       } | ||||
|     } | ||||
|     rel = ensureAbsoluteUrl(rel, uri); | ||||
|     version ??= (await checkDownloadHash(rel)).toString(); | ||||
|     return APKDetails(version, [rel].map((e) => MapEntry(e, e)).toList(), | ||||
|         AppNames(uri.host, tr('app'))); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -40,7 +40,7 @@ class IzzyOnDroid extends AppSource { | ||||
|     Map<String, dynamic> additionalSettings, | ||||
|   ) async { | ||||
|     String? appId = await tryInferringAppId(standardUrl); | ||||
|     return getAPKUrlsFromFDroidPackagesAPIResponse( | ||||
|     return fd.getAPKUrlsFromFDroidPackagesAPIResponse( | ||||
|         await sourceRequest( | ||||
|             'https://apt.izzysoft.de/fdroid/api/v1/packages/$appId'), | ||||
|         'https://android.izzysoft.de/frepo/$appId', | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import 'package:hsluv/hsluv.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:obtainium/components/generated_form_modal.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
|  | ||||
| abstract class GeneratedFormItem { | ||||
|   late String key; | ||||
| @@ -27,21 +28,17 @@ class GeneratedFormTextField extends GeneratedFormItem { | ||||
|   late bool password; | ||||
|   late TextInputType? textInputType; | ||||
|  | ||||
|   GeneratedFormTextField(String key, | ||||
|       {String label = 'Input', | ||||
|       List<Widget> belowWidgets = const [], | ||||
|       String defaultValue = '', | ||||
|       List<String? Function(String? value)> additionalValidators = const [], | ||||
|   GeneratedFormTextField(super.key, | ||||
|       {super.label, | ||||
|       super.belowWidgets, | ||||
|       String super.defaultValue = '', | ||||
|       List<String? Function(String? value)> super.additionalValidators = | ||||
|           const [], | ||||
|       this.required = true, | ||||
|       this.max = 1, | ||||
|       this.hint, | ||||
|       this.password = false, | ||||
|       this.textInputType}) | ||||
|       : super(key, | ||||
|             label: label, | ||||
|             belowWidgets: belowWidgets, | ||||
|             defaultValue: defaultValue, | ||||
|             additionalValidators: additionalValidators); | ||||
|       this.textInputType}); | ||||
|  | ||||
|   @override | ||||
|   String ensureType(val) { | ||||
| @@ -54,18 +51,14 @@ class GeneratedFormDropdown extends GeneratedFormItem { | ||||
|   List<String>? disabledOptKeys; | ||||
|  | ||||
|   GeneratedFormDropdown( | ||||
|     String key, | ||||
|     super.key, | ||||
|     this.opts, { | ||||
|     String label = 'Input', | ||||
|     List<Widget> belowWidgets = const [], | ||||
|     String defaultValue = '', | ||||
|     super.label, | ||||
|     super.belowWidgets, | ||||
|     String super.defaultValue = '', | ||||
|     this.disabledOptKeys, | ||||
|     List<String? Function(String? value)> additionalValidators = const [], | ||||
|   }) : super(key, | ||||
|             label: label, | ||||
|             belowWidgets: belowWidgets, | ||||
|             defaultValue: defaultValue, | ||||
|             additionalValidators: additionalValidators); | ||||
|     List<String? Function(String? value)> super.additionalValidators = const [], | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   String ensureType(val) { | ||||
| @@ -75,16 +68,12 @@ class GeneratedFormDropdown extends GeneratedFormItem { | ||||
|  | ||||
| class GeneratedFormSwitch extends GeneratedFormItem { | ||||
|   GeneratedFormSwitch( | ||||
|     String key, { | ||||
|     String label = 'Input', | ||||
|     List<Widget> belowWidgets = const [], | ||||
|     bool defaultValue = false, | ||||
|     List<String? Function(bool value)> additionalValidators = const [], | ||||
|   }) : super(key, | ||||
|             label: label, | ||||
|             belowWidgets: belowWidgets, | ||||
|             defaultValue: defaultValue, | ||||
|             additionalValidators: additionalValidators); | ||||
|     super.key, { | ||||
|     super.label, | ||||
|     super.belowWidgets, | ||||
|     bool super.defaultValue = false, | ||||
|     List<String? Function(bool value)> super.additionalValidators = const [], | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   bool ensureType(val) { | ||||
| @@ -98,22 +87,17 @@ class GeneratedFormTagInput extends GeneratedFormItem { | ||||
|   late WrapAlignment alignment; | ||||
|   late String emptyMessage; | ||||
|   late bool showLabelWhenNotEmpty; | ||||
|   GeneratedFormTagInput(String key, | ||||
|       {String label = 'Input', | ||||
|       List<Widget> belowWidgets = const [], | ||||
|       Map<String, MapEntry<int, bool>> defaultValue = const {}, | ||||
|   GeneratedFormTagInput(super.key, | ||||
|       {super.label, | ||||
|       super.belowWidgets, | ||||
|       Map<String, MapEntry<int, bool>> super.defaultValue = const {}, | ||||
|       List<String? Function(Map<String, MapEntry<int, bool>> value)> | ||||
|           additionalValidators = const [], | ||||
|           super.additionalValidators = const [], | ||||
|       this.deleteConfirmationMessage, | ||||
|       this.singleSelect = false, | ||||
|       this.alignment = WrapAlignment.start, | ||||
|       this.emptyMessage = 'Input', | ||||
|       this.showLabelWhenNotEmpty = true}) | ||||
|       : super(key, | ||||
|             label: label, | ||||
|             belowWidgets: belowWidgets, | ||||
|             defaultValue: defaultValue, | ||||
|             additionalValidators: additionalValidators); | ||||
|       this.showLabelWhenNotEmpty = true}); | ||||
|  | ||||
|   @override | ||||
|   Map<String, MapEntry<int, bool>> ensureType(val) { | ||||
| @@ -135,6 +119,18 @@ class GeneratedForm extends StatefulWidget { | ||||
|   State<GeneratedForm> createState() => _GeneratedFormState(); | ||||
| } | ||||
|  | ||||
| class GeneratedFormSubForm extends GeneratedFormItem { | ||||
|   final List<List<GeneratedFormItem>> items; | ||||
|  | ||||
|   GeneratedFormSubForm(super.key, this.items, | ||||
|       {super.label, super.belowWidgets, super.defaultValue}); | ||||
|  | ||||
|   @override | ||||
|   ensureType(val) { | ||||
|     return val; // Not easy to validate List<Map<String, dynamic>> | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Generates a color in the HSLuv (Pastel) color space | ||||
| // https://pub.dev/documentation/hsluv/latest/hsluv/Hsluv/hpluvToRgb.html | ||||
| Color generateRandomLightColor() { | ||||
| @@ -151,28 +147,39 @@ Color generateRandomLightColor() { | ||||
|   return Color.fromARGB(255, rgbValues[0], rgbValues[1], rgbValues[2]); | ||||
| } | ||||
|  | ||||
| int generateRandomNumber(int seed1, | ||||
|     {int seed2 = 0, int seed3 = 0, max = 10000}) { | ||||
|   int combinedSeed = seed1.hashCode ^ seed2.hashCode ^ seed3.hashCode; | ||||
|   Random random = Random(combinedSeed); | ||||
|   int randomNumber = random.nextInt(max); | ||||
|   return randomNumber; | ||||
| } | ||||
|  | ||||
| bool validateTextField(TextFormField tf) => | ||||
|     (tf.key as GlobalKey<FormFieldState>).currentState?.isValid == true; | ||||
|  | ||||
| class _GeneratedFormState extends State<GeneratedForm> { | ||||
|   final _formKey = GlobalKey<FormState>(); | ||||
|   Map<String, dynamic> values = {}; | ||||
|   late List<List<Widget>> formInputs; | ||||
|   List<List<Widget>> rows = []; | ||||
|   String? initKey; | ||||
|   int forceUpdateKeyCount = 0; | ||||
|  | ||||
|   // If any value changes, call this to update the parent with value and validity | ||||
|   void someValueChanged({bool isBuilding = false}) { | ||||
|   void someValueChanged({bool isBuilding = false, bool forceInvalid = false}) { | ||||
|     Map<String, dynamic> returnValues = values; | ||||
|     var valid = true; | ||||
|     for (int r = 0; r < widget.items.length; r++) { | ||||
|       for (int i = 0; i < widget.items[r].length; i++) { | ||||
|         if (formInputs[r][i] is TextFormField) { | ||||
|           var fieldState = | ||||
|               (formInputs[r][i].key as GlobalKey<FormFieldState>).currentState; | ||||
|           if (fieldState != null) { | ||||
|             valid = valid && fieldState.isValid; | ||||
|           } | ||||
|           valid = valid && validateTextField(formInputs[r][i] as TextFormField); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     if (forceInvalid) { | ||||
|       valid = false; | ||||
|     } | ||||
|     widget.onValueChanges(returnValues, valid, isBuilding); | ||||
|   } | ||||
|  | ||||
| @@ -247,6 +254,17 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|                   someValueChanged(); | ||||
|                 }); | ||||
|               }); | ||||
|         } else if (formItem is GeneratedFormSubForm) { | ||||
|           values[formItem.key] = []; | ||||
|           for (Map<String, dynamic> v | ||||
|               in ((formItem.defaultValue ?? []) as List<dynamic>)) { | ||||
|             var fullDefaults = getDefaultValuesFromFormItems(formItem.items); | ||||
|             for (var element in v.entries) { | ||||
|               fullDefaults[element.key] = element.value; | ||||
|             } | ||||
|             values[formItem.key].add(fullDefaults); | ||||
|           } | ||||
|           return Container(); | ||||
|         } else { | ||||
|           return Container(); // Some input types added in build | ||||
|         } | ||||
| @@ -268,6 +286,7 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|     } | ||||
|     for (var r = 0; r < formInputs.length; r++) { | ||||
|       for (var e = 0; e < formInputs[r].length; e++) { | ||||
|         String fieldKey = widget.items[r][e].key; | ||||
|         if (widget.items[r][e] is GeneratedFormSwitch) { | ||||
|           formInputs[r][e] = Row( | ||||
|             mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
| @@ -277,10 +296,10 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|                 width: 8, | ||||
|               ), | ||||
|               Switch( | ||||
|                   value: values[widget.items[r][e].key], | ||||
|                   value: values[fieldKey], | ||||
|                   onChanged: (value) { | ||||
|                     setState(() { | ||||
|                       values[widget.items[r][e].key] = value; | ||||
|                       values[fieldKey] = value; | ||||
|                       someValueChanged(); | ||||
|                     }); | ||||
|                   }) | ||||
| @@ -289,8 +308,7 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|         } else if (widget.items[r][e] is GeneratedFormTagInput) { | ||||
|           formInputs[r][e] = | ||||
|               Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ | ||||
|             if ((values[widget.items[r][e].key] | ||||
|                             as Map<String, MapEntry<int, bool>>?) | ||||
|             if ((values[fieldKey] as Map<String, MapEntry<int, bool>>?) | ||||
|                         ?.isNotEmpty == | ||||
|                     true && | ||||
|                 (widget.items[r][e] as GeneratedFormTagInput) | ||||
| @@ -313,8 +331,7 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|                   (widget.items[r][e] as GeneratedFormTagInput).alignment, | ||||
|               crossAxisAlignment: WrapCrossAlignment.center, | ||||
|               children: [ | ||||
|                 (values[widget.items[r][e].key] | ||||
|                                 as Map<String, MapEntry<int, bool>>?) | ||||
|                 (values[fieldKey] as Map<String, MapEntry<int, bool>>?) | ||||
|                             ?.isEmpty == | ||||
|                         true | ||||
|                     ? Text( | ||||
| @@ -322,8 +339,7 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|                             .emptyMessage, | ||||
|                       ) | ||||
|                     : const SizedBox.shrink(), | ||||
|                 ...(values[widget.items[r][e].key] | ||||
|                             as Map<String, MapEntry<int, bool>>?) | ||||
|                 ...(values[fieldKey] as Map<String, MapEntry<int, bool>>?) | ||||
|                         ?.entries | ||||
|                         .map((e2) { | ||||
|                       return Padding( | ||||
| @@ -336,11 +352,10 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|                             selected: e2.value.value, | ||||
|                             onSelected: (value) { | ||||
|                               setState(() { | ||||
|                                 (values[widget.items[r][e].key] as Map<String, | ||||
|                                 (values[fieldKey] as Map<String, | ||||
|                                         MapEntry<int, bool>>)[e2.key] = | ||||
|                                     MapEntry( | ||||
|                                         (values[widget.items[r][e].key] as Map< | ||||
|                                                 String, | ||||
|                                         (values[fieldKey] as Map<String, | ||||
|                                                 MapEntry<int, bool>>)[e2.key]! | ||||
|                                             .key, | ||||
|                                         value); | ||||
| @@ -348,22 +363,18 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|                                             as GeneratedFormTagInput) | ||||
|                                         .singleSelect && | ||||
|                                     value == true) { | ||||
|                                   for (var key in (values[ | ||||
|                                               widget.items[r][e].key] | ||||
|                                   for (var key in (values[fieldKey] | ||||
|                                           as Map<String, MapEntry<int, bool>>) | ||||
|                                       .keys) { | ||||
|                                     if (key != e2.key) { | ||||
|                                       (values[widget.items[r][e].key] as Map< | ||||
|                                               String, | ||||
|                                               MapEntry<int, bool>>)[key] = | ||||
|                                           MapEntry( | ||||
|                                               (values[widget.items[r][e].key] | ||||
|                                                       as Map< | ||||
|                                                           String, | ||||
|                                                           MapEntry<int, | ||||
|                                                               bool>>)[key]! | ||||
|                                                   .key, | ||||
|                                               false); | ||||
|                                       (values[fieldKey] as Map< | ||||
|                                           String, | ||||
|                                           MapEntry<int, | ||||
|                                               bool>>)[key] = MapEntry( | ||||
|                                           (values[fieldKey] as Map<String, | ||||
|                                                   MapEntry<int, bool>>)[key]! | ||||
|                                               .key, | ||||
|                                           false); | ||||
|                                     } | ||||
|                                   } | ||||
|                                 } | ||||
| @@ -373,8 +384,7 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|                           )); | ||||
|                     }) ?? | ||||
|                     [const SizedBox.shrink()], | ||||
|                 (values[widget.items[r][e].key] | ||||
|                                 as Map<String, MapEntry<int, bool>>?) | ||||
|                 (values[fieldKey] as Map<String, MapEntry<int, bool>>?) | ||||
|                             ?.values | ||||
|                             .where((e) => e.value) | ||||
|                             .length == | ||||
| @@ -384,7 +394,7 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|                         child: IconButton( | ||||
|                           onPressed: () { | ||||
|                             setState(() { | ||||
|                               var temp = values[widget.items[r][e].key] | ||||
|                               var temp = values[fieldKey] | ||||
|                                   as Map<String, MapEntry<int, bool>>; | ||||
|                               // get selected category str where bool is true | ||||
|                               final oldEntry = temp.entries | ||||
| @@ -397,7 +407,7 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|                               // Update entry with new color, remain selected | ||||
|                               temp.update(oldEntry.key, | ||||
|                                   (old) => MapEntry(newColor, old.value)); | ||||
|                               values[widget.items[r][e].key] = temp; | ||||
|                               values[fieldKey] = temp; | ||||
|                               someValueChanged(); | ||||
|                             }); | ||||
|                           }, | ||||
| @@ -406,8 +416,7 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|                           tooltip: tr('colour'), | ||||
|                         )) | ||||
|                     : const SizedBox.shrink(), | ||||
|                 (values[widget.items[r][e].key] | ||||
|                                 as Map<String, MapEntry<int, bool>>?) | ||||
|                 (values[fieldKey] as Map<String, MapEntry<int, bool>>?) | ||||
|                             ?.values | ||||
|                             .where((e) => e.value) | ||||
|                             .isNotEmpty == | ||||
| @@ -418,10 +427,10 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|                           onPressed: () { | ||||
|                             fn() { | ||||
|                               setState(() { | ||||
|                                 var temp = values[widget.items[r][e].key] | ||||
|                                 var temp = values[fieldKey] | ||||
|                                     as Map<String, MapEntry<int, bool>>; | ||||
|                                 temp.removeWhere((key, value) => value.value); | ||||
|                                 values[widget.items[r][e].key] = temp; | ||||
|                                 values[fieldKey] = temp; | ||||
|                                 someValueChanged(); | ||||
|                               }); | ||||
|                             } | ||||
| @@ -472,7 +481,7 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|                           String? label = value?['label']; | ||||
|                           if (label != null) { | ||||
|                             setState(() { | ||||
|                               var temp = values[widget.items[r][e].key] | ||||
|                               var temp = values[fieldKey] | ||||
|                                   as Map<String, MapEntry<int, bool>>?; | ||||
|                               temp ??= {}; | ||||
|                               if (temp[label] == null) { | ||||
| @@ -485,7 +494,7 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|                                 temp[label] = MapEntry( | ||||
|                                     generateRandomLightColor().value, | ||||
|                                     !(someSelected && singleSelect)); | ||||
|                                 values[widget.items[r][e].key] = temp; | ||||
|                                 values[fieldKey] = temp; | ||||
|                                 someValueChanged(); | ||||
|                               } | ||||
|                             }); | ||||
| @@ -499,6 +508,93 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|               ], | ||||
|             ) | ||||
|           ]); | ||||
|         } else if (widget.items[r][e] is GeneratedFormSubForm) { | ||||
|           List<Widget> subformColumn = []; | ||||
|           for (int i = 0; i < values[fieldKey].length; i++) { | ||||
|             var items = (widget.items[r][e] as GeneratedFormSubForm) | ||||
|                 .items | ||||
|                 .map((x) => x.map((y) { | ||||
|                       y.defaultValue = values[fieldKey]?[i]?[y.key]; | ||||
|                       return y; | ||||
|                     }).toList()) | ||||
|                 .toList(); | ||||
|             var internalFormKey = ValueKey(generateRandomNumber( | ||||
|                 values[fieldKey].length, | ||||
|                 seed2: i, | ||||
|                 seed3: forceUpdateKeyCount)); | ||||
|             subformColumn.add(Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                 const Divider(), | ||||
|                 const SizedBox( | ||||
|                   height: 16, | ||||
|                 ), | ||||
|                 Text( | ||||
|                   '${(widget.items[r][e] as GeneratedFormSubForm).label} (${i + 1})', | ||||
|                   style: const TextStyle(fontWeight: FontWeight.bold), | ||||
|                 ), | ||||
|                 GeneratedForm( | ||||
|                   key: internalFormKey, | ||||
|                   items: items, | ||||
|                   onValueChanges: (values, valid, isBuilding) { | ||||
|                     if (valid) { | ||||
|                       this.values[fieldKey]?[i] = values; | ||||
|                     } | ||||
|                     someValueChanged( | ||||
|                         isBuilding: isBuilding, forceInvalid: !valid); | ||||
|                   }, | ||||
|                 ), | ||||
|                 Row( | ||||
|                   mainAxisAlignment: MainAxisAlignment.end, | ||||
|                   children: [ | ||||
|                     TextButton.icon( | ||||
|                         style: TextButton.styleFrom( | ||||
|                             foregroundColor: | ||||
|                                 Theme.of(context).colorScheme.error), | ||||
|                         onPressed: (values[fieldKey].length > 0) | ||||
|                             ? () { | ||||
|                                 var temp = List.from(values[fieldKey]); | ||||
|                                 temp.removeAt(i); | ||||
|                                 values[fieldKey] = List.from(temp); | ||||
|                                 forceUpdateKeyCount++; | ||||
|                                 someValueChanged(); | ||||
|                               } | ||||
|                             : null, | ||||
|                         label: Text( | ||||
|                           '${(widget.items[r][e] as GeneratedFormSubForm).label} (${i + 1})', | ||||
|                         ), | ||||
|                         icon: const Icon( | ||||
|                           Icons.delete_outline_rounded, | ||||
|                         )) | ||||
|                   ], | ||||
|                 ), | ||||
|               ], | ||||
|             )); | ||||
|           } | ||||
|           subformColumn.add(Padding( | ||||
|             padding: EdgeInsets.only( | ||||
|                 bottom: values[fieldKey].length > 0 ? 24 : 0, top: 8), | ||||
|             child: Row( | ||||
|               children: [ | ||||
|                 Expanded( | ||||
|                     child: ElevatedButton.icon( | ||||
|                         onPressed: () { | ||||
|                           values[fieldKey].add(getDefaultValuesFromFormItems( | ||||
|                               (widget.items[r][e] as GeneratedFormSubForm) | ||||
|                                   .items)); | ||||
|                           forceUpdateKeyCount++; | ||||
|                           someValueChanged(); | ||||
|                         }, | ||||
|                         icon: const Icon(Icons.add), | ||||
|                         label: Text((widget.items[r][e] as GeneratedFormSubForm) | ||||
|                             .label))), | ||||
|               ], | ||||
|             ), | ||||
|           )); | ||||
|           if (values[fieldKey].length > 0) { | ||||
|             subformColumn.add(const Divider()); | ||||
|           } | ||||
|           formInputs[r][e] = Column(children: subformColumn); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   | ||||
| @@ -34,7 +34,9 @@ class CredsNeededError extends ObtainiumError { | ||||
| } | ||||
|  | ||||
| class NoReleasesError extends ObtainiumError { | ||||
|   NoReleasesError() : super(tr('noReleaseFound')); | ||||
|   NoReleasesError({String? note}) | ||||
|       : super( | ||||
|             '${tr('noReleaseFound')}${note?.isNotEmpty == true ? '\n\n$note' : ''}'); | ||||
| } | ||||
|  | ||||
| class NoAPKError extends ObtainiumError { | ||||
|   | ||||
| @@ -12,14 +12,14 @@ import 'package:permission_handler/permission_handler.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:dynamic_color/dynamic_color.dart'; | ||||
| import 'package:device_info_plus/device_info_plus.dart'; | ||||
| import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart'; | ||||
| import 'package:background_fetch/background_fetch.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| // ignore: implementation_imports | ||||
| import 'package:easy_localization/src/easy_localization_controller.dart'; | ||||
| // ignore: implementation_imports | ||||
| import 'package:easy_localization/src/localization.dart'; | ||||
|  | ||||
| const String currentVersion = '0.14.33'; | ||||
| const String currentVersion = '0.15.1'; | ||||
| const String currentReleaseTag = | ||||
|     'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES | ||||
|  | ||||
| @@ -76,6 +76,19 @@ Future<void> loadTranslations() async { | ||||
|       fallbackTranslations: controller.fallbackTranslations); | ||||
| } | ||||
|  | ||||
| @pragma('vm:entry-point') | ||||
| void backgroundFetchHeadlessTask(HeadlessTask task) async { | ||||
|   String taskId = task.taskId; | ||||
|   bool isTimeout = task.timeout; | ||||
|   if (isTimeout) { | ||||
|     print('BG update task timed out.'); | ||||
|     BackgroundFetch.finish(taskId); | ||||
|     return; | ||||
|   } | ||||
|   await bgUpdateCheck(taskId, null); | ||||
|   BackgroundFetch.finish(taskId); | ||||
| } | ||||
|  | ||||
| void main() async { | ||||
|   WidgetsFlutterBinding.ensureInitialized(); | ||||
|   try { | ||||
| @@ -93,7 +106,6 @@ void main() async { | ||||
|     ); | ||||
|     SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); | ||||
|   } | ||||
|   await AndroidAlarmManager.initialize(); | ||||
|   runApp(MultiProvider( | ||||
|     providers: [ | ||||
|       ChangeNotifierProvider(create: (context) => AppsProvider()), | ||||
| @@ -108,6 +120,7 @@ void main() async { | ||||
|         useOnlyLangCode: true, | ||||
|         child: const Obtainium()), | ||||
|   )); | ||||
|   BackgroundFetch.registerHeadlessTask(backgroundFetchHeadlessTask); | ||||
| } | ||||
|  | ||||
| var defaultThemeColour = Colors.deepPurple; | ||||
| @@ -122,6 +135,33 @@ class Obtainium extends StatefulWidget { | ||||
| class _ObtainiumState extends State<Obtainium> { | ||||
|   var existingUpdateInterval = -1; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     initPlatformState(); | ||||
|   } | ||||
|  | ||||
|   Future<void> initPlatformState() async { | ||||
|     await BackgroundFetch.configure( | ||||
|         BackgroundFetchConfig( | ||||
|             minimumFetchInterval: 15, | ||||
|             stopOnTerminate: false, | ||||
|             enableHeadless: true, | ||||
|             requiresBatteryNotLow: false, | ||||
|             requiresCharging: false, | ||||
|             requiresStorageNotLow: false, | ||||
|             requiresDeviceIdle: false, | ||||
|             requiredNetworkType: NetworkType.ANY), (String taskId) async { | ||||
|       // We don't want periodic tasks in the foreground - ignore | ||||
|       await bgUpdateCheck(taskId, null); | ||||
|       BackgroundFetch.finish(taskId); | ||||
|     }, (String taskId) async { | ||||
|       context.read<LogsProvider>().add('BG update task timed out.'); | ||||
|       BackgroundFetch.finish(taskId); | ||||
|     }); | ||||
|     if (!mounted) return; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     SettingsProvider settingsProvider = context.watch<SettingsProvider>(); | ||||
| @@ -161,30 +201,6 @@ class _ObtainiumState extends State<Obtainium> { | ||||
|                   context.locale.languageCode)) { | ||||
|         settingsProvider.resetLocaleSafe(context); | ||||
|       } | ||||
|       // Register the background update task according to the user's setting | ||||
|       var actualUpdateInterval = settingsProvider.updateInterval; | ||||
|       if (existingUpdateInterval != actualUpdateInterval) { | ||||
|         if (actualUpdateInterval == 0) { | ||||
|           AndroidAlarmManager.cancel(bgUpdateCheckAlarmId); | ||||
|         } else { | ||||
|           var settingChanged = existingUpdateInterval != -1; | ||||
|           var lastCheckWasTooLongAgo = actualUpdateInterval != 0 && | ||||
|               settingsProvider.lastBGCheckTime | ||||
|                   .add(Duration(minutes: actualUpdateInterval + 60)) | ||||
|                   .isBefore(DateTime.now()); | ||||
|           if (settingChanged || lastCheckWasTooLongAgo) { | ||||
|             logs.add( | ||||
|                 'Update interval was set to ${actualUpdateInterval.toString()} (reason: ${settingChanged ? 'setting changed' : 'last check was ${settingsProvider.lastBGCheckTime.toLocal().toString()}'}).'); | ||||
|             AndroidAlarmManager.periodic( | ||||
|                 Duration(minutes: actualUpdateInterval), | ||||
|                 bgUpdateCheckAlarmId, | ||||
|                 bgUpdateCheck, | ||||
|                 rescheduleOnReboot: true, | ||||
|                 wakeup: true); | ||||
|           } | ||||
|         } | ||||
|         existingUpdateInterval = actualUpdateInterval; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return DynamicColorBuilder( | ||||
|   | ||||
| @@ -21,10 +21,10 @@ class AddAppPage extends StatefulWidget { | ||||
|   const AddAppPage({super.key}); | ||||
|  | ||||
|   @override | ||||
|   State<AddAppPage> createState() => _AddAppPageState(); | ||||
|   State<AddAppPage> createState() => AddAppPageState(); | ||||
| } | ||||
|  | ||||
| class _AddAppPageState extends State<AddAppPage> { | ||||
| class AddAppPageState extends State<AddAppPage> { | ||||
|   bool gettingAppInfo = false; | ||||
|   bool searching = false; | ||||
|  | ||||
| @@ -36,9 +36,62 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|   bool additionalSettingsValid = true; | ||||
|   bool inferAppIdIfOptional = true; | ||||
|   List<String> pickedCategories = []; | ||||
|   int searchnum = 0; | ||||
|   int urlInputKey = 0; | ||||
|   SourceProvider sourceProvider = SourceProvider(); | ||||
|  | ||||
|   linkFn(String input) { | ||||
|     try { | ||||
|       if (input.isEmpty) { | ||||
|         throw UnsupportedURLError(); | ||||
|       } | ||||
|       sourceProvider.getSource(input); | ||||
|       changeUserInput(input, true, false, updateUrlInput: true); | ||||
|     } catch (e) { | ||||
|       showError(e, context); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   changeUserInput(String input, bool valid, bool isBuilding, | ||||
|       {bool updateUrlInput = false}) { | ||||
|     userInput = input; | ||||
|     if (!isBuilding) { | ||||
|       setState(() { | ||||
|         if (updateUrlInput) { | ||||
|           urlInputKey++; | ||||
|         } | ||||
|         var prevHost = pickedSource?.host; | ||||
|         try { | ||||
|           var naturalSource = | ||||
|               valid ? sourceProvider.getSource(userInput) : null; | ||||
|           if (naturalSource != null && | ||||
|               naturalSource.runtimeType.toString() != | ||||
|                   HTML().runtimeType.toString()) { | ||||
|             // If input has changed to match a regular source, reset the override | ||||
|             pickedSourceOverride = null; | ||||
|           } | ||||
|         } catch (e) { | ||||
|           // ignore | ||||
|         } | ||||
|         var source = valid | ||||
|             ? sourceProvider.getSource(userInput, | ||||
|                 overrideSource: pickedSourceOverride) | ||||
|             : null; | ||||
|         if (pickedSource.runtimeType != source.runtimeType || | ||||
|             (prevHost != null && prevHost != source?.host)) { | ||||
|           pickedSource = source; | ||||
|           additionalSettings = source != null | ||||
|               ? getDefaultValuesFromFormItems( | ||||
|                   source.combinedAppSpecificSettingFormItems) | ||||
|               : {}; | ||||
|           additionalSettingsValid = source != null | ||||
|               ? !sourceProvider.ifRequiredAppSpecificSettingsExist(source) | ||||
|               : true; | ||||
|           inferAppIdIfOptional = true; | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     AppsProvider appsProvider = context.read<AppsProvider>(); | ||||
| @@ -48,47 +101,6 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|  | ||||
|     bool doingSomething = gettingAppInfo || searching; | ||||
|  | ||||
|     changeUserInput(String input, bool valid, bool isBuilding, | ||||
|         {bool isSearch = false}) { | ||||
|       userInput = input; | ||||
|       if (!isBuilding) { | ||||
|         setState(() { | ||||
|           if (isSearch) { | ||||
|             searchnum++; | ||||
|           } | ||||
|           var prevHost = pickedSource?.host; | ||||
|           try { | ||||
|             var naturalSource = | ||||
|                 valid ? sourceProvider.getSource(userInput) : null; | ||||
|             if (naturalSource != null && | ||||
|                 naturalSource.runtimeType.toString() != | ||||
|                     HTML().runtimeType.toString()) { | ||||
|               // If input has changed to match a regular source, reset the override | ||||
|               pickedSourceOverride = null; | ||||
|             } | ||||
|           } catch (e) { | ||||
|             // ignore | ||||
|           } | ||||
|           var source = valid | ||||
|               ? sourceProvider.getSource(userInput, | ||||
|                   overrideSource: pickedSourceOverride) | ||||
|               : null; | ||||
|           if (pickedSource.runtimeType != source.runtimeType || | ||||
|               (prevHost != null && prevHost != source?.host)) { | ||||
|             pickedSource = source; | ||||
|             additionalSettings = source != null | ||||
|                 ? getDefaultValuesFromFormItems( | ||||
|                     source.combinedAppSpecificSettingFormItems) | ||||
|                 : {}; | ||||
|             additionalSettingsValid = source != null | ||||
|                 ? !sourceProvider.ifRequiredAppSpecificSettingsExist(source) | ||||
|                 : true; | ||||
|             inferAppIdIfOptional = true; | ||||
|           } | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     Future<bool> getTrackOnlyConfirmationIfNeeded(bool userPickedTrackOnly, | ||||
|         {bool ignoreHideSetting = false}) async { | ||||
|       var useTrackOnly = userPickedTrackOnly || pickedSource!.enforceTrackOnly; | ||||
| @@ -205,7 +217,7 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|           children: [ | ||||
|             Expanded( | ||||
|                 child: GeneratedForm( | ||||
|                     key: Key(searchnum.toString()), | ||||
|                     key: Key(urlInputKey.toString()), | ||||
|                     items: [ | ||||
|                       [ | ||||
|                         GeneratedFormTextField('appSourceURL', | ||||
| @@ -254,57 +266,79 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|           ], | ||||
|         ); | ||||
|  | ||||
|     runSearch() async { | ||||
|     runSearch({bool filtered = true}) async { | ||||
|       setState(() { | ||||
|         searching = true; | ||||
|       }); | ||||
|       var sourceStrings = <String, List<String>>{}; | ||||
|       sourceProvider.sources | ||||
|           .where((e) => e.canSearch && !e.excludeFromMassSearch) | ||||
|           .forEach((s) { | ||||
|         sourceStrings[s.name] = [s.name]; | ||||
|       }); | ||||
|       try { | ||||
|         var results = await Future.wait(sourceProvider.sources | ||||
|             .where((e) => e.canSearch && !e.excludeFromMassSearch) | ||||
|             .map((e) async { | ||||
|           try { | ||||
|             return await e.search(searchQuery); | ||||
|           } catch (err) { | ||||
|             if (err is! CredsNeededError) { | ||||
|               rethrow; | ||||
|             } else { | ||||
|               return <String, List<String>>{}; | ||||
|             } | ||||
|           } | ||||
|         })); | ||||
|  | ||||
|         // .then((results) async { | ||||
|         // Interleave results instead of simple reduce | ||||
|         Map<String, List<String>> res = {}; | ||||
|         var si = 0; | ||||
|         var done = false; | ||||
|         while (!done) { | ||||
|           done = true; | ||||
|           for (var r in results) { | ||||
|             if (r.length > si) { | ||||
|               done = false; | ||||
|               res.addEntries([r.entries.elementAt(si)]); | ||||
|             } | ||||
|           } | ||||
|           si++; | ||||
|         } | ||||
|         if (res.isEmpty) { | ||||
|           throw ObtainiumError(tr('noResults')); | ||||
|         } | ||||
|         List<String>? selectedUrls = res.isEmpty | ||||
|             ? [] | ||||
|             // ignore: use_build_context_synchronously | ||||
|             : await showDialog<List<String>?>( | ||||
|         var searchSources = await showDialog<List<String>?>( | ||||
|                 context: context, | ||||
|                 builder: (BuildContext ctx) { | ||||
|                   return UrlSelectionModal( | ||||
|                     urlsWithDescriptions: res, | ||||
|                     selectedByDefault: false, | ||||
|                     onlyOneSelectionAllowed: true, | ||||
|                   return SelectionModal( | ||||
|                     title: tr('selectX', args: [plural('source', 2)]), | ||||
|                     entries: sourceStrings, | ||||
|                     selectedByDefault: true, | ||||
|                     onlyOneSelectionAllowed: false, | ||||
|                     titlesAreLinks: false, | ||||
|                   ); | ||||
|                 }); | ||||
|         if (selectedUrls != null && selectedUrls.isNotEmpty) { | ||||
|           changeUserInput(selectedUrls[0], true, false, isSearch: true); | ||||
|                 }) ?? | ||||
|             []; | ||||
|         if (searchSources.isNotEmpty) { | ||||
|           var results = await Future.wait(sourceProvider.sources | ||||
|               .where((e) => searchSources.contains(e.name)) | ||||
|               .map((e) async { | ||||
|             try { | ||||
|               return await e.search(searchQuery); | ||||
|             } catch (err) { | ||||
|               if (err is! CredsNeededError) { | ||||
|                 rethrow; | ||||
|               } else { | ||||
|                 err.unexpected = true; | ||||
|                 showError(err, context); | ||||
|                 return <String, List<String>>{}; | ||||
|               } | ||||
|             } | ||||
|           })); | ||||
|  | ||||
|           // .then((results) async { | ||||
|           // Interleave results instead of simple reduce | ||||
|           Map<String, List<String>> res = {}; | ||||
|           var si = 0; | ||||
|           var done = false; | ||||
|           while (!done) { | ||||
|             done = true; | ||||
|             for (var r in results) { | ||||
|               if (r.length > si) { | ||||
|                 done = false; | ||||
|                 res.addEntries([r.entries.elementAt(si)]); | ||||
|               } | ||||
|             } | ||||
|             si++; | ||||
|           } | ||||
|           if (res.isEmpty) { | ||||
|             throw ObtainiumError(tr('noResults')); | ||||
|           } | ||||
|           List<String>? selectedUrls = res.isEmpty | ||||
|               ? [] | ||||
|               // ignore: use_build_context_synchronously | ||||
|               : await showDialog<List<String>?>( | ||||
|                   context: context, | ||||
|                   builder: (BuildContext ctx) { | ||||
|                     return SelectionModal( | ||||
|                       entries: res, | ||||
|                       selectedByDefault: false, | ||||
|                       onlyOneSelectionAllowed: true, | ||||
|                     ); | ||||
|                   }); | ||||
|           if (selectedUrls != null && selectedUrls.isNotEmpty) { | ||||
|             changeUserInput(selectedUrls[0], true, false, updateUrlInput: true); | ||||
|           } | ||||
|         } | ||||
|       } catch (e) { | ||||
|         showError(e, context); | ||||
| @@ -470,23 +504,21 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|               const SizedBox( | ||||
|                 height: 16, | ||||
|               ), | ||||
|               ...sourceProvider.sources | ||||
|                   .map((e) => GestureDetector( | ||||
|                       onTap: e.host != null | ||||
|                           ? () { | ||||
|                               launchUrlString('https://${e.host}', | ||||
|                                   mode: LaunchMode.externalApplication); | ||||
|                             } | ||||
|                           : null, | ||||
|                       child: Text( | ||||
|                         '${e.name}${e.enforceTrackOnly ? ' ${tr('trackOnlyInBrackets')}' : ''}${e.canSearch ? ' ${tr('searchableInBrackets')}' : ''}', | ||||
|                         style: TextStyle( | ||||
|                             decoration: e.host != null | ||||
|                                 ? TextDecoration.underline | ||||
|                                 : TextDecoration.none, | ||||
|                             fontStyle: FontStyle.italic), | ||||
|                       ))) | ||||
|                   .toList() | ||||
|               ...sourceProvider.sources.map((e) => GestureDetector( | ||||
|                   onTap: e.host != null | ||||
|                       ? () { | ||||
|                           launchUrlString('https://${e.host}', | ||||
|                               mode: LaunchMode.externalApplication); | ||||
|                         } | ||||
|                       : null, | ||||
|                   child: Text( | ||||
|                     '${e.name}${e.enforceTrackOnly ? ' ${tr('trackOnlyInBrackets')}' : ''}${e.canSearch ? ' ${tr('searchableInBrackets')}' : ''}', | ||||
|                     style: TextStyle( | ||||
|                         decoration: e.host != null | ||||
|                             ? TextDecoration.underline | ||||
|                             : TextDecoration.none, | ||||
|                         fontStyle: FontStyle.italic), | ||||
|                   ))) | ||||
|             ]); | ||||
|  | ||||
|     return Scaffold( | ||||
|   | ||||
| @@ -145,6 +145,29 @@ class _AppPageState extends State<AppPage> { | ||||
|                     appsProvider.saveApps([app.app]); | ||||
|                   } | ||||
|                 }), | ||||
|             if (app?.app.additionalSettings['about'] is String && | ||||
|                 app?.app.additionalSettings['about'].isNotEmpty) | ||||
|               Column( | ||||
|                 children: [ | ||||
|                   const SizedBox( | ||||
|                     height: 48, | ||||
|                   ), | ||||
|                   GestureDetector( | ||||
|                     onLongPress: () { | ||||
|                       Clipboard.setData(ClipboardData( | ||||
|                           text: app?.app.additionalSettings['about'] ?? '')); | ||||
|                       ScaffoldMessenger.of(context).showSnackBar(SnackBar( | ||||
|                         content: Text(tr('copiedToClipboard')), | ||||
|                       )); | ||||
|                     }, | ||||
|                     child: Text( | ||||
|                       app?.app.additionalSettings['about'], | ||||
|                       textAlign: TextAlign.center, | ||||
|                       style: const TextStyle(fontStyle: FontStyle.italic), | ||||
|                     ), | ||||
|                   ) | ||||
|                 ], | ||||
|               ), | ||||
|           ], | ||||
|         ); | ||||
|  | ||||
|   | ||||
| @@ -496,14 +496,8 @@ class AppsPageState extends State<AppsPage> { | ||||
|       var transparent = | ||||
|           Theme.of(context).colorScheme.background.withAlpha(0).value; | ||||
|       List<double> stops = [ | ||||
|         ...listedApps[index] | ||||
|             .app | ||||
|             .categories | ||||
|             .asMap() | ||||
|             .entries | ||||
|             .map((e) => | ||||
|                 ((e.key / (listedApps[index].app.categories.length - 1)))) | ||||
|             .toList(), | ||||
|         ...listedApps[index].app.categories.asMap().entries.map( | ||||
|             (e) => ((e.key / (listedApps[index].app.categories.length - 1)))), | ||||
|         1 | ||||
|       ]; | ||||
|       if (stops.length == 2) { | ||||
| @@ -516,13 +510,9 @@ class AppsPageState extends State<AppsPage> { | ||||
|                   begin: const Alignment(-1, 0), | ||||
|                   end: const Alignment(-0.97, 0), | ||||
|                   colors: [ | ||||
|                 ...listedApps[index] | ||||
|                     .app | ||||
|                     .categories | ||||
|                     .map((e) => | ||||
|                         Color(settingsProvider.categories[e] ?? transparent) | ||||
|                             .withAlpha(255)) | ||||
|                     .toList(), | ||||
|                 ...listedApps[index].app.categories.map((e) => | ||||
|                     Color(settingsProvider.categories[e] ?? transparent) | ||||
|                         .withAlpha(255)), | ||||
|                 Color(transparent) | ||||
|               ])), | ||||
|           child: ListTile( | ||||
| @@ -881,7 +871,7 @@ class AppsPageState extends State<AppsPage> { | ||||
|                         onPressed: () { | ||||
|                           String urls = ''; | ||||
|                           for (var a in selectedApps) { | ||||
|                             urls += '${a.url}\n'; | ||||
|                             urls += 'obtainium://add/${a.url}\n'; | ||||
|                           } | ||||
|                           urls = urls.substring(0, urls.length - 1); | ||||
|                           Share.share(urls, | ||||
| @@ -981,10 +971,8 @@ class AppsPageState extends State<AppsPage> { | ||||
|                       defaultValue: filter.sourceFilter, | ||||
|                       [ | ||||
|                         MapEntry('', tr('none')), | ||||
|                         ...sourceProvider.sources | ||||
|                             .map((e) => | ||||
|                                 MapEntry(e.runtimeType.toString(), e.name)) | ||||
|                             .toList() | ||||
|                         ...sourceProvider.sources.map( | ||||
|                             (e) => MapEntry(e.runtimeType.toString(), e.name)) | ||||
|                       ]) | ||||
|                 ] | ||||
|               ], | ||||
|   | ||||
| @@ -1,7 +1,11 @@ | ||||
| import 'dart:async'; | ||||
|  | ||||
| import 'package:animations/animations.dart'; | ||||
| import 'package:app_links/app_links.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/pages/add_app.dart'; | ||||
| import 'package:obtainium/pages/apps.dart'; | ||||
| import 'package:obtainium/pages/import_export.dart'; | ||||
| @@ -30,58 +34,119 @@ class _HomePageState extends State<HomePage> { | ||||
|   bool isReversing = false; | ||||
|   int prevAppCount = -1; | ||||
|   bool prevIsLoading = true; | ||||
|   late AppLinks _appLinks; | ||||
|   StreamSubscription<Uri>? _linkSubscription; | ||||
|   bool isLinkActivity = false; | ||||
|  | ||||
|   List<NavigationPageItem> pages = [ | ||||
|     NavigationPageItem(tr('appsString'), Icons.apps, | ||||
|         AppsPage(key: GlobalKey<AppsPageState>())), | ||||
|     NavigationPageItem(tr('addApp'), Icons.add, const AddAppPage()), | ||||
|     NavigationPageItem( | ||||
|         tr('addApp'), Icons.add, AddAppPage(key: GlobalKey<AddAppPageState>())), | ||||
|     NavigationPageItem( | ||||
|         tr('importExport'), Icons.import_export, const ImportExportPage()), | ||||
|     NavigationPageItem(tr('settings'), Icons.settings, const SettingsPage()) | ||||
|   ]; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     initDeepLinks(); | ||||
|   } | ||||
|  | ||||
|   Future<void> initDeepLinks() async { | ||||
|     _appLinks = AppLinks(); | ||||
|  | ||||
|     goToAddApp(String data) async { | ||||
|       switchToPage(1); | ||||
|       while ( | ||||
|           (pages[1].widget.key as GlobalKey<AddAppPageState>?)?.currentState == | ||||
|               null) { | ||||
|         await Future.delayed(const Duration(microseconds: 1)); | ||||
|       } | ||||
|       (pages[1].widget.key as GlobalKey<AddAppPageState>?) | ||||
|           ?.currentState | ||||
|           ?.linkFn(data); | ||||
|     } | ||||
|  | ||||
|     interpretLink(Uri uri) async { | ||||
|       isLinkActivity = true; | ||||
|       var action = uri.host; | ||||
|       var data = uri.path.length > 1 ? uri.path.substring(1) : ""; | ||||
|       try { | ||||
|         if (action == 'add') { | ||||
|           await goToAddApp(data); | ||||
|         } else if (action == 'app') { | ||||
|           await context | ||||
|               .read<AppsProvider>() | ||||
|               .import('{ "apps": [${Uri.decodeComponent(data)}] }'); | ||||
|         } else if (action == 'apps') { | ||||
|           await context | ||||
|               .read<AppsProvider>() | ||||
|               .import('{ "apps": ${Uri.decodeComponent(data)} }'); | ||||
|         } else { | ||||
|           throw ObtainiumError(tr('unknown')); | ||||
|         } | ||||
|       } catch (e) { | ||||
|         showError(e, context); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // Check initial link if app was in cold state (terminated) | ||||
|     final appLink = await _appLinks.getInitialAppLink(); | ||||
|     if (appLink != null) { | ||||
|       await interpretLink(appLink); | ||||
|     } | ||||
|  | ||||
|     // Handle link when app is in warm state (front or background) | ||||
|     _linkSubscription = _appLinks.uriLinkStream.listen((uri) async { | ||||
|       await interpretLink(uri); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   setIsReversing(int targetIndex) { | ||||
|     bool reversing = selectedIndexHistory.isNotEmpty && | ||||
|         selectedIndexHistory.last > targetIndex; | ||||
|     setState(() { | ||||
|       isReversing = reversing; | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   switchToPage(int index) async { | ||||
|     setIsReversing(index); | ||||
|     if (index == 0) { | ||||
|       while ((pages[0].widget.key as GlobalKey<AppsPageState>).currentState != | ||||
|           null) { | ||||
|         // Avoid duplicate GlobalKey error | ||||
|         await Future.delayed(const Duration(microseconds: 1)); | ||||
|       } | ||||
|       setState(() { | ||||
|         selectedIndexHistory.clear(); | ||||
|       }); | ||||
|     } else if (selectedIndexHistory.isEmpty || | ||||
|         (selectedIndexHistory.isNotEmpty && | ||||
|             selectedIndexHistory.last != index)) { | ||||
|       setState(() { | ||||
|         int existingInd = selectedIndexHistory.indexOf(index); | ||||
|         if (existingInd >= 0) { | ||||
|           selectedIndexHistory.removeAt(existingInd); | ||||
|         } | ||||
|         selectedIndexHistory.add(index); | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     AppsProvider appsProvider = context.watch<AppsProvider>(); | ||||
|     SettingsProvider settingsProvider = context.watch<SettingsProvider>(); | ||||
|  | ||||
|     setIsReversing(int targetIndex) { | ||||
|       bool reversing = selectedIndexHistory.isNotEmpty && | ||||
|           selectedIndexHistory.last > targetIndex; | ||||
|       setState(() { | ||||
|         isReversing = reversing; | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     switchToPage(int index) async { | ||||
|       setIsReversing(index); | ||||
|       if (index == 0) { | ||||
|         while ((pages[0].widget.key as GlobalKey<AppsPageState>).currentState != | ||||
|             null) { | ||||
|           // Avoid duplicate GlobalKey error | ||||
|           await Future.delayed(const Duration(microseconds: 1)); | ||||
|         } | ||||
|         setState(() { | ||||
|           selectedIndexHistory.clear(); | ||||
|         }); | ||||
|       } else if (selectedIndexHistory.isEmpty || | ||||
|           (selectedIndexHistory.isNotEmpty && | ||||
|               selectedIndexHistory.last != index)) { | ||||
|         setState(() { | ||||
|           int existingInd = selectedIndexHistory.indexOf(index); | ||||
|           if (existingInd >= 0) { | ||||
|             selectedIndexHistory.removeAt(existingInd); | ||||
|           } | ||||
|           selectedIndexHistory.add(index); | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     if (!prevIsLoading && | ||||
|         prevAppCount >= 0 && | ||||
|         appsProvider.apps.length > prevAppCount && | ||||
|         selectedIndexHistory.isNotEmpty && | ||||
|         selectedIndexHistory.last == 1) { | ||||
|         selectedIndexHistory.last == 1 && | ||||
|         !isLinkActivity) { | ||||
|       switchToPage(0); | ||||
|     } | ||||
|     prevAppCount = appsProvider.apps.length; | ||||
| @@ -129,6 +194,11 @@ class _HomePageState extends State<HomePage> { | ||||
|           ), | ||||
|         ), | ||||
|         onWillPop: () async { | ||||
|           if (isLinkActivity && | ||||
|               selectedIndexHistory.length == 1 && | ||||
|               selectedIndexHistory.last == 1) { | ||||
|             return true; | ||||
|           } | ||||
|           setIsReversing(selectedIndexHistory.length >= 2 | ||||
|               ? selectedIndexHistory.reversed.toList()[1] | ||||
|               : 0); | ||||
| @@ -143,4 +213,10 @@ class _HomePageState extends State<HomePage> { | ||||
|               ?.clearSelected(); | ||||
|         }); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     super.dispose(); | ||||
|     _linkSubscription?.cancel(); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import 'dart:io'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:obtainium/app_sources/fdroidrepo.dart'; | ||||
| import 'package:obtainium/components/custom_app_bar.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:obtainium/components/generated_form_modal.dart'; | ||||
| @@ -105,7 +106,7 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|     runObtainiumExport({bool pickOnly = false}) async { | ||||
|       HapticFeedback.selectionClick(); | ||||
|       appsProvider | ||||
|           .exportApps( | ||||
|           .export( | ||||
|               pickOnly: | ||||
|                   pickOnly || (await settingsProvider.getExportDir()) == null, | ||||
|               sp: settingsProvider) | ||||
| @@ -131,7 +132,7 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|           } catch (e) { | ||||
|             throw ObtainiumError(tr('invalidInput')); | ||||
|           } | ||||
|           appsProvider.importApps(data).then((value) { | ||||
|           appsProvider.import(data).then((value) { | ||||
|             var cats = settingsProvider.categories; | ||||
|             appsProvider.apps.forEach((key, value) { | ||||
|               for (var c in value.app.categories) { | ||||
| @@ -142,7 +143,10 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|             }); | ||||
|             appsProvider.addMissingCategories(settingsProvider); | ||||
|             showMessage( | ||||
|                 tr('importedX', args: [plural('apps', value)]), context); | ||||
|                 '${tr('importedX', args: [ | ||||
|                       plural('apps', value.key) | ||||
|                     ])}${value.value ? ' + ${tr('settings')}' : ''}', | ||||
|                 context); | ||||
|           }); | ||||
|         } else { | ||||
|           // User canceled the picker | ||||
| @@ -189,17 +193,29 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                 items: [ | ||||
|                   [ | ||||
|                     GeneratedFormTextField('searchQuery', | ||||
|                         label: tr('searchQuery')) | ||||
|                         label: tr('searchQuery'), | ||||
|                         required: source.name != FDroidRepo().name) | ||||
|                   ], | ||||
|                   ...source.searchQuerySettingFormItems.map((e) => [e]), | ||||
|                   [ | ||||
|                     GeneratedFormTextField('url', | ||||
|                         label: source.host != null | ||||
|                             ? tr('overrideSource') | ||||
|                             : plural('url', 1).substring(2), | ||||
|                         defaultValue: source.host ?? '', | ||||
|                         required: true) | ||||
|                   ], | ||||
|                   ...source.searchQuerySettingFormItems.map((e) => [e]) | ||||
|                 ], | ||||
|               ); | ||||
|             }); | ||||
|         if (values != null && | ||||
|             (values['searchQuery'] as String?)?.isNotEmpty == true) { | ||||
|         if (values != null) { | ||||
|           setState(() { | ||||
|             importInProgress = true; | ||||
|           }); | ||||
|           if (values['url'] != source.host) { | ||||
|             source = sourceProvider.getSource(values['url'], | ||||
|                 overrideSource: source.runtimeType.toString()); | ||||
|           } | ||||
|           var urlsWithDescriptions = await source | ||||
|               .search(values['searchQuery'] as String, querySettings: values); | ||||
|           if (urlsWithDescriptions.isNotEmpty) { | ||||
| @@ -208,8 +224,8 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                 await showDialog<List<String>?>( | ||||
|                     context: context, | ||||
|                     builder: (BuildContext ctx) { | ||||
|                       return UrlSelectionModal( | ||||
|                         urlsWithDescriptions: urlsWithDescriptions, | ||||
|                       return SelectionModal( | ||||
|                         entries: urlsWithDescriptions, | ||||
|                         selectedByDefault: false, | ||||
|                       ); | ||||
|                     }); | ||||
| @@ -269,8 +285,7 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|               await showDialog<List<String>?>( | ||||
|                   context: context, | ||||
|                   builder: (BuildContext ctx) { | ||||
|                     return UrlSelectionModal( | ||||
|                         urlsWithDescriptions: urlsWithDescriptions); | ||||
|                     return SelectionModal(entries: urlsWithDescriptions); | ||||
|                   }); | ||||
|           if (selectedUrls != null) { | ||||
|             var errors = await appsProvider.addAppsByURL(selectedUrls); | ||||
| @@ -300,6 +315,11 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     var sourceStrings = <String, List<String>>{}; | ||||
|     sourceProvider.sources.where((e) => e.canSearch).forEach((s) { | ||||
|       sourceStrings[s.name] = [s.name]; | ||||
|     }); | ||||
|  | ||||
|     return Scaffold( | ||||
|         backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|         body: CustomScrollView(slivers: <Widget>[ | ||||
| @@ -327,7 +347,8 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                         : () { | ||||
|                                             runObtainiumExport(pickOnly: true); | ||||
|                                           }, | ||||
|                                     child: Text(tr('pickExportDir')), | ||||
|                                     child: Text(tr('pickExportDir'), | ||||
|                                         textAlign: TextAlign.center), | ||||
|                                   )), | ||||
|                                   const SizedBox( | ||||
|                                     width: 16, | ||||
| @@ -340,7 +361,8 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                             snapshot.data == null | ||||
|                                         ? null | ||||
|                                         : runObtainiumExport, | ||||
|                                     child: Text(tr('obtainiumExport')), | ||||
|                                     child: Text(tr('obtainiumExport'), | ||||
|                                         textAlign: TextAlign.center), | ||||
|                                   )), | ||||
|                                 ], | ||||
|                               ), | ||||
| @@ -355,7 +377,8 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                           onPressed: importInProgress | ||||
|                                               ? null | ||||
|                                               : runObtainiumImport, | ||||
|                                           child: Text(tr('obtainiumImport')))), | ||||
|                                           child: Text(tr('obtainiumImport'), | ||||
|                                               textAlign: TextAlign.center))), | ||||
|                                 ], | ||||
|                               ), | ||||
|                               if (snapshot.data != null) | ||||
| @@ -371,6 +394,14 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                               defaultValue: settingsProvider | ||||
|                                                   .autoExportOnChanges, | ||||
|                                             ) | ||||
|                                           ], | ||||
|                                           [ | ||||
|                                             GeneratedFormSwitch( | ||||
|                                               'exportSettings', | ||||
|                                               label: tr('includeSettings'), | ||||
|                                               defaultValue: settingsProvider | ||||
|                                                   .exportSettings, | ||||
|                                             ) | ||||
|                                           ] | ||||
|                                         ], | ||||
|                                         onValueChanges: | ||||
| @@ -383,6 +414,12 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                                       'autoExportOnChanges'] == | ||||
|                                                   true; | ||||
|                                             } | ||||
|                                             if (value['exportSettings'] != | ||||
|                                                 null) { | ||||
|                                               settingsProvider.exportSettings = | ||||
|                                                   value['exportSettings'] == | ||||
|                                                       true; | ||||
|                                             } | ||||
|                                           } | ||||
|                                         }), | ||||
|                                   ], | ||||
| @@ -409,6 +446,54 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                             const Divider( | ||||
|                               height: 32, | ||||
|                             ), | ||||
|                             Row( | ||||
|                               children: [ | ||||
|                                 Expanded( | ||||
|                                     child: TextButton( | ||||
|                                         onPressed: importInProgress | ||||
|                                             ? null | ||||
|                                             : () async { | ||||
|                                                 var searchSourceName = | ||||
|                                                     await showDialog< | ||||
|                                                                 List<String>?>( | ||||
|                                                             context: context, | ||||
|                                                             builder: | ||||
|                                                                 (BuildContext | ||||
|                                                                     ctx) { | ||||
|                                                               return SelectionModal( | ||||
|                                                                 title: tr( | ||||
|                                                                     'selectX', | ||||
|                                                                     args: [ | ||||
|                                                                       tr('source') | ||||
|                                                                     ]), | ||||
|                                                                 entries: | ||||
|                                                                     sourceStrings, | ||||
|                                                                 selectedByDefault: | ||||
|                                                                     false, | ||||
|                                                                 onlyOneSelectionAllowed: | ||||
|                                                                     true, | ||||
|                                                                 titlesAreLinks: | ||||
|                                                                     false, | ||||
|                                                               ); | ||||
|                                                             }) ?? | ||||
|                                                         []; | ||||
|                                                 var searchSource = | ||||
|                                                     sourceProvider.sources | ||||
|                                                         .where((e) => | ||||
|                                                             searchSourceName | ||||
|                                                                 .contains( | ||||
|                                                                     e.name)) | ||||
|                                                         .toList(); | ||||
|                                                 if (searchSource.isNotEmpty) { | ||||
|                                                   runSourceSearch( | ||||
|                                                       searchSource[0]); | ||||
|                                                 } | ||||
|                                               }, | ||||
|                                         child: Text(tr('searchX', | ||||
|                                             args: [tr('source')])))), | ||||
|                               ], | ||||
|                             ), | ||||
|                             const SizedBox(height: 8), | ||||
|                             TextButton( | ||||
|                                 onPressed: | ||||
|                                     importInProgress ? null : urlListImport, | ||||
| @@ -424,39 +509,19 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                 )), | ||||
|                           ], | ||||
|                         ), | ||||
|                       ...sourceProvider.sources | ||||
|                           .where((element) => element.canSearch) | ||||
|                           .map((source) => Column( | ||||
|                                   crossAxisAlignment: | ||||
|                                       CrossAxisAlignment.stretch, | ||||
|                                   children: [ | ||||
|                                     const SizedBox(height: 8), | ||||
|                                     TextButton( | ||||
|                                         onPressed: importInProgress | ||||
|                                             ? null | ||||
|                                             : () { | ||||
|                                                 runSourceSearch(source); | ||||
|                                               }, | ||||
|                                         child: Text( | ||||
|                                             tr('searchX', args: [source.name]))) | ||||
|                                   ])) | ||||
|                           .toList(), | ||||
|                       ...sourceProvider.massUrlSources | ||||
|                           .map((source) => Column( | ||||
|                                   crossAxisAlignment: | ||||
|                                       CrossAxisAlignment.stretch, | ||||
|                                   children: [ | ||||
|                                     const SizedBox(height: 8), | ||||
|                                     TextButton( | ||||
|                                         onPressed: importInProgress | ||||
|                                             ? null | ||||
|                                             : () { | ||||
|                                                 runMassSourceImport(source); | ||||
|                                               }, | ||||
|                                         child: Text( | ||||
|                                             tr('importX', args: [source.name]))) | ||||
|                                   ])) | ||||
|                           .toList(), | ||||
|                       ...sourceProvider.massUrlSources.map((source) => Column( | ||||
|                               crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                               children: [ | ||||
|                                 const SizedBox(height: 8), | ||||
|                                 TextButton( | ||||
|                                     onPressed: importInProgress | ||||
|                                         ? null | ||||
|                                         : () { | ||||
|                                             runMassSourceImport(source); | ||||
|                                           }, | ||||
|                                     child: Text( | ||||
|                                         tr('importX', args: [source.name]))) | ||||
|                               ])), | ||||
|                       const Spacer(), | ||||
|                       const Divider( | ||||
|                         height: 32, | ||||
| @@ -518,7 +583,7 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> { | ||||
|                   style: const TextStyle(fontStyle: FontStyle.italic), | ||||
|                 ) | ||||
|               ]); | ||||
|         }).toList() | ||||
|         }) | ||||
|       ]), | ||||
|       actions: [ | ||||
|         TextButton( | ||||
| @@ -532,112 +597,171 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> { | ||||
| } | ||||
|  | ||||
| // ignore: must_be_immutable | ||||
| class UrlSelectionModal extends StatefulWidget { | ||||
|   UrlSelectionModal( | ||||
| class SelectionModal extends StatefulWidget { | ||||
|   SelectionModal( | ||||
|       {super.key, | ||||
|       required this.urlsWithDescriptions, | ||||
|       required this.entries, | ||||
|       this.selectedByDefault = true, | ||||
|       this.onlyOneSelectionAllowed = false}); | ||||
|       this.onlyOneSelectionAllowed = false, | ||||
|       this.titlesAreLinks = true, | ||||
|       this.title}); | ||||
|  | ||||
|   Map<String, List<String>> urlsWithDescriptions; | ||||
|   String? title; | ||||
|   Map<String, List<String>> entries; | ||||
|   bool selectedByDefault; | ||||
|   bool onlyOneSelectionAllowed; | ||||
|   bool titlesAreLinks; | ||||
|  | ||||
|   @override | ||||
|   State<UrlSelectionModal> createState() => _UrlSelectionModalState(); | ||||
|   State<SelectionModal> createState() => _SelectionModalState(); | ||||
| } | ||||
|  | ||||
| class _UrlSelectionModalState extends State<UrlSelectionModal> { | ||||
|   Map<MapEntry<String, List<String>>, bool> urlWithDescriptionSelections = {}; | ||||
| class _SelectionModalState extends State<SelectionModal> { | ||||
|   Map<MapEntry<String, List<String>>, bool> entrySelections = {}; | ||||
|   String filterRegex = ''; | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     for (var url in widget.urlsWithDescriptions.entries) { | ||||
|       urlWithDescriptionSelections.putIfAbsent(url, | ||||
|     for (var url in widget.entries.entries) { | ||||
|       entrySelections.putIfAbsent(url, | ||||
|           () => widget.selectedByDefault && !widget.onlyOneSelectionAllowed); | ||||
|     } | ||||
|     if (widget.selectedByDefault && widget.onlyOneSelectionAllowed) { | ||||
|       selectOnlyOne(widget.urlsWithDescriptions.entries.first.key); | ||||
|       selectOnlyOne(widget.entries.entries.first.key); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   selectOnlyOne(String url) { | ||||
|     for (var uwd in urlWithDescriptionSelections.keys) { | ||||
|       urlWithDescriptionSelections[uwd] = uwd.key == url; | ||||
|     for (var e in entrySelections.keys) { | ||||
|       entrySelections[e] = e.key == url; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     Map<MapEntry<String, List<String>>, bool> filteredEntrySelections = {}; | ||||
|     entrySelections.forEach((key, value) { | ||||
|       var searchableText = key.value.isEmpty ? key.key : key.value[0]; | ||||
|       if (filterRegex.isEmpty || RegExp(filterRegex).hasMatch(searchableText)) { | ||||
|         filteredEntrySelections.putIfAbsent(key, () => value); | ||||
|       } | ||||
|     }); | ||||
|     if (filterRegex.isNotEmpty && filteredEntrySelections.isEmpty) { | ||||
|       entrySelections.forEach((key, value) { | ||||
|         var searchableText = key.value.isEmpty ? key.key : key.value[0]; | ||||
|         if (filterRegex.isEmpty || | ||||
|             RegExp(filterRegex, caseSensitive: false) | ||||
|                 .hasMatch(searchableText)) { | ||||
|           filteredEntrySelections.putIfAbsent(key, () => value); | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|     return AlertDialog( | ||||
|       scrollable: true, | ||||
|       title: Text( | ||||
|           widget.onlyOneSelectionAllowed ? tr('selectURL') : tr('selectURLs')), | ||||
|       title: Text(widget.title ?? tr('pick')), | ||||
|       content: Column(children: [ | ||||
|         ...urlWithDescriptionSelections.keys.map((urlWithD) { | ||||
|         GeneratedForm( | ||||
|             items: [ | ||||
|               [ | ||||
|                 GeneratedFormTextField('filter', | ||||
|                     label: tr('filter'), | ||||
|                     required: false, | ||||
|                     additionalValidators: [ | ||||
|                       (value) { | ||||
|                         return regExValidator(value); | ||||
|                       } | ||||
|                     ]) | ||||
|               ] | ||||
|             ], | ||||
|             onValueChanges: (value, valid, isBuilding) { | ||||
|               if (valid && !isBuilding) { | ||||
|                 if (value['filter'] != null) { | ||||
|                   setState(() { | ||||
|                     filterRegex = value['filter']; | ||||
|                   }); | ||||
|                 } | ||||
|               } | ||||
|             }), | ||||
|         ...filteredEntrySelections.keys.map((entry) { | ||||
|           selectThis(bool? value) { | ||||
|             setState(() { | ||||
|               value ??= false; | ||||
|               if (value! && widget.onlyOneSelectionAllowed) { | ||||
|                 selectOnlyOne(urlWithD.key); | ||||
|                 selectOnlyOne(entry.key); | ||||
|               } else { | ||||
|                 urlWithDescriptionSelections[urlWithD] = value!; | ||||
|                 entrySelections[entry] = value!; | ||||
|               } | ||||
|             }); | ||||
|           } | ||||
|  | ||||
|           var urlLink = GestureDetector( | ||||
|               onTap: () { | ||||
|                 launchUrlString(urlWithD.key, | ||||
|                     mode: LaunchMode.externalApplication); | ||||
|               }, | ||||
|               onTap: !widget.titlesAreLinks | ||||
|                   ? null | ||||
|                   : () { | ||||
|                       launchUrlString(entry.key, | ||||
|                           mode: LaunchMode.externalApplication); | ||||
|                     }, | ||||
|               child: Column( | ||||
|                 crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                 children: [ | ||||
|                   Text( | ||||
|                     urlWithD.value[0], | ||||
|                     style: const TextStyle( | ||||
|                         decoration: TextDecoration.underline, | ||||
|                     entry.value.isEmpty ? entry.key : entry.value[0], | ||||
|                     style: TextStyle( | ||||
|                         decoration: widget.titlesAreLinks | ||||
|                             ? TextDecoration.underline | ||||
|                             : null, | ||||
|                         fontWeight: FontWeight.bold), | ||||
|                     textAlign: TextAlign.start, | ||||
|                   ), | ||||
|                   Text( | ||||
|                     Uri.parse(urlWithD.key).host, | ||||
|                     style: const TextStyle( | ||||
|                         decoration: TextDecoration.underline, fontSize: 12), | ||||
|                   ) | ||||
|                   if (widget.titlesAreLinks) | ||||
|                     Text( | ||||
|                       Uri.parse(entry.key).host, | ||||
|                       style: const TextStyle( | ||||
|                           decoration: TextDecoration.underline, fontSize: 12), | ||||
|                     ) | ||||
|                 ], | ||||
|               )); | ||||
|  | ||||
|           var descriptionText = Text( | ||||
|             urlWithD.value[1].length > 128 | ||||
|                 ? '${urlWithD.value[1].substring(0, 128)}...' | ||||
|                 : urlWithD.value[1], | ||||
|             style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12), | ||||
|           ); | ||||
|           var descriptionText = entry.value.length <= 1 | ||||
|               ? const SizedBox.shrink() | ||||
|               : Text( | ||||
|                   entry.value[1].length > 128 | ||||
|                       ? '${entry.value[1].substring(0, 128)}...' | ||||
|                       : entry.value[1], | ||||
|                   style: const TextStyle( | ||||
|                       fontStyle: FontStyle.italic, fontSize: 12), | ||||
|                 ); | ||||
|  | ||||
|           var selectedUrlsWithDs = urlWithDescriptionSelections.entries | ||||
|               .where((e) => e.value) | ||||
|               .toList(); | ||||
|           var selectedEntries = | ||||
|               entrySelections.entries.where((e) => e.value).toList(); | ||||
|  | ||||
|           var singleSelectTile = ListTile( | ||||
|             title: urlLink, | ||||
|             subtitle: GestureDetector( | ||||
|               onTap: () { | ||||
|                 setState(() { | ||||
|                   selectOnlyOne(urlWithD.key); | ||||
|                 }); | ||||
|               }, | ||||
|               child: descriptionText, | ||||
|             ), | ||||
|             leading: Radio<String>( | ||||
|               value: urlWithD.key, | ||||
|               groupValue: selectedUrlsWithDs.isEmpty | ||||
|             title: GestureDetector( | ||||
|               onTap: widget.titlesAreLinks | ||||
|                   ? null | ||||
|                   : selectedUrlsWithDs.first.key.key, | ||||
|                   : () { | ||||
|                       selectThis(!(entrySelections[entry] ?? false)); | ||||
|                     }, | ||||
|               child: urlLink, | ||||
|             ), | ||||
|             subtitle: entry.value.length <= 1 | ||||
|                 ? null | ||||
|                 : GestureDetector( | ||||
|                     onTap: () { | ||||
|                       setState(() { | ||||
|                         selectOnlyOne(entry.key); | ||||
|                       }); | ||||
|                     }, | ||||
|                     child: descriptionText, | ||||
|                   ), | ||||
|             leading: Radio<String>( | ||||
|               value: entry.key, | ||||
|               groupValue: selectedEntries.isEmpty | ||||
|                   ? null | ||||
|                   : selectedEntries.first.key.key, | ||||
|               onChanged: (value) { | ||||
|                 setState(() { | ||||
|                   selectOnlyOne(urlWithD.key); | ||||
|                   selectOnlyOne(entry.key); | ||||
|                 }); | ||||
|               }, | ||||
|             ), | ||||
| @@ -645,7 +769,7 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> { | ||||
|  | ||||
|           var multiSelectTile = Row(children: [ | ||||
|             Checkbox( | ||||
|                 value: urlWithDescriptionSelections[urlWithD], | ||||
|                 value: entrySelections[entry], | ||||
|                 onChanged: (value) { | ||||
|                   selectThis(value); | ||||
|                 }), | ||||
| @@ -660,14 +784,22 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> { | ||||
|                 const SizedBox( | ||||
|                   height: 8, | ||||
|                 ), | ||||
|                 urlLink, | ||||
|                 GestureDetector( | ||||
|                   onTap: () { | ||||
|                     selectThis( | ||||
|                         !(urlWithDescriptionSelections[urlWithD] ?? false)); | ||||
|                   }, | ||||
|                   child: descriptionText, | ||||
|                   onTap: widget.titlesAreLinks | ||||
|                       ? null | ||||
|                       : () { | ||||
|                           selectThis(!(entrySelections[entry] ?? false)); | ||||
|                         }, | ||||
|                   child: urlLink, | ||||
|                 ), | ||||
|                 entry.value.length <= 1 | ||||
|                     ? const SizedBox.shrink() | ||||
|                     : GestureDetector( | ||||
|                         onTap: () { | ||||
|                           selectThis(!(entrySelections[entry] ?? false)); | ||||
|                         }, | ||||
|                         child: descriptionText, | ||||
|                       ), | ||||
|                 const SizedBox( | ||||
|                   height: 8, | ||||
|                 ) | ||||
| @@ -687,24 +819,18 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> { | ||||
|             }, | ||||
|             child: Text(tr('cancel'))), | ||||
|         TextButton( | ||||
|             onPressed: | ||||
|                 urlWithDescriptionSelections.values.where((b) => b).isEmpty | ||||
|                     ? null | ||||
|                     : () { | ||||
|                         Navigator.of(context).pop(urlWithDescriptionSelections | ||||
|                             .entries | ||||
|                             .where((entry) => entry.value) | ||||
|                             .map((e) => e.key.key) | ||||
|                             .toList()); | ||||
|                       }, | ||||
|             onPressed: entrySelections.values.where((b) => b).isEmpty | ||||
|                 ? null | ||||
|                 : () { | ||||
|                     Navigator.of(context).pop(entrySelections.entries | ||||
|                         .where((entry) => entry.value) | ||||
|                         .map((e) => e.key.key) | ||||
|                         .toList()); | ||||
|                   }, | ||||
|             child: Text(widget.onlyOneSelectionAllowed | ||||
|                 ? tr('pick') | ||||
|                 : tr('importX', args: [ | ||||
|                     plural( | ||||
|                         'url', | ||||
|                         urlWithDescriptionSelections.values | ||||
|                             .where((b) => b) | ||||
|                             .length) | ||||
|                 : tr('selectX', args: [ | ||||
|                     entrySelections.values.where((b) => b).length.toString() | ||||
|                   ]))) | ||||
|       ], | ||||
|     ); | ||||
|   | ||||
| @@ -1,4 +1,3 @@ | ||||
| import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart'; | ||||
| import 'package:device_info_plus/device_info_plus.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| @@ -30,6 +29,29 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|       settingsProvider.initializeSettings(); | ||||
|     } | ||||
|  | ||||
|     var installMethodDropdown = DropdownButtonFormField( | ||||
|         decoration: InputDecoration(labelText: tr('installMethod')), | ||||
|         value: settingsProvider.installMethod, | ||||
|         items: [ | ||||
|           DropdownMenuItem( | ||||
|             value: InstallMethodSettings.normal, | ||||
|             child: Text(tr('normal')), | ||||
|           ), | ||||
|           DropdownMenuItem( | ||||
|             value: InstallMethodSettings.shizuku, | ||||
|             child: Text(tr('shizuku')), | ||||
|           ), | ||||
|           DropdownMenuItem( | ||||
|             value: InstallMethodSettings.root, | ||||
|             child: Text(tr('root')), | ||||
|           ) | ||||
|         ], | ||||
|         onChanged: (value) { | ||||
|           if (value != null) { | ||||
|             settingsProvider.installMethod = value; | ||||
|           } | ||||
|         }); | ||||
|  | ||||
|     var themeDropdown = DropdownButtonFormField( | ||||
|         decoration: InputDecoration(labelText: tr('theme')), | ||||
|         value: settingsProvider.theme, | ||||
| @@ -327,6 +349,21 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|                                     }) | ||||
|                               ], | ||||
|                             ), | ||||
|                             height16, | ||||
|                             installMethodDropdown, | ||||
|                             height16, | ||||
|                             Row( | ||||
|                               mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                               children: [ | ||||
|                                 Flexible(child: Text(tr('parallelDownloads'))), | ||||
|                                 Switch( | ||||
|                                     value: settingsProvider.parallelDownloads, | ||||
|                                     onChanged: (value) { | ||||
|                                       settingsProvider.parallelDownloads = | ||||
|                                           value; | ||||
|                                     }) | ||||
|                               ], | ||||
|                             ), | ||||
|                             height32, | ||||
|                             Text( | ||||
|                               tr('sourceSpecific'), | ||||
| @@ -570,38 +607,35 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|                 const Divider( | ||||
|                   height: 32, | ||||
|                 ), | ||||
|                 Padding( | ||||
|                   padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), | ||||
|                   child: Column(children: [ | ||||
|                     Row( | ||||
|                       mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                       children: [ | ||||
|                         Flexible(child: Text(tr('debugMenu'))), | ||||
|                         Switch( | ||||
|                             value: settingsProvider.showDebugOpts, | ||||
|                             onChanged: (value) { | ||||
|                               settingsProvider.showDebugOpts = value; | ||||
|                             }) | ||||
|                       ], | ||||
|                     ), | ||||
|                     if (settingsProvider.showDebugOpts) | ||||
|                       Column( | ||||
|                         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                         children: [ | ||||
|                           height16, | ||||
|                           TextButton( | ||||
|                               onPressed: () { | ||||
|                                 AndroidAlarmManager.oneShot( | ||||
|                                     const Duration(seconds: 0), | ||||
|                                     bgUpdateCheckAlarmId + 200, | ||||
|                                     bgUpdateCheck); | ||||
|                                 showMessage(tr('bgTaskStarted'), context); | ||||
|                               }, | ||||
|                               child: Text(tr('runBgCheckNow'))) | ||||
|                         ], | ||||
|                       ), | ||||
|                   ]), | ||||
|                 ), | ||||
|                 // Padding( | ||||
|                 //   padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), | ||||
|                 //   child: Column(children: [ | ||||
|                 //     Row( | ||||
|                 //       mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                 //       children: [ | ||||
|                 //         Flexible(child: Text(tr('debugMenu'))), | ||||
|                 //         Switch( | ||||
|                 //             value: settingsProvider.showDebugOpts, | ||||
|                 //             onChanged: (value) { | ||||
|                 //               settingsProvider.showDebugOpts = value; | ||||
|                 //             }) | ||||
|                 //       ], | ||||
|                 //     ), | ||||
|                 //     if (settingsProvider.showDebugOpts) | ||||
|                 //       Column( | ||||
|                 //         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                 //         children: [ | ||||
|                 //           height16, | ||||
|                 //           TextButton( | ||||
|                 //               onPressed: () { | ||||
|                 //                 bgUpdateCheck('taskId', null); | ||||
|                 //                 showMessage(tr('bgTaskStarted'), context); | ||||
|                 //               }, | ||||
|                 //               child: Text(tr('runBgCheckNow'))) | ||||
|                 //         ], | ||||
|                 //       ), | ||||
|                 //   ]), | ||||
|                 // ), | ||||
|               ], | ||||
|             ), | ||||
|           ) | ||||
|   | ||||
| @@ -6,8 +6,8 @@ import 'dart:convert'; | ||||
| import 'dart:io'; | ||||
| import 'dart:math'; | ||||
| import 'package:http/http.dart' as http; | ||||
| import 'package:crypto/crypto.dart'; | ||||
|  | ||||
| import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart'; | ||||
| import 'package:android_intent_plus/flag.dart'; | ||||
| import 'package:android_package_installer/android_package_installer.dart'; | ||||
| import 'package:android_package_manager/android_package_manager.dart'; | ||||
| @@ -32,6 +32,7 @@ import 'package:http/http.dart'; | ||||
| import 'package:android_intent_plus/android_intent.dart'; | ||||
| import 'package:flutter_archive/flutter_archive.dart'; | ||||
| import 'package:shared_storage/shared_storage.dart' as saf; | ||||
| import 'installers_provider.dart'; | ||||
|  | ||||
| final pm = AndroidPackageManager(); | ||||
|  | ||||
| @@ -139,6 +140,100 @@ List<MapEntry<String, int>> moveStrToEndMapEntryWithCount( | ||||
|   return arr; | ||||
| } | ||||
|  | ||||
| Future<File> downloadFileWithRetry( | ||||
|     String url, String fileNameNoExt, Function? onProgress, String destDir, | ||||
|     {bool useExisting = true, | ||||
|     Map<String, String>? headers, | ||||
|     int retries = 3}) async { | ||||
|   try { | ||||
|     return await downloadFile(url, fileNameNoExt, onProgress, destDir, | ||||
|         useExisting: useExisting, headers: headers); | ||||
|   } catch (e) { | ||||
|     if (retries > 0 && e is ClientException) { | ||||
|       await Future.delayed(const Duration(seconds: 5)); | ||||
|       return await downloadFileWithRetry( | ||||
|           url, fileNameNoExt, onProgress, destDir, | ||||
|           useExisting: useExisting, headers: headers, retries: (retries - 1)); | ||||
|     } else { | ||||
|       rethrow; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| String hashListOfLists(List<List<int>> data) { | ||||
|   var bytes = utf8.encode(jsonEncode(data)); | ||||
|   var digest = sha256.convert(bytes); | ||||
|   var hash = digest.toString(); | ||||
|   return hash.hashCode.toString(); | ||||
| } | ||||
|  | ||||
| Future<String> checkDownloadHash(String url, | ||||
|     {int bytesToGrab = 1024, Map<String, String>? headers}) async { | ||||
|   var req = Request('GET', Uri.parse(url)); | ||||
|   if (headers != null) { | ||||
|     req.headers.addAll(headers); | ||||
|   } | ||||
|   req.headers[HttpHeaders.rangeHeader] = 'bytes=0-$bytesToGrab'; | ||||
|   var client = http.Client(); | ||||
|   var response = await client.send(req); | ||||
|   if (response.statusCode < 200 || response.statusCode > 299) { | ||||
|     throw ObtainiumError(response.reasonPhrase ?? tr('unexpectedError')); | ||||
|   } | ||||
|   List<List<int>> bytes = await response.stream.take(bytesToGrab).toList(); | ||||
|   return hashListOfLists(bytes); | ||||
| } | ||||
|  | ||||
| Future<File> downloadFile( | ||||
|     String url, String fileNameNoExt, Function? onProgress, String destDir, | ||||
|     {bool useExisting = true, Map<String, String>? headers}) async { | ||||
|   var req = Request('GET', Uri.parse(url)); | ||||
|   if (headers != null) { | ||||
|     req.headers.addAll(headers); | ||||
|   } | ||||
|   var client = http.Client(); | ||||
|   StreamedResponse response = await client.send(req); | ||||
|   String ext = | ||||
|       response.headers['content-disposition']?.split('.').last ?? 'apk'; | ||||
|   if (ext.endsWith('"') || ext.endsWith("other")) { | ||||
|     ext = ext.substring(0, ext.length - 1); | ||||
|   } | ||||
|   if (url.toLowerCase().endsWith('.apk') && ext != 'apk') { | ||||
|     ext = 'apk'; | ||||
|   } | ||||
|   File downloadedFile = File('$destDir/$fileNameNoExt.$ext'); | ||||
|   if (!(downloadedFile.existsSync() && useExisting)) { | ||||
|     File tempDownloadedFile = File('${downloadedFile.path}.part'); | ||||
|     if (tempDownloadedFile.existsSync()) { | ||||
|       tempDownloadedFile.deleteSync(recursive: true); | ||||
|     } | ||||
|     var length = response.contentLength; | ||||
|     var received = 0; | ||||
|     double? progress; | ||||
|     var sink = tempDownloadedFile.openWrite(); | ||||
|     await response.stream.map((s) { | ||||
|       received += s.length; | ||||
|       progress = (length != null ? received / length * 100 : 30); | ||||
|       if (onProgress != null) { | ||||
|         onProgress(progress); | ||||
|       } | ||||
|       return s; | ||||
|     }).pipe(sink); | ||||
|     await sink.close(); | ||||
|     progress = null; | ||||
|     if (onProgress != null) { | ||||
|       onProgress(progress); | ||||
|     } | ||||
|     if (response.statusCode != 200) { | ||||
|       tempDownloadedFile.deleteSync(recursive: true); | ||||
|       throw response.reasonPhrase ?? tr('unexpectedError'); | ||||
|     } | ||||
|     tempDownloadedFile.renameSync(downloadedFile.path); | ||||
|   } else { | ||||
|     client.close(); | ||||
|   } | ||||
|   return downloadedFile; | ||||
| } | ||||
|  | ||||
| class AppsProvider with ChangeNotifier { | ||||
|   // In memory App state (should always be kept in sync with local storage versions) | ||||
|   Map<String, AppInMemory> apps = {}; | ||||
| @@ -192,77 +287,6 @@ class AppsProvider with ChangeNotifier { | ||||
|     }(); | ||||
|   } | ||||
|  | ||||
|   Future<File> downloadFileWithRetry( | ||||
|       String url, String fileNameNoExt, Function? onProgress, | ||||
|       {bool useExisting = true, | ||||
|       Map<String, String>? headers, | ||||
|       int retries = 3}) async { | ||||
|     try { | ||||
|       return await downloadFile(url, fileNameNoExt, onProgress, | ||||
|           useExisting: useExisting, headers: headers); | ||||
|     } catch (e) { | ||||
|       if (retries > 0 && e is ClientException) { | ||||
|         await Future.delayed(const Duration(seconds: 5)); | ||||
|         return await downloadFileWithRetry(url, fileNameNoExt, onProgress, | ||||
|             useExisting: useExisting, headers: headers, retries: (retries - 1)); | ||||
|       } else { | ||||
|         rethrow; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<File> downloadFile( | ||||
|       String url, String fileNameNoExt, Function? onProgress, | ||||
|       {bool useExisting = true, Map<String, String>? headers}) async { | ||||
|     var destDir = APKDir.path; | ||||
|     var req = Request('GET', Uri.parse(url)); | ||||
|     if (headers != null) { | ||||
|       req.headers.addAll(headers); | ||||
|     } | ||||
|     var client = http.Client(); | ||||
|     StreamedResponse response = await client.send(req); | ||||
|     String ext = | ||||
|         response.headers['content-disposition']?.split('.').last ?? 'apk'; | ||||
|     if (ext.endsWith('"') || ext.endsWith("other")) { | ||||
|       ext = ext.substring(0, ext.length - 1); | ||||
|     } | ||||
|     if (url.toLowerCase().endsWith('.apk') && ext != 'apk') { | ||||
|       ext = 'apk'; | ||||
|     } | ||||
|     File downloadedFile = File('$destDir/$fileNameNoExt.$ext'); | ||||
|     if (!(downloadedFile.existsSync() && useExisting)) { | ||||
|       File tempDownloadedFile = File('${downloadedFile.path}.part'); | ||||
|       if (tempDownloadedFile.existsSync()) { | ||||
|         tempDownloadedFile.deleteSync(recursive: true); | ||||
|       } | ||||
|       var length = response.contentLength; | ||||
|       var received = 0; | ||||
|       double? progress; | ||||
|       var sink = tempDownloadedFile.openWrite(); | ||||
|       await response.stream.map((s) { | ||||
|         received += s.length; | ||||
|         progress = (length != null ? received / length * 100 : 30); | ||||
|         if (onProgress != null) { | ||||
|           onProgress(progress); | ||||
|         } | ||||
|         return s; | ||||
|       }).pipe(sink); | ||||
|       await sink.close(); | ||||
|       progress = null; | ||||
|       if (onProgress != null) { | ||||
|         onProgress(progress); | ||||
|       } | ||||
|       if (response.statusCode != 200) { | ||||
|         tempDownloadedFile.deleteSync(recursive: true); | ||||
|         throw response.reasonPhrase ?? tr('unexpectedError'); | ||||
|       } | ||||
|       tempDownloadedFile.renameSync(downloadedFile.path); | ||||
|     } else { | ||||
|       client.close(); | ||||
|     } | ||||
|     return downloadedFile; | ||||
|   } | ||||
|  | ||||
|   Future<File> handleAPKIDChange(App app, PackageInfo? newInfo, | ||||
|       File downloadedFile, String downloadUrl) async { | ||||
|     // If the APK package ID is different from the App ID, it is either new (using a placeholder ID) or the ID has changed | ||||
| @@ -322,7 +346,7 @@ class AppsProvider with ChangeNotifier { | ||||
|           notificationsProvider?.notify(notif); | ||||
|         } | ||||
|         prevProg = prog; | ||||
|       }); | ||||
|       }, APKDir.path); | ||||
|       // Set to 90 for remaining steps, will make null in 'finally' | ||||
|       if (apps[app.id] != null) { | ||||
|         apps[app.id]!.downloadProgress = -1; | ||||
| @@ -480,7 +504,8 @@ class AppsProvider with ChangeNotifier { | ||||
|         !(await canDowngradeApps())) { | ||||
|       throw DowngradeError(); | ||||
|     } | ||||
|     if (needsBGWorkaround) { | ||||
|     if (needsBGWorkaround && | ||||
|         settingsProvider.installMethod == InstallMethodSettings.normal) { | ||||
|       // The below 'await' will never return if we are in a background process | ||||
|       // To work around this, we should assume the install will be successful | ||||
|       // So we update the app's installed version first as we will never get to the later code | ||||
| @@ -491,8 +516,21 @@ class AppsProvider with ChangeNotifier { | ||||
|       await saveApps([apps[file.appId]!.app], | ||||
|           attemptToCorrectInstallStatus: false); | ||||
|     } | ||||
|     int? code = | ||||
|         await AndroidPackageInstaller.installApk(apkFilePath: file.file.path); | ||||
|     int? code; | ||||
|     switch (settingsProvider.installMethod) { | ||||
|       case InstallMethodSettings.normal: | ||||
|         code = await AndroidPackageInstaller.installApk( | ||||
|             apkFilePath: file.file.path); | ||||
|       case InstallMethodSettings.shizuku: | ||||
|         code = (await Installers.installWithShizuku( | ||||
|                 apkFileUri: file.file.uri.toString())) | ||||
|             ? 0 | ||||
|             : 1; | ||||
|       case InstallMethodSettings.root: | ||||
|         code = (await Installers.installWithRoot(apkFilePath: file.file.path)) | ||||
|             ? 0 | ||||
|             : 1; | ||||
|     } | ||||
|     bool installed = false; | ||||
|     if (code != null && code != 0 && code != 3) { | ||||
|       throw InstallError(code); | ||||
| @@ -582,7 +620,8 @@ class AppsProvider with ChangeNotifier { | ||||
|   // Returns an array of Ids for Apps that were successfully downloaded, regardless of installation result | ||||
|   Future<List<String>> downloadAndInstallLatestApps( | ||||
|       List<String> appIds, BuildContext? context, | ||||
|       {NotificationsProvider? notificationsProvider}) async { | ||||
|       {NotificationsProvider? notificationsProvider, | ||||
|       bool forceParallelDownloads = false}) async { | ||||
|     notificationsProvider = | ||||
|         notificationsProvider ?? context?.read<NotificationsProvider>(); | ||||
|     List<String> appsToInstall = []; | ||||
| @@ -633,7 +672,7 @@ class AppsProvider with ChangeNotifier { | ||||
|     appsToInstall = | ||||
|         moveStrToEnd(appsToInstall, obtainiumId, strB: obtainiumTempId); | ||||
|  | ||||
|     for (var id in appsToInstall) { | ||||
|     Future<void> updateFn(String id, {bool skipInstalls = false}) async { | ||||
|       try { | ||||
|         var downloadedArtifact = | ||||
|             // ignore: use_build_context_synchronously | ||||
| @@ -648,8 +687,23 @@ class AppsProvider with ChangeNotifier { | ||||
|         } | ||||
|         var appId = downloadedFile?.appId ?? downloadedDir!.appId; | ||||
|         bool willBeSilent = await canInstallSilently(apps[appId]!.app); | ||||
|         if (!(await settingsProvider.getInstallPermission(enforce: false))) { | ||||
|           throw ObtainiumError(tr('cancelled')); | ||||
|         switch (settingsProvider.installMethod) { | ||||
|           case InstallMethodSettings.normal: | ||||
|             if (!(await settingsProvider.getInstallPermission( | ||||
|                 enforce: false))) { | ||||
|               throw ObtainiumError(tr('cancelled')); | ||||
|             } | ||||
|           case InstallMethodSettings.shizuku: | ||||
|             int code = await Installers.checkPermissionShizuku(); | ||||
|             if (code == -1) { | ||||
|               throw ObtainiumError(tr('shizukuBinderNotFound')); | ||||
|             } else if (code == 0) { | ||||
|               throw ObtainiumError(tr('cancelled')); | ||||
|             } | ||||
|           case InstallMethodSettings.root: | ||||
|             if (!(await Installers.checkPermissionRoot())) { | ||||
|               throw ObtainiumError(tr('cancelled')); | ||||
|             } | ||||
|         } | ||||
|         if (!willBeSilent && context != null) { | ||||
|           // ignore: use_build_context_synchronously | ||||
| @@ -658,24 +712,26 @@ class AppsProvider with ChangeNotifier { | ||||
|         apps[id]?.downloadProgress = -1; | ||||
|         notifyListeners(); | ||||
|         try { | ||||
|           if (downloadedFile != null) { | ||||
|             if (willBeSilent && context == null) { | ||||
|               installApk(downloadedFile, needsBGWorkaround: true); | ||||
|           if (!skipInstalls) { | ||||
|             if (downloadedFile != null) { | ||||
|               if (willBeSilent && context == null) { | ||||
|                 installApk(downloadedFile, needsBGWorkaround: true); | ||||
|               } else { | ||||
|                 await installApk(downloadedFile); | ||||
|               } | ||||
|             } else { | ||||
|               await installApk(downloadedFile); | ||||
|               if (willBeSilent && context == null) { | ||||
|                 installXApkDir(downloadedDir!, needsBGWorkaround: true); | ||||
|               } else { | ||||
|                 await installXApkDir(downloadedDir!); | ||||
|               } | ||||
|             } | ||||
|           } else { | ||||
|             if (willBeSilent && context == null) { | ||||
|               installXApkDir(downloadedDir!, needsBGWorkaround: true); | ||||
|             } else { | ||||
|               await installXApkDir(downloadedDir!); | ||||
|               notificationsProvider?.notify(SilentUpdateAttemptNotification( | ||||
|                   [apps[appId]!.app], | ||||
|                   id: appId.hashCode)); | ||||
|             } | ||||
|           } | ||||
|           if (willBeSilent && context == null) { | ||||
|             notificationsProvider?.notify(SilentUpdateAttemptNotification( | ||||
|                 [apps[appId]!.app], | ||||
|                 id: appId.hashCode)); | ||||
|           } | ||||
|         } finally { | ||||
|           apps[id]?.downloadProgress = null; | ||||
|           notifyListeners(); | ||||
| @@ -686,6 +742,20 @@ class AppsProvider with ChangeNotifier { | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     if (forceParallelDownloads || !settingsProvider.parallelDownloads) { | ||||
|       for (var id in appsToInstall) { | ||||
|         await updateFn(id); | ||||
|       } | ||||
|     } else { | ||||
|       await Future.wait( | ||||
|           appsToInstall.map((id) => updateFn(id, skipInstalls: true))); | ||||
|       for (var id in appsToInstall) { | ||||
|         if (!errors.appIdNames.containsKey(id)) { | ||||
|           await updateFn(id); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     if (errors.idsByErrorString.isNotEmpty) { | ||||
|       throw errors; | ||||
|     } | ||||
| @@ -702,12 +772,15 @@ class AppsProvider with ChangeNotifier { | ||||
|     return appsDir; | ||||
|   } | ||||
|  | ||||
|   Future<PackageInfo?> getInstalledInfo(String? packageName) async { | ||||
|   Future<PackageInfo?> getInstalledInfo(String? packageName, | ||||
|       {bool printErr = true}) async { | ||||
|     if (packageName != null) { | ||||
|       try { | ||||
|         return await pm.getPackageInfo(packageName: packageName); | ||||
|       } catch (e) { | ||||
|         print(e); // OK | ||||
|         if (printErr) { | ||||
|           print(e); // OK | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return null; | ||||
| @@ -950,7 +1023,7 @@ class AppsProvider with ChangeNotifier { | ||||
|       } | ||||
|     } | ||||
|     notifyListeners(); | ||||
|     exportApps(isAuto: true); | ||||
|     export(isAuto: true); | ||||
|   } | ||||
|  | ||||
|   Future<void> removeApps(List<String> appIds) async { | ||||
| @@ -972,7 +1045,7 @@ class AppsProvider with ChangeNotifier { | ||||
|     } | ||||
|     if (appIds.isNotEmpty) { | ||||
|       notifyListeners(); | ||||
|       exportApps(isAuto: true); | ||||
|       export(isAuto: true); | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -1149,7 +1222,7 @@ class AppsProvider with ChangeNotifier { | ||||
|     return updateAppIds; | ||||
|   } | ||||
|  | ||||
|   Future<String?> exportApps( | ||||
|   Future<String?> export( | ||||
|       {bool pickOnly = false, isAuto = false, SettingsProvider? sp}) async { | ||||
|     SettingsProvider settingsProvider = sp ?? this.settingsProvider; | ||||
|     var exportDir = await settingsProvider.getExportDir(); | ||||
| @@ -1179,12 +1252,22 @@ class AppsProvider with ChangeNotifier { | ||||
|     } | ||||
|     String? returnPath; | ||||
|     if (!pickOnly) { | ||||
|       Map<String, dynamic> finalExport = {}; | ||||
|       finalExport['apps'] = apps.values.map((e) => e.app.toJson()).toList(); | ||||
|       if (settingsProvider.exportSettings) { | ||||
|         finalExport['settings'] = Map<String, Object?>.fromEntries( | ||||
|             (settingsProvider.prefs | ||||
|                     ?.getKeys() | ||||
|                     .map((key) => | ||||
|                         MapEntry(key, settingsProvider.prefs?.get(key))) | ||||
|                     .toList()) ?? | ||||
|                 []); | ||||
|       } | ||||
|       var result = await saf.createFile(exportDir, | ||||
|           displayName: | ||||
|               '${tr('obtainiumExportHyphenatedLowercase')}-${DateTime.now().toIso8601String().replaceAll(':', '-')}${isAuto ? '-auto' : ''}.json', | ||||
|           mimeType: 'application/json', | ||||
|           bytes: Uint8List.fromList(utf8.encode( | ||||
|               jsonEncode(apps.values.map((e) => e.app.toJson()).toList())))); | ||||
|           bytes: Uint8List.fromList(utf8.encode(jsonEncode(finalExport)))); | ||||
|       if (result == null) { | ||||
|         throw ObtainiumError(tr('unexpectedError')); | ||||
|       } | ||||
| @@ -1194,21 +1277,36 @@ class AppsProvider with ChangeNotifier { | ||||
|     return returnPath; | ||||
|   } | ||||
|  | ||||
|   Future<int> importApps(String appsJSON) async { | ||||
|     List<App> importedApps = (jsonDecode(appsJSON) as List<dynamic>) | ||||
|         .map((e) => App.fromJson(e)) | ||||
|         .toList(); | ||||
|   Future<MapEntry<int, bool>> import(String appsJSON) async { | ||||
|     var decodedJSON = jsonDecode(appsJSON); | ||||
|     var newFormat = decodedJSON is! List; | ||||
|     List<App> importedApps = | ||||
|         ((newFormat ? decodedJSON['apps'] : decodedJSON) as List<dynamic>) | ||||
|             .map((e) => App.fromJson(e)) | ||||
|             .toList(); | ||||
|     while (loadingApps) { | ||||
|       await Future.delayed(const Duration(microseconds: 1)); | ||||
|     } | ||||
|     for (App a in importedApps) { | ||||
|       if (apps[a.id]?.app.installedVersion != null) { | ||||
|         a.installedVersion = apps[a.id]?.app.installedVersion; | ||||
|       } | ||||
|       a.installedVersion = | ||||
|           (await getInstalledInfo(a.id, printErr: false))?.versionName; | ||||
|     } | ||||
|     await saveApps(importedApps, onlyIfExists: false); | ||||
|     notifyListeners(); | ||||
|     return importedApps.length; | ||||
|     if (newFormat && decodedJSON['settings'] != null) { | ||||
|       var settingsMap = decodedJSON['settings'] as Map<String, Object?>; | ||||
|       settingsMap.forEach((key, value) { | ||||
|         if (value is int) { | ||||
|           settingsProvider.prefs?.setInt(key, value); | ||||
|         } else if (value is bool) { | ||||
|           settingsProvider.prefs?.setBool(key, value); | ||||
|         } else { | ||||
|           settingsProvider.prefs?.setString(key, value as String); | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|     return MapEntry<int, bool>( | ||||
|         importedApps.length, newFormat && decodedJSON['settings'] != null); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
| @@ -1350,19 +1448,17 @@ class _APKOriginWarningDialogState extends State<APKOriginWarningDialog> { | ||||
| /// When toCheck is empty, the function is in "install mode" (else it is in "update mode"). | ||||
| /// In update mode, all apps in toCheck are checked for updates (in parallel). | ||||
| /// If an update is available and it cannot be installed silently, the user is notified of the available update. | ||||
| /// If there are any errors, the task is run again for the remaining apps after a few minutes (based on the error with the longest retry interval). | ||||
| /// Any app that has reached it's retry limit, the user is notified that it could not be checked. | ||||
| /// If there are any errors, we recursively call the same function with retry count for the relevant apps decremented (if zero, the user is notified). | ||||
| /// | ||||
| /// Once all update checks are complete, the task is run again in install mode. | ||||
| /// In this mode, all pending silent updates are downloaded and installed in the background (serially - one at a time). | ||||
| /// If there is an error, the offending app is moved to the back of the line of remaining apps, and the task is retried. | ||||
| /// If an app repeatedly fails to install up to its retry limit, the user is notified. | ||||
| /// In this mode, all pending silent updates are downloaded (in parallel) and installed in the background. | ||||
| /// If there is an error, the user is notified. | ||||
| /// | ||||
| @pragma('vm:entry-point') | ||||
| Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async { | ||||
| Future<void> bgUpdateCheck(String taskId, Map<String, dynamic>? params) async { | ||||
|   // ignore: avoid_print | ||||
|   print('Started $taskId: ${params.toString()}'); | ||||
|   WidgetsFlutterBinding.ensureInitialized(); | ||||
|   await EasyLocalization.ensureInitialized(); | ||||
|   await AndroidAlarmManager.initialize(); | ||||
|   await loadTranslations(); | ||||
|  | ||||
|   LogsProvider logs = LogsProvider(); | ||||
| @@ -1371,11 +1467,20 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async { | ||||
|   await appsProvider.loadApps(); | ||||
|  | ||||
|   int maxAttempts = 4; | ||||
|   int maxRetryWaitSeconds = 5; | ||||
|  | ||||
|   var netResult = await (Connectivity().checkConnectivity()); | ||||
|   if (netResult == ConnectivityResult.none) { | ||||
|     logs.add('BG update task: No network.'); | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   params ??= {}; | ||||
|   if (params['toCheck'] == null) { | ||||
|     appsProvider.settingsProvider.lastBGCheckTime = DateTime.now(); | ||||
|   } | ||||
|  | ||||
|   bool firstEverUpdateTask = DateTime.fromMillisecondsSinceEpoch(0) | ||||
|           .compareTo(appsProvider.settingsProvider.lastCompletedBGCheckTime) == | ||||
|       0; | ||||
|  | ||||
|   List<MapEntry<String, int>> toCheck = <MapEntry<String, int>>[ | ||||
|     ...(params['toCheck'] | ||||
|             ?.map((entry) => MapEntry<String, int>( | ||||
| @@ -1383,6 +1488,11 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async { | ||||
|             .toList() ?? | ||||
|         appsProvider | ||||
|             .getAppsSortedByUpdateCheckTime( | ||||
|                 ignoreAppsCheckedAfter: params['toCheck'] == null | ||||
|                     ? firstEverUpdateTask | ||||
|                         ? null | ||||
|                         : appsProvider.settingsProvider.lastCompletedBGCheckTime | ||||
|                     : null, | ||||
|                 onlyCheckInstalledOrTrackOnlyApps: appsProvider | ||||
|                     .settingsProvider.onlyCheckInstalledOrTrackOnlyApps) | ||||
|             .map((e) => MapEntry(e, 0))) | ||||
| @@ -1395,51 +1505,34 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async { | ||||
|         (<List<MapEntry<String, int>>>[])) | ||||
|   ]; | ||||
|  | ||||
|   var netResult = await (Connectivity().checkConnectivity()); | ||||
|  | ||||
|   if (netResult == ConnectivityResult.none) { | ||||
|     var networkBasedRetryInterval = 15; | ||||
|     var nextRegularCheck = appsProvider.settingsProvider.lastBGCheckTime | ||||
|         .add(Duration(minutes: appsProvider.settingsProvider.updateInterval)); | ||||
|     var potentialNetworkRetryCheck = | ||||
|         DateTime.now().add(Duration(minutes: networkBasedRetryInterval)); | ||||
|     var shouldRetry = potentialNetworkRetryCheck.isBefore(nextRegularCheck); | ||||
|     logs.add( | ||||
|         'BG update task $taskId: No network. Will ${shouldRetry ? 'retry in $networkBasedRetryInterval minutes' : 'not retry'}.'); | ||||
|     AndroidAlarmManager.oneShot( | ||||
|         const Duration(minutes: 15), taskId + 1, bgUpdateCheck, | ||||
|         params: { | ||||
|           'toCheck': toCheck | ||||
|               .map((entry) => {'key': entry.key, 'value': entry.value}) | ||||
|               .toList(), | ||||
|           'toInstall': toInstall | ||||
|               .map((entry) => {'key': entry.key, 'value': entry.value}) | ||||
|               .toList(), | ||||
|         }); | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   var networkRestricted = false; | ||||
|   if (appsProvider.settingsProvider.bgUpdatesOnWiFiOnly) { | ||||
|     networkRestricted = (netResult != ConnectivityResult.wifi) && | ||||
|         (netResult != ConnectivityResult.ethernet); | ||||
|   } | ||||
|  | ||||
|   bool installMode = | ||||
|       toCheck.isEmpty; // Task is either in update mode or install mode | ||||
|  | ||||
|   logs.add( | ||||
|       'BG ${installMode ? 'install' : 'update'} task $taskId: Started (${installMode ? toInstall.length : toCheck.length}).'); | ||||
|  | ||||
|   if (!installMode) { | ||||
|   if (toCheck.isNotEmpty) { | ||||
|     // Task is either in update mode or install mode | ||||
|     // If in update mode, we check for updates. | ||||
|     // We divide the results into 4 groups: | ||||
|     // - toNotify - Apps with updates that the user will be notified about (can't be silently installed) | ||||
|     // - toRetry - Apps with update check errors that will be retried in a while | ||||
|     // - toThrow - Apps with update check errors that the user will be notified about (no retry) | ||||
|     // After grouping the updates, we take care of toNotify and toThrow first | ||||
|     // Then if toRetry is not empty, we schedule another update task to run in a while | ||||
|     // If toRetry is empty, we take care of schedule another task that will run in install mode (toCheck is empty) | ||||
|     // Then we run the function again in install mode (toCheck is empty) | ||||
|  | ||||
|     var enoughTimePassed = appsProvider.settingsProvider.updateInterval != 0 && | ||||
|         appsProvider.settingsProvider.lastCompletedBGCheckTime | ||||
|             .add( | ||||
|                 Duration(minutes: appsProvider.settingsProvider.updateInterval)) | ||||
|             .isBefore(DateTime.now()); | ||||
|     if (!enoughTimePassed) { | ||||
|       // ignore: avoid_print | ||||
|       print( | ||||
|           'BG update task: Too early for another check (last check was ${appsProvider.settingsProvider.lastCompletedBGCheckTime.toIso8601String()}, interval is ${appsProvider.settingsProvider.updateInterval}).'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     logs.add('BG update task: Started (${toCheck.length}).'); | ||||
|  | ||||
|     // Init. vars. | ||||
|     List<App> updates = []; // All updates found (silent and non-silent) | ||||
| @@ -1447,8 +1540,7 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async { | ||||
|         []; // All non-silent updates that the user will be notified about | ||||
|     List<MapEntry<String, int>> toRetry = | ||||
|         []; // All apps that got errors while checking | ||||
|     var retryAfterXSeconds = | ||||
|         0; // How long to wait until the next attempt (if there are errors) | ||||
|     var retryAfterXSeconds = 0; | ||||
|     MultiAppMultiError? | ||||
|         errors; // All errors including those that will lead to a retry | ||||
|     MultiAppMultiError toThrow = | ||||
| @@ -1471,27 +1563,32 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async { | ||||
|           specificIds: toCheck.map((e) => e.key).toList(), | ||||
|           sp: appsProvider.settingsProvider); | ||||
|     } catch (e) { | ||||
|       // If there were errors, group them into toRetry and toThrow based on max retry count per app | ||||
|       if (e is Map) { | ||||
|         updates = e['updates']; | ||||
|         errors = e['errors']; | ||||
|         errors!.rawErrors.forEach((key, err) { | ||||
|           logs.add( | ||||
|               'BG update task $taskId: Got error on checking for $key \'${err.toString()}\'.'); | ||||
|               'BG update task: Got error on checking for $key \'${err.toString()}\'.'); | ||||
|  | ||||
|           var toCheckApp = toCheck.where((element) => element.key == key).first; | ||||
|           if (toCheckApp.value < maxAttempts) { | ||||
|             toRetry.add(MapEntry(toCheckApp.key, toCheckApp.value + 1)); | ||||
|             // Next task interval is based on the error with the longest retry time | ||||
|             var minRetryIntervalForThisApp = err is RateLimitError | ||||
|             int minRetryIntervalForThisApp = err is RateLimitError | ||||
|                 ? (err.remainingMinutes * 60) | ||||
|                 : e is ClientException | ||||
|                     ? (15 * 60) | ||||
|                     : pow(toCheckApp.value + 1, 2).toInt(); | ||||
|                     : (toCheckApp.value + 1); | ||||
|             if (minRetryIntervalForThisApp > maxRetryWaitSeconds) { | ||||
|               minRetryIntervalForThisApp = maxRetryWaitSeconds; | ||||
|             } | ||||
|             if (minRetryIntervalForThisApp > retryAfterXSeconds) { | ||||
|               retryAfterXSeconds = minRetryIntervalForThisApp; | ||||
|             } | ||||
|           } else { | ||||
|             toThrow.add(key, err, appName: errors?.appIdNames[key]); | ||||
|             if (err is! RateLimitError) { | ||||
|               toThrow.add(key, err, appName: errors?.appIdNames[key]); | ||||
|             } | ||||
|           } | ||||
|         }); | ||||
|       } else { | ||||
| @@ -1526,37 +1623,32 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async { | ||||
|             id: Random().nextInt(10000))); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // if there are update checks to retry, schedule a retry task | ||||
|     logs.add('BG update task: Done checking for updates.'); | ||||
|     if (toRetry.isNotEmpty) { | ||||
|       logs.add( | ||||
|           'BG update task $taskId: Will retry in $retryAfterXSeconds seconds.'); | ||||
|       AndroidAlarmManager.oneShot( | ||||
|           Duration(seconds: retryAfterXSeconds), taskId + 1, bgUpdateCheck, | ||||
|           params: { | ||||
|             'toCheck': toRetry | ||||
|                 .map((entry) => {'key': entry.key, 'value': entry.value}) | ||||
|                 .toList(), | ||||
|             'toInstall': toInstall | ||||
|                 .map((entry) => {'key': entry.key, 'value': entry.value}) | ||||
|                 .toList(), | ||||
|           }); | ||||
|       return await bgUpdateCheck(taskId, { | ||||
|         'toCheck': toRetry | ||||
|             .map((entry) => {'key': entry.key, 'value': entry.value}) | ||||
|             .toList(), | ||||
|         'toInstall': toInstall | ||||
|             .map((entry) => {'key': entry.key, 'value': entry.value}) | ||||
|             .toList(), | ||||
|       }); | ||||
|     } else { | ||||
|       // If there are no more update checks, schedule an install task | ||||
|       logs.add( | ||||
|           'BG update task $taskId: Done. Scheduling install task to run immediately.'); | ||||
|       AndroidAlarmManager.oneShot( | ||||
|           const Duration(minutes: 0), taskId + 1, bgUpdateCheck, | ||||
|           params: { | ||||
|             'toCheck': [], | ||||
|             'toInstall': toInstall | ||||
|                 .map((entry) => {'key': entry.key, 'value': entry.value}) | ||||
|                 .toList() | ||||
|           }); | ||||
|       // If there are no more update checks, call the function in install mode | ||||
|       logs.add('BG update task: Done checking for updates.'); | ||||
|       return await bgUpdateCheck(taskId, { | ||||
|         'toCheck': [], | ||||
|         'toInstall': toInstall | ||||
|             .map((entry) => {'key': entry.key, 'value': entry.value}) | ||||
|             .toList() | ||||
|       }); | ||||
|     } | ||||
|   } else { | ||||
|     // In install mode... | ||||
|     // If you haven't explicitly been given updates to install (which is the case for new tasks), grab all available silent updates | ||||
|     // If you haven't explicitly been given updates to install, grab all available silent updates | ||||
|     if (toInstall.isEmpty && !networkRestricted) { | ||||
|       var temp = appsProvider.findExistingUpdates(installedOnly: true); | ||||
|       for (var i = 0; i < temp.length; i++) { | ||||
| @@ -1566,60 +1658,34 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async { | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     var didCompleteInstalling = false; | ||||
|     var tempObtArr = toInstall.where((element) => element.key == obtainiumId); | ||||
|     if (tempObtArr.isNotEmpty) { | ||||
|       // Move obtainium to the end of the list as it must always install last | ||||
|       var obt = tempObtArr.first; | ||||
|       toInstall = moveStrToEndMapEntryWithCount(toInstall, obt); | ||||
|     } | ||||
|     // Loop through all updates and install each | ||||
|     for (var i = 0; i < toInstall.length; i++) { | ||||
|       var appId = toInstall[i].key; | ||||
|       var retryCount = toInstall[i].value; | ||||
|     if (toInstall.isNotEmpty) { | ||||
|       logs.add('BG install task: Started (${toInstall.length}).'); | ||||
|       var tempObtArr = toInstall.where((element) => element.key == obtainiumId); | ||||
|       if (tempObtArr.isNotEmpty) { | ||||
|         // Move obtainium to the end of the list as it must always install last | ||||
|         var obt = tempObtArr.first; | ||||
|         toInstall = moveStrToEndMapEntryWithCount(toInstall, obt); | ||||
|       } | ||||
|       // Loop through all updates and install each | ||||
|       try { | ||||
|         logs.add( | ||||
|             'BG install task $taskId: Attempting to update $appId in the background.'); | ||||
|         await appsProvider.downloadAndInstallLatestApps([appId], null, | ||||
|             notificationsProvider: notificationsProvider); | ||||
|         await Future.delayed(const Duration( | ||||
|             seconds: | ||||
|                 5)); // Just in case task ending causes install fail (not clear) | ||||
|         if (i == (toCheck.length - 1)) { | ||||
|           didCompleteInstalling = true; | ||||
|         } | ||||
|         await appsProvider.downloadAndInstallLatestApps( | ||||
|             toInstall.map((e) => e.key).toList(), null, | ||||
|             notificationsProvider: notificationsProvider, | ||||
|             forceParallelDownloads: true); | ||||
|       } catch (e) { | ||||
|         // If you got an error, move the offender to the back of the line (increment their fail count) and schedule another task to continue installing shortly | ||||
|         logs.add( | ||||
|             'BG install task $taskId: Got error on updating $appId \'${e.toString()}\'.'); | ||||
|         if (retryCount < maxAttempts) { | ||||
|           var remainingSeconds = retryCount; | ||||
|           logs.add( | ||||
|               'BG install task $taskId: Will continue in $remainingSeconds seconds (with $appId moved to the end of the line).'); | ||||
|           var remainingToInstall = moveStrToEndMapEntryWithCount( | ||||
|               toInstall.sublist(i), MapEntry(appId, retryCount + 1)); | ||||
|           AndroidAlarmManager.oneShot( | ||||
|               Duration(seconds: remainingSeconds), taskId + 1, bgUpdateCheck, | ||||
|               params: { | ||||
|                 'toCheck': toCheck | ||||
|                     .map((entry) => {'key': entry.key, 'value': entry.value}) | ||||
|                     .toList(), | ||||
|                 'toInstall': remainingToInstall | ||||
|                     .map((entry) => {'key': entry.key, 'value': entry.value}) | ||||
|                     .toList(), | ||||
|               }); | ||||
|           break; | ||||
|         if (e is MultiAppMultiError) { | ||||
|           e.idsByErrorString.forEach((key, value) { | ||||
|             notificationsProvider.notify(ErrorCheckingUpdatesNotification( | ||||
|                 e.errorsAppsString(key, value))); | ||||
|           }); | ||||
|         } else { | ||||
|           // If the offender has reached its fail limit, notify the user and remove it from the list (task can continue) | ||||
|           toInstall.removeAt(i); | ||||
|           i--; | ||||
|           notificationsProvider | ||||
|               .notify(ErrorCheckingUpdatesNotification(e.toString())); | ||||
|           // We don't expect to ever get here in any situation so no need to catch (but log it in case) | ||||
|           logs.add('Fatal error in BG install task: ${e.toString()}'); | ||||
|           rethrow; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     if (didCompleteInstalling || toInstall.isEmpty) { | ||||
|       logs.add('BG install task $taskId: Done.'); | ||||
|       logs.add('BG install task: Done installing updates.'); | ||||
|     } | ||||
|   } | ||||
|   appsProvider.settingsProvider.lastCompletedBGCheckTime = DateTime.now(); | ||||
| } | ||||
|   | ||||
							
								
								
									
										56
									
								
								lib/providers/installers_provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								lib/providers/installers_provider.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,56 @@ | ||||
| import 'dart:async'; | ||||
| import 'package:flutter/services.dart'; | ||||
|  | ||||
| class Installers { | ||||
|   static const MethodChannel _channel = MethodChannel('installers'); | ||||
|   static bool _callbacksApplied = false; | ||||
|   static int _resPermShizuku = -2;  // not set | ||||
|  | ||||
|   static Future waitWhile(bool Function() test, | ||||
|       [Duration pollInterval = const Duration(milliseconds: 250)]) { | ||||
|     var completer = Completer(); | ||||
|     check() { | ||||
|       if (test()) { | ||||
|         Timer(pollInterval, check); | ||||
|       } else { | ||||
|         completer.complete(); | ||||
|       } | ||||
|     } | ||||
|     check(); | ||||
|     return completer.future; | ||||
|   } | ||||
|  | ||||
|   static Future handleCalls(MethodCall call) async { | ||||
|     if (call.method == 'resPermShizuku') { | ||||
|       _resPermShizuku = call.arguments['res']; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   static Future<int> checkPermissionShizuku() async { | ||||
|     if (!_callbacksApplied) { | ||||
|       _channel.setMethodCallHandler(handleCalls); | ||||
|       _callbacksApplied = true; | ||||
|     } | ||||
|     int res = await _channel.invokeMethod('checkPermissionShizuku'); | ||||
|     if(res == -2) { | ||||
|       await waitWhile(() => _resPermShizuku == -2); | ||||
|       res = _resPermShizuku; | ||||
|       _resPermShizuku = -2; | ||||
|     } | ||||
|     return res; | ||||
|   } | ||||
|  | ||||
|   static Future<bool> checkPermissionRoot() async { | ||||
|     return await _channel.invokeMethod('checkPermissionRoot'); | ||||
|   } | ||||
|  | ||||
|   static Future<bool> installWithShizuku({required String apkFileUri}) async { | ||||
|     return await _channel.invokeMethod( | ||||
|         'installWithShizuku', {'apkFileUri': apkFileUri}); | ||||
|   } | ||||
|  | ||||
|   static Future<bool> installWithRoot({required String apkFilePath}) async { | ||||
|     return await _channel.invokeMethod( | ||||
|         'installWithRoot', {'apkFilePath': apkFilePath}); | ||||
|   } | ||||
| } | ||||
| @@ -17,6 +17,8 @@ import 'package:shared_storage/shared_storage.dart' as saf; | ||||
| String obtainiumTempId = 'imranr98_obtainium_${GitHub().host}'; | ||||
| String obtainiumId = 'dev.imranr.obtainium'; | ||||
|  | ||||
| enum InstallMethodSettings { normal, shizuku, root } | ||||
|  | ||||
| enum ThemeSettings { system, light, dark } | ||||
|  | ||||
| enum ColourSettings { basic, materialYou } | ||||
| @@ -49,6 +51,16 @@ class SettingsProvider with ChangeNotifier { | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   InstallMethodSettings get installMethod { | ||||
|     return InstallMethodSettings.values[ | ||||
|         prefs?.getInt('installMethod') ?? InstallMethodSettings.normal.index]; | ||||
|   } | ||||
|  | ||||
|   set installMethod(InstallMethodSettings t) { | ||||
|     prefs?.setInt('installMethod', t.index); | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   ThemeSettings get theme { | ||||
|     return ThemeSettings | ||||
|         .values[prefs?.getInt('theme') ?? ThemeSettings.system.index]; | ||||
| @@ -213,7 +225,8 @@ class SettingsProvider with ChangeNotifier { | ||||
|   } | ||||
|  | ||||
|   String? getSettingString(String settingId) { | ||||
|     return prefs?.getString(settingId); | ||||
|     String? str = prefs?.getString(settingId); | ||||
|     return str?.isNotEmpty == true ? str : null; | ||||
|   } | ||||
|  | ||||
|   void setSettingString(String settingId, String value) { | ||||
| @@ -332,15 +345,15 @@ class SettingsProvider with ChangeNotifier { | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   DateTime get lastBGCheckTime { | ||||
|     int? temp = prefs?.getInt('lastBGCheckTime'); | ||||
|   DateTime get lastCompletedBGCheckTime { | ||||
|     int? temp = prefs?.getInt('lastCompletedBGCheckTime'); | ||||
|     return temp != null | ||||
|         ? DateTime.fromMillisecondsSinceEpoch(temp) | ||||
|         : DateTime.fromMillisecondsSinceEpoch(0); | ||||
|   } | ||||
|  | ||||
|   set lastBGCheckTime(DateTime val) { | ||||
|     prefs?.setInt('lastBGCheckTime', val.millisecondsSinceEpoch); | ||||
|   set lastCompletedBGCheckTime(DateTime val) { | ||||
|     prefs?.setInt('lastCompletedBGCheckTime', val.millisecondsSinceEpoch); | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
| @@ -415,4 +428,22 @@ class SettingsProvider with ChangeNotifier { | ||||
|     prefs?.setBool('onlyCheckInstalledOrTrackOnlyApps', val); | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   bool get exportSettings { | ||||
|     return prefs?.getBool('exportSettings') ?? false; | ||||
|   } | ||||
|  | ||||
|   set exportSettings(bool val) { | ||||
|     prefs?.setBool('exportSettings', val); | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   bool get parallelDownloads { | ||||
|     return prefs?.getBool('parallelDownloads') ?? false; | ||||
|   } | ||||
|  | ||||
|   set parallelDownloads(bool val) { | ||||
|     prefs?.setBool('parallelDownloads', val); | ||||
|     notifyListeners(); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -67,10 +67,11 @@ appJSONCompatibilityModifiers(Map<String, dynamic> json) { | ||||
|       .reduce((value, element) => [...value, ...element]); | ||||
|   Map<String, dynamic> additionalSettings = | ||||
|       getDefaultValuesFromFormItems([formItems]); | ||||
|   Map<String, dynamic> originalAdditionalSettings = {}; | ||||
|   if (json['additionalSettings'] != null) { | ||||
|     additionalSettings.addEntries( | ||||
|         Map<String, dynamic>.from(jsonDecode(json['additionalSettings'])) | ||||
|             .entries); | ||||
|     originalAdditionalSettings = | ||||
|         Map<String, dynamic>.from(jsonDecode(json['additionalSettings'])); | ||||
|     additionalSettings.addEntries(originalAdditionalSettings.entries); | ||||
|   } | ||||
|   // If needed, migrate old-style additionalData to newer-style additionalSettings (V1) | ||||
|   if (json['additionalData'] != null) { | ||||
| @@ -134,6 +135,35 @@ appJSONCompatibilityModifiers(Map<String, dynamic> json) { | ||||
|   if (additionalSettings['autoApkFilterByArch'] == null) { | ||||
|     additionalSettings['autoApkFilterByArch'] = false; | ||||
|   } | ||||
|   if (source.runtimeType == HTML().runtimeType) { | ||||
|     // HTML 'fixed URL' support should be disabled if it previously did not exist | ||||
|     if (originalAdditionalSettings['supportFixedAPKURL'] == null) { | ||||
|       additionalSettings['supportFixedAPKURL'] = false; | ||||
|     } | ||||
|     // HTML key rename | ||||
|     if (originalAdditionalSettings['sortByFileNamesNotLinks'] != null) { | ||||
|       additionalSettings['sortByLastLinkSegment'] = | ||||
|           originalAdditionalSettings['sortByFileNamesNotLinks']; | ||||
|     } | ||||
|     // HTML single 'intermediate link' should be converted to multi-support version | ||||
|     if (originalAdditionalSettings['intermediateLinkRegex'] != null && | ||||
|         additionalSettings['intermediateLinkRegex']?.isNotEmpty != true) { | ||||
|       additionalSettings['intermediateLink'] = [ | ||||
|         { | ||||
|           'customLinkFilterRegex': | ||||
|               originalAdditionalSettings['intermediateLinkRegex'], | ||||
|           'filterByLinkText': | ||||
|               originalAdditionalSettings['intermediateLinkByText'] | ||||
|         } | ||||
|       ]; | ||||
|     } | ||||
|     if ((additionalSettings['intermediateLink']?.length ?? 0) > 0) { | ||||
|       additionalSettings['intermediateLink'] = | ||||
|           additionalSettings['intermediateLink'].where((e) { | ||||
|         return e['intermediateLinkRegex']?.isNotEmpty == true; | ||||
|       }).toList(); | ||||
|     } | ||||
|   } | ||||
|   json['additionalSettings'] = jsonEncode(additionalSettings); | ||||
|   // F-Droid no longer needs cloudflare exception since override can be used - migrate apps appropriately | ||||
|   // This allows us to reverse the changes made for issue #418 (support cloudflare.f-droid) | ||||
| @@ -448,7 +478,8 @@ abstract class AppSource { | ||||
|     [ | ||||
|       GeneratedFormSwitch('skipUpdateNotifications', | ||||
|           label: tr('skipUpdateNotifications')) | ||||
|     ] | ||||
|     ], | ||||
|     [GeneratedFormTextField('about', label: tr('about'), required: false)] | ||||
|   ]; | ||||
|  | ||||
|   // Previous 2 variables combined into one at runtime for convenient usage | ||||
| @@ -596,7 +627,7 @@ class SourceProvider { | ||||
|     AppSource? source; | ||||
|     for (var s in sources.where((element) => element.host != null)) { | ||||
|       if (RegExp( | ||||
|               '://(${s.allowSubDomains ? '([^\\.]+\\.)*' : ''}|www\\.)${s.host}(/|\\z)?') | ||||
|               '://${s.allowSubDomains ? '([^\\.]+\\.)*' : '(www\\.)?'}${s.host}(/|\\z)?') | ||||
|           .hasMatch(url)) { | ||||
|         source = s; | ||||
|         break; | ||||
| @@ -677,8 +708,9 @@ class SourceProvider { | ||||
|     name = name.isNotEmpty ? name : apk.names.name; | ||||
|     App finalApp = App( | ||||
|         currentApp?.id ?? | ||||
|             ((!source.appIdInferIsOptional || | ||||
|                     (source.appIdInferIsOptional && inferAppIdIfOptional)) | ||||
|             (!trackOnly && | ||||
|                     (!source.appIdInferIsOptional || | ||||
|                         (source.appIdInferIsOptional && inferAppIdIfOptional)) | ||||
|                 ? await source.tryInferringAppId(standardUrl, | ||||
|                     additionalSettings: additionalSettings) | ||||
|                 : null) ?? | ||||
| @@ -698,8 +730,9 @@ class SourceProvider { | ||||
|         changeLog: apk.changeLog, | ||||
|         overrideSource: overrideSource ?? currentApp?.overrideSource, | ||||
|         allowIdChange: currentApp?.allowIdChange ?? | ||||
|             source.appIdInferIsOptional && | ||||
|                 inferAppIdIfOptional // Optional ID inferring may be incorrect - allow correction on first install | ||||
|             trackOnly || | ||||
|                 (source.appIdInferIsOptional && | ||||
|                     inferAppIdIfOptional) // Optional ID inferring may be incorrect - allow correction on first install | ||||
|         ); | ||||
|     return source.endOfGetAppChanges(finalApp); | ||||
|   } | ||||
|   | ||||
							
								
								
									
										150
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						
									
										150
									
								
								pubspec.lock
									
									
									
									
									
								
							| @@ -1,14 +1,6 @@ | ||||
| # Generated by pub | ||||
| # See https://dart.dev/tools/pub/glossary#lockfile | ||||
| packages: | ||||
|   android_alarm_manager_plus: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: android_alarm_manager_plus | ||||
|       sha256: "82fb28c867c4b3dd7e9157728e46426b8916362f977dbba46b949210f00099f4" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.0.3" | ||||
|   android_intent_plus: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -38,10 +30,18 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: animations | ||||
|       sha256: ef57563eed3620bd5d75ad96189846aca1e033c0c45fc9a7d26e80ab02b88a70 | ||||
|       sha256: "708e4b68c23228c264b038fe7003a2f5d01ce85fc64d8cae090e86b27fcea6c5" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.0.8" | ||||
|     version: "2.0.10" | ||||
|   app_links: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: app_links | ||||
|       sha256: "4e392b5eba997df356ca6021f28431ce1cfeb16758699553a94b13add874a3bb" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.5.0" | ||||
|   archive: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -66,6 +66,14 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.11.0" | ||||
|   background_fetch: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: background_fetch | ||||
|       sha256: f70b28a0f7a3156195e9742229696f004ea3bf10f74039b7bf4c78a74fbda8a4 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.2.1" | ||||
|   boolean_selector: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -94,10 +102,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: cli_util | ||||
|       sha256: b8db3080e59b2503ca9e7922c3df2072cf13992354d5e944074ffa836fba43b7 | ||||
|       sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.4.0" | ||||
|     version: "0.4.1" | ||||
|   clock: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -118,10 +126,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: connectivity_plus | ||||
|       sha256: b502a681ba415272ecc41400bd04fe543ed1a62632137dc84d25a91e7746f55f | ||||
|       sha256: "224a77051d52a11fbad53dd57827594d3bd24f945af28bd70bab376d68d437f0" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "5.0.1" | ||||
|     version: "5.0.2" | ||||
|   connectivity_plus_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -142,12 +150,12 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: cross_file | ||||
|       sha256: "445db18de832dba8d851e287aff8ccf169bed30d2e94243cb54c7d2f1ed2142c" | ||||
|       sha256: fedaadfa3a6996f75211d835aaeb8fede285dae94262485698afd832371b9a5e | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.3.3+6" | ||||
|     version: "0.3.3+8" | ||||
|   crypto: | ||||
|     dependency: transitive | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: crypto | ||||
|       sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab | ||||
| @@ -182,10 +190,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: device_info_plus | ||||
|       sha256: "7035152271ff67b072a211152846e9f1259cf1be41e34cd3e0b5463d2d6b8419" | ||||
|       sha256: "0042cb3b2a76413ea5f8a2b40cec2a33e01d0c937e91f0f7c211fde4f7739ba6" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "9.1.0" | ||||
|     version: "9.1.1" | ||||
|   device_info_plus_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -198,10 +206,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: dynamic_color | ||||
|       sha256: "8b8bd1d798bd393e11eddeaa8ae95b12ff028bf7d5998fc5d003488cd5f4ce2f" | ||||
|       sha256: a866f1f8947bfdaf674d7928e769eac7230388a2e7a2542824fad4bb5b87be3b | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.6.8" | ||||
|     version: "1.6.9" | ||||
|   easy_localization: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -259,10 +267,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: flutter_archive | ||||
|       sha256: aec85d1da65e5b33a529db00a86df0b8e92bda78088a7cfaeeba5187701d0d85 | ||||
|       sha256: "004132780d382df5171589ab793e2efc9c3eef570fe72d78b4ccfbfbe52762ae" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "5.0.0" | ||||
|     version: "6.0.0" | ||||
|   flutter_fgbg: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -291,10 +299,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: flutter_local_notifications | ||||
|       sha256: "6d11ea777496061e583623aaf31923f93a9409ef8fcaeeefdd6cd78bf4fe5bb3" | ||||
|       sha256: "892ada16046d641263f30c72e7432397088810a84f34479f6677494802a2b535" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "16.1.0" | ||||
|     version: "16.3.0" | ||||
|   flutter_local_notifications_linux: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -350,6 +358,14 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "8.2.4" | ||||
|   gtk: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: gtk | ||||
|       sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.1.0" | ||||
|   hsluv: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -370,10 +386,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: http | ||||
|       sha256: "759d1a329847dd0f39226c688d3e06a6b8679668e350e2891a6474f8b4bb8525" | ||||
|       sha256: d4872660c46d929f6b8a9ef4e7a7eff7e49bbf0c4ec3f385ee32df5119175139 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.1.0" | ||||
|     version: "1.1.2" | ||||
|   http_parser: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -498,10 +514,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: path_provider_android | ||||
|       sha256: e595b98692943b4881b219f0a9e3945118d3c16bd7e2813f98ec6e532d905f72 | ||||
|       sha256: "477184d672607c0a3bf68fbbf601805f92ef79c82b64b4d6eb318cbca4c48668" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.2.1" | ||||
|     version: "2.2.2" | ||||
|   path_provider_foundation: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -538,50 +554,58 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: permission_handler | ||||
|       sha256: "284a66179cabdf942f838543e10413246f06424d960c92ba95c84439154fcac8" | ||||
|       sha256: "860c6b871c94c78e202dc69546d4d8fd84bd59faeb36f8fb9888668a53ff4f78" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "11.0.1" | ||||
|     version: "11.1.0" | ||||
|   permission_handler_android: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: permission_handler_android | ||||
|       sha256: f9fddd3b46109bd69ff3f9efa5006d2d309b7aec0f3c1c5637a60a2d5659e76e | ||||
|       sha256: "2f1bec180ee2f5665c22faada971a8f024761f632e93ddc23310487df52dcfa6" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "11.1.0" | ||||
|     version: "12.0.1" | ||||
|   permission_handler_apple: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: permission_handler_apple | ||||
|       sha256: "99e220bce3f8877c78e4ace901082fb29fa1b4ebde529ad0932d8d664b34f3f5" | ||||
|       sha256: "1a816084338ada8d574b1cb48390e6e8b19305d5120fe3a37c98825bacc78306" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "9.1.4" | ||||
|     version: "9.2.0" | ||||
|   permission_handler_html: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: permission_handler_html | ||||
|       sha256: "11b762a8c123dced6461933a88ea1edbbe036078c3f9f41b08886e678e7864df" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.1.0+2" | ||||
|   permission_handler_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: permission_handler_platform_interface | ||||
|       sha256: "6760eb5ef34589224771010805bea6054ad28453906936f843a8cc4d3a55c4a4" | ||||
|       sha256: d87349312f7eaf6ce0adaf668daf700ac5b06af84338bd8b8574dfbd93ffe1a1 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.12.0" | ||||
|     version: "4.0.2" | ||||
|   permission_handler_windows: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: permission_handler_windows | ||||
|       sha256: cc074aace208760f1eee6aa4fae766b45d947df85bc831cde77009cdb4720098 | ||||
|       sha256: "1e8640c1e39121128da6b816d236e714d2cf17fac5a105dd6acdd3403a628004" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.1.3" | ||||
|     version: "0.2.0" | ||||
|   petitparser: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: petitparser | ||||
|       sha256: eeb2d1428ee7f4170e2bd498827296a18d4e7fc462b71727d111c0ac7707cfa6 | ||||
|       sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "6.0.1" | ||||
|     version: "6.0.2" | ||||
|   platform: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -759,10 +783,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: synchronized | ||||
|       sha256: "5fcbd27688af6082f5abd611af56ee575342c30e87541d0245f7ff99faa02c60" | ||||
|       sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.1.0" | ||||
|     version: "3.1.0+1" | ||||
|   term_glyph: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -799,10 +823,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: url_launcher | ||||
|       sha256: b1c9e98774adf8820c96fbc7ae3601231d324a7d5ebd8babe27b6dfac91357ba | ||||
|       sha256: e9aa5ea75c84cf46b3db4eea212523591211c3cf2e13099ee4ec147f54201c86 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "6.2.1" | ||||
|     version: "6.2.2" | ||||
|   url_launcher_android: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -823,10 +847,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: url_launcher_linux | ||||
|       sha256: "9f2d390e096fdbe1e6e6256f97851e51afc2d9c423d3432f1d6a02a8a9a8b9fd" | ||||
|       sha256: ab360eb661f8879369acac07b6bb3ff09d9471155357da8443fd5d3cf7363811 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.1.0" | ||||
|     version: "3.1.1" | ||||
|   url_launcher_macos: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -847,26 +871,26 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: url_launcher_web | ||||
|       sha256: "7fd2f55fe86cea2897b963e864dc01a7eb0719ecc65fcef4c1cc3d686d718bb2" | ||||
|       sha256: "7286aec002c8feecc338cc33269e96b73955ab227456e9fb2a91f7fab8a358e9" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.2.0" | ||||
|     version: "2.2.2" | ||||
|   url_launcher_windows: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: url_launcher_windows | ||||
|       sha256: "7754a1ad30ee896b265f8d14078b0513a4dba28d358eabb9d5f339886f4a1adc" | ||||
|       sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.1.0" | ||||
|     version: "3.1.1" | ||||
|   uuid: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: uuid | ||||
|       sha256: df5a4d8f22ee4ccd77f8839ac7cb274ebc11ef9adcce8b92be14b797fe889921 | ||||
|       sha256: "22c94e5ad1e75f9934b766b53c742572ee2677c56bc871d850a57dad0f82127f" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "4.2.1" | ||||
|     version: "4.2.2" | ||||
|   vector_math: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -895,34 +919,34 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: webview_flutter_android | ||||
|       sha256: "8326ee235f87605a2bfc444a4abc897f4abc78d83f054ba7d3d1074ce82b4fbf" | ||||
|       sha256: b54c89fe14a6d26a2a46e24880da0441cdd2bf1f6d01a5b3e1d39558feb1de0b | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.12.1" | ||||
|     version: "3.13.1" | ||||
|   webview_flutter_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: webview_flutter_platform_interface | ||||
|       sha256: "6d9213c65f1060116757a7c473247c60f3f7f332cac33dc417c9e362a9a13e4f" | ||||
|       sha256: dbe745ee459a16b6fec296f7565a8ef430d0d681001d8ae521898b9361854943 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.6.0" | ||||
|     version: "2.9.0" | ||||
|   webview_flutter_wkwebview: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: webview_flutter_wkwebview | ||||
|       sha256: accdaaa49a2aca2dc3c3230907988954cdd23fed0a19525d6c9789d380f4dc76 | ||||
|       sha256: eebfabfa8a115b535b52031b8b26f7a4b58ceceab378bc9db8762b0fb46f7b5d | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.9.4" | ||||
|     version: "3.10.0" | ||||
|   win32: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: win32 | ||||
|       sha256: "7c99c0e1e2fa190b48d25c81ca5e42036d5cac81430ef249027d97b0935c553f" | ||||
|       sha256: b0f37db61ba2f2e9b7a78a1caece0052564d1bc70668156cf3a29d676fe4e574 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "5.1.0" | ||||
|     version: "5.1.1" | ||||
|   win32_registry: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -943,10 +967,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: xml | ||||
|       sha256: af5e77e9b83f2f4adc5d3f0a4ece1c7f45a2467b695c2540381bac793e34e556 | ||||
|       sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "6.4.2" | ||||
|     version: "6.5.0" | ||||
|   yaml: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|   | ||||
| @@ -17,7 +17,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev | ||||
| # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html | ||||
| # In Windows, build-name is used as the major, minor, and patch parts | ||||
| # of the product and file versions while build-number is used as the build suffix. | ||||
| version: 0.14.33+226 # When changing this, update the tag in main() accordingly | ||||
| version: 0.15.1+237 # When changing this, update the tag in main() accordingly | ||||
|  | ||||
| environment: | ||||
|   sdk: '>=3.0.0 <4.0.0' | ||||
| @@ -57,15 +57,17 @@ dependencies: | ||||
|       ref: main | ||||
|   android_package_manager: ^0.6.0 | ||||
|   share_plus: ^7.0.0 | ||||
|   android_alarm_manager_plus: ^3.0.0 | ||||
|   sqflite: ^2.2.0+3 | ||||
|   easy_localization: ^3.0.1 | ||||
|   android_intent_plus: ^4.0.0 | ||||
|   flutter_markdown: ^0.6.14 | ||||
|   flutter_archive: ^5.0.0 | ||||
|   flutter_archive: ^6.0.0 | ||||
|   hsluv: ^1.1.3 | ||||
|   connectivity_plus: ^5.0.0 | ||||
|   shared_storage: ^0.8.0 | ||||
|   crypto: ^3.0.3 | ||||
|   app_links: ^3.5.0 | ||||
|   background_fetch: ^1.2.1 | ||||
|  | ||||
| dev_dependencies: | ||||
|   flutter_test: | ||||
|   | ||||
		Reference in New Issue
	
	Block a user