mirror of
				https://github.com/ImranR98/Obtainium.git
				synced 2025-10-31 13:33:28 +01:00 
			
		
		
		
	Compare commits
	
		
			53 Commits
		
	
	
		
			v0.14.34-b
			...
			v0.14.38-b
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 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 | 
							
								
								
									
										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 | ||||
|   | ||||
| @@ -71,9 +71,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 | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -28,8 +28,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 +46,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 +59,24 @@ | ||||
|             </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> | ||||
|     </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> | ||||
| @@ -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,53 @@ | ||||
|     "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.", | ||||
|     "sortByFileNamesNotLinks": "Sortirajte po imenima datoteka umjesto po punim linkovima", | ||||
|     "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": "Filtrirajte da prvo posjetite 'Intemediate' link", | ||||
|     "intermediateLinkNotFound": "Intermediate link nije nađen", | ||||
|     "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", | ||||
|     "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 {}", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "Želite li ukloniti aplikaciju?", | ||||
|         "other": "Želite li ukloniti aplikacije?" | ||||
| @@ -324,7 +327,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", | ||||
| @@ -256,6 +256,7 @@ | ||||
|     "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", | ||||
| @@ -275,6 +276,8 @@ | ||||
|     "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 {}", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "Odstranit Apku?", | ||||
|         "other": "Odstranit Apky?" | ||||
|   | ||||
| @@ -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", | ||||
| @@ -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", | ||||
| @@ -256,6 +256,7 @@ | ||||
|     "highlightTouchTargets": "Weniger offensichtliche Touch-Ziele hervorheben", | ||||
|     "pickExportDir": "Export-Verzeichnis wählen", | ||||
|     "autoExportOnChanges": "Automatischer Export bei Änderung(en)", | ||||
|     "includeSettings": "Include settings", | ||||
|     "filterVersionsByRegEx": "Versionen nach regulären Ausdrücken filtern", | ||||
|     "trySelectingSuggestedVersionCode": "Versuchen, den vorgeschlagenen APK-Versionscode auszuwählen", | ||||
|     "dontSortReleasesList": "Freigaberelease von der API ordern", | ||||
| @@ -275,6 +276,8 @@ | ||||
|     "completeAppInstallationNotifChannel": "App Installation abschließen", | ||||
|     "checkingForUpdatesNotifChannel": "Nach Aktualisierungen suchen", | ||||
|     "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 {}", | ||||
|     "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", | ||||
| @@ -256,6 +256,7 @@ | ||||
|     "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", | ||||
| @@ -275,6 +276,8 @@ | ||||
|     "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 {}", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "Remove App?", | ||||
|         "other": "Remove Apps?" | ||||
|   | ||||
| @@ -14,8 +14,8 @@ | ||||
|     "githubPATLabel": "Token de Acceso Personal de GitHub (Reduce tiempos de espera)", | ||||
|     "includePrereleases": "Incluir versiones preliminares", | ||||
|     "fallbackToOlderReleases": "Retorceder a versiones previas", | ||||
|     "filterReleaseTitlesByRegEx": "Filtra Títulos de Versiones mediantes Expresiones Regulares", | ||||
|     "invalidRegEx": "Expresión regular inválida", | ||||
|     "filterReleaseTitlesByRegEx": "Filtrar Títulos de Versiones", | ||||
|     "invalidRegEx": "Expresión inválida", | ||||
|     "noDescription": "Sin descripción", | ||||
|     "cancel": "Cancelar", | ||||
|     "continue": "Continuar", | ||||
| @@ -30,7 +30,7 @@ | ||||
|     "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.", | ||||
|     "trackOnlyAppDescription": "Se monitorizará la aplicación en busca de actualizaciones, pero Obtainium no será capaz de descargarla o actalizarla.", | ||||
|     "cancelled": "Cancelado", | ||||
|     "appAlreadyAdded": "Aplicación ya añadida", | ||||
|     "alreadyUpToDateQuestion": "¿Aplicación ya actualizada?", | ||||
| @@ -55,13 +55,13 @@ | ||||
|     "notInstalled": "No Instalado", | ||||
|     "estimateInBrackets": "(Aproximado)", | ||||
|     "selectAll": "Seleccionar Todo", | ||||
|     "deselectN": "Deseleccionar {}", | ||||
|     "deselectX": "Deseleccionar {}", | ||||
|     "xWillBeRemovedButRemainInstalled": "{} será borrada de Obtainium pero continuará instalada en el dispositivo.", | ||||
|     "removeSelectedAppsQuestion": "¿Borrar aplicaciones seleccionadas?", | ||||
|     "removeSelectedApps": "Borrar Aplicaciones Seleccionadas", | ||||
|     "updateX": "Actualizar {}", | ||||
|     "installX": "Instalar {}", | ||||
|     "markXTrackOnlyAsUpdated": "Marcar {}\n(Solo Seguimient)\ncomo Actualizada", | ||||
|     "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", | ||||
| @@ -100,7 +100,7 @@ | ||||
|     "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", | ||||
|     "importErrors": "Errores de Importación", | ||||
|     "importedXOfYApps": "{} de {} Aplicaciones importadas.", | ||||
|     "followingURLsHadErrors": "Las siguientes URLs tuvieron problemas:", | ||||
|     "okay": "Correcto", | ||||
| @@ -135,7 +135,7 @@ | ||||
|     "share": "Compartir", | ||||
|     "appNotFound": "Aplicación no encontrada", | ||||
|     "obtainiumExportHyphenatedLowercase": "obtainium-export", | ||||
|     "pickAnAPK": "Elige una APK", | ||||
|     "pickAnAPK": "Selecciona 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:", | ||||
| @@ -154,8 +154,8 @@ | ||||
|     "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", | ||||
| @@ -180,14 +180,14 @@ | ||||
|     "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", | ||||
| @@ -196,14 +196,14 @@ | ||||
|     "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'.", | ||||
|     "categoryDeleteWarning": "Todas las aplicaciones en las categorías borradas 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.", | ||||
| @@ -211,70 +211,73 @@ | ||||
|     "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 de URls desde un Archivo (como OPML)", | ||||
|     "versionDetection": "Detección de Versiones", | ||||
|     "standardVersionDetection": "Detección de versiones estándar", | ||||
|     "groupByCategory": "Agrupar por Categoría", | ||||
|     "autoApkFilterByArch": "Tratar de filtrar las APKs mediante arquitecturas de procesador si es posible", | ||||
|     "autoApkFilterByArch": "Filtrar las APKs mediante arquitecturas de procesador, si es posible", | ||||
|     "overrideSource": "Sobrescribir Fuente", | ||||
|     "dontShowAgain": "No mostrar de nuevo", | ||||
|     "dontShowTrackOnlyWarnings": "No mostrar avisos de 'Solo Seguimiento'", | ||||
|     "dontShowAPKOriginWarnings": "No mostrar avisos de las fuentes de las APks", | ||||
|     "moveNonInstalledAppsToBottom": "Move non-installed Apps to bottom of Apps view", | ||||
|     "gitlabPATLabel": "GitLab Personal Access Token\n(Enables Search and Better APK Discovery)", | ||||
|     "about": "About", | ||||
|     "requiresCredentialsInSettings": "This needs additional credentials (in Settings)", | ||||
|     "checkOnStart": "Check for updates on startup", | ||||
|     "tryInferAppIdFromCode": "Try inferring App ID from source code", | ||||
|     "removeOnExternalUninstall": "Automatically remove externally uninstalled Apps", | ||||
|     "pickHighestVersionCode": "Auto-select highest version code APK", | ||||
|     "checkUpdateOnDetailPage": "Check for updates on opening an App detail page", | ||||
|     "disablePageTransitions": "Disable page transition animations", | ||||
|     "reversePageTransitions": "Reverse page transition animations", | ||||
|     "minStarCount": "Minimum Star Count", | ||||
|     "addInfoBelow": "Add this info below.", | ||||
|     "addInfoInSettings": "Add this info in the Settings.", | ||||
|     "githubSourceNote": "GitHub rate limiting can be avoided using an API key.", | ||||
|     "gitlabSourceNote": "GitLab APK extraction may not work without an API key.", | ||||
|     "sortByFileNamesNotLinks": "Sort by file names instead of full links", | ||||
|     "filterReleaseNotesByRegEx": "Filter Release Notes by Regular Expression", | ||||
|     "customLinkFilterRegex": "Custom APK Link Filter by Regular Expression (Default '.apk$')", | ||||
|     "appsPossiblyUpdated": "App Updates Attempted", | ||||
|     "appsPossiblyUpdatedNotifDescription": "Notifies the user that updates to one or more Apps were potentially applied in the background", | ||||
|     "xWasPossiblyUpdatedToY": "{} may have been updated to {}.", | ||||
|     "enableBackgroundUpdates": "Enable background updates", | ||||
|     "backgroundUpdateReqsExplanation": "Background updates may not be possible for all apps.", | ||||
|     "backgroundUpdateLimitsExplanation": "The success of a background install can only be determined when Obtainium is opened.", | ||||
|     "verifyLatestTag": "Verify the 'latest' tag", | ||||
|     "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit First", | ||||
|     "intermediateLinkNotFound": "Intermediate link not found", | ||||
|     "exemptFromBackgroundUpdates": "Exempt from background updates (if enabled)", | ||||
|     "bgUpdatesOnWiFiOnly": "Disable background updates when not on WiFi", | ||||
|     "autoSelectHighestVersionCode": "Auto-select highest versionCode APK", | ||||
|     "versionExtractionRegEx": "Version Extraction RegEx", | ||||
|     "moveNonInstalledAppsToBottom": "Mover las Apps no instaladas al final de la vista de Apps", | ||||
|     "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 las animaciones de transición de la página", | ||||
|     "minStarCount": "Número Mínimo de Estrellas", | ||||
|     "addInfoBelow": "Añadir esta información debajo.", | ||||
|     "addInfoInSettings": "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.", | ||||
|     "sortByFileNamesNotLinks": "Ordenar por nombres de fichero en vez de por enlaces completos", | ||||
|     "filterReleaseNotesByRegEx": "Filtrar por Notas de Versió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 verificar con Obtainium abierto.", | ||||
|     "verifyLatestTag": "Verifica la etiqueta 'latest'", | ||||
|     "intermediateLinkRegex": "Filtrar por Enlace 'Intermedio' para Visitar Primero", | ||||
|     "intermediateLinkNotFound": "Enlace Intermedio no encontrado", | ||||
|     "exemptFromBackgroundUpdates": "Exento 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": "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", | ||||
|     "highlightTouchTargets": "Resaltar objetivos menos obvios", | ||||
|     "pickExportDir": "Selecciona el Directorio para Exportar", | ||||
|     "autoExportOnChanges": "Auto Exportar cuando haya cambios", | ||||
|     "includeSettings": "Include settings", | ||||
|     "filterVersionsByRegEx": "Filtrar por Versiones", | ||||
|     "trySelectingSuggestedVersionCode": "Prueba seleccionando la versionCode APK sugerida", | ||||
|     "dontSortReleasesList": "Mantener el order de publicación desde API", | ||||
|     "reverseSort": "Orden inverso", | ||||
|     "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 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", | ||||
|     "supportFixedAPKURL": "Soporte para URLs fijas de APK", | ||||
|     "selectX": "Selecciona {}", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "¿Eliminar Aplicación?", | ||||
|         "other": "¿Eliminar Aplicaciones?" | ||||
| @@ -317,14 +320,14 @@ | ||||
|     }, | ||||
|     "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": "حذف خودکار برنامه های حذف نصب شده خارجی", | ||||
| @@ -239,42 +239,45 @@ | ||||
|     "sortByFileNamesNotLinks": "مرتب سازی بر اساس نام فایل به جای پیوندهای کامل", | ||||
|     "filterReleaseNotesByRegEx": "یادداشت های انتشار را با بیان منظم فیلتر کنید", | ||||
|     "customLinkFilterRegex": "فیلتر پیوند سفارشی بر اساس عبارت منظم (پیشفرض '.apk$')", | ||||
|     "appsPossiblyUpdated": "App Updates Attempted", | ||||
|     "appsPossiblyUpdatedNotifDescription": "Notifies the user that updates to one or more Apps were potentially applied in the background", | ||||
|     "xWasPossiblyUpdatedToY": "{} may have been updated to {}.", | ||||
|     "enableBackgroundUpdates": "Enable background updates", | ||||
|     "backgroundUpdateReqsExplanation": "Background updates may not be possible for all apps.", | ||||
|     "backgroundUpdateLimitsExplanation": "The success of a background install can only be determined when Obtainium is opened.", | ||||
|     "verifyLatestTag": "Verify the 'latest' tag", | ||||
|     "intermediateLinkRegex": "Filter for an 'Intermediate' Link to Visit First", | ||||
|     "intermediateLinkNotFound": "Intermediate link not found", | ||||
|     "exemptFromBackgroundUpdates": "Exempt from background updates (if enabled)", | ||||
|     "bgUpdatesOnWiFiOnly": "Disable background updates when not on WiFi", | ||||
|     "autoSelectHighestVersionCode": "Auto-select highest versionCode APK", | ||||
|     "versionExtractionRegEx": "Version Extraction RegEx", | ||||
|     "matchGroupToUse": "Match Group to Use", | ||||
|     "highlightTouchTargets": "Highlight less obvious touch targets", | ||||
|     "pickExportDir": "Pick Export Directory", | ||||
|     "autoExportOnChanges": "Auto-export on changes", | ||||
|     "filterVersionsByRegEx": "Filter Versions by Regular Expression", | ||||
|     "trySelectingSuggestedVersionCode": "Try selecting suggested versionCode APK", | ||||
|     "dontSortReleasesList": "Retain release order from API", | ||||
|     "reverseSort": "Reverse sorting", | ||||
|     "debugMenu": "Debug Menu", | ||||
|     "bgTaskStarted": "Background task started - check logs.", | ||||
|     "runBgCheckNow": "Run Background Update Check Now", | ||||
|     "versionExtractWholePage": "Apply Version Extraction Regex to Entire Page", | ||||
|     "installing": "Installing", | ||||
|     "skipUpdateNotifications": "Skip update notifications", | ||||
|     "appsPossiblyUpdated": "بهروزرسانی برنامه انجام شد", | ||||
|     "appsPossiblyUpdatedNotifDescription": "به کاربر اطلاع میدهد که بهروزرسانیهای یک یا چند برنامه به طور بالقوه در پسزمینه اعمال شده است", | ||||
|     "xWasPossiblyUpdatedToY": "ممکن است {} به {} به روز شده باشد.", | ||||
|     "enableBackgroundUpdates": "به روز رسانی پس زمینه را فعال کنید", | ||||
|     "backgroundUpdateReqsExplanation": "به روز رسانی پس زمینه ممکن است برای همه برنامه ها امکان پذیر نباشد.", | ||||
|     "backgroundUpdateLimitsExplanation": "موفقیت نصب پسزمینه تنها زمانی مشخص میشود که Obtainium باز شود.", | ||||
|     "verifyLatestTag": "برچسب "آخرین" را تأیید کنید", | ||||
|     "intermediateLinkRegex": "برای اولین بار بازدید از لینک "متوسط" را فیلتر کنید", | ||||
|     "intermediateLinkNotFound": "لینک میانی پیدا نشد", | ||||
|     "exemptFromBackgroundUpdates": "معاف از بهروزرسانیهای پسزمینه (در صورت فعال بودن)", | ||||
|     "bgUpdatesOnWiFiOnly": "بهروزرسانیهای پسزمینه را در صورت عدم اتصال به WiFi غیرفعال کنید", | ||||
|     "autoSelectHighestVersionCode": "انتخاب خودکار بالاترین نسخه کد APK", | ||||
|     "versionExtractionRegEx": "نسخه استخراج RegEx", | ||||
|     "matchGroupToUse": "گروه مورد استفاده را مطابقت دهید", | ||||
|     "highlightTouchTargets": "اهداف لمسی کمتر واضح را برجسته کنید", | ||||
|     "pickExportDir": "فهرست صادرات را انتخاب کنید", | ||||
|     "autoExportOnChanges": "صادرات خودکار تغییرات", | ||||
|     "includeSettings": "Include settings", | ||||
|     "filterVersionsByRegEx": "فیلتر کردن نسخه ها با RegEx", | ||||
|     "trySelectingSuggestedVersionCode": "نسخه پیشنهادی APK نسخه کد را انتخاب کنید", | ||||
|     "dontSortReleasesList": "حفظ سفارش انتشار از API", | ||||
|     "reverseSort": "مرتب سازی معکوس", | ||||
|     "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": "انتخاب کنید {}", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "برنامه حذف شود؟", | ||||
|         "other": "برنامه ها حذف شوند؟" | ||||
| @@ -324,7 +327,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", | ||||
| @@ -256,6 +256,7 @@ | ||||
|     "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", | ||||
| @@ -275,6 +276,8 @@ | ||||
|     "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 {}", | ||||
|     "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", | ||||
| @@ -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", | ||||
| @@ -255,6 +255,7 @@ | ||||
|     "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", | ||||
| @@ -275,6 +276,8 @@ | ||||
|     "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 {}", | ||||
|     "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", | ||||
| @@ -256,6 +256,7 @@ | ||||
|     "highlightTouchTargets": "Evidenzia elementi toccabili meno ovvi", | ||||
|     "pickExportDir": "Scegli cartella esp.", | ||||
|     "autoExportOnChanges": "Auto-esporta dopo modifiche", | ||||
|     "includeSettings": "Include settings", | ||||
|     "filterVersionsByRegEx": "Filtra versioni con espressione regolare", | ||||
|     "trySelectingSuggestedVersionCode": "Prova a selezionare APK con versionCode suggerito", | ||||
|     "dontSortReleasesList": "Conserva l'ordine di release da API", | ||||
| @@ -275,6 +276,8 @@ | ||||
|     "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": "Support fixed APK URLs", | ||||
|     "selectX": "Select {}", | ||||
|     "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": "外部でアンインストールされたアプリを自動的に削除する", | ||||
| @@ -256,6 +256,7 @@ | ||||
|     "highlightTouchTargets": "目立たないタップ可能な対象をハイライトする", | ||||
|     "pickExportDir": "エクスポートディレクトリを選択", | ||||
|     "autoExportOnChanges": "変更があった際に自動でエクスポートする", | ||||
|     "includeSettings": "Include settings", | ||||
|     "filterVersionsByRegEx": "正規表現でバージョンをフィルタリングする", | ||||
|     "trySelectingSuggestedVersionCode": "提案されたバージョンコードのAPKを選択する", | ||||
|     "dontSortReleasesList": "APIからのリリース順を保持する", | ||||
| @@ -275,6 +276,8 @@ | ||||
|     "completeAppInstallationNotifChannel": "アプリのインストールを完了する", | ||||
|     "checkingForUpdatesNotifChannel": "アップデートを確認中", | ||||
|     "onlyCheckInstalledOrTrackOnlyApps": "インストール済みのアプリと「追跡のみ」のアプリのアップデートのみを確認する", | ||||
|     "supportFixedAPKURL": "Support fixed APK URLs", | ||||
|     "selectX": "Select {}", | ||||
|     "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", | ||||
| @@ -256,6 +256,7 @@ | ||||
|     "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", | ||||
| @@ -275,6 +276,8 @@ | ||||
|     "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 {}", | ||||
|     "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", | ||||
| @@ -256,6 +256,7 @@ | ||||
|     "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", | ||||
| @@ -275,6 +276,8 @@ | ||||
|     "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 {}", | ||||
|     "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", | ||||
| @@ -256,6 +256,7 @@ | ||||
|     "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", | ||||
| @@ -275,6 +276,8 @@ | ||||
|     "completeAppInstallationNotifChannel": "Instalação completa do App", | ||||
|     "checkingForUpdatesNotifChannel": "Checando por Atualizações", | ||||
|     "onlyCheckInstalledOrTrackOnlyApps": "Only check installed and Track-Only apps for updates", | ||||
|     "supportFixedAPKURL": "Support fixed APK URLs", | ||||
|     "selectX": "Select {}", | ||||
|     "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": "Автоматически убирать из списка удаленные извне приложения", | ||||
| @@ -256,6 +256,7 @@ | ||||
|     "highlightTouchTargets": "Выделить менее очевидные элементы управления касанием", | ||||
|     "pickExportDir": "Выбрать каталог для экспорта", | ||||
|     "autoExportOnChanges": "Автоэкспорт при изменениях", | ||||
|     "includeSettings": "Include settings", | ||||
|     "filterVersionsByRegEx": "Фильтровать версии по регулярному выражению", | ||||
|     "trySelectingSuggestedVersionCode": "Попробуйте выбрать предложенный код версии APK", | ||||
|     "dontSortReleasesList": "Сохранить порядок релизов от API", | ||||
| @@ -275,6 +276,8 @@ | ||||
|     "completeAppInstallationNotifChannel": "Завершение установки приложения", | ||||
|     "checkingForUpdatesNotifChannel": "Проверка обновлений", | ||||
|     "onlyCheckInstalledOrTrackOnlyApps": "Only check installed and Track-Only apps for updates", | ||||
|     "supportFixedAPKURL": "Support fixed APK URLs", | ||||
|     "selectX": "Select {}", | ||||
|     "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", | ||||
| @@ -256,6 +256,7 @@ | ||||
|     "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", | ||||
|   | ||||
| @@ -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", | ||||
| @@ -256,6 +256,7 @@ | ||||
|     "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", | ||||
| @@ -275,6 +276,8 @@ | ||||
|     "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 {}", | ||||
|     "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", | ||||
| @@ -256,6 +256,7 @@ | ||||
|     "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", | ||||
| @@ -275,6 +276,8 @@ | ||||
|     "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 {}", | ||||
|     "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": "自动删除已卸载的外部应用", | ||||
| @@ -256,6 +256,7 @@ | ||||
|     "highlightTouchTargets": "突出展示不明显的触摸区域", | ||||
|     "pickExportDir": "选择导出文件夹", | ||||
|     "autoExportOnChanges": "数据变更时自动导出", | ||||
|     "includeSettings": "Include settings", | ||||
|     "filterVersionsByRegEx": "筛选版本号(正则表达式)", | ||||
|     "trySelectingSuggestedVersionCode": "尝试选择推荐版本的 APK 文件", | ||||
|     "dontSortReleasesList": "保持来自 API 的发行顺序", | ||||
| @@ -275,6 +276,8 @@ | ||||
|     "completeAppInstallationNotifChannel": "完成应用安装", | ||||
|     "checkingForUpdatesNotifChannel": "正在检查更新", | ||||
|     "onlyCheckInstalledOrTrackOnlyApps": "只对已安装和“仅追踪”的应用进行更新检查", | ||||
|     "supportFixedAPKURL": "Support fixed APK URLs", | ||||
|     "selectX": "Select {}", | ||||
|     "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); | ||||
|   | ||||
| @@ -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 != '') | ||||
|                | ||||
|         ]; | ||||
|         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) { | ||||
| @@ -94,6 +95,10 @@ class HTML extends AppSource { | ||||
|             label: tr('sortByFileNamesNotLinks')) | ||||
|       ], | ||||
|       [GeneratedFormSwitch('reverseSort', label: tr('reverseSort'))], | ||||
|       [ | ||||
|         GeneratedFormSwitch('supportFixedAPKURL', | ||||
|             defaultValue: true, label: tr('supportFixedAPKURL')), | ||||
|       ], | ||||
|       [ | ||||
|         GeneratedFormTextField('customLinkFilterRegex', | ||||
|             label: tr('customLinkFilterRegex'), | ||||
| @@ -222,7 +227,10 @@ class HTML extends AppSource { | ||||
|         throw NoReleasesError(); | ||||
|       } | ||||
|       var rel = links.last; | ||||
|       String? version = rel.hashCode.toString(); | ||||
|       String? version; | ||||
|       if (additionalSettings['supportFixedAPKURL'] != true) { | ||||
|         version = rel.hashCode.toString(); | ||||
|       } | ||||
|       var versionExtractionRegEx = | ||||
|           additionalSettings['versionExtractionRegEx'] as String?; | ||||
|       if (versionExtractionRegEx?.isNotEmpty == true) { | ||||
| @@ -243,9 +251,9 @@ class HTML extends AppSource { | ||||
|           throw NoVersionError(); | ||||
|         } | ||||
|       } | ||||
|       List<String> apkUrls = | ||||
|           [rel].map((e) => ensureAbsoluteUrl(e, uri)).toList(); | ||||
|       return APKDetails(version!, apkUrls.map((e) => MapEntry(e, e)).toList(), | ||||
|       rel = ensureAbsoluteUrl(rel, uri); | ||||
|       version ??= (await checkDownloadHash(rel)).toString(); | ||||
|       return APKDetails(version, [rel].map((e) => MapEntry(e, e)).toList(), | ||||
|           AppNames(uri.host, tr('app'))); | ||||
|     } else { | ||||
|       throw getObtainiumHttpError(res); | ||||
|   | ||||
| @@ -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', | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -19,7 +19,7 @@ import 'package:easy_localization/src/easy_localization_controller.dart'; | ||||
| // ignore: implementation_imports | ||||
| import 'package:easy_localization/src/localization.dart'; | ||||
|  | ||||
| const String currentVersion = '0.14.34'; | ||||
| const String currentVersion = '0.14.38'; | ||||
| const String currentReleaseTag = | ||||
|     'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES | ||||
|  | ||||
|   | ||||
| @@ -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), | ||||
|                       ))) | ||||
|                    | ||||
|               ...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), | ||||
|                     ), | ||||
|                   ) | ||||
|                 ], | ||||
|               ), | ||||
|           ], | ||||
|         ); | ||||
|  | ||||
|   | ||||
| @@ -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]))) | ||||
|                                   ])) | ||||
|                           , | ||||
|                       ...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]))) | ||||
|                                   ])) | ||||
|                           , | ||||
|                       ...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, | ||||
| @@ -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() | ||||
|                   ]))) | ||||
|       ], | ||||
|     ); | ||||
|   | ||||
| @@ -6,6 +6,7 @@ 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'; | ||||
| @@ -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; | ||||
| @@ -950,7 +974,7 @@ class AppsProvider with ChangeNotifier { | ||||
|       } | ||||
|     } | ||||
|     notifyListeners(); | ||||
|     exportApps(isAuto: true); | ||||
|     export(isAuto: true); | ||||
|   } | ||||
|  | ||||
|   Future<void> removeApps(List<String> appIds) async { | ||||
| @@ -972,7 +996,7 @@ class AppsProvider with ChangeNotifier { | ||||
|     } | ||||
|     if (appIds.isNotEmpty) { | ||||
|       notifyListeners(); | ||||
|       exportApps(isAuto: true); | ||||
|       export(isAuto: true); | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -1149,7 +1173,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 +1203,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,10 +1228,13 @@ 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)); | ||||
|     } | ||||
| @@ -1208,7 +1245,20 @@ class AppsProvider with ChangeNotifier { | ||||
|     } | ||||
|     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 | ||||
|   | ||||
| @@ -213,7 +213,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) { | ||||
| @@ -415,4 +416,13 @@ 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(); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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,11 @@ appJSONCompatibilityModifiers(Map<String, dynamic> json) { | ||||
|   if (additionalSettings['autoApkFilterByArch'] == null) { | ||||
|     additionalSettings['autoApkFilterByArch'] = false; | ||||
|   } | ||||
|   // HTML 'fixed URL' support should be disabled if it previously did not exist | ||||
|   if (source.runtimeType == HTML().runtimeType && | ||||
|       originalAdditionalSettings['supportFixedAPKURL'] == null) { | ||||
|     additionalSettings['supportFixedAPKURL'] = false; | ||||
|   } | ||||
|   json['additionalSettings'] = jsonEncode(additionalSettings); | ||||
|   // F-Droid no longer needs cloudflare exception since override can be used - migrate apps appropriately | ||||
|   // This allows us to reverse the changes made for issue #418 (support cloudflare.f-droid) | ||||
| @@ -448,7 +454,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 +603,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 +684,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 +706,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); | ||||
|   } | ||||
|   | ||||
							
								
								
									
										122
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						
									
										122
									
								
								pubspec.lock
									
									
									
									
									
								
							| @@ -5,10 +5,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: android_alarm_manager_plus | ||||
|       sha256: "82fb28c867c4b3dd7e9157728e46426b8916362f977dbba46b949210f00099f4" | ||||
|       sha256: "84720c8ad2758aabfbeafd24a8c355d8c8dd3aa52b01eaf3bb827c7210f61a91" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.0.3" | ||||
|     version: "3.0.4" | ||||
|   android_intent_plus: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -38,10 +38,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: | ||||
| @@ -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: | ||||
| @@ -291,10 +299,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: flutter_local_notifications | ||||
|       sha256: "6d11ea777496061e583623aaf31923f93a9409ef8fcaeeefdd6cd78bf4fe5bb3" | ||||
|       sha256: bb5cd63ff7c91d6efe452e41d0d0ae6348925c82eafd10ce170ef585ea04776e | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "16.1.0" | ||||
|     version: "16.2.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: | ||||
| @@ -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,18 +919,18 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: webview_flutter_android | ||||
|       sha256: "8326ee235f87605a2bfc444a4abc897f4abc78d83f054ba7d3d1074ce82b4fbf" | ||||
|       sha256: e313dcdf45d4c95bcb8960351ef2389b7f0687b90bc92483f7f7983ae5758456 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.12.1" | ||||
|     version: "3.13.0" | ||||
|   webview_flutter_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: webview_flutter_platform_interface | ||||
|       sha256: "6d9213c65f1060116757a7c473247c60f3f7f332cac33dc417c9e362a9a13e4f" | ||||
|       sha256: "68e86162aa8fc646ae859e1585995c096c95fc2476881fa0c4a8d10f56013a5a" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.6.0" | ||||
|     version: "2.8.0" | ||||
|   webview_flutter_wkwebview: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -919,10 +943,10 @@ packages: | ||||
|     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.34+228 # When changing this, update the tag in main() accordingly | ||||
| version: 0.14.38+232 # When changing this, update the tag in main() accordingly | ||||
|  | ||||
| environment: | ||||
|   sdk: '>=3.0.0 <4.0.0' | ||||
| @@ -66,6 +66,8 @@ dependencies: | ||||
|   hsluv: ^1.1.3 | ||||
|   connectivity_plus: ^5.0.0 | ||||
|   shared_storage: ^0.8.0 | ||||
|   crypto: ^3.0.3 | ||||
|   app_links: ^3.5.0 | ||||
|  | ||||
| dev_dependencies: | ||||
|   flutter_test: | ||||
|   | ||||
		Reference in New Issue
	
	Block a user