Compare commits

...

131 Commits

Author SHA1 Message Date
8ece0bbef9 Increment version 2022-12-18 13:13:44 -05:00
6a41283e74 Merge pull request #167 from bluefly000/main
Fix Japanese translations
2022-12-18 13:06:20 -05:00
e6d5c7db3e Add Japanese translation of untranslated sections 2022-12-18 19:30:11 +09:00
d4c016d8ee Fix Japanese translation (corrections to translations of notifications) 2022-12-18 18:58:06 +09:00
67b986de93 Merge pull request #164 from gidano/Editing
HU text length adjustment
2022-12-18 01:31:54 -05:00
aafe4bc515 Increment version 2022-12-18 01:31:40 -05:00
e524335900 Add App bugfix 2022-12-18 01:21:14 -05:00
77751fa03f HU text length adjustment 2022-12-17 20:22:22 +01:00
b4e06ffb8e Increment version 2022-12-17 13:35:20 -05:00
af511deeca Merge pull request #162 from gidano/main
Hungarian translate
2022-12-17 13:33:31 -05:00
71c6db9510 Hungarian translate 2022-12-17 09:56:52 +01:00
8fac67c9e9 Fix Japanese translation 2022-12-17 17:56:42 +09:00
c317f23741 Increment version 2022-12-17 00:22:17 -05:00
12c0dd8489 Merge pull request #161 from HRTK92/main
fix translation japanese
2022-12-17 00:21:41 -05:00
1c7385ab56 fix translation japanese 2022-12-17 13:38:57 +09:00
b46347a6e3 Increment version 2022-12-16 22:47:21 -05:00
a7104c89dc Merge pull request #160 from HRTK92/main
add Japanese translation
2022-12-16 22:46:42 -05:00
347d2c2738 unified indentation 2022-12-17 12:04:19 +09:00
cc17260e54 add japanese translation 2022-12-17 12:01:54 +09:00
1985dcec3a Fixed bug for FDroid repos with uppercase in AppID 2022-12-16 19:48:48 -05:00
d435481f0b Increment version 2022-12-16 19:37:22 -05:00
a68d49c71c Added Steam as a Source (#159) + Bugfixes 2022-12-16 19:26:07 -05:00
2b6a16637e Merge branch 'main' of github.com:ImranR98/Obtainium 2022-12-16 18:56:06 -05:00
e46e4e5dbc Merge pull request #157 from atilluF/Italian-TL
Update it.json
2022-12-16 18:54:18 -05:00
848c8eaf5e Merge pull request #156 from RanTranslations/main
assets: Update Simplified Chinese translations
2022-12-16 18:54:07 -05:00
ebc48169a1 Bugfix #158 2022-12-16 18:25:51 -05:00
54c37641d5 Update it.json 2022-12-16 08:33:08 +01:00
05ad01bf85 assets: Update Simplified Chinese translations 2022-12-16 13:02:40 +08:00
049b023e01 Adding from custom fdroid repos is easier (name based) 2022-12-15 21:39:05 -05:00
f6ca5d42e8 Initial third party F-Droid repo support
Plus various bugfixes
And version increment
2022-12-15 21:22:03 -05:00
6d0cac5894 Bugfix for switching pages while downloading #150 2022-12-15 18:57:06 -05:00
bfa661c8e0 Enabled italian translations, increment version 2022-12-15 12:15:35 -05:00
e5825fe1d3 Merge pull request #153 from atilluF/Italian-TL
Italian translation
2022-12-15 12:12:00 -05:00
9e09aba444 Merge pull request #152 from atilluF/README
Added SourceForge to README.md
2022-12-15 12:11:55 -05:00
8f5e07a5ca Added Italian translation 2022-12-15 18:01:58 +01:00
e7f3cdafe5 Added SourceForge to README.md 2022-12-15 17:55:02 +01:00
14ae43de92 Internationalized more strings
Added ZH to supported language codes
Increment version
2022-12-15 11:09:03 -05:00
a8f0d784a2 Merge pull request #151 from RanTranslations/main
assets: Add Simplified Chinese translations
2022-12-15 10:32:29 -05:00
b1fb06e90b assets: Add Simplified Chinese translations 2022-12-15 18:53:33 +08:00
481204665c Workaround for version detection error in BG 2022-12-14 19:10:05 -05:00
317b5ac83a Added a log for prev. commit 2022-12-12 20:56:14 -05:00
f3b1ca4541 Attempt to disable ver. det. in bg if needed 2022-12-12 20:53:42 -05:00
a00cfa2ba6 Fixed some strings 2022-12-11 11:46:00 -05:00
f81f6374bb Enhanced Version Detection (Again) (#144)
* Simpler approach to EVD

* Download notifs now have progress bars

* Removed unused import, changed some comments

* Re-added "Please Wait" on Apps list (accidentally removed)

* Updated README.md
2022-12-11 01:59:45 -05:00
da8695834e Re-added APKMirror to README 2022-12-08 19:09:14 -05:00
c4ba1e9dbc Increment version 2022-12-08 19:01:00 -05:00
49862ad2a6 Reduced download notification importance 2022-12-08 18:57:53 -05:00
1b892f4e0d Avoid overflow for long version strings on Apps page 2022-12-08 18:54:40 -05:00
a4555f07f9 Fixed typo 2022-12-08 18:33:36 -05:00
73fbdd84f0 Updated version 2022-12-07 20:46:12 -05:00
a1518480db Updated build number 2022-12-07 20:43:35 -05:00
fd3ee02e52 Completely removed enhanced version detection 2022-12-07 20:36:14 -05:00
609366675d Fix translation error in BG check task 2022-12-07 19:48:59 -05:00
fbff498ae1 Addresses #139 2022-12-05 20:10:42 -05:00
bb4e470760 Slight tweaks 2022-12-05 20:09:16 -05:00
15183c3a95 Simplified EVD (only xx.yy.zz) 2022-12-05 16:31:43 -05:00
b496a416ff Increment version 2022-12-05 15:56:43 -05:00
6ac7ba204f EVD bugfix 2022-12-05 15:46:47 -05:00
0951c007d1 Bugfix for enhanced version detection 2022-12-05 15:39:36 -05:00
d835beec76 Bugfix for localization error in BG 2022-12-05 14:57:38 -05:00
2654bf12d3 Removed unused import 2022-12-04 17:15:08 -05:00
3951108bc9 Refactor - removed duplicate code 2022-12-04 17:12:10 -05:00
d934ce2e13 Enhanced detect bugfix + outdated apps show curr. ver. 2022-12-04 17:08:11 -05:00
66cc7f059f Disable mark as updated for enhanced detect apps 2022-12-04 16:58:04 -05:00
098428dac9 Typo 2022-12-04 14:35:49 -05:00
9e7c21b408 Enhanced ver. detection fix for track only apps 2022-12-04 14:18:02 -05:00
31c2c6b7c1 Enhanced ver. detection bugfix 2022-12-04 14:15:15 -05:00
f70049aded Changed a default (enhanced version detect bugfix) 2022-12-04 13:51:44 -05:00
60c28bf912 Attempting to add enhanced version detection #132 2022-12-04 13:40:58 -05:00
a6ed1e7c98 Increment version, upgrade packages 2022-12-04 12:49:16 -05:00
963f51dc53 Added download notifications
(removed toast during add app)
2022-12-04 12:48:12 -05:00
17b1f6e5b0 Internationalization (#131)
Replaced hardcoded English strings with locale-based variables based on the [easy_localization](https://pub.dev/packages/easy_localization) Flutter plugin.
2022-11-26 23:53:11 -05:00
086b2b949f Fixed bugfix with GitHub track-only Apps with no APK 2022-11-25 23:12:15 -05:00
9b5b212e96 APKMirror version extraction bugfix 2022-11-25 23:04:37 -05:00
6c8f9ebcbf Ran flutter upgrade 2022-11-25 20:45:49 -05:00
4d5773bdcc Slight UI changes on Add App page 2022-11-25 20:41:57 -05:00
f81ef6a416 Search results now interleaved on Add App page 2022-11-25 20:35:51 -05:00
47324fcb49 Added search bar on Add App page 2022-11-25 20:31:52 -05:00
377e0e07bd Removed unused imports 2022-11-25 19:17:08 -05:00
b5aae70274 Bugfix from prev. commit 2022-11-25 19:13:29 -05:00
42475fa42a Only ask for install perm. for non-track-only apps 2022-11-25 19:07:05 -05:00
d29534ef2e Increment version 2022-11-25 18:56:43 -05:00
25953399ac Re-added APKMirror as a Track-Only source 2022-11-25 18:55:17 -05:00
b04d2fad5c Adds Track-Only App Support (Addresses #119 and Sets Groundwork for #44) (#123)
- All Sources now have a "Track-Only" option that will prevent Obtainium from looking for APKs (though the App must still have a release of some kind so that a version string can be grabbed).
    - These Apps cannot be installed through Obtainium, but update notifications will still be sent.
    - The user needs to manually mark them as updated when appropriate.
    - This addresses issue #119.
    - It also partially addresses #44 by allowing some sources to be configured as "Track-Only"-only. The first such source (APKMirror) will be added later.
- Includes various UI changes to accommodate the above change.
- Also makes App loading a bit more responsive (sending Obtainium to the background then returning will now cause App re-load to pick up changes in App versioning that may have been made in the meantime, for instance through update checking).
2022-11-24 21:12:46 -05:00
868ba84c9a Tiny bugfix 2022-11-20 11:05:33 -05:00
602f0c3bb2 Increment version 2022-11-19 16:26:44 -05:00
00721e8ac4 Added days filter to logs dialog (#117) 2022-11-19 16:20:42 -05:00
d19f9101d6 Added dropdown support to generated form 2022-11-19 15:42:20 -05:00
a4bc278e4c Increment version 2022-11-17 19:02:44 -05:00
b04986622b IzzyOnDroid now uses API (+ other minor tweaks) 2022-11-17 19:00:27 -05:00
2059e4fd44 Switched to F-Droid API 2022-11-17 18:36:05 -05:00
618a1523cf Add app only downloads APK if needed 2022-11-16 20:57:58 -05:00
ba1cdc2c73 Increment version 2022-11-14 21:10:30 -05:00
aa2a25fffe Better GitHub error messages (#112) 2022-11-14 20:56:04 -05:00
c8ec67aef3 Existing APKs now reused again
With partial APKs avoided (#113)
2022-11-14 20:38:02 -05:00
9576a99a4e More efficient loading (addresses #110) 2022-11-14 12:56:52 -05:00
0202224fa6 Merge pull request #108 from ImranR98/logging
Added basic logging - mainly just for the BG task and errors right now.
2022-11-12 19:18:12 -05:00
631ffd5c34 Added basic logging + increment version
Logging is mainly just for the BG task and errors right now.
2022-11-12 19:17:05 -05:00
feed7ffc0b Increment version 2022-11-12 11:27:15 -05:00
296485de8a Added "Reset Install Status" button 2022-11-12 11:21:46 -05:00
d2f226d442 Slight refactoring 2022-11-12 10:44:59 -05:00
cbdb449e35 Bugfix in moveObtainiumToStart 2022-11-12 10:43:12 -05:00
3100a3a08c Added descriptions to GitHub starred imports 2022-11-12 10:40:54 -05:00
18951d6461 Added descriptions to search results 2022-11-12 10:35:59 -05:00
0e0a39a40f Allow App downgrades if com.berdik.letmedowngrade installed 2022-11-12 10:05:46 -05:00
55cae0620b Updated packages 2022-11-12 09:46:23 -05:00
ba6cea3ae6 Slightly changed icon 2022-11-12 09:42:42 -05:00
4be33374c2 Merge pull request #106 from ImranR98/search
GitHub Search and Pinned Apps
2022-11-12 03:30:36 -05:00
e2bf834981 Increment version 2022-11-12 02:15:23 -05:00
9bd7ddb21b Added App pinning 2022-11-12 02:14:45 -05:00
905a807ee9 GitHub search added 2022-11-12 01:25:32 -05:00
ab57b97875 Ready to implement GitHub search (UI done) 2022-11-12 01:05:16 -05:00
5db2c5f0b1 Initial changes to support search 2022-11-11 21:44:20 -05:00
e158c23cca Added a note about imported apps 'not installed' 2022-11-10 13:17:51 -05:00
208f125e12 Increment version 2022-11-10 13:02:37 -05:00
b7ccf3fa49 Fixed App import and legacy Apps upgrade (#103) 2022-11-10 12:55:04 -05:00
c746e89052 Fixed error reporting in add app box 2022-11-10 10:29:00 -05:00
ee758e8470 Fixed bug from previous commit 2022-11-10 10:26:36 -05:00
68d903e092 Increment version 2022-11-09 20:57:03 -05:00
c47b752344 Cancel update notifications on new install (#101)
Can't get more granular due to flutter_local_notifications/issues/1700
2022-11-09 20:56:40 -05:00
62a05996cf Fixed regression for #20 from last cleanup 2022-11-09 19:25:41 -05:00
1cda941fbe Removed APKMirror code (previously unused but present) 2022-11-07 16:05:07 -05:00
49cb908d04 Increment version 2022-11-07 15:33:02 -05:00
139f44d31d UI improvement in APKPicker 2022-11-07 15:32:42 -05:00
ed955ac6a2 Add arch info (#100) 2022-11-07 15:14:00 -05:00
f3ead6caf1 Fixed breaking bug from previous commit 2022-11-06 14:21:00 -05:00
97ab723d04 Cleanup (#98) 2022-11-05 23:29:12 -04:00
ed4a26d348 Switch to AlarmManager plugin from WorkManager for more reliable update checking (for #87) (#97) 2022-11-05 23:25:19 -04:00
bd5f21984e Shorter default interval (see #87) 2022-11-04 19:10:20 -04:00
5037d77b14 Increment version 2022-11-04 18:57:06 -04:00
c9711c7734 Addresses #76 and #93 2022-11-04 18:53:25 -04:00
50 changed files with 3957 additions and 1545 deletions

1
.gitignore vendored
View File

@ -9,6 +9,7 @@
.history .history
.svn/ .svn/
migrate_working_dir/ migrate_working_dir/
.vscode/
# IntelliJ related # IntelliJ related
*.iml *.iml

View File

@ -13,9 +13,13 @@ Currently supported App sources:
- [IzzyOnDroid](https://android.izzysoft.de/) - [IzzyOnDroid](https://android.izzysoft.de/)
- [Mullvad](https://mullvad.net/en/) - [Mullvad](https://mullvad.net/en/)
- [Signal](https://signal.org/) - [Signal](https://signal.org/)
- [SourceForge](https://sourceforge.net/)
- [APKMirror](https://apkmirror.com/) (Track-Only)
- Third Party F-Droid Repos (URLs ending with `/fdroid/repo`)
- [Steam](https://store.steampowered.com/mobile)
## Limitations ## Limitations
- App installs are assumed to have succeeded; failures and cancelled installs cannot be detected. - App installs happen asynchronously and the success/failure of an install cannot be determined directly. This results in install statuses and versions sometimes being out of sync with the OS until the next launch or until the problem is manually corrected.
- Auto (unattended) updates are unsupported due to a lack of any capable Flutter plugin. - Auto (unattended) updates are unsupported due to a lack of any capable Flutter plugin.
- For some sources, data is gathered using Web scraping and can easily break due to changes in website design. In such cases, more reliable methods may be unavailable. - For some sources, data is gathered using Web scraping and can easily break due to changes in website design. In such cases, more reliable methods may be unavailable.

View File

@ -30,7 +30,25 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<service
android:name="dev.fluttercommunity.plus.androidalarmmanager.AlarmService"
android:permission="android.permission.BIND_JOB_SERVICE"
android:exported="false"/>
<receiver
android:name="dev.fluttercommunity.plus.androidalarmmanager.AlarmBroadcastReceiver"
android:exported="false"/>
<receiver
android:name="dev.fluttercommunity.plus.androidalarmmanager.RebootBroadcastReceiver"
android:enabled="false"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application> </application>
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<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"/>
</manifest> </manifest>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 918 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

235
assets/translations/en.json Normal file
View File

@ -0,0 +1,235 @@
{
"invalidURLForSource": "Not a valid {} App URL",
"noReleaseFound": "Could not find a suitable release",
"noVersionFound": "Could not determine release version",
"urlMatchesNoSource": "URL does not match a known source",
"cantInstallOlderVersion": "Cannot install an older version of an App",
"appIdMismatch": "Downloaded package ID does not match existing App ID",
"functionNotImplemented": "This class has not implemented this function",
"placeholder": "Placeholder",
"someErrors": "Some Errors Occurred",
"unexpectedError": "Unexpected Error",
"ok": "Okay",
"and": "and",
"startedBgUpdateTask": "Started BG update check task",
"bgUpdateIgnoreAfterIs": "Bg update ignoreAfter is {}",
"startedActualBGUpdateCheck": "Started actual BG update checking",
"bgUpdateTaskFinished": "Finished BG update check task",
"firstRun": "This is the first ever run of Obtainium",
"settingUpdateCheckIntervalTo": "Setting update interval to {}",
"githubPATLabel": "GitHub Personal Access Token (Increases Rate Limit)",
"githubPATHint": "PAT must be in this format: username:token",
"githubPATFormat": "username:token",
"githubPATLinkText": "About GitHub PATs",
"includePrereleases": "Include prereleases",
"fallbackToOlderReleases": "Fallback to older releases",
"filterReleaseTitlesByRegEx": "Filter Release Titles by Regular Expression",
"invalidRegEx": "Invalid regular expression",
"noDescription": "No description",
"cancel": "Cancel",
"continue": "Continue",
"requiredInBrackets": "(Required)",
"dropdownNoOptsError": "ERROR: DROPDOWN MUST HAVE AT LEAST ONE OPT",
"colour": "Colour",
"githubStarredRepos": "GitHub Starred Repos",
"uname": "Username",
"wrongArgNum": "Wrong number of arguments provided",
"xIsTrackOnly": "{} is Track-Only",
"source": "Source",
"app": "App",
"appsFromSourceAreTrackOnly": "Apps from this source are 'Track-Only'.' ",
"youPickedTrackOnly": "You have selected the 'Track-Only' option.",
"trackOnlyAppDescription": "The App will be tracked for updates, but Obtainium will not be able to download or install it.",
"cancelled": "Cancelled",
"appAlreadyAdded": "App already added",
"alreadyUpToDateQuestion": "App Already up to Date?",
"addApp": "Add App",
"appSourceURL": "App Source URL",
"error": "Error",
"add": "Add",
"searchSomeSourcesLabel": "Search (Some Sources Only)",
"search": "Search",
"additionalOptsFor": "Additional Options for {}",
"supportedSourcesBelow": "Supported Sources:",
"trackOnlyInBrackets": "(Track-Only)",
"searchableInBrackets": "(Searchable)",
"appsString": "Apps",
"noApps": "No Apps",
"noAppsForFilter": "No Apps for Filter",
"byX": "By {}",
"percentProgress": "Progress: {}%",
"pleaseWait": "Please Wait",
"updateAvailable": "Update Available",
"estimateInBracketsShort": "(Est.)",
"notInstalled": "Not Installed",
"estimateInBrackets": "(Estimate)",
"selectAll": "Select All",
"deselectN": "Deselect {}",
"xWillBeRemovedButRemainInstalled": "{} will be removed from Obtainium but remain installed on device.",
"removeSelectedAppsQuestion": "Remove Selected Apps?",
"removeSelectedApps": "Remove Selected Apps",
"updateX": "Update {}",
"installX": "Install {}",
"markXTrackOnlyAsUpdated": "Mark {}\n(Track-Only)\nas Updated",
"changeX": "Change {}",
"installUpdateApps": "Install/Update Apps",
"installUpdateSelectedApps": "Install/Update Selected Apps",
"onlyWorksWithNonEVDApps": "Only works for Apps whose install status cannot be automatically detected (uncommon).",
"markXSelectedAppsAsUpdated": "Mark {} Selected Apps as Updated?",
"no": "No",
"yes": "Yes",
"markSelectedAppsUpdated": "Mark Selected Apps as Updated",
"pinToTop": "Pin to top",
"unpinFromTop": "Unpin from top",
"resetInstallStatusForSelectedAppsQuestion": "Reset Install Status for Selected Apps?",
"installStatusOfXWillBeResetExplanation": "The install status of any selected Apps will be reset.\n\nThis can help when the App version shown in Obtainium is incorrect due to failed updates or other issues.",
"shareSelectedAppURLs": "Share Selected App URLs",
"resetInstallStatus": "Reset Install Status",
"more": "More",
"removeOutdatedFilter": "Remove Out-of-Date App Filter",
"showOutdatedOnly": "Show Out-of-Date Apps Only",
"filter": "Filter",
"filterActive": "Filter *",
"filterApps": "Filter Apps",
"appName": "App Name",
"author": "Author",
"upToDateApps": "Up to Date Apps",
"nonInstalledApps": "Non-Installed Apps",
"importExport": "Import/Export",
"settings": "Settings",
"exportedTo": "Exported to {}",
"obtainiumExport": "Obtainium Export",
"invalidInput": "Invalid input",
"importedX": "Imported {}",
"obtainiumImport": "Obtainium Import",
"importFromURLList": "Import from URL List",
"searchQuery": "Search Query",
"appURLList": "App URL List",
"line": "Line",
"searchX": "Search {}",
"noResults": "No results found",
"importX": "Import {}",
"importedAppsIdDisclaimer": "Imported Apps may incorrectly show as \"Not Installed\".\nTo fix this, re-install them through Obtainium.\nThis should not affect App data.\n\nOnly affects URL and third-party import methods.",
"importErrors": "Import Errors",
"importedXOfYApps": "{} of {} Apps imported.",
"followingURLsHadErrors": "The following URLs had errors:",
"okay": "Okay",
"selectURL": "Select URL",
"selectURLs": "Select URLs",
"pick": "Pick",
"theme": "Theme",
"dark": "Dark",
"light": "Light",
"followSystem": "Follow System",
"obtainium": "Obtainium",
"materialYou": "Material You",
"appSortBy": "App Sort By",
"authorName": "Author/Name",
"nameAuthor": "Name/Author",
"asAdded": "As Added",
"appSortOrder": "App Sort Order",
"ascending": "Ascending",
"descending": "Descending",
"bgUpdateCheckInterval": "Background Update Checking Interval",
"neverManualOnly": "Never - Manual Only",
"appearance": "Appearance",
"showWebInAppView": "Show Source Webpage in App View",
"pinUpdates": "Pin Updates to Top of Apps View",
"updates": "Updated",
"sourceSpecific": "Source-Specific",
"appSource": "App Source",
"noLogs": "No Logs",
"appLogs": "App Logs",
"close": "Close",
"share": "Share",
"appNotFound": "App not found",
"obtainiumExportHyphenatedLowercase": "obtainium-export",
"pickAnAPK": "Pick an APK",
"appHasMoreThanOnePackage": "{} has more than one package:",
"deviceSupportsXArch": "Your device supports the {} CPU architecture.",
"deviceSupportsFollowingArchs": "Your device supports the following CPU architectures:",
"warning": "Warning",
"sourceIsXButPackageFromYPrompt": "The App source is '{}' but the release package comes from '{}'. Continue?",
"updatesAvailable": "Updates Available",
"updatesAvailableNotifDescription": "Notifies the user that updates are available for one or more Apps tracked by Obtainium",
"noNewUpdates": "No new updates.",
"xHasAnUpdate": "{} has an update.",
"appsUpdated": "Apps Updated",
"appsUpdatedNotifDescription": "Notifies the user that updates to one or more Apps were applied in the background",
"xWasUpdatedToY": "{} was updated to {}.",
"errorCheckingUpdates": "Error Checking for Updates",
"errorCheckingUpdatesNotifDescription": "A notification that shows when background update checking fails",
"appsRemoved": "Apps Removed",
"appsRemovedNotifDescription": "Notifies the user that one or more Apps were removed due to errors while loading them",
"xWasRemovedDueToErrorY": "{} was removed due to this error: {}",
"completeAppInstallation": "Complete App Installation",
"obtainiumMustBeOpenToInstallApps": "Obtainium must be open to install Apps",
"completeAppInstallationNotifDescription": "Asks the user to return to Obtainium to finish installing an App",
"checkingForUpdates": "Checking for Updates",
"checkingForUpdatesNotifDescription": "Transient notification that appears when checking for updates",
"pleaseAllowInstallPerm": "Please allow Obtainium to install Apps",
"trackOnly": "Track-Only",
"errorWithHttpStatusCode": "Error {}",
"versionCorrectionDisabled": "Version correction disabled (plugin doesn't seem to work)",
"unknown": "Unknown",
"none": "None",
"never": "Never",
"latestVersionX": "Latest Version: {}",
"installedVersionX": "Installed Version: {}",
"lastUpdateCheckX": "Last Update Check: {}",
"remove": "Remove",
"removeAppQuestion": "Remove App?",
"yesMarkUpdated": "Yes, Mark as Updated",
"fdroid": "F-Droid",
"appIdOrName": "App ID or Name",
"appWithIdOrNameNotFound": "No App was found with that ID or Name",
"reposHaveMultipleApps": "Repos may contain multiple Apps",
"fdroidThirdPartyRepo": "F-Droid Third-Party Repo",
"steam": "Steam",
"steamMobile": "Steam Mobile",
"steamChat": "Steam Chat",
"tooManyRequestsTryAgainInMinutes": {
"one": "Too many requests (rate limited) - try again in {} minute",
"other": "Too many requests (rate limited) - try again in {} minutes"
},
"bgUpdateGotErrorRetryInMinutes": {
"one": "BG update checking encountered a {}, will schedule a retry check in {} minute",
"other": "BG update checking encountered a {}, will schedule a retry check in {} minutes"
},
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
"one": "BG update checking found {} update - will notify user if needed",
"other": "BG update checking found {} updates - will notify user if needed"
},
"apps": {
"one": "{} App",
"other": "{} Apps"
},
"url": {
"one": "{} URL",
"other": "{} URLs"
},
"minute": {
"one": "{} Minute",
"other": "{} Minutes"
},
"hour": {
"one": "{} Hour",
"other": "{} Hours"
},
"day": {
"one": "{} Day",
"other": "{} Days"
},
"clearedNLogsBeforeXAfterY": {
"one": "Cleared {n} log (before = {before}, after = {after})",
"other": "Cleared {n} logs (before = {before}, after = {after})"
},
"xAndNMoreUpdatesAvailable": {
"one": "{} and 1 more app have updates.",
"other": "{} and {} more apps have updates."
},
"xAndNMoreUpdatesInstalled": {
"one": "{} and 1 more app were updated.",
"other": "{} and {} more apps were updated."
}
}

235
assets/translations/hu.json Normal file
View File

@ -0,0 +1,235 @@
{
"invalidURLForSource": "Érvénytelen a(z) {} app URL-je",
"noReleaseFound": "Nem található megfelelő kiadás",
"noVersionFound": "Nem sikerült meghatározni a kiadás verzióját",
"urlMatchesNoSource": "Az URL nem egyezik ismert forrással",
"cantInstallOlderVersion": "Nem telepíthető egy app régebbi verziója",
"appIdMismatch": "A letöltött csomagazonosító nem egyezik a meglévő app azonosítóval",
"functionNotImplemented": "Ez az osztály nem valósította meg ezt a függvényt",
"placeholder": "Helykitöltő",
"someErrors": "Néhány hiba történt",
"unexpectedError": "Váratlan hiba",
"ok": "Oké",
"and": "és",
"startedBgUpdateTask": "Háttérfrissítés ellenőrzési feladat elindítva",
"bgUpdateIgnoreAfterIs": "Háttérfrissítés ignoreAfter a következő: {}",
"startedActualBGUpdateCheck": "Elkezdődött a tényleges BG frissítés ellenőrzése",
"bgUpdateTaskFinished": "A háttérfrissítés ellenőrzési feladat befejeződött",
"firstRun": "Ez az Obtainium első futása",
"settingUpdateCheckIntervalTo": "A frissítési intervallum beállítása a erre: {}",
"githubPATLabel": "GitHub személyes hozzáférési token (megnöveli a díjkorlátot)",
"githubPATHint": "A PAT-nak a következő formátumban kell lennie: felhasználónév:token",
"githubPATFormat": "felhasználónév:token",
"githubPATLinkText": "A GitHub PAT-okról",
"includePrereleases": "Tartalmazza az előzetes kiadásokat",
"fallbackToOlderReleases": "Visszatérés a régebbi kiadásokhoz",
"filterReleaseTitlesByRegEx": "A kiadás címeinek szűrése reguláris kifejezéssel",
"invalidRegEx": "Érvénytelen reguláris kifejezés",
"noDescription": "Nincs leírás",
"cancel": "Mégse",
"continue": "Tovább",
"requiredInBrackets": "(Kötlező)",
"dropdownNoOptsError": "HIBA: A LEDOBÁST LEGALÁBB EGY OPCIÓBAN KELL RENDELNI",
"colour": "Szín",
"githubStarredRepos": "GitHub Csillagozott Repo-k",
"uname": "Felh.név",
"wrongArgNum": "Rossz számú argumentumot adott meg",
"xIsTrackOnly": "{} csak nyomon követhető",
"source": "Forrás",
"app": "App",
"appsFromSourceAreTrackOnly": "Az ebből a forrásból származó alkalmazások 'Csak nyomon követhetőek'.' ",
"youPickedTrackOnly": "A 'Csak követés' opciót választotta.",
"trackOnlyAppDescription": "Az alkalmazás frissítéseit nyomon követi, de az Obtainium nem tudja letölteni vagy telepíteni.",
"cancelled": "Törölve",
"appAlreadyAdded": "Az app már hozzáadva",
"alreadyUpToDateQuestion": "Az app már naprakész?",
"addApp": "App hozzáadás",
"appSourceURL": "App forrás URL",
"error": "Hiba",
"add": "Hozzáadás",
"searchSomeSourcesLabel": "Keresés (csak egyes források)",
"search": "Keresés",
"additionalOptsFor": "További lehetőségek a következőhöz: {}",
"supportedSourcesBelow": "Támogatott források:",
"trackOnlyInBrackets": "(Csak nyomonkövetés)",
"searchableInBrackets": "(Kereshető)",
"appsString": "Appok",
"noApps": "Nincs App",
"noAppsForFilter": "Nincsenek appok a szűrőhöz",
"byX": "By {}",
"percentProgress": "Folyamat: {}%",
"pleaseWait": "Kis türelmet",
"updateAvailable": "Frissítés elérhető",
"estimateInBracketsShort": "(Becsült)",
"notInstalled": "Nem telepített",
"estimateInBrackets": "(Becslés)",
"selectAll": "Mindet kiválaszt",
"deselectN": "Törölje {} kijelölését",
"xWillBeRemovedButRemainInstalled": "{} el lesz távolítva az Obtainiumból, de továbbra is telepítve marad az eszközön.",
"removeSelectedAppsQuestion": "Eltávolítja a kiválasztott appokat?",
"removeSelectedApps": "Távolítsa el a kiválasztott appokat",
"updateX": "Frissítés: {}",
"installX": "Telepítés {}",
"markXTrackOnlyAsUpdated": "Jelölje meg: {}\n(Csak nyomon követhető)\nas Frissítve",
"changeX": "Változás {}",
"installUpdateApps": "Appok telepítése/frissítése",
"installUpdateSelectedApps": "Telepítse/frissítse a kiválasztott appokat",
"onlyWorksWithNonEVDApps": "Csak azoknál az alkalmazásoknál működik, amelyek telepítési állapota nem észlelhető automatikusan (nem gyakori).",
"markXSelectedAppsAsUpdated": "Megjelöl {} kiválasztott alkalmazást frissítettként?",
"no": "Nem",
"yes": "Igen",
"markSelectedAppsUpdated": "Jelölje meg a kiválasztott appokat frissítettként",
"pinToTop": "Rögzítés a felülre",
"unpinFromTop": "Eltávolít felülről",
"resetInstallStatusForSelectedAppsQuestion": "Visszaállítja a kiválasztott appok telepítési állapotát?",
"installStatusOfXWillBeResetExplanation": "A kiválasztott appok telepítési állapota visszaáll.\n\nEz akkor segíthet, ha az Obtainiumban megjelenített app verzió hibás, frissítések vagy egyéb problémák miatt.",
"shareSelectedAppURLs": "Ossza meg a kiválasztott app URL címeit",
"resetInstallStatus": "Telepítési állapot visszaállítása",
"more": "További",
"removeOutdatedFilter": "Távolítsa el az elavult alkalmazásszűrőt",
"showOutdatedOnly": "Csak az elavult alkalmazások megjelenítése",
"filter": "Szűrő",
"filterActive": "Szűrő *",
"filterApps": "Appok szűrése",
"appName": "App név",
"author": "Szerző",
"upToDateApps": "Naprakész appok",
"nonInstalledApps": "Nem telepített appok",
"importExport": "Import/Export",
"settings": "Beállítások",
"exportedTo": "Exportálva ide {}",
"obtainiumExport": "Obtainium Export",
"invalidInput": "Hibás bemenet",
"importedX": "Importálva innen {}",
"obtainiumImport": "Obtainium Import",
"importFromURLList": "Importálás URL listából",
"searchQuery": "Keresési lekérdezés",
"appURLList": "App URL lista",
"line": "Sor",
"searchX": "Keresés {}",
"noResults": "Nincs találat",
"importX": "Import {}",
"importedAppsIdDisclaimer": "Előfordulhat, hogy az importált appok helytelenül \"Nincs telepítve\" jelzéssel jelennek meg.\nA probléma megoldásához telepítse újra őket az Obtainiumon keresztül.\nEz nem érinti az alkalmazásadatokat.\n\nCsak az URL-ekre és a harmadik féltől származó importálási módszerekre vonatkozik..",
"importErrors": "Importálási hibák",
"importedXOfYApps": "{}/{} app importálva.",
"followingURLsHadErrors": "A következő URL-ek hibákat tartalmaztak:",
"okay": "Oké",
"selectURL": "Válassza ki az URL-t",
"selectURLs": "Kiválasztott URL-ek",
"pick": "Válasszon",
"theme": "Téma",
"dark": "Söét",
"light": "Világos",
"followSystem": "Rendszer szerint",
"obtainium": "Obtainium",
"materialYou": "Material You",
"appSortBy": "App rendezés...",
"authorName": "Szerző/Név",
"nameAuthor": "Név/Szerző",
"asAdded": "Mint hozzáadott",
"appSortOrder": "Appok rendezése",
"ascending": "Emelkedő",
"descending": "Csökkenő",
"bgUpdateCheckInterval": "Háttérfrissítés ellenőrzési időköz",
"neverManualOnly": "Soha csak manuális",
"appearance": "Megjelenés",
"showWebInAppView": "Forrás webhely megjelenítése az Appok nézetben",
"pinUpdates": "Frissítések kitűzése az App nézet tetejére",
"updates": "Frissítve",
"sourceSpecific": "Forrás-specifikus",
"appSource": "App forrás",
"noLogs": "Nincsenek naplók",
"appLogs": "App naplók",
"close": "Bezár",
"share": "Megoszt",
"appNotFound": "App nem található",
"obtainiumExportHyphenatedLowercase": "obtainium-export",
"pickAnAPK": "Válasszon egy APK-t",
"appHasMoreThanOnePackage": "{} egynél több csomaggal rendelkezik:",
"deviceSupportsXArch": "Eszköze támogatja a {} CPU architektúrát.",
"deviceSupportsFollowingArchs": "Az eszköze a következő CPU architektúrákat támogatja:",
"warning": "Figyelem",
"sourceIsXButPackageFromYPrompt": "Az alkalmazás forrása „{}”, de a kiadási csomag innen származik: „{}”. Folytatja?",
"updatesAvailable": "Frissítések érhetők el",
"updatesAvailableNotifDescription": "Értesíti a felhasználót, hogy frissítések állnak rendelkezésre egy vagy több, az Obtainium által nyomon követett alkalmazáshoz",
"noNewUpdates": "Nincsenek új frissítések.",
"xHasAnUpdate": "{} frissítést kapott.",
"appsUpdated": "Alkalmazások frissítve",
"appsUpdatedNotifDescription": "Értesíti a felhasználót, hogy egy vagy több app frissítése történt a háttérben",
"xWasUpdatedToY": "{} frissítve a következőre: {}.",
"errorCheckingUpdates": "Hiba a frissítések keresésekor",
"errorCheckingUpdatesNotifDescription": "Értesítés, amely akkor jelenik meg, ha a háttérbeli frissítések ellenőrzése sikertelen",
"appsRemoved": "Alkalmazások eltávolítva",
"appsRemovedNotifDescription": "Értesíti a felhasználót egy vagy több alkalmazás eltávolításáról a betöltésük során fellépő hibák miatt",
"xWasRemovedDueToErrorY": "A(z) {} a következő hiba miatt lett eltávolítva: {}",
"completeAppInstallation": "Teljes alkalmazástelepítés",
"obtainiumMustBeOpenToInstallApps": "Az Obtainiumnak megnyitva kell lennie az alkalmazások telepítéséhez",
"completeAppInstallationNotifDescription": "Megkéri a felhasználót, hogy térjen vissza az Obtainiumhoz, hogy befejezze az alkalmazás telepítését",
"checkingForUpdates": "Frissítések keresése",
"checkingForUpdatesNotifDescription": "Átmeneti értesítés, amely a frissítések keresésekor jelenik meg",
"pleaseAllowInstallPerm": "Kérjük, engedélyezze az Obtainiumnak az alkalmazások telepítését",
"trackOnly": "Csak követés",
"errorWithHttpStatusCode": "Hiba {}",
"versionCorrectionDisabled": "Verzió korrekció letiltva (úgy tűnik, a beépülő modul nem működik)",
"unknown": "Ismeretlen",
"none": "Egyik sem",
"never": "Soha",
"latestVersionX": "Legújabb verzió: {}",
"installedVersionX": "Telepített verzió: {}",
"lastUpdateCheckX": "Frissítés ellenőrizve: {}",
"remove": "Eltávolítás",
"removeAppQuestion": "Eltávolítja az alkalmazást?",
"yesMarkUpdated": "Igen, megjelölés frissítettként",
"fdroid": "F-Droid",
"appIdOrName": "App ID vagy név",
"appWithIdOrNameNotFound": "Nem található app ezzel az azonosítóval vagy névvel",
"reposHaveMultipleApps": "A repók több alkalmazást is tartalmazhatnak",
"fdroidThirdPartyRepo": "F-Droid Harmadik fél Repo",
"steam": "Steam",
"steamMobile": "Steam Mobile",
"steamChat": "Steam Chat",
"tooManyRequestsTryAgainInMinutes": {
"one": "Túl sok kérés (korlátozott arány) próbálja újra {} perc múlva",
"other": "Túl sok kérés (korlátozott arány) próbálja újra {} perc múlva"
},
"bgUpdateGotErrorRetryInMinutes": {
"one": "A háttérfrissítések ellenőrzése {}-t észlelt, {} perc múlva ütemezi az újrapróbálkozást",
"other": "A háttérfrissítések ellenőrzése {}-t észlelt, {} perc múlva ütemezi az újrapróbálkozást"
},
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
"one": "A háttérfrissítés ellenőrzése {} frissítést talált szükség esetén értesíti a felhasználót",
"other": "A háttérfrissítés ellenőrzése {} frissítést talált szükség esetén értesíti a felhasználót"
},
"apps": {
"one": "{} app",
"other": "{} app"
},
"url": {
"one": "{} URL",
"other": "{} URL"
},
"minute": {
"one": "{} perc",
"other": "{} perc"
},
"hour": {
"one": "{} óra",
"other": "{} óra"
},
"day": {
"one": "{} nap",
"other": "{} nap"
},
"clearedNLogsBeforeXAfterY": {
"one": "{n} napló törölve (előtte = {előtte}, utána = {utána})",
"other": "{n} napló törölve (előtte = {előtte}, utána = {utána})"
},
"xAndNMoreUpdatesAvailable": {
"one": "A(z) {} és 1 további alkalmazás frissítéseket kapott.",
"other": "{} és további {} alkalmazás frissítéseket kapott."
},
"xAndNMoreUpdatesInstalled": {
"one": "A(z) {} és 1 további alkalmazás frissítve.",
"other": "{} és további {} alkalmazás frissítve."
}
}

235
assets/translations/it.json Normal file
View File

@ -0,0 +1,235 @@
{
"invalidURLForSource": "URL dell'App da {} non valido",
"noReleaseFound": "Impossibile trovare una release adatta",
"noVersionFound": "Impossibile determinare la versione della release",
"urlMatchesNoSource": "L'URL non corrisponde ad alcuna fonte conosciuta",
"cantInstallOlderVersion": "Impossibile installare una versione precedente di un'App",
"appIdMismatch": "L'ID del pacchetto scaricato non corrisponde all'ID dell'App esistente",
"functionNotImplemented": "Questa classe non ha implementato questa funzione",
"placeholder": "Segnaposto",
"someErrors": "Si sono verificati degli errori",
"unexpectedError": "Errore imprevisto",
"ok": "Va bene",
"and": "e",
"startedBgUpdateTask": "Avviata l'attività di controllo degli aggiornamenti in background",
"bgUpdateIgnoreAfterIs": "Bg update ignoreAfter is {}",
"startedActualBGUpdateCheck": "Avviato il controllo effettivo degli aggiornamenti in background",
"bgUpdateTaskFinished": "Terminata l'attività di controllo degli aggiornamenti in background",
"firstRun": "Questo è il primo avvio di sempre di Obtainium",
"settingUpdateCheckIntervalTo": "Imposta l'intervallo di aggiornamento a {}",
"githubPATLabel": "GitHub Personal Access Token (aumenta il limite di traffico)",
"githubPATHint": "PAT deve seguire questo formato: username:token",
"githubPATFormat": "username:token",
"githubPATLinkText": "Informazioni su GitHub PAT",
"includePrereleases": "Includi prerelease",
"fallbackToOlderReleases": "Ripiega su release datate",
"filterReleaseTitlesByRegEx": "Filtra le release con le espressioni regolari",
"invalidRegEx": "Espressione regolare invalida",
"noDescription": "Descrizione assente",
"cancel": "Annulla",
"continue": "Continua",
"requiredInBrackets": "(Richiesto)",
"dropdownNoOptsError": "ERRORE: LA TENDINA DEVE AVERE ALMENO UN'OPZIONE",
"colour": "Colore",
"githubStarredRepos": "i repository stellati da GitHub",
"uname": "Username",
"wrongArgNum": "Numero di argomenti forniti errato",
"xIsTrackOnly": "{} è Solo-Monitoraggio",
"source": "Fonte",
"app": "App",
"appsFromSourceAreTrackOnly": "Le App da questa fonte sono in modalità 'Solo-Monitoraggio'.",
"youPickedTrackOnly": "Hai selezionato l'opzione 'Solo-Monitoraggio'.",
"trackOnlyAppDescription": "L'App sarà monitorata per gli aggiornamenti, ma Obtainium non sarà in grado di scaricarli o di installarli.",
"cancelled": "Annullato",
"appAlreadyAdded": "App già aggiunta",
"alreadyUpToDateQuestion": "App già aggiornata?",
"addApp": "Aggiungi App",
"appSourceURL": "URL della fonte dell'App",
"error": "Errore",
"add": "Aggiungi",
"searchSomeSourcesLabel": "Cerca (disponibile solo per alcune fonti)",
"search": "Cerca",
"additionalOptsFor": "Opzioni aggiuntive per {}",
"supportedSourcesBelow": "Fonti supportate:",
"trackOnlyInBrackets": "(Solo-Monitoraggio)",
"searchableInBrackets": "(ricercabile)",
"appsString": "App",
"noApps": "Nessuna App",
"noAppsForFilter": "Nessuna App per i filtri selezionati",
"byX": "Da {}",
"percentProgress": "Progresso: {}%",
"pleaseWait": "Attendere prego",
"updateAvailable": "Aggiornamento disponibile",
"estimateInBracketsShort": "(Prev.)",
"notInstalled": "Non installato",
"estimateInBrackets": "(Previsto)",
"selectAll": "Seleziona tutto",
"deselectN": "Deseleziona {}",
"xWillBeRemovedButRemainInstalled": "{} sarà rimosso da Obtainium ma resterà installato sul dispositivo.",
"removeSelectedAppsQuestion": "Rimuovere le App selezionate?",
"removeSelectedApps": "Rimuovi le App selezionate",
"updateX": "Aggiorna {}",
"installX": "Installa {}",
"markXTrackOnlyAsUpdated": "Contrassegna {}\n(Solo-Monitoraggio)\ncome aggiornato",
"changeX": "modifica {}",
"installUpdateApps": "Installa/Aggiorna le App",
"installUpdateSelectedApps": "Installa/Aggiornale le App selezionate",
"onlyWorksWithNonEVDApps": "Funziona solo per le App il cui stato d'installazione non può essere rilevato automaticamente (inconsueto).",
"markXSelectedAppsAsUpdated": "Contrassegnare le {} App selezionate come aggiornate?",
"no": "No",
"yes": "Sì",
"markSelectedAppsUpdated": "Contrassegna le App selezionate come aggiornate",
"pinToTop": "Fissa in alto",
"unpinFromTop": "Rimuovi dall'alto",
"resetInstallStatusForSelectedAppsQuestion": "Ripristinare lo stato d'installazione delle App selezionate?",
"installStatusOfXWillBeResetExplanation": "Lo stato d'installazione di ogni App selezionata sarà ripristinato.\n\nCiò può essere d'aiuto nel caso in cui la versione mostrata dell'App in Obtainium non è corretta a causa di un aggiornamento fallito o di altri problemi.",
"shareSelectedAppURLs": "Condividi gli URL delle App selezionate",
"resetInstallStatus": "Ripristina lo stato d'installazione",
"more": "Di più",
"removeOutdatedFilter": "Rimuovi il filtro per le App datate",
"showOutdatedOnly": "Mostra solo le App datate",
"filter": "Filtri",
"filterActive": "Filtri *",
"filterApps": "Filtra App",
"appName": "Nome dell'App",
"author": "Autore",
"upToDateApps": "App aggiornate",
"nonInstalledApps": "App non installate",
"importExport": "Importa/Esporta",
"settings": "Impostazioni",
"exportedTo": "Esportato in {}",
"obtainiumExport": "Esporta da Obtainium",
"invalidInput": "Inserimento non valido",
"importedX": "Importato {}",
"obtainiumImport": "Importa in Obtainium",
"importFromURLList": "Importa da lista di URL",
"searchQuery": "Stringa di ricerca",
"appURLList": "Lista di URL delle App",
"line": "Linea",
"searchX": "Cerca {}",
"noResults": "Nessun risultato trovato",
"importX": "Importa {}",
"importedAppsIdDisclaimer": "Le App importate potrebbero essere visualizzate erroneamente come \"Non installate\".\nPer risolvere il problema, reinstallale con Obtainium.\nQuesto non dovrebbe influire sui dati delle App.\n\nRiguarda solo l'URL e i metodi di importazione di terze parti.",
"importErrors": "Errori dell'importazione",
"importedXOfYApps": "{} App di {} importate.",
"followingURLsHadErrors": "I seguenti URL contengono errori:",
"okay": "Va bene",
"selectURL": "Seleziona l'URL",
"selectURLs": "Seleziona gli URL",
"pick": "Seleziona",
"theme": "Tema",
"dark": "Scuro",
"light": "Chiaro",
"followSystem": "Segui il sistema",
"obtainium": "Obtainium",
"materialYou": "Material You",
"appSortBy": "App ordinate per",
"authorName": "Autore/Nome",
"nameAuthor": "Nome/Autore",
"asAdded": "Data di aggiunta",
"appSortOrder": "Ordinamento",
"ascending": "Ascendente",
"descending": "Discendente",
"bgUpdateCheckInterval": "Intervallo di controllo degli aggiornamenti in background",
"neverManualOnly": "Mai - Solo manuale",
"appearance": "Aspetto",
"showWebInAppView": "Mostra la pagina web dell'App se selezionata",
"pinUpdates": "Fissa in alto gli aggiornamenti disponibili nella pagina delle App",
"updates": "Aggiornato",
"sourceSpecific": "Specifico per la fonte",
"appSource": "Sorgente dell'App",
"noLogs": "Nessun log",
"appLogs": "Log dell'App",
"close": "Chiudi",
"share": "Condividi",
"appNotFound": "App non trovata",
"obtainiumExportHyphenatedLowercase": "esportazione-obtainium",
"pickAnAPK": "Seleziona un APK",
"appHasMoreThanOnePackage": "{} offre più di un pacchetto:",
"deviceSupportsXArch": "Il tuo dispositivo supporta l'architettura {} della CPU.",
"deviceSupportsFollowingArchs": "Il tuo dispositivo supporta le seguenti architetture della CPU:",
"warning": "Attenzione",
"sourceIsXButPackageFromYPrompt": "L'origine dell'App è '{}' ma il pacchetto della release proviene da '{}'. Continuare?",
"updatesAvailable": "Aggiornamenti disponibili",
"updatesAvailableNotifDescription": "Avvisa l'utente che sono disponibili gli aggiornamenti di una o più App monitorate da Obtainium",
"noNewUpdates": "Nessun nuovo aggiornamento.",
"xHasAnUpdate": "{} è stato aggiornato.",
"appsUpdated": "App aggiornate",
"appsUpdatedNotifDescription": "Avvisa l'utente che una o più App sono state aggiornate in background",
"xWasUpdatedToY": "{} è stato aggiornato a {}.",
"errorCheckingUpdates": "Controllo degli errori per gli aggiornamenti",
"errorCheckingUpdatesNotifDescription": "Una notifica che mostra quando il controllo degli aggiornamenti in background fallisce",
"appsRemoved": "App rimosse",
"appsRemovedNotifDescription": "Avvisa l'utente che una o più App sono state rimosse a causa di errori durante il caricamento",
"xWasRemovedDueToErrorY": "{} è stata rimosso a causa di questo errore: {}",
"completeAppInstallation": "Completa l'installazione dell'App",
"obtainiumMustBeOpenToInstallApps": "Obtainium deve essere aperto per poter installare le App",
"completeAppInstallationNotifDescription": "Chiede all'utente di riaprire Obtainium per terminare l'installazione di un App",
"checkingForUpdates": "Controllo degli aggiornamenti in corso",
"checkingForUpdatesNotifDescription": "Notifica transitoria che appare durante la verifica degli aggiornamenti",
"pleaseAllowInstallPerm": "Per favore permetti a Obtainium di installare le App",
"trackOnly": "Solo-Monitoraggio",
"errorWithHttpStatusCode": "Errore {}",
"versionCorrectionDisabled": "Correzione della versione disabilitata (il plugin non pare funzionare)",
"unknown": "Sconosciuto",
"none": "Nessuno",
"never": "Mai",
"latestVersionX": "Ultima versione: {}",
"installedVersionX": "Versione installata: {}",
"lastUpdateCheckX": "Ultimo controllo degli aggiornamenti: {}",
"remove": "Rimuovi",
"removeAppQuestion": "Rimuovere App?",
"yesMarkUpdated": "Sì, contrassegna come aggiornato",
"fdroid": "F-Droid",
"appIdOrName": "ID o nome dell'App",
"appWithIdOrNameNotFound": "Non è stata trovata alcuna App con quell'ID o nome",
"reposHaveMultipleApps": "I repository possono contenere più App",
"fdroidThirdPartyRepo": "Repository di terze parti di F-Droid",
"steam": "Steam",
"steamMobile": "Steam Mobile",
"steamChat": "Steam Chat",
"tooManyRequestsTryAgainInMinutes": {
"one": "Troppe richieste (traffico limitato) - riprova tra {} minuto",
"other": "Troppe richieste (traffico limitato) - riprova tra {} minuti"
},
"bgUpdateGotErrorRetryInMinutes": {
"one": "Il controllo degli aggiornamenti in background ha incontrato un {}, ricontrollo tra {} minuto",
"other": "Il controllo degli aggiornamenti in background ha incontrato un {}, ricontrollo tra {} minuti"
},
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
"one": "Il controllo degli aggiornamenti in background ha trovato {} aggiornamento - avviserà l'utento se necessario",
"other": "Il controllo degli aggiornamenti in background ha trovato {} aggiornamenti - avviserà l'utento se necessario"
},
"apps": {
"one": "{} App",
"other": "{} App"
},
"url": {
"one": "{} URL",
"other": "{} URL"
},
"minute": {
"one": "{} minuto",
"other": "{} minuti"
},
"hour": {
"one": "{} ora",
"other": "{} ore"
},
"day": {
"one": "{} giorno",
"other": "{} giorni"
},
"clearedNLogsBeforeXAfterY": {
"one": "Pulito {n} log (prima = {before}, dopo = {after})",
"other": "Puliti {n} log (prima = {before}, dopo = {after})"
},
"xAndNMoreUpdatesAvailable": {
"one": "{} e un'altra App hanno aggiornamenti disponibili.",
"other": "{} e altre {} App hanno aggiornamenti disponibili."
},
"xAndNMoreUpdatesInstalled": {
"one": "{} e un'altra App sono state aggiornate.",
"other": "{} e altre {} App sono state aggiornate."
}
}

235
assets/translations/ja.json Normal file
View File

@ -0,0 +1,235 @@
{
"invalidURLForSource": "{}は有効なソースURLではありません",
"noReleaseFound": "適切なリリースが見つかりませんでした",
"noVersionFound": "リリースバージョンを特定できませんでした",
"urlMatchesNoSource": "URLが既知のソースと一致しません",
"cantInstallOlderVersion": "旧バージョンのアプリをインストールできません",
"appIdMismatch": "ダウンロードしたパッケージのIDが既存のApp IDと一致しません",
"functionNotImplemented": "このクラスはこの機能を実装していません",
"placeholder": "プレースホルダー",
"someErrors": "いくつかのエラーが発生しました",
"unexpectedError": "予期せぬエラーが発生しました",
"ok": "OK",
"and": "と",
"startedBgUpdateTask": "バックグラウンドのアップデート確認タスクを開始",
"bgUpdateIgnoreAfterIs": "Bg update ignoreAfter is {}",
"startedActualBGUpdateCheck": "実際のバックグラウンドのアップデート確認を開始",
"bgUpdateTaskFinished": "バックグラウンドのアップデート確認タスクを終了",
"firstRun": "これがObtainiumの最初の実行です",
"settingUpdateCheckIntervalTo": "更新間隔を{}に設定する",
"githubPATLabel": "GitHub パーソナルアクセストークン (レート制限の引き上げ)",
"githubPATHint": "PATは次の形式でなければなりません: ユーザー名:トークン",
"githubPATFormat": "ユーザー名:トークン",
"githubPATLinkText": "GitHub PATsについて",
"includePrereleases": "プレリリースを含む",
"fallbackToOlderReleases": "旧リリースへのフォールバック",
"filterReleaseTitlesByRegEx": "正規表現でリリースタイトルを絞り込む",
"invalidRegEx": "無効な正規表現",
"noDescription": "説明はありません",
"cancel": "キャンセル",
"continue": "続ける",
"requiredInBrackets": "(必須)",
"dropdownNoOptsError": "エラー: ドロップダウンには、少なくとも1つのoptが必要です。",
"colour": "カラー",
"githubStarredRepos": "Githubでスターしたリポジトリ",
"uname": "ユーザー名",
"wrongArgNum": "提供する引数の数が間違っています",
"xIsTrackOnly": "{} は'追跡のみ'です",
"source": "ソース",
"app": "アプリ",
"appsFromSourceAreTrackOnly": "このソースからのアプリは'追跡のみ'です'。",
"youPickedTrackOnly": "'追跡のみ'を選択しています",
"trackOnlyAppDescription": "アプリのアップデートは追跡されますが、Obtainiumはアプリのダウンロードやインストールをすることはできません。",
"cancelled": "キャンセルしました",
"appAlreadyAdded": "アプリはすでに追加されています",
"alreadyUpToDateQuestion": "アプリはすでに最新ですか?",
"addApp": "アプリの追加",
"appSourceURL": "アプリのソースURL",
"error": "エラー",
"add": "追加",
"searchSomeSourcesLabel": "検索 (一部ソースのみ)",
"search": "検索",
"additionalOptsFor": "{}の追加オプション",
"supportedSourcesBelow": "対応するソース:",
"trackOnlyInBrackets": "(追跡のみ)",
"searchableInBrackets": "(検索可能)",
"appsString": "アプリ",
"noApps": "アプリはありません",
"noAppsForFilter": "フィルターに一致するアプリはありません",
"byX": "by {}",
"percentProgress": "ダウンロード中: {}%",
"pleaseWait": "しばらくお待ちください",
"updateAvailable": "アップデートが利用可能",
"estimateInBracketsShort": "(推定)",
"notInstalled": "未インストール",
"estimateInBrackets": "(推定)",
"selectAll": "すべて選択",
"deselectN": "{}件を選択解除",
"xWillBeRemovedButRemainInstalled": "{}はObtainiumから削除されますが、デバイスにはインストールされたままです。",
"removeSelectedAppsQuestion": "選択したアプリを削除しますか?",
"removeSelectedApps": "選択したアプリを削除する",
"updateX": "{}をアップデートする",
"installX": "{}をインストールする",
"markXTrackOnlyAsUpdated": "{}\n(追跡のみ)\nをアップデート済みとしてマークする",
"changeX": "{}を変更する",
"installUpdateApps": "アプリのインストール/アップデート",
"installUpdateSelectedApps": "選択したアプリのインストール/アップデート",
"onlyWorksWithNonEVDApps": "インストール状況を自動検出できないアプリ(一般的でないもの)のみ動作します。",
"markXSelectedAppsAsUpdated": "{}個の選択したアプリをアップデート済みとしてマークしますか?",
"no": "いいえ",
"yes": "はい",
"markSelectedAppsUpdated": "選択したアプリをアップデート済みとしてマークする",
"pinToTop": "トップに固定",
"unpinFromTop": "トップから固定解除",
"resetInstallStatusForSelectedAppsQuestion": "選択したアプリのインストール状態をリセットしますか?",
"installStatusOfXWillBeResetExplanation": "選択したアプリのインストール状態がリセットされます。\n\nアップデートに失敗するなどして、Obtainiumに表示されるアプリのバージョンが正しくない場合に役立ちます。",
"shareSelectedAppURLs": "選択したアプリのURLを共有する",
"resetInstallStatus": "インストール状態をリセットする",
"more": "もっと見る",
"removeOutdatedFilter": "アップデートが存在するアプリのフィルターを解除",
"showOutdatedOnly": "アップデートが存在するアプリのみ表示する",
"filter": "フィルター",
"filterActive": "フィルター *",
"filterApps": "アプリを絞り込む",
"appName": "アプリ名",
"author": "作者",
"upToDateApps": "最新のアプリ",
"nonInstalledApps": "未インストールのアプリ",
"importExport": "インポート/エクスポート",
"settings": "設定",
"exportedTo": "{}にエクスポートしました",
"obtainiumExport": "Obtainium エクスポート",
"invalidInput": "無効な入力",
"importedX": "{}をインポートしました",
"obtainiumImport": "Obtainium インポート",
"importFromURLList": "URLリストからのインポート",
"searchQuery": "検索キーワード",
"appURLList": "アプリのURLリスト",
"line": "行",
"searchX": "{}で検索",
"noResults": "結果は見つかりませんでした",
"importX": "{}をインポートする",
"importedAppsIdDisclaimer": "インポートしたアプリが「未インストール」と表示されることがあります。\nこれを解決するには、Obtainiumから再インストールしてください。\nアプリのデータには影響しません。\n\nURLとサードパーティーのインポートメソッドにのみ影響します。",
"importErrors": "インポートエラー",
"importedXOfYApps": "{} / {} アプリをインポートしました",
"followingURLsHadErrors": "以下のURLでエラーが発生しました:",
"okay": "OK",
"selectURL": "URLを選択",
"selectURLs": "URLを選択",
"pick": "選択",
"theme": "テーマ",
"dark": "ダーク",
"light": "ライト",
"followSystem": "システムに従う",
"obtainium": "Obtainium",
"materialYou": "Material You",
"appSortBy": "アプリの並び方",
"authorName": "作者/アプリ名",
"nameAuthor": "アプリ名/作者",
"asAdded": "追加順",
"appSortOrder": "並び順",
"ascending": "昇順",
"descending": "降順",
"bgUpdateCheckInterval": "バックグラウンド更新の確認間隔",
"neverManualOnly": "手動",
"appearance": "外観",
"showWebInAppView": "アプリビューにソースウェブページを表示する",
"pinUpdates": "アップデートがあるアプリをトップに固定する",
"updates": "更新",
"sourceSpecific": "Github アクセストークン",
"appSource": "アプリのソース",
"noLogs": "ログはありません",
"appLogs": "アプリのログ",
"close": "閉じる",
"share": "共有",
"appNotFound": "アプリが見つかりません",
"obtainiumExportHyphenatedLowercase": "obtainium-エクスポート",
"pickAnAPK": "APKを選ぶ",
"appHasMoreThanOnePackage": "{}は複数のパッケージが存在します: ",
"deviceSupportsXArch": "お使いのデバイスは{} CPUアーキテクチャに対応しています。",
"deviceSupportsFollowingArchs": "お使いのデバイスは、以下のCPUアーキテクチャをサポートしています:",
"warning": "警告",
"sourceIsXButPackageFromYPrompt": "アプリのソースは'{}'ですが、リリースパッケージは'{}'から来ています。続行しますか?",
"updatesAvailable": "アップデートが利用可能",
"updatesAvailableNotifDescription": "Obtainiumが追跡している1つまたは複数のアプリのアップデートが利用可能であることをユーザーに通知する",
"noNewUpdates": "新しいアップデートはありません。",
"xHasAnUpdate": "{}のアップデートが利用可能です",
"appsUpdated": "アプリをアップデートしました",
"appsUpdatedNotifDescription": "1つまたは複数のAppのアップデートがバックグラウンドで適用されたことをユーザーに通知する",
"xWasUpdatedToY": "{}が{}にアップデートされました。",
"errorCheckingUpdates": "アップデート確認中のエラー",
"errorCheckingUpdatesNotifDescription": "バックグラウンドでのアップデート確認に失敗した際に表示される通知",
"appsRemoved": "削除されたアプリ",
"appsRemovedNotifDescription": "アプリの読み込み中にエラーが発生したため、1つまたは複数のアプリが削除されたことをユーザーに通知する",
"xWasRemovedDueToErrorY": "このエラーのため、{}は削除されました: {}",
"completeAppInstallation": "アプリのインストールを完了する",
"obtainiumMustBeOpenToInstallApps": "アプリをインストールするにはObtainiumを開いている必要があります。",
"completeAppInstallationNotifDescription": "アプリのインストールを完了するために、Obtainiumに戻る必要があります。",
"checkingForUpdates": "アップデートを確認中",
"checkingForUpdatesNotifDescription": "アップデートを確認する際に表示される一時的な通知",
"pleaseAllowInstallPerm": "Obtainiumによるアプリのインストールを許可してください。",
"trackOnly": "追跡のみ",
"errorWithHttpStatusCode": "エラー {}",
"versionCorrectionDisabled": "バージョン補正無効 (プラグインが動作していません)",
"unknown": "不明",
"none": "なし",
"never": "Never",
"latestVersionX": "最新のバージョン: {}",
"installedVersionX": "インストールされたバージョン: {}",
"lastUpdateCheckX": "最終アップデート確認: {}",
"remove": "削除",
"removeAppQuestion": "アプリを削除しますか?",
"yesMarkUpdated": "はい、アップデート済みとしてマークします",
"fdroid": "F-Droid",
"appIdOrName": "アプリIDまたは名前",
"appWithIdOrNameNotFound": "そのIDや名前を持つアプリは見つかりませんでした",
"reposHaveMultipleApps": "リポジトリには複数のアプリが含まれることがあります",
"fdroidThirdPartyRepo": "F-Droid Third-Party Repo",
"steam": "Steam",
"steamMobile": "Steam Mobile",
"steamChat": "Steam Chat",
"tooManyRequestsTryAgainInMinutes": {
"one": "リクエストが多すぎます(レート制限)- {}分後に再試行してください",
"other": "リクエストが多すぎます(レート制限)- {}分後に再試行してください"
},
"bgUpdateGotErrorRetryInMinutes": {
"one": "バックグラウンドのアップデート確認で {} の問題が発生, {} 分後に再試行します",
"other": "バックグラウンドのアップデート確認で {} の問題が発生, {} 分後に再試行します"
},
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
"one": "バックグラウンドのアップデート確認で {} 個のアップデートを発見 - 必要に応じてユーザーに通知します",
"other": "バックグラウンドのアップデート確認で {} 個のアップデートを発見 - 必要に応じてユーザーに通知します"
},
"apps": {
"one": "{}個のアプリ",
"other": "{}個のアプリ"
},
"url": {
"one": "{}個のURL",
"other": "{}個のURL"
},
"minute": {
"one": "{}分",
"other": "{}分"
},
"hour": {
"one": "{}時間",
"other": "{}時間"
},
"day": {
"one": "{}日",
"other": "{}日"
},
"clearedNLogsBeforeXAfterY": {
"one": "{n}個のログをクリアしました (前 = {before}, 後 = {after})",
"other": "{n}個のログをクリアしました (前 = {before}, 後 = {after})"
},
"xAndNMoreUpdatesAvailable": {
"one": "{}とさらに{}個のアプリのアップデートが利用可能です。",
"other": "{}とさらに{}個のアプリのアップデートが利用可能です。"
},
"xAndNMoreUpdatesInstalled": {
"one": "{}とさらに{}個のアプリがアップデートされました。",
"other": "{}とさらに{}個のアプリがアップデートされました。"
}
}

235
assets/translations/zh.json Normal file
View File

@ -0,0 +1,235 @@
{
"invalidURLForSource": "不是一个有效的 {} URL",
"noReleaseFound": "找不到合适的更新",
"noVersionFound": "无法确定更新版本",
"urlMatchesNoSource": "URL 与已知来源不符",
"cantInstallOlderVersion": "无法安装旧版应用程序",
"appIdMismatch": "下载的软件包名与现有的应用程序包名不一致",
"functionNotImplemented": "该类没有实现此功能",
"placeholder": "占位符",
"someErrors": "出现了一些错误",
"unexpectedError": "意外错误",
"ok": "好的",
"and": "和",
"startedBgUpdateTask": "开始后台检查更新任务",
"bgUpdateIgnoreAfterIs": "Bg update ignoreAfter is {}",
"startedActualBGUpdateCheck": "后台检查更新已开始",
"bgUpdateTaskFinished": "后台检查更新已完成",
"firstRun": "这是你第一次运行 Obtainium",
"settingUpdateCheckIntervalTo": "设置检查更新间隔为 {}",
"githubPATLabel": "GitHub 个人访问令牌 (提高 API 限制)",
"githubPATHint": "个人访问令牌必须为: username:token 形式",
"githubPATFormat": "username:token",
"githubPATLinkText": "关于 GitHub 个人访问令牌",
"includePrereleases": "包含预发布版",
"fallbackToOlderReleases": "回落到旧版",
"filterReleaseTitlesByRegEx": "通过正则表达式过滤发布标题",
"invalidRegEx": "无效的正则表达式",
"noDescription": "无描述",
"cancel": "取消",
"continue": "继续",
"requiredInBrackets": "(必须)",
"dropdownNoOptsError": "错误:下拉菜单必须至少有一个选项",
"colour": "颜色",
"githubStarredRepos": "GitHub 已星标仓库",
"uname": "用户名",
"wrongArgNum": "提供了错误的参数数量",
"xIsTrackOnly": "{} 仅追踪",
"source": "源码",
"app": "应用程序",
"appsFromSourceAreTrackOnly": "来自此来源的应用为仅追踪",
"youPickedTrackOnly": "你已选择仅追踪选项",
"trackOnlyAppDescription": "该应用程序将被跟踪更新,但 Obtainium 无法下载或安装它",
"cancelled": "已取消",
"appAlreadyAdded": "此应用程序已被添加",
"alreadyUpToDateQuestion": "应用已是最新?",
"addApp": "添加应用",
"appSourceURL": "应用来源 URL",
"error": "错误",
"add": "添加",
"searchSomeSourcesLabel": "搜索 (仅部分来源)",
"search": "搜索",
"additionalOptsFor": "{} 的更多选项",
"supportedSourcesBelow": "受支持的来源:",
"trackOnlyInBrackets": "(仅追踪)",
"searchableInBrackets": "(可被搜索)",
"appsString": "应用程序",
"noApps": "无应用程序",
"noAppsForFilter": "没有应用可被过滤",
"byX": "来自 {}",
"percentProgress": "进度: {}%",
"pleaseWait": "请等待...",
"updateAvailable": "更新可用",
"estimateInBracketsShort": "(预计.)",
"notInstalled": "未安装",
"estimateInBrackets": "(预计)",
"selectAll": "全选",
"deselectN": "取消选择 {}",
"xWillBeRemovedButRemainInstalled": "{} 将被从 Obtainium 中删除,但仍安装在设备上。",
"removeSelectedAppsQuestion": "删除已选择的应用程序吗?",
"removeSelectedApps": "删除已选择的应用程序",
"updateX": "更新 {}",
"installX": "安装 {}",
"markXTrackOnlyAsUpdated": "将仅追踪编辑为已更新",
"changeX": "更改 {}",
"installUpdateApps": "安装/更新应用程序",
"installUpdateSelectedApps": "安装/更新已选择的应用程序",
"onlyAppliesToInstalledAndOutdatedApps": "'只适用于已安装但已过时的应用程序",
"markXSelectedAppsAsUpdated": "将已选择的 {} 个应用程序标记为已更新?",
"no": "不要",
"yes": "好的",
"markSelectedAppsUpdated": "标记已选择的应用程序为已更新",
"pinToTop": "置顶",
"unpinFromTop": "取消置顶",
"resetInstallStatusForSelectedAppsQuestion": "为已选择的应用程序重置安装状态吗?",
"installStatusOfXWillBeResetExplanation": "当 Obtainium 中显示的应用程序版本由于更新失败或其他问题而不正确时,这将有助于重置任何选定应用程序的安装状态。",
"shareSelectedAppURLs": "分享已选择的应用程序 URL",
"resetInstallStatus": "重置安装状态",
"more": "更多",
"removeOutdatedFilter": "删除过时的应用程序过滤器",
"showOutdatedOnly": "只显示过时的应用程序",
"filter": "过滤器",
"filterActive": "过滤器 *",
"filterApps": "过滤应用",
"appName": "应用名称",
"author": "作者",
"upToDateApps": "已更新的应用程序",
"nonInstalledApps": "未安装的应用程序",
"importExport": "导入/导出",
"settings": "设置",
"exportedTo": "导出到 {}",
"obtainiumExport": "Obtainium 导出",
"invalidInput": "无效输入",
"importedX": "已导出到 {}",
"obtainiumImport": "Obtainium 导入",
"importFromURLList": "从 URL 列表导入",
"searchQuery": "搜索查询",
"appURLList": "应用 URL 列表",
"line": "行",
"searchX": "搜索 {}",
"noResults": "无结果",
"importX": "导入 {}",
"importedAppsIdDisclaimer": "导入的应用程序可能不正确地显示为未安装。要解决这个问题,请通过 Obtainium 重新安装它们。",
"importErrors": "导入错误",
"importedXOfYApps": "{} 中的 {} 个应用已导入",
"followingURLsHadErrors": "以下 URL 有错误:",
"okay": "好的",
"selectURL": "已选择的 URL",
"selectURLs": "已选择的 URL",
"pick": "选择",
"theme": "主题",
"dark": "深色",
"light": "浅色",
"followSystem": "跟随系统",
"obtainium": "Obtainium",
"materialYou": "Material You",
"appSortBy": "排列方式",
"authorName": "作者 / 名字",
"nameAuthor": "名字 / 作者",
"asAdded": "添加顺序",
"appSortOrder": "排列顺序",
"ascending": "升序",
"descending": "降序",
"bgUpdateCheckInterval": "后台更新检查间隔",
"neverManualOnly": "手动",
"appearance": "外观",
"showWebInAppView": "在应用来源页显示网页",
"pinUpdates": "将需要更新的应用固定到顶部",
"updates": "检查间隔",
"sourceSpecific": "Github 访问令牌",
"appSource": "源代码",
"noLogs": "无日志",
"appLogs": "应用日志",
"close": "关闭",
"share": "分享",
"appNotFound": "未找到应用",
"obtainiumExportHyphenatedLowercase": "obtainium-导出",
"pickAnAPK": "选择一个安装包",
"appHasMoreThanOnePackage": "{} 有多于一个安装包:",
"deviceSupportsXArch": "你的设备支持 {} CPU 架构",
"deviceSupportsFollowingArchs": "你的设备支持以下 CPU 架构:",
"warning": "警告",
"sourceIsXButPackageFromYPrompt": "此应用来源是 '{}' 但更新包来自 '{}'。 继续吗?",
"updatesAvailable": "更新可用",
"updatesAvailableNotifDescription": "通知 Obtainium 所跟踪应用程序的更新",
"noNewUpdates": "你的应用已是最新。",
"xHasAnUpdate": "{} 有更新啦",
"appsUpdated": "应用已更新",
"appsUpdatedNotifDescription": "通知在后台安装应用程序的更新",
"xWasUpdatedToY": "{} 已更新到 {}.",
"errorCheckingUpdates": "检查更新出错",
"errorCheckingUpdatesNotifDescription": "当后台更新检查失败时显示的通知",
"appsRemoved": "应用已删除",
"appsRemovedNotifDescription": "通知由于加载应用程序时出错而被删除",
"xWasRemovedDueToErrorY": "{} 已因以下错误被删除: {}",
"completeAppInstallation": "完成应用安装",
"obtainiumMustBeOpenToInstallApps": "Obtainium 需要被启动以安装更新",
"completeAppInstallationNotifDescription": "需要返回 Obtainium以完成应用程序的安装。",
"checkingForUpdates": "检查更新中",
"checkingForUpdatesNotifDescription": "检查更新时出现的瞬时通知",
"pleaseAllowInstallPerm": "请允许 Obtainium 安装应用程序",
"trackOnly": "仅追踪",
"errorWithHttpStatusCode": "错误 {}",
"versionCorrectionDisabled": "禁用版本更正(插件似乎未起作用)",
"unknown": "未知",
"none": "无",
"never": "从不",
"latestVersionX": "最新: {}",
"installedVersionX": "已安装: {}",
"lastUpdateCheckX": "最后检查: {}",
"remove": "删除",
"removeAppQuestion": "删除应用?",
"yesMarkUpdated": "'是的,标为已更新",
"fdroid": "F-Droid",
"appIdOrName": "应用 ID 或名称",
"appWithIdOrNameNotFound": "没有发现具有此 ID 或名称的应用",
"reposHaveMultipleApps": "来源可能包含多个应用",
"fdroidThirdPartyRepo": "F-Droid 第三方源",
"steam": "Steam",
"steamMobile": "Steam Mobile",
"steamChat": "Steam Chat",
"tooManyRequestsTryAgainInMinutes": {
"one": "请求过多 (API 限制) - 在 {} 分钟后重试",
"other": "请求过多 (API 限制) - 在 {} 分钟后重试"
},
"bgUpdateGotErrorRetryInMinutes": {
"one": "后台更新检查遇到了 {} 问题, 将在 {} 分钟后重试",
"other": "后台更新检查遇到了 {} 问题, 将在 {} 分钟后重试"
},
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
"one": "后台更新检查找到了 {} 个更新 - 将通知用户",
"other": "后台更新检查找到了 {} 个更新 - 将通知用户"
},
"apps": {
"one": "{} 个应用",
"other": "{} 个应用"
},
"url": {
"one": "{} 个 URL",
"other": "{} 个 URL"
},
"minute": {
"one": "{} 分钟",
"other": "{} 分钟"
},
"hour": {
"one": "{} 小时",
"other": "{} 小时"
},
"day": {
"one": "{} 天",
"other": "{} 天"
},
"clearedNLogsBeforeXAfterY": {
"one": "清除了 {n} 个日志 (清除前 = {before}, 清除后 = {after})",
"other": "清除了 {n} 个日志 (清除前 = {before}, 清除后 = {after})"
},
"xAndNMoreUpdatesAvailable": {
"one": "{} 和 {} 更多应用已被更新",
"other": "{} 和 {} 更多应用已被更新"
},
"xAndNMoreUpdatesInstalled": {
"one": "{} 和 {} 更多应用已被安装",
"other": "{} 和 {} 更多应用已被安装"
}
}

View File

@ -1,112 +1,57 @@
import 'package:html/parser.dart'; import 'package:html/parser.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class APKMirror implements AppSource { class APKMirror extends AppSource {
@override APKMirror() {
late String host = 'apkmirror.com'; host = 'apkmirror.com';
enforceTrackOnly = true;
}
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/apk/[^/]+/[^/]+'); RegExp standardUrlRegEx = RegExp('^https?://$host/apk/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) { if (match == null) {
throw notValidURL(runtimeType.toString()); throw InvalidURLError(name);
} }
return url.substring(0, match.end); return url.substring(0, match.end);
} }
@override @override
String? changeLogPageFromStandardUrl(String standardUrl) => String? changeLogPageFromStandardUrl(String standardUrl) =>
'$standardUrl#whatsnew'; '$standardUrl/#whatsnew';
@override
Future<String> apkUrlPrefetchModifier(String apkUrl) async {
var originalUri = Uri.parse(apkUrl);
var res = await get(originalUri);
if (res.statusCode != 200) {
throw false;
}
var href =
parse(res.body).querySelector('.downloadButton')?.attributes['href'];
if (href == null) {
throw false;
}
var res2 = await get(Uri.parse('${originalUri.origin}$href'), headers: {
'User-Agent':
'Mozilla/5.0 (X11; Linux x86_64; rv:105.0) Gecko/20100101 Firefox/105.0'
});
if (res2.statusCode != 200) {
throw false;
}
var links = parse(res2.body)
.querySelectorAll('a')
.where((element) => element.innerHtml == 'here')
.map((e) => e.attributes['href'])
.where((element) => element != null)
.toList();
if (links.isEmpty) {
throw false;
}
return '${originalUri.origin}${links[0]}';
}
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async { String standardUrl, List<String> additionalData,
{bool trackOnly = false}) async {
Response res = await get(Uri.parse('$standardUrl/feed')); Response res = await get(Uri.parse('$standardUrl/feed'));
if (res.statusCode != 200) { if (res.statusCode == 200) {
throw couldNotFindReleases; String? titleString = parse(res.body)
.querySelector('item')
?.querySelector('title')
?.innerHtml;
String? version = titleString
?.substring(RegExp('[0-9]').firstMatch(titleString)?.start ?? 0,
RegExp(' by ').firstMatch(titleString)?.start ?? 0)
.trim();
if (version == null || version.isEmpty) {
version = titleString;
}
if (version == null || version.isEmpty) {
throw NoVersionError();
}
return APKDetails(version, [], getAppNames(standardUrl));
} else {
throw NoReleasesError();
} }
var nextUrl = parse(res.body)
.querySelector('item')
?.querySelector('link')
?.nextElementSibling
?.innerHtml;
if (nextUrl == null) {
throw couldNotFindReleases;
}
Response res2 = await get(Uri.parse(nextUrl), headers: {
'User-Agent':
'Mozilla/5.0 (X11; Linux x86_64; rv:105.0) Gecko/20100101 Firefox/105.0'
});
if (res2.statusCode != 200) {
throw couldNotFindReleases;
}
var html2 = parse(res2.body);
var origin = Uri.parse(standardUrl).origin;
List<String> apkUrls = html2
.querySelectorAll('.apkm-badge')
.map((e) => e.innerHtml != 'APK'
? ''
: e.previousElementSibling?.attributes['href'] ?? '')
.where((element) => element.isNotEmpty)
.map((e) => '$origin$e')
.toList();
if (apkUrls.isEmpty) {
throw noAPKFound;
}
var version = html2.querySelector('span.active.accent_color')?.innerHtml;
if (version == null) {
throw couldNotFindLatestVersion;
}
return APKDetails(version, apkUrls);
} }
@override
AppNames getAppNames(String standardUrl) { AppNames getAppNames(String standardUrl) {
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3); String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/'); List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
return AppNames(names[1], names[2]); return AppNames(names[1], names[2]);
} }
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
} }

View File

@ -1,11 +1,15 @@
import 'package:html/parser.dart'; import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class FDroid implements AppSource { class FDroid extends AppSource {
@override FDroid() {
late String host = 'f-droid.org'; host = 'f-droid.org';
name = tr('fdroid');
}
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
@ -18,7 +22,7 @@ class FDroid implements AppSource {
RegExp standardUrlRegExA = RegExp('^https?://$host/+packages/+[^/]+'); RegExp standardUrlRegExA = RegExp('^https?://$host/+packages/+[^/]+');
match = standardUrlRegExA.firstMatch(url.toLowerCase()); match = standardUrlRegExA.firstMatch(url.toLowerCase());
if (match == null) { if (match == null) {
throw notValidURL(runtimeType.toString()); throw InvalidURLError(name);
} }
return url.substring(0, match.end); return url.substring(0, match.end);
} }
@ -27,62 +31,41 @@ class FDroid implements AppSource {
String? changeLogPageFromStandardUrl(String standardUrl) => null; String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override @override
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl; String? tryInferringAppId(String standardUrl,
{List<String> additionalData = const []}) {
return Uri.parse(standardUrl).pathSegments.last;
}
@override APKDetails getAPKUrlsFromFDroidPackagesAPIResponse(
Future<APKDetails> getLatestAPKDetails( Response res, String apkUrlPrefix, String standardUrl) {
String standardUrl, List<String> additionalData) async {
Response res = await get(Uri.parse(standardUrl));
if (res.statusCode == 200) { if (res.statusCode == 200) {
var releases = parse(res.body).querySelectorAll('.package-version'); List<dynamic> releases = jsonDecode(res.body)['packages'] ?? [];
if (releases.isEmpty) { if (releases.isEmpty) {
throw couldNotFindReleases; throw NoReleasesError();
} }
String? latestVersion = releases[0] String? latestVersion = releases[0]['versionName'];
.querySelector('.package-version-header b')
?.innerHtml
.split(' ')
.sublist(1)
.join(' ');
if (latestVersion == null) { if (latestVersion == null) {
throw couldNotFindLatestVersion; throw NoVersionError();
} }
List<String> apkUrls = releases List<String> apkUrls = releases
.where((element) => .where((element) => element['versionName'] == latestVersion)
element .map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk')
.querySelector('.package-version-header b')
?.innerHtml
.split(' ')
.sublist(1)
.join(' ') ==
latestVersion)
.map((e) =>
e
.querySelector('.package-version-download a')
?.attributes['href'] ??
'')
.where((element) => element.isNotEmpty)
.toList(); .toList();
if (apkUrls.isEmpty) { return APKDetails(latestVersion, apkUrls,
throw noAPKFound; AppNames(name, Uri.parse(standardUrl).pathSegments.last));
}
return APKDetails(latestVersion, apkUrls);
} else { } else {
throw couldNotFindReleases; throw NoReleasesError();
} }
} }
@override @override
AppNames getAppNames(String standardUrl) { Future<APKDetails> getLatestAPKDetails(
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last); String standardUrl, List<String> additionalData,
{bool trackOnly = false}) async {
String? appId = tryInferringAppId(standardUrl);
return getAPKUrlsFromFDroidPackagesAPIResponse(
await get(Uri.parse('https://f-droid.org/api/v1/packages/$appId')),
'https://f-droid.org/repo/$appId',
standardUrl);
} }
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
} }

View File

@ -0,0 +1,90 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class FDroidRepo extends AppSource {
FDroidRepo() {
name = tr('fdroidThirdPartyRepo');
additionalSourceAppSpecificFormItems = [
[
GeneratedFormItem(
label: tr('appIdOrName'),
hint: tr('reposHaveMultipleApps'),
required: true,
key: 'appIdOrName')
]
];
}
@override
String standardizeURL(String url) {
RegExp standardUrlRegExp =
RegExp('^https?://.+/fdroid/(repo(/|\\?)|repo\$)');
RegExpMatch? match = standardUrlRegExp.firstMatch(url.toLowerCase());
if (match == null) {
throw InvalidURLError(name);
}
return url.substring(0, match.end);
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData,
{bool trackOnly = false}) async {
String? appIdOrName = findGeneratedFormValueByKey(
additionalSourceAppSpecificFormItems
.reduce((value, element) => [...value, ...element]),
additionalData,
'appIdOrName');
if (appIdOrName == null) {
throw NoReleasesError();
}
var res = await get(Uri.parse('$standardUrl/index.xml'));
if (res.statusCode == 200) {
var body = parse(res.body);
var foundApps = body.querySelectorAll('application').where((element) {
return element.attributes['id'] == appIdOrName;
}).toList();
if (foundApps.isEmpty) {
foundApps = body.querySelectorAll('application').where((element) {
return element.querySelector('name')?.innerHtml.toLowerCase() ==
appIdOrName.toLowerCase();
}).toList();
}
if (foundApps.isEmpty) {
foundApps = body.querySelectorAll('application').where((element) {
return element
.querySelector('name')
?.innerHtml
.toLowerCase()
.contains(appIdOrName.toLowerCase()) ??
false;
}).toList();
}
if (foundApps.isEmpty) {
throw ObtainiumError(tr('appWithIdOrNameNotFound'));
}
var authorName = body.querySelector('repo')?.attributes['name'] ?? name;
var appName =
foundApps[0].querySelector('name')?.innerHtml ?? appIdOrName;
var releases = foundApps[0].querySelectorAll('package');
String? latestVersion = releases[0].querySelector('version')?.innerHtml;
if (latestVersion == null) {
throw NoVersionError();
}
List<String> apkUrls = releases
.where((element) =>
element.querySelector('version')?.innerHtml == latestVersion &&
element.querySelector('apkname') != null)
.map((e) => '$standardUrl/${e.querySelector('apkname')!.innerHtml}')
.toList();
return APKDetails(latestVersion, apkUrls, AppNames(authorName, appName));
} else {
throw NoReleasesError();
}
}
}

View File

@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form.dart';
@ -7,16 +8,89 @@ import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
class GitHub implements AppSource { class GitHub extends AppSource {
@override GitHub() {
late String host = 'github.com'; host = 'github.com';
additionalSourceAppSpecificDefaults = ['true', 'true', ''];
additionalSourceSpecificSettingFormItems = [
GeneratedFormItem(
label: tr('githubPATLabel'),
id: 'github-creds',
required: false,
additionalValidators: [
(value) {
if (value != null && value.trim().isNotEmpty) {
if (value
.split(':')
.where((element) => element.trim().isNotEmpty)
.length !=
2) {
return tr('githubPATHint');
}
}
return null;
}
],
hint: tr('githubPATFormat'),
belowWidgets: [
const SizedBox(
height: 8,
),
GestureDetector(
onTap: () {
launchUrlString(
'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token',
mode: LaunchMode.externalApplication);
},
child: Text(
tr('githubPATLinkText'),
style: const TextStyle(
decoration: TextDecoration.underline, fontSize: 12),
))
])
];
additionalSourceAppSpecificFormItems = [
[
GeneratedFormItem(
label: tr('includePrereleases'), type: FormItemType.bool)
],
[
GeneratedFormItem(
label: tr('fallbackToOlderReleases'), type: FormItemType.bool)
],
[
GeneratedFormItem(
label: tr('filterReleaseTitlesByRegEx'),
type: FormItemType.string,
required: false,
additionalValidators: [
(value) {
if (value == null || value.isEmpty) {
return null;
}
try {
RegExp(value);
} catch (e) {
return tr('invalidRegEx');
}
return null;
}
])
]
];
canSearch = true;
}
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+'); RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) { if (match == null) {
throw notValidURL(runtimeType.toString()); throw InvalidURLError(name);
} }
return url.substring(0, match.end); return url.substring(0, match.end);
} }
@ -24,8 +98,8 @@ class GitHub implements AppSource {
Future<String> getCredentialPrefixIfAny() async { Future<String> getCredentialPrefixIfAny() async {
SettingsProvider settingsProvider = SettingsProvider(); SettingsProvider settingsProvider = SettingsProvider();
await settingsProvider.initializeSettings(); await settingsProvider.initializeSettings();
String? creds = String? creds = settingsProvider
settingsProvider.getSettingString(moreSourceSettingsFormItems[0].id); .getSettingString(additionalSourceSpecificSettingFormItems[0].id);
return creds != null && creds.isNotEmpty ? '$creds@' : ''; return creds != null && creds.isNotEmpty ? '$creds@' : '';
} }
@ -33,12 +107,10 @@ class GitHub implements AppSource {
String? changeLogPageFromStandardUrl(String standardUrl) => String? changeLogPageFromStandardUrl(String standardUrl) =>
'$standardUrl/releases'; '$standardUrl/releases';
@override
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async { String standardUrl, List<String> additionalData,
{bool trackOnly = false}) async {
var includePrereleases = var includePrereleases =
additionalData.isNotEmpty && additionalData[0] == 'true'; additionalData.isNotEmpty && additionalData[0] == 'true';
var fallbackToOlderReleases = var fallbackToOlderReleases =
@ -72,11 +144,11 @@ class GitHub implements AppSource {
if (regexFilter != null && if (regexFilter != null &&
!RegExp(regexFilter) !RegExp(regexFilter)
.hasMatch((releases[i]['tag_name'] as String).trim())) { .hasMatch((releases[i]['name'] as String).trim())) {
continue; continue;
} }
var apkUrls = getReleaseAPKUrls(releases[i]); var apkUrls = getReleaseAPKUrls(releases[i]);
if (apkUrls.isEmpty) { if (apkUrls.isEmpty && !trackOnly) {
continue; continue;
} }
targetRelease = releases[i]; targetRelease = releases[i];
@ -84,29 +156,20 @@ class GitHub implements AppSource {
break; break;
} }
if (targetRelease == null) { if (targetRelease == null) {
throw couldNotFindReleases; throw NoReleasesError();
}
if ((targetRelease['apkUrls'] as List<String>).isEmpty) {
throw noAPKFound;
} }
String? version = targetRelease['tag_name']; String? version = targetRelease['tag_name'];
if (version == null) { if (version == null) {
throw couldNotFindLatestVersion; throw NoVersionError();
} }
return APKDetails(version, targetRelease['apkUrls']); return APKDetails(version, targetRelease['apkUrls'] as List<String>,
getAppNames(standardUrl));
} else { } else {
if (res.headers['x-ratelimit-remaining'] == '0') { rateLimitErrorCheck(res);
throw RateLimitError( throw getObtainiumHttpError(res);
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
60000000)
.round());
}
throw couldNotFindReleases;
} }
} }
@override
AppNames getAppNames(String standardUrl) { AppNames getAppNames(String standardUrl) {
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3); String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/'); List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
@ -114,72 +177,31 @@ class GitHub implements AppSource {
} }
@override @override
List<List<GeneratedFormItem>> additionalDataFormItems = [ Future<Map<String, String>> search(String query) async {
[GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool)], Response res = await get(Uri.parse(
[ 'https://${await getCredentialPrefixIfAny()}api.$host/search/repositories?q=${Uri.encodeQueryComponent(query)}&per_page=100'));
GeneratedFormItem( if (res.statusCode == 200) {
label: 'Fallback to older releases', type: FormItemType.bool) Map<String, String> urlsWithDescriptions = {};
], for (var e in (jsonDecode(res.body)['items'] as List<dynamic>)) {
[ urlsWithDescriptions.addAll({
GeneratedFormItem( e['html_url'] as String: e['description'] != null
label: 'Filter Release Titles by Regular Expression', ? e['description'] as String
type: FormItemType.string, : tr('noDescription')
required: false, });
additionalValidators: [ }
(value) { return urlsWithDescriptions;
if (value == null || value.isEmpty) { } else {
return null; rateLimitErrorCheck(res);
} throw getObtainiumHttpError(res);
try { }
RegExp(value); }
} catch (e) {
return 'Invalid regular expression';
}
return null;
}
])
]
];
@override rateLimitErrorCheck(Response res) {
List<String> additionalDataDefaults = ['true', 'true', '']; if (res.headers['x-ratelimit-remaining'] == '0') {
throw RateLimitError(
@override (int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
List<GeneratedFormItem> moreSourceSettingsFormItems = [ 60000000)
GeneratedFormItem( .round());
label: 'GitHub Personal Access Token (Increases Rate Limit)', }
id: 'github-creds', }
required: false,
additionalValidators: [
(value) {
if (value != null && value.trim().isNotEmpty) {
if (value
.split(':')
.where((element) => element.trim().isNotEmpty)
.length !=
2) {
return 'PAT must be in this format: username:token';
}
}
return null;
}
],
hint: 'username:token',
belowWidgets: [
const SizedBox(
height: 8,
),
GestureDetector(
onTap: () {
launchUrlString(
'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token',
mode: LaunchMode.externalApplication);
},
child: const Text(
'About GitHub PATs',
style: TextStyle(
decoration: TextDecoration.underline, fontSize: 12),
))
])
];
} }

View File

@ -1,19 +1,20 @@
import 'package:html/parser.dart'; import 'package:html/parser.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/app_sources/github.dart'; import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class GitLab implements AppSource { class GitLab extends AppSource {
@override GitLab() {
late String host = 'gitlab.com'; host = 'gitlab.com';
}
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+'); RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) { if (match == null) {
throw notValidURL(runtimeType.toString()); throw InvalidURLError(name);
} }
return url.substring(0, match.end); return url.substring(0, match.end);
} }
@ -22,12 +23,10 @@ class GitLab implements AppSource {
String? changeLogPageFromStandardUrl(String standardUrl) => String? changeLogPageFromStandardUrl(String standardUrl) =>
'$standardUrl/-/releases'; '$standardUrl/-/releases';
@override
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async { String standardUrl, List<String> additionalData,
{bool trackOnly = false}) async {
Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom')); Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom'));
if (res.statusCode == 200) { if (res.statusCode == 200) {
var standardUri = Uri.parse(standardUrl); var standardUri = Uri.parse(standardUrl);
@ -35,11 +34,13 @@ class GitLab implements AppSource {
var entry = parsedHtml.querySelector('entry'); var entry = parsedHtml.querySelector('entry');
var entryContent = var entryContent =
parse(parseFragment(entry?.querySelector('content')!.innerHtml).text); parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
var apkUrlList = [ var apkUrls = [
...getLinksFromParsedHTML( ...getLinksFromParsedHTML(
entryContent, entryContent,
RegExp( RegExp(
'^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$', '^${standardUri.path.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
return '\\${x[0]}';
})}/uploads/[^/]+/[^/]+\\.apk\$',
caseSensitive: false), caseSensitive: false),
standardUri.origin), standardUri.origin),
// GitLab releases may contain links to externally hosted APKs // GitLab releases may contain links to externally hosted APKs
@ -48,34 +49,16 @@ class GitLab implements AppSource {
.where((element) => Uri.parse(element).host != '') .where((element) => Uri.parse(element).host != '')
.toList() .toList()
]; ];
if (apkUrlList.isEmpty) {
throw noAPKFound;
}
var entryId = entry?.querySelector('id')?.innerHtml; var entryId = entry?.querySelector('id')?.innerHtml;
var version = var version =
entryId == null ? null : Uri.parse(entryId).pathSegments.last; entryId == null ? null : Uri.parse(entryId).pathSegments.last;
if (version == null) { if (version == null) {
throw couldNotFindLatestVersion; throw NoVersionError();
} }
return APKDetails(version, apkUrlList); return APKDetails(version, apkUrls, GitHub().getAppNames(standardUrl));
} else { } else {
throw couldNotFindReleases; throw NoReleasesError();
} }
} }
@override
AppNames getAppNames(String standardUrl) {
// Same as GitHub
return GitHub().getAppNames(standardUrl);
}
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
} }

View File

@ -1,18 +1,19 @@
import 'package:html/parser.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/app_sources/fdroid.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class IzzyOnDroid implements AppSource { class IzzyOnDroid extends AppSource {
@override IzzyOnDroid() {
late String host = 'android.izzysoft.de'; host = 'android.izzysoft.de';
}
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+'); RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) { if (match == null) {
throw notValidURL(runtimeType.toString()); throw InvalidURLError(name);
} }
return url.substring(0, match.end); return url.substring(0, match.end);
} }
@ -21,54 +22,20 @@ class IzzyOnDroid implements AppSource {
String? changeLogPageFromStandardUrl(String standardUrl) => null; String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override @override
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl; String? tryInferringAppId(String standardUrl,
{List<String> additionalData = const []}) {
return FDroid().tryInferringAppId(standardUrl);
}
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async { String standardUrl, List<String> additionalData,
Response res = await get(Uri.parse(standardUrl)); {bool trackOnly = false}) async {
if (res.statusCode == 200) { String? appId = tryInferringAppId(standardUrl);
var parsedHtml = parse(res.body); return FDroid().getAPKUrlsFromFDroidPackagesAPIResponse(
var multipleVersionApkUrls = parsedHtml await get(
.querySelectorAll('a') Uri.parse('https://apt.izzysoft.de/fdroid/api/v1/packages/$appId')),
.where((element) => 'https://android.izzysoft.de/frepo/$appId',
element.attributes['href']?.toLowerCase().endsWith('.apk') ?? standardUrl);
false)
.map((e) => 'https://$host${e.attributes['href'] ?? ''}')
.toList();
if (multipleVersionApkUrls.isEmpty) {
throw noAPKFound;
}
var version = parsedHtml
.querySelector('#keydata')
?.querySelectorAll('b')
.where(
(element) => element.innerHtml.toLowerCase().contains('version'))
.toList()[0]
.parentNode
?.parentNode
?.children[1]
.innerHtml;
if (version == null) {
throw couldNotFindLatestVersion;
}
return APKDetails(version, [multipleVersionApkUrls[0]]);
} else {
throw couldNotFindReleases;
}
} }
@override
AppNames getAppNames(String standardUrl) {
return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last);
}
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
} }

View File

@ -1,18 +1,19 @@
import 'package:html/parser.dart'; import 'package:html/parser.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class Mullvad implements AppSource { class Mullvad extends AppSource {
@override Mullvad() {
late String host = 'mullvad.net'; host = 'mullvad.net';
}
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host'); RegExp standardUrlRegEx = RegExp('^https?://$host');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) { if (match == null) {
throw notValidURL(runtimeType.toString()); throw InvalidURLError(name);
} }
return url.substring(0, match.end); return url.substring(0, match.end);
} }
@ -21,12 +22,10 @@ class Mullvad implements AppSource {
String? changeLogPageFromStandardUrl(String standardUrl) => String? changeLogPageFromStandardUrl(String standardUrl) =>
'https://github.com/mullvad/mullvadvpn-app/blob/master/CHANGELOG.md'; 'https://github.com/mullvad/mullvadvpn-app/blob/master/CHANGELOG.md';
@override
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async { String standardUrl, List<String> additionalData,
{bool trackOnly = false}) async {
Response res = await get(Uri.parse('$standardUrl/en/download/android')); Response res = await get(Uri.parse('$standardUrl/en/download/android'));
if (res.statusCode == 200) { if (res.statusCode == 200) {
var version = parse(res.body) var version = parse(res.body)
@ -36,26 +35,14 @@ class Mullvad implements AppSource {
?.split('/') ?.split('/')
.last; .last;
if (version == null) { if (version == null) {
throw couldNotFindLatestVersion; throw NoVersionError();
} }
return APKDetails( return APKDetails(
version, ['https://mullvad.net/download/app/apk/latest']); version,
['https://mullvad.net/download/app/apk/latest'],
AppNames(name, 'Mullvad-VPN'));
} else { } else {
throw couldNotFindReleases; throw NoReleasesError();
} }
} }
@override
AppNames getAppNames(String standardUrl) {
return AppNames('Mullvad-VPN', 'Mullvad-VPN');
}
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
} }

View File

@ -1,11 +1,12 @@
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class Signal implements AppSource { class Signal extends AppSource {
@override Signal() {
late String host = 'signal.org'; host = 'signal.org';
}
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
@ -15,39 +16,23 @@ class Signal implements AppSource {
@override @override
String? changeLogPageFromStandardUrl(String standardUrl) => null; String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async { String standardUrl, List<String> additionalData,
{bool trackOnly = false}) async {
Response res = Response res =
await get(Uri.parse('https://updates.$host/android/latest.json')); await get(Uri.parse('https://updates.$host/android/latest.json'));
if (res.statusCode == 200) { if (res.statusCode == 200) {
var json = jsonDecode(res.body); var json = jsonDecode(res.body);
String? apkUrl = json['url']; String? apkUrl = json['url'];
if (apkUrl == null) { List<String> apkUrls = apkUrl == null ? [] : [apkUrl];
throw noAPKFound;
}
String? version = json['versionName']; String? version = json['versionName'];
if (version == null) { if (version == null) {
throw couldNotFindLatestVersion; throw NoVersionError();
} }
return APKDetails(version, [apkUrl]); return APKDetails(version, apkUrls, AppNames(name, 'Signal'));
} else { } else {
throw couldNotFindReleases; throw NoReleasesError();
} }
} }
@override
AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal');
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
} }

View File

@ -1,18 +1,19 @@
import 'package:html/parser.dart'; import 'package:html/parser.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class SourceForge implements AppSource { class SourceForge extends AppSource {
@override SourceForge() {
late String host = 'sourceforge.net'; host = 'sourceforge.net';
}
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/projects/[^/]+'); RegExp standardUrlRegEx = RegExp('^https?://$host/projects/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) { if (match == null) {
throw notValidURL(runtimeType.toString()); throw InvalidURLError(name);
} }
return url.substring(0, match.end); return url.substring(0, match.end);
} }
@ -20,12 +21,10 @@ class SourceForge implements AppSource {
@override @override
String? changeLogPageFromStandardUrl(String standardUrl) => null; String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async { String standardUrl, List<String> additionalData,
{bool trackOnly = false}) async {
Response res = await get(Uri.parse('$standardUrl/rss?path=/')); Response res = await get(Uri.parse('$standardUrl/rss?path=/'));
if (res.statusCode == 200) { if (res.statusCode == 200) {
var parsedHtml = parse(res.body); var parsedHtml = parse(res.body);
@ -42,7 +41,7 @@ class SourceForge implements AppSource {
String? version = getVersion(allDownloadLinks[0]); String? version = getVersion(allDownloadLinks[0]);
if (version == null) { if (version == null) {
throw couldNotFindLatestVersion; throw NoVersionError();
} }
var apkUrlListAllReleases = allDownloadLinks var apkUrlListAllReleases = allDownloadLinks
.where((element) => element.toLowerCase().endsWith('.apk/download')) .where((element) => element.toLowerCase().endsWith('.apk/download'))
@ -51,27 +50,13 @@ class SourceForge implements AppSource {
apkUrlListAllReleases // This can be used skipped for fallback support later apkUrlListAllReleases // This can be used skipped for fallback support later
.where((element) => getVersion(element) == version) .where((element) => getVersion(element) == version)
.toList(); .toList();
if (apkUrlList.isEmpty) { return APKDetails(
throw noAPKFound; version,
} apkUrlList,
return APKDetails(version, apkUrlList); AppNames(
name, standardUrl.substring(standardUrl.lastIndexOf('/') + 1)));
} else { } else {
throw couldNotFindReleases; throw NoReleasesError();
} }
} }
@override
AppNames getAppNames(String standardUrl) {
return AppNames(runtimeType.toString(),
standardUrl.substring(standardUrl.lastIndexOf('/') + 1));
}
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
} }

View File

@ -0,0 +1,69 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class SteamMobile extends AppSource {
SteamMobile() {
host = 'store.steampowered.com';
name = tr('steam');
additionalSourceAppSpecificFormItems = [
[
GeneratedFormItem(
label: tr('app'),
key: 'app',
required: true,
opts: apks.entries.toList())
]
];
}
final apks = {'steam': tr('steamMobile'), 'steam-chat-app': tr('steamChat')};
@override
String standardizeURL(String url) {
return 'https://$host';
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData,
{bool trackOnly = false}) async {
Response res = await get(Uri.parse('https://$host/mobile'));
if (res.statusCode == 200) {
var apkNamePrefix = findGeneratedFormValueByKey(
additionalSourceAppSpecificFormItems
.reduce((value, element) => [...value, ...element]),
additionalData,
'app');
if (apkNamePrefix == null) {
throw NoReleasesError();
}
String apkInURLRegexPattern = '/$apkNamePrefix-[^/]+\\.apk\$';
var links = parse(res.body)
.querySelectorAll('a')
.map((e) => e.attributes['href'] ?? '')
.where((e) => RegExp('https://.*$apkInURLRegexPattern').hasMatch(e))
.toList();
if (links.isEmpty) {
throw NoReleasesError();
}
var versionMatch = RegExp(apkInURLRegexPattern).firstMatch(links[0]);
if (versionMatch == null) {
throw NoVersionError();
}
var version = links[0].substring(
versionMatch.start + apkNamePrefix.length + 2, versionMatch.end - 4);
var apkUrls = [links[0]];
return APKDetails(version, apkUrls, AppNames(name, apks[apkNamePrefix]!));
} else {
throw NoReleasesError();
}
}
}

View File

@ -1,10 +1,13 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
enum FormItemType { string, bool } enum FormItemType { string, bool }
typedef OnValueChanges = void Function(List<String> values, bool valid); typedef OnValueChanges = void Function(
List<String> values, bool valid, bool isBuilding);
class GeneratedFormItem { class GeneratedFormItem {
late String key;
late String label; late String label;
late FormItemType type; late FormItemType type;
late bool required; late bool required;
@ -13,6 +16,7 @@ class GeneratedFormItem {
late String id; late String id;
late List<Widget> belowWidgets; late List<Widget> belowWidgets;
late String? hint; late String? hint;
late List<MapEntry<String, String>>? opts;
GeneratedFormItem( GeneratedFormItem(
{this.label = 'Input', {this.label = 'Input',
@ -22,7 +26,13 @@ class GeneratedFormItem {
this.additionalValidators = const [], this.additionalValidators = const [],
this.id = 'input', this.id = 'input',
this.belowWidgets = const [], this.belowWidgets = const [],
this.hint}); this.hint,
this.opts,
this.key = 'default'}) {
if (type != FormItemType.string) {
required = false;
}
}
} }
class GeneratedForm extends StatefulWidget { class GeneratedForm extends StatefulWidget {
@ -47,7 +57,7 @@ class _GeneratedFormState extends State<GeneratedForm> {
List<List<Widget>> rows = []; List<List<Widget>> rows = [];
// If any value changes, call this to update the parent with value and validity // If any value changes, call this to update the parent with value and validity
void someValueChanged() { void someValueChanged({bool isBuilding = false}) {
List<String> returnValues = []; List<String> returnValues = [];
var valid = true; var valid = true;
for (int r = 0; r < values.length; r++) { for (int r = 0; r < values.length; r++) {
@ -62,7 +72,7 @@ class _GeneratedFormState extends State<GeneratedForm> {
} }
} }
} }
widget.onValueChanges(returnValues, valid); widget.onValueChanges(returnValues, valid, isBuilding);
} }
@override @override
@ -75,14 +85,16 @@ class _GeneratedFormState extends State<GeneratedForm> {
.map((row) => row.map((e) { .map((row) => row.map((e) {
return j < widget.defaultValues.length return j < widget.defaultValues.length
? widget.defaultValues[j++] ? widget.defaultValues[j++]
: ''; : e.opts != null
? e.opts!.first.key
: '';
}).toList()) }).toList())
.toList(); .toList();
// Dynamically create form inputs // Dynamically create form inputs
formInputs = widget.items.asMap().entries.map((row) { formInputs = widget.items.asMap().entries.map((row) {
return row.value.asMap().entries.map((e) { return row.value.asMap().entries.map((e) {
if (e.value.type == FormItemType.string) { if (e.value.type == FormItemType.string && e.value.opts == null) {
final formFieldKey = GlobalKey<FormFieldState>(); final formFieldKey = GlobalKey<FormFieldState>();
return TextFormField( return TextFormField(
key: formFieldKey, key: formFieldKey,
@ -101,7 +113,7 @@ class _GeneratedFormState extends State<GeneratedForm> {
maxLines: e.value.max <= 1 ? 1 : e.value.max, maxLines: e.value.max <= 1 ? 1 : e.value.max,
validator: (value) { validator: (value) {
if (e.value.required && (value == null || value.trim().isEmpty)) { if (e.value.required && (value == null || value.trim().isEmpty)) {
return '${e.value.label} (required)'; return '${e.value.label} ${tr('requiredInBrackets')}';
} }
for (var validator in e.value.additionalValidators) { for (var validator in e.value.additionalValidators) {
String? result = validator(value); String? result = validator(value);
@ -112,11 +124,30 @@ class _GeneratedFormState extends State<GeneratedForm> {
return null; return null;
}, },
); );
} else if (e.value.type == FormItemType.string &&
e.value.opts != null) {
if (e.value.opts!.isEmpty) {
return Text(tr('dropdownNoOptsError'));
}
return DropdownButtonFormField(
decoration: InputDecoration(labelText: e.value.label),
value: values[row.key][e.key],
items: e.value.opts!
.map((e) =>
DropdownMenuItem(value: e.key, child: Text(e.value)))
.toList(),
onChanged: (value) {
setState(() {
values[row.key][e.key] = value ?? e.value.opts!.first.key;
someValueChanged();
});
});
} else { } else {
return Container(); // Some input types added in build return Container(); // Some input types added in build
} }
}).toList(); }).toList();
}).toList(); }).toList();
someValueChanged(isBuilding: true);
} }
@override @override
@ -186,3 +217,18 @@ class _GeneratedFormState extends State<GeneratedForm> {
)); ));
} }
} }
String? findGeneratedFormValueByKey(
List<GeneratedFormItem> items, List<String> values, String key) {
var foundIndex = -1;
for (var i = 0; i < items.length; i++) {
if (items[i].key == key) {
foundIndex = i;
break;
}
}
if (foundIndex >= 0 && foundIndex < values.length) {
return values[foundIndex];
}
return null;
}

View File

@ -1,3 +1,4 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form.dart';
@ -28,7 +29,8 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
valid = widget.initValid; values = widget.defaultValues;
valid = widget.initValid || widget.items.isEmpty;
} }
@override @override
@ -45,11 +47,16 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
), ),
GeneratedForm( GeneratedForm(
items: widget.items, items: widget.items,
onValueChanges: (values, valid) { onValueChanges: (values, valid, isBuilding) {
setState(() { if (isBuilding) {
this.values = values; this.values = values;
this.valid = valid; this.valid = valid;
}); } else {
setState(() {
this.values = values;
this.valid = valid;
});
}
}, },
defaultValues: widget.defaultValues) defaultValues: widget.defaultValues)
]), ]),
@ -58,7 +65,7 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
onPressed: () { onPressed: () {
Navigator.of(context).pop(null); Navigator.of(context).pop(null);
}, },
child: const Text('Cancel')), child: Text(tr('cancel'))),
TextButton( TextButton(
onPressed: !valid onPressed: !valid
? null ? null
@ -68,7 +75,7 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
Navigator.of(context).pop(values); Navigator.of(context).pop(values);
} }
}, },
child: const Text('Continue')) child: Text(tr('continue')))
], ],
); );
} }

View File

@ -1,8 +1,123 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:obtainium/providers/logs_provider.dart';
import 'package:provider/provider.dart';
class ObtainiumError {
late String message;
bool unexpected;
ObtainiumError(this.message, {this.unexpected = false});
@override
String toString() {
return message;
}
}
class RateLimitError { class RateLimitError {
late int remainingMinutes; late int remainingMinutes;
RateLimitError(this.remainingMinutes); RateLimitError(this.remainingMinutes);
@override @override
String toString() => String toString() =>
'Too many requests (rate limited) - try again in $remainingMinutes minutes'; plural('tooManyRequestsTryAgainInMinutes', remainingMinutes);
}
class InvalidURLError extends ObtainiumError {
InvalidURLError(String sourceName)
: super(tr('invalidURLForSource', args: [sourceName]));
}
class NoReleasesError extends ObtainiumError {
NoReleasesError() : super(tr('noReleaseFound'));
}
class NoAPKError extends ObtainiumError {
NoAPKError() : super(tr('noReleaseFound'));
}
class NoVersionError extends ObtainiumError {
NoVersionError() : super(tr('noVersionFound'));
}
class UnsupportedURLError extends ObtainiumError {
UnsupportedURLError() : super(tr('urlMatchesNoSource'));
}
class DowngradeError extends ObtainiumError {
DowngradeError() : super(tr('cantInstallOlderVersion'));
}
class IDChangedError extends ObtainiumError {
IDChangedError() : super(tr('appIdMismatch'));
}
class NotImplementedError extends ObtainiumError {
NotImplementedError() : super(tr('functionNotImplemented'));
}
class MultiAppMultiError extends ObtainiumError {
Map<String, List<String>> content = {};
MultiAppMultiError() : super(tr('placeholder'), unexpected: true);
add(String appId, String string) {
var tempIds = content.remove(string);
tempIds ??= [];
tempIds.add(appId);
content.putIfAbsent(string, () => tempIds!);
}
@override
String toString() {
String finalString = '';
for (var e in content.keys) {
finalString += '$e: ${content[e].toString()}\n\n';
}
return finalString;
}
}
showError(dynamic e, BuildContext context) {
Provider.of<LogsProvider>(context, listen: false)
.add(e.toString(), level: LogLevels.error);
if (e is String || (e is ObtainiumError && !e.unexpected)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
} else {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
scrollable: true,
title: Text(e is MultiAppMultiError
? tr('someErrors')
: tr('unexpectedError')),
content: Text(e.toString()),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(null);
},
child: Text(tr('ok'))),
],
);
});
}
}
String list2FriendlyString(List<String> list) {
return list.length == 2
? '${list[0]} ${tr('and')} ${list[1]}'
: list
.asMap()
.entries
.map((e) =>
e.value +
(e.key == list.length - 1
? ''
: e.key == list.length - 2
? ', and '
: ', '))
.join('');
} }

View File

@ -1,61 +1,109 @@
import 'dart:io'; import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/pages/home.dart'; import 'package:obtainium/pages/home.dart';
import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/logs_provider.dart';
import 'package:obtainium/providers/notifications_provider.dart'; import 'package:obtainium/providers/notifications_provider.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:workmanager/workmanager.dart';
import 'package:dynamic_color/dynamic_color.dart'; import 'package:dynamic_color/dynamic_color.dart';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart';
import 'package:easy_localization/easy_localization.dart';
// ignore: implementation_imports
import 'package:easy_localization/src/easy_localization_controller.dart';
// ignore: implementation_imports
import 'package:easy_localization/src/localization.dart';
const String currentVersion = '0.6.5'; const String currentVersion = '0.8.20';
const String currentReleaseTag = const String currentReleaseTag =
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES 'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
const String bgUpdateCheckTaskName = 'bg-update-check'; const int bgUpdateCheckAlarmId = 666;
bgUpdateCheck(int? ignoreAfterMicroseconds) async { const supportedLocales = [
Locale('en'),
Locale('zh'),
Locale('it'),
Locale('ja'),
Locale('hu')
];
const fallbackLocale = Locale('en');
const localeDir = 'assets/translations';
final globalNavigatorKey = GlobalKey<NavigatorState>();
Future<void> loadTranslations() async {
// See easy_localization/issues/210
await EasyLocalizationController.initEasyLocation();
final controller = EasyLocalizationController(
saveLocale: true,
fallbackLocale: fallbackLocale,
supportedLocales: supportedLocales,
assetLoader: const RootBundleAssetLoader(),
useOnlyLangCode: false,
useFallbackTranslations: true,
path: localeDir,
onLoadError: (FlutterError e) {
throw e;
},
);
await controller.loadTranslations();
Localization.load(controller.locale,
translations: controller.translations,
fallbackTranslations: controller.fallbackTranslations);
}
@pragma('vm:entry-point')
Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await EasyLocalization.ensureInitialized();
await loadTranslations();
LogsProvider logs = LogsProvider();
logs.add(tr('startedBgUpdateTask'));
int? ignoreAfterMicroseconds = params?['ignoreAfterMicroseconds'];
await AndroidAlarmManager.initialize();
DateTime? ignoreAfter = ignoreAfterMicroseconds != null DateTime? ignoreAfter = ignoreAfterMicroseconds != null
? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds) ? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds)
: null; : null;
logs.add(tr('bgUpdateIgnoreAfterIs', args: [ignoreAfter.toString()]));
var notificationsProvider = NotificationsProvider(); var notificationsProvider = NotificationsProvider();
await notificationsProvider.notify(checkingUpdatesNotification); await notificationsProvider.notify(checkingUpdatesNotification);
try { try {
var appsProvider = AppsProvider(); var appsProvider = AppsProvider();
await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id); await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id);
await appsProvider.loadApps(shouldCorrectInstallStatus: false); await appsProvider.loadApps();
List<String> existingUpdateIds = List<String> existingUpdateIds =
appsProvider.getExistingUpdates(installedOnly: true); appsProvider.findExistingUpdates(installedOnly: true);
DateTime nextIgnoreAfter = DateTime.now(); DateTime nextIgnoreAfter = DateTime.now();
String? err; String? err;
try { try {
logs.add(tr('startedActualBGUpdateCheck'));
await appsProvider.checkUpdates( await appsProvider.checkUpdates(
ignoreAfter: ignoreAfter, ignoreAppsCheckedAfter: ignoreAfter, throwErrorsForRetry: true);
immediatelyThrowRateLimitError: true,
immediatelyThrowSocketError: true,
shouldCorrectInstallStatus: false);
} catch (e) { } catch (e) {
if (e is RateLimitError || e is SocketException) { if (e is RateLimitError || e is SocketException) {
String nextTaskName = var remainingMinutes = e is RateLimitError ? e.remainingMinutes : 15;
'$bgUpdateCheckTaskName-${nextIgnoreAfter.microsecondsSinceEpoch.toString()}'; logs.add(plural('bgUpdateGotErrorRetryInMinutes', remainingMinutes,
Workmanager().registerOneOffTask(nextTaskName, nextTaskName, args: [e.toString(), remainingMinutes.toString()]));
constraints: Constraints(networkType: NetworkType.connected), AndroidAlarmManager.oneShot(Duration(minutes: remainingMinutes),
initialDelay: Duration( Random().nextInt(pow(2, 31) as int), bgUpdateCheck, params: {
minutes: e is RateLimitError ? e.remainingMinutes : 15), 'ignoreAfterMicroseconds': nextIgnoreAfter.microsecondsSinceEpoch
inputData: {'ignoreAfter': nextIgnoreAfter.microsecondsSinceEpoch}); });
} else { } else {
err = e.toString(); err = e.toString();
} }
} }
List<App> newUpdates = appsProvider List<App> newUpdates = appsProvider
.getExistingUpdates(installedOnly: true) .findExistingUpdates(installedOnly: true)
.where((id) => !existingUpdateIds.contains(id)) .where((id) => !existingUpdateIds.contains(id))
.map((e) => appsProvider.apps[e]!.app) .map((e) => appsProvider.apps[e]!.app)
.toList(); .toList();
@ -73,53 +121,45 @@ bgUpdateCheck(int? ignoreAfterMicroseconds) async {
// silentlyUpdated.map((e) => appsProvider.apps[e]!.app).toList()), // silentlyUpdated.map((e) => appsProvider.apps[e]!.app).toList()),
// cancelExisting: true); // cancelExisting: true);
// } // }
logs.add(
plural('bgCheckFoundUpdatesWillNotifyIfNeeded', newUpdates.length));
if (newUpdates.isNotEmpty) { if (newUpdates.isNotEmpty) {
notificationsProvider.notify(UpdateNotification(newUpdates)); notificationsProvider.notify(UpdateNotification(newUpdates));
} }
if (err != null) { if (err != null) {
throw err; throw err;
} }
return Future.value(true);
} catch (e) { } catch (e) {
notificationsProvider notificationsProvider
.notify(ErrorCheckingUpdatesNotification(e.toString())); .notify(ErrorCheckingUpdatesNotification(e.toString()));
return Future.error(false);
} finally { } finally {
logs.add(tr('bgUpdateTaskFinished'));
await notificationsProvider.cancel(checkingUpdatesNotification.id); await notificationsProvider.cancel(checkingUpdatesNotification.id);
} }
} }
@pragma('vm:entry-point')
void bgTaskCallback() {
// Background process callback
Workmanager().executeTask((task, inputData) async {
return await bgUpdateCheck(inputData?['ignoreAfter']);
});
}
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await EasyLocalization.ensureInitialized();
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) { if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) {
SystemChrome.setSystemUIOverlayStyle( SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent), const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
); );
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
} }
Workmanager().initialize( await AndroidAlarmManager.initialize();
bgTaskCallback,
);
runApp(MultiProvider( runApp(MultiProvider(
providers: [ providers: [
ChangeNotifierProvider( ChangeNotifierProvider(create: (context) => AppsProvider()),
create: (context) => AppsProvider(
shouldLoadApps: true,
shouldCheckUpdatesAfterLoad: false,
shouldDeleteAPKs: true)),
ChangeNotifierProvider(create: (context) => SettingsProvider()), ChangeNotifierProvider(create: (context) => SettingsProvider()),
Provider(create: (context) => NotificationsProvider()) Provider(create: (context) => NotificationsProvider()),
Provider(create: (context) => LogsProvider())
], ],
child: const Obtainium(), child: EasyLocalization(
supportedLocales: supportedLocales,
path: localeDir,
fallbackLocale: fallbackLocale,
child: const Obtainium()),
)); ));
} }
@ -139,17 +179,19 @@ class _ObtainiumState extends State<Obtainium> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
SettingsProvider settingsProvider = context.watch<SettingsProvider>(); SettingsProvider settingsProvider = context.watch<SettingsProvider>();
AppsProvider appsProvider = context.read<AppsProvider>(); AppsProvider appsProvider = context.read<AppsProvider>();
LogsProvider logs = context.read<LogsProvider>();
if (settingsProvider.prefs == null) { if (settingsProvider.prefs == null) {
settingsProvider.initializeSettings(); settingsProvider.initializeSettings();
} else { } else {
bool isFirstRun = settingsProvider.checkAndFlipFirstRun(); bool isFirstRun = settingsProvider.checkAndFlipFirstRun();
if (isFirstRun) { if (isFirstRun) {
logs.add(tr('firstRun'));
// If this is the first run, ask for notification permissions and add Obtainium to the Apps list // If this is the first run, ask for notification permissions and add Obtainium to the Apps list
Permission.notification.request(); Permission.notification.request();
appsProvider.saveApps([ appsProvider.saveApps([
App( App(
'dev.imranr.obtainium', obtainiumId,
'https://github.com/ImranR98/Obtainium', 'https://github.com/ImranR98/Obtainium',
'ImranR98', 'ImranR98',
'Obtainium', 'Obtainium',
@ -158,24 +200,27 @@ class _ObtainiumState extends State<Obtainium> {
[], [],
0, 0,
['true'], ['true'],
null) null,
false,
false)
]); ]);
} }
// Register the background update task according to the user's setting // Register the background update task according to the user's setting
if (existingUpdateInterval != settingsProvider.updateInterval) { if (existingUpdateInterval != settingsProvider.updateInterval) {
if (existingUpdateInterval != -1) {
logs.add(tr('settingUpdateCheckIntervalTo',
args: [settingsProvider.updateInterval.toString()]));
}
existingUpdateInterval = settingsProvider.updateInterval; existingUpdateInterval = settingsProvider.updateInterval;
if (existingUpdateInterval == 0) { if (existingUpdateInterval == 0) {
Workmanager().cancelByUniqueName(bgUpdateCheckTaskName); AndroidAlarmManager.cancel(bgUpdateCheckAlarmId);
} else { } else {
Workmanager().registerPeriodicTask( AndroidAlarmManager.periodic(
bgUpdateCheckTaskName, bgUpdateCheckTaskName, Duration(minutes: existingUpdateInterval),
frequency: Duration(minutes: existingUpdateInterval), bgUpdateCheckAlarmId,
initialDelay: Duration(minutes: existingUpdateInterval), bgUpdateCheck,
constraints: Constraints(networkType: NetworkType.connected), rescheduleOnReboot: true,
existingWorkPolicy: ExistingWorkPolicy.replace, wakeup: true);
backoffPolicy: BackoffPolicy.linear,
backoffPolicyDelay:
const Duration(minutes: minUpdateIntervalMinutes));
} }
} }
} }
@ -197,6 +242,10 @@ class _ObtainiumState extends State<Obtainium> {
} }
return MaterialApp( return MaterialApp(
title: 'Obtainium', title: 'Obtainium',
localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales,
locale: context.locale,
navigatorKey: globalNavigatorKey,
theme: ThemeData( theme: ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: settingsProvider.theme == ThemeSettings.dark colorScheme: settingsProvider.theme == ThemeSettings.dark

View File

@ -1,51 +1,54 @@
import 'dart:convert'; import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/app_sources/github.dart'; import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class GitHubStars implements MassAppSource { class GitHubStars implements MassAppUrlSource {
@override @override
late String name = 'GitHub Starred Repos'; late String name = tr('githubStarredRepos');
@override @override
late List<String> requiredArgs = ['Username']; late List<String> requiredArgs = [tr('uname')];
Future<List<String>> getOnePageOfUserStarredUrls( Future<Map<String, String>> getOnePageOfUserStarredUrlsWithDescriptions(
String username, int page) async { String username, int page) async {
Response res = await get(Uri.parse( Response res = await get(Uri.parse(
'https://${await GitHub().getCredentialPrefixIfAny()}api.github.com/users/$username/starred?per_page=100&page=$page')); 'https://${await GitHub().getCredentialPrefixIfAny()}api.github.com/users/$username/starred?per_page=100&page=$page'));
if (res.statusCode == 200) { if (res.statusCode == 200) {
return (jsonDecode(res.body) as List<dynamic>) Map<String, String> urlsWithDescriptions = {};
.map((e) => e['html_url'] as String) for (var e in (jsonDecode(res.body) as List<dynamic>)) {
.toList(); urlsWithDescriptions.addAll({
} else { e['html_url'] as String: e['description'] != null
if (res.headers['x-ratelimit-remaining'] == '0') { ? e['description'] as String
throw RateLimitError( : tr('noDescription')
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / });
60000000)
.round());
} }
return urlsWithDescriptions;
throw 'Unable to find user\'s starred repos'; } else {
var gh = GitHub();
gh.rateLimitErrorCheck(res);
throw getObtainiumHttpError(res);
} }
} }
@override @override
Future<List<String>> getUrls(List<String> args) async { Future<Map<String, String>> getUrlsWithDescriptions(List<String> args) async {
if (args.length != requiredArgs.length) { if (args.length != requiredArgs.length) {
throw 'Wrong number of arguments provided'; throw ObtainiumError(tr('wrongArgNum'));
} }
List<String> urls = []; Map<String, String> urlsWithDescriptions = {};
var page = 1; var page = 1;
while (true) { while (true) {
var pageUrls = await getOnePageOfUserStarredUrls(args[0], page++); var pageUrls =
urls.addAll(pageUrls); await getOnePageOfUserStarredUrlsWithDescriptions(args[0], page++);
urlsWithDescriptions.addAll(pageUrls);
if (pageUrls.length < 100) { if (pageUrls.length < 100) {
break; break;
} }
} }
return urls; return urlsWithDescriptions;
} }
} }

View File

@ -1,8 +1,13 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:obtainium/components/custom_app_bar.dart'; import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/main.dart';
import 'package:obtainium/pages/app.dart'; import 'package:obtainium/pages/app.dart';
import 'package:obtainium/pages/import_export.dart';
import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
@ -20,17 +25,126 @@ class _AddAppPageState extends State<AddAppPage> {
bool gettingAppInfo = false; bool gettingAppInfo = false;
String userInput = ''; String userInput = '';
String searchQuery = '';
AppSource? pickedSource; AppSource? pickedSource;
List<String> additionalData = []; List<String> sourceSpecificAdditionalData = [];
bool validAdditionalData = true; bool sourceSpecificDataIsValid = true;
List<String> otherAdditionalData = [];
bool otherAdditionalDataIsValid = true;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider(); SourceProvider sourceProvider = SourceProvider();
AppsProvider appsProvider = context.read<AppsProvider>();
changeUserInput(String input, bool valid, bool isBuilding) {
userInput = input;
fn() {
var source = valid ? sourceProvider.getSource(userInput) : null;
if (pickedSource.runtimeType != source.runtimeType) {
pickedSource = source;
sourceSpecificAdditionalData =
source != null ? source.additionalSourceAppSpecificDefaults : [];
sourceSpecificDataIsValid = source != null
? !sourceProvider.ifSourceAppsRequireAdditionalData(source)
: true;
}
}
if (isBuilding) {
fn();
} else {
setState(() {
fn();
});
}
}
addApp({bool resetUserInputAfter = false}) async {
setState(() {
gettingAppInfo = true;
});
var settingsProvider = context.read<SettingsProvider>();
() async {
var userPickedTrackOnly = findGeneratedFormValueByKey(
pickedSource!.additionalAppSpecificSourceAgnosticFormItems,
otherAdditionalData,
'trackOnlyFormItemKey') ==
'true';
var cont = true;
if ((userPickedTrackOnly || pickedSource!.enforceTrackOnly) &&
await showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr('xIsTrackOnly', args: [
pickedSource!.enforceTrackOnly
? tr('source')
: tr('app')
]),
items: const [],
defaultValues: const [],
message:
'${pickedSource!.enforceTrackOnly ? tr('appsFromSourceAreTrackOnly') : tr('youPickedTrackOnly')}\n\n${tr('trackOnlyAppDescription')}',
);
}) ==
null) {
cont = false;
}
if (cont) {
HapticFeedback.selectionClick();
var trackOnly = pickedSource!.enforceTrackOnly || userPickedTrackOnly;
App app = await sourceProvider.getApp(
pickedSource!, userInput, sourceSpecificAdditionalData,
trackOnly: trackOnly);
if (!trackOnly) {
await settingsProvider.getInstallPermission();
}
// Only download the APK here if you need to for the package ID
if (sourceProvider.isTempId(app.id) && !app.trackOnly) {
// ignore: use_build_context_synchronously
var apkUrl = await appsProvider.confirmApkUrl(app, context);
if (apkUrl == null) {
throw ObtainiumError(tr('cancelled'));
}
app.preferredApkIndex = app.apkUrls.indexOf(apkUrl);
// ignore: use_build_context_synchronously
var downloadedApk = await appsProvider.downloadApp(
app, globalNavigatorKey.currentContext);
app.id = downloadedApk.appId;
}
if (appsProvider.apps.containsKey(app.id)) {
throw ObtainiumError(tr('appAlreadyAdded'));
}
if (app.trackOnly) {
app.installedVersion = app.latestVersion;
}
await appsProvider.saveApps([app]);
return app;
}
}()
.then((app) {
if (app != null) {
Navigator.push(context,
MaterialPageRoute(builder: (context) => AppPage(appId: app.id)));
}
}).catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
gettingAppInfo = false;
if (resetUserInputAfter) {
changeUserInput('', false, true);
}
});
});
}
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[ body: CustomScrollView(slivers: <Widget>[
const CustomAppBar(title: 'Add App'), CustomAppBar(title: tr('addApp')),
SliverFillRemaining( SliverFillRemaining(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@ -44,7 +158,7 @@ class _AddAppPageState extends State<AddAppPage> {
items: [ items: [
[ [
GeneratedFormItem( GeneratedFormItem(
label: 'App Source Url', label: tr('appSourceURL'),
additionalValidators: [ additionalValidators: [
(value) { (value) {
try { try {
@ -56,31 +170,18 @@ class _AddAppPageState extends State<AddAppPage> {
} catch (e) { } catch (e) {
return e is String return e is String
? e ? e
: 'Error'; : e is ObtainiumError
? e.toString()
: tr('error');
} }
return null; return null;
} }
]) ])
] ]
], ],
onValueChanges: (values, valid) { onValueChanges: (values, valid, isBuilding) {
setState(() { changeUserInput(
userInput = values[0]; values[0], valid, isBuilding);
var source = valid
? sourceProvider.getSource(userInput)
: null;
if (pickedSource != source) {
pickedSource = source;
additionalData = source != null
? source.additionalDataDefaults
: [];
validAdditionalData = source != null
? sourceProvider
.doesSourceHaveRequiredAdditionalData(
source)
: true;
}
});
}, },
defaultValues: const [])), defaultValues: const [])),
const SizedBox( const SizedBox(
@ -91,73 +192,115 @@ class _AddAppPageState extends State<AddAppPage> {
: ElevatedButton( : ElevatedButton(
onPressed: gettingAppInfo || onPressed: gettingAppInfo ||
pickedSource == null || pickedSource == null ||
(pickedSource!.additionalDataFormItems (pickedSource!
.additionalSourceAppSpecificFormItems
.isNotEmpty && .isNotEmpty &&
!validAdditionalData) !sourceSpecificDataIsValid) ||
(pickedSource!
.additionalAppSpecificSourceAgnosticDefaults
.isNotEmpty &&
!otherAdditionalDataIsValid)
? null ? null
: () async { : addApp,
setState(() { child: Text(tr('add')))
gettingAppInfo = true;
});
var appsProvider =
context.read<AppsProvider>();
var settingsProvider =
context.read<SettingsProvider>();
() async {
HapticFeedback.selectionClick();
App app =
await sourceProvider.getApp(
pickedSource!,
userInput,
additionalData);
await settingsProvider
.getInstallPermission();
// ignore: use_build_context_synchronously
var apkUrl = await appsProvider
.selectApkUrl(app, context);
if (apkUrl == null) {
throw 'Cancelled';
}
app.preferredApkIndex =
app.apkUrls.indexOf(apkUrl);
var downloadedApk =
await appsProvider.downloadApp(
app,
showOccasionalProgressToast:
true);
app.id = downloadedApk.appId;
if (appsProvider.apps
.containsKey(app.id)) {
throw 'App already added';
}
await appsProvider.saveApps([app]);
return app;
}()
.then((app) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
AppPage(
appId: app.id)));
}).catchError((e) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: Text(e.toString())),
);
}).whenComplete(() {
setState(() {
gettingAppInfo = false;
});
});
},
child: const Text('Add'))
], ],
), ),
if (sourceProvider.sources
.where((e) => e.canSearch)
.isNotEmpty &&
pickedSource == null &&
userInput.isEmpty)
const SizedBox(
height: 16,
),
if (sourceProvider.sources
.where((e) => e.canSearch)
.isNotEmpty &&
pickedSource == null &&
userInput.isEmpty)
Row(
children: [
Expanded(
child: GeneratedForm(
items: [
[
GeneratedFormItem(
label: tr('searchSomeSourcesLabel'),
required: false),
]
],
onValueChanges: (values, valid, isBuilding) {
if (values.isNotEmpty && valid) {
setState(() {
searchQuery = values[0].trim();
});
}
},
defaultValues: const ['']),
),
const SizedBox(
width: 16,
),
ElevatedButton(
onPressed: searchQuery.isEmpty || gettingAppInfo
? null
: () {
Future.wait(sourceProvider.sources
.where((e) => e.canSearch)
.map((e) =>
e.search(searchQuery)))
.then((results) async {
// Interleave results instead of simple reduce
Map<String, 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++;
}
List<String>? selectedUrls = res
.isEmpty
? []
: await showDialog<List<String>?>(
context: context,
builder: (BuildContext ctx) {
return UrlSelectionModal(
urlsWithDescriptions: res,
selectedByDefault: false,
onlyOneSelectionAllowed:
true,
);
});
if (selectedUrls != null &&
selectedUrls.isNotEmpty) {
changeUserInput(
selectedUrls[0], true, true);
addApp(resetUserInputAfter: true);
}
}).catchError((e) {
showError(e, context);
});
},
child: Text(tr('search')))
],
),
if (pickedSource != null && if (pickedSource != null &&
pickedSource!.additionalDataDefaults.isNotEmpty) (pickedSource!.additionalSourceAppSpecificDefaults
.isNotEmpty ||
pickedSource!
.additionalAppSpecificSourceAgnosticFormItems
.where((e) => pickedSource!.enforceTrackOnly
? e.key != 'trackOnlyFormItemKey'
: true)
.map((e) => [e])
.isNotEmpty))
Column( Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@ -165,7 +308,8 @@ class _AddAppPageState extends State<AddAppPage> {
height: 64, height: 64,
), ),
Text( Text(
'Additional Options for ${pickedSource?.runtimeType}', tr('additionalOptsFor',
args: [pickedSource?.name ?? tr('source')]),
style: TextStyle( style: TextStyle(
color: color:
Theme.of(context).colorScheme.primary)), Theme.of(context).colorScheme.primary)),
@ -173,22 +317,45 @@ class _AddAppPageState extends State<AddAppPage> {
height: 16, height: 16,
), ),
if (pickedSource! if (pickedSource!
.additionalDataFormItems.isNotEmpty) .additionalSourceAppSpecificFormItems
.isNotEmpty)
GeneratedForm( GeneratedForm(
items: pickedSource!.additionalDataFormItems, items: pickedSource!
onValueChanges: (values, valid) { .additionalSourceAppSpecificFormItems,
setState(() { onValueChanges: (values, valid, isBuilding) {
additionalData = values; if (!isBuilding) {
validAdditionalData = valid; setState(() {
}); sourceSpecificAdditionalData = values;
sourceSpecificDataIsValid = valid;
});
}
}, },
defaultValues: defaultValues: pickedSource!
pickedSource!.additionalDataDefaults), .additionalSourceAppSpecificDefaults),
if (pickedSource! if (pickedSource!
.additionalDataFormItems.isNotEmpty) .additionalAppSpecificSourceAgnosticDefaults
.isNotEmpty)
const SizedBox( const SizedBox(
height: 8, height: 8,
), ),
GeneratedForm(
items: pickedSource!
.additionalAppSpecificSourceAgnosticFormItems
.where((e) => pickedSource!.enforceTrackOnly
? e.key != 'trackOnlyFormItemKey'
: true)
.map((e) => [e])
.toList(),
onValueChanges: (values, valid, isBuilding) {
if (!isBuilding) {
setState(() {
otherAdditionalData = values;
otherAdditionalDataIsValid = valid;
});
}
},
defaultValues: pickedSource!
.additionalAppSpecificSourceAgnosticDefaults),
], ],
) )
else else
@ -197,32 +364,38 @@ class _AddAppPageState extends State<AddAppPage> {
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// const SizedBox( const SizedBox(
// height: 48, height: 48,
// ), ),
const Text( Text(
'Supported Sources:', tr('supportedSourcesBelow'),
), ),
const SizedBox( const SizedBox(
height: 8, height: 8,
), ),
...sourceProvider ...sourceProvider.sources
.getSourceHosts()
.map((e) => GestureDetector( .map((e) => GestureDetector(
onTap: () { onTap: e.host != null
launchUrlString('https://$e', ? () {
mode: launchUrlString(
LaunchMode.externalApplication); 'https://${e.host}',
}, mode: LaunchMode
.externalApplication);
}
: null,
child: Text( child: Text(
e, '${e.name}${e.enforceTrackOnly ? ' ${tr('trackOnlyInBrackets')}' : ''}${e.canSearch ? ' ${tr('searchableInBrackets')}' : ''}',
style: const TextStyle( style: TextStyle(
decoration: decoration: e.host != null
TextDecoration.underline, ? TextDecoration.underline
: TextDecoration.none,
fontStyle: FontStyle.italic), fontStyle: FontStyle.italic),
))) )))
.toList() .toList()
])), ])),
const SizedBox(
height: 8,
),
])), ])),
) )
])); ]));

View File

@ -1,6 +1,9 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:obtainium/components/generated_form_modal.dart'; import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/main.dart';
import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
@ -25,10 +28,8 @@ class _AppPageState extends State<AppPage> {
var appsProvider = context.watch<AppsProvider>(); var appsProvider = context.watch<AppsProvider>();
var settingsProvider = context.watch<SettingsProvider>(); var settingsProvider = context.watch<SettingsProvider>();
getUpdate(String id) { getUpdate(String id) {
appsProvider.getUpdate(id).catchError((e) { appsProvider.checkUpdate(id).catchError((e) {
ScaffoldMessenger.of(context).showSnackBar( showError(e, context);
SnackBar(content: Text(e.toString())),
);
}); });
} }
@ -76,7 +77,7 @@ class _AppPageState extends State<AppPage> {
style: Theme.of(context).textTheme.displayLarge, style: Theme.of(context).textTheme.displayLarge,
), ),
Text( Text(
'By ${app?.app.author ?? 'Unknown'}', tr('byX', args: [app?.app.author ?? tr('unknown')]),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium, style: Theme.of(context).textTheme.headlineMedium,
), ),
@ -102,12 +103,17 @@ class _AppPageState extends State<AppPage> {
height: 32, height: 32,
), ),
Text( Text(
'Latest Version: ${app?.app.latestVersion ?? 'Unknown'}', tr('latestVersionX',
args: [app?.app.latestVersion ?? tr('unknown')]),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
), ),
Text( Text(
'Installed Version: ${app?.app.installedVersion ?? 'None'}', '${tr('installedVersionX', args: [
app?.app.installedVersion ?? tr('none')
])}${app?.app.trackOnly == true ? ' ${tr('estimateInBrackets')}\n\n${tr('xIsTrackOnly', args: [
tr('app')
])}' : ''}',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
), ),
@ -115,7 +121,11 @@ class _AppPageState extends State<AppPage> {
height: 32, height: 32,
), ),
Text( Text(
'Last Update Check: ${app?.app.lastUpdateCheck == null ? 'Never' : '\n${app?.app.lastUpdateCheck?.toLocal()}'}', tr('lastUpdateCheckX', args: [
app?.app.lastUpdateCheck == null
? tr('never')
: '\n${app?.app.lastUpdateCheck?.toLocal()}'
]),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle( style: const TextStyle(
fontStyle: FontStyle.italic, fontSize: 12), fontStyle: FontStyle.italic, fontSize: 12),
@ -141,6 +151,7 @@ class _AppPageState extends State<AppPage> {
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
if (app?.app.installedVersion != null && if (app?.app.installedVersion != null &&
app?.app.trackOnly == false &&
app?.app.installedVersion != app?.app.latestVersion) app?.app.installedVersion != app?.app.latestVersion)
IconButton( IconButton(
onPressed: app?.downloadProgress != null onPressed: app?.downloadProgress != null
@ -150,15 +161,22 @@ class _AppPageState extends State<AppPage> {
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
return AlertDialog( return AlertDialog(
title: const Text( title: Text(tr(
'App Already up to Date?'), 'alreadyUpToDateQuestion')),
content: Text(
tr('onlyWorksWithNonEVDApps'),
style: const TextStyle(
fontWeight:
FontWeight.bold,
fontStyle:
FontStyle.italic)),
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
Navigator.of(context) Navigator.of(context)
.pop(); .pop();
}, },
child: const Text('No')), child: Text(tr('no'))),
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback HapticFeedback
@ -175,8 +193,8 @@ class _AppPageState extends State<AppPage> {
Navigator.of(context) Navigator.of(context)
.pop(); .pop();
}, },
child: const Text( child: Text(
'Yes, Mark as Updated')) tr('yesMarkUpdated')))
], ],
); );
}); });
@ -184,7 +202,8 @@ class _AppPageState extends State<AppPage> {
tooltip: 'Mark as Updated', tooltip: 'Mark as Updated',
icon: const Icon(Icons.done)), icon: const Icon(Icons.done)),
if (source != null && if (source != null &&
source.additionalDataFormItems.isNotEmpty) source.additionalSourceAppSpecificFormItems
.isNotEmpty)
IconButton( IconButton(
onPressed: app?.downloadProgress != null onPressed: app?.downloadProgress != null
? null ? null
@ -195,11 +214,11 @@ class _AppPageState extends State<AppPage> {
return GeneratedFormModal( return GeneratedFormModal(
title: 'Additional Options', title: 'Additional Options',
items: source items: source
.additionalDataFormItems, .additionalSourceAppSpecificFormItems,
defaultValues: app != null defaultValues: app != null
? app.app.additionalData ? app.app.additionalData
: source : source
.additionalDataDefaults); .additionalSourceAppSpecificDefaults);
}).then((values) { }).then((values) {
if (app != null && values != null) { if (app != null && values != null) {
var changedApp = app.app; var changedApp = app.app;
@ -217,31 +236,40 @@ class _AppPageState extends State<AppPage> {
Expanded( Expanded(
child: ElevatedButton( child: ElevatedButton(
onPressed: (app?.app.installedVersion == null || onPressed: (app?.app.installedVersion == null ||
appsProvider app?.app.installedVersion !=
.checkAppObjectForUpdate( app?.app.latestVersion) &&
app!.app)) &&
!appsProvider.areDownloadsRunning() !appsProvider.areDownloadsRunning()
? () { ? () {
HapticFeedback.heavyImpact(); HapticFeedback.heavyImpact();
appsProvider () async {
.downloadAndInstallLatestApps( if (app?.app.trackOnly != true) {
[app!.app.id], await settingsProvider
context).then((res) { .getInstallPermission();
if (res.isNotEmpty && mounted) {
Navigator.of(context).pop();
} }
}()
.then((value) {
appsProvider
.downloadAndInstallLatestApps(
[app!.app.id],
globalNavigatorKey
.currentContext).then(
(res) {
if (res.isNotEmpty && mounted) {
Navigator.of(context).pop();
}
});
}).catchError((e) { }).catchError((e) {
ScaffoldMessenger.of(context) showError(e, context);
.showSnackBar(
SnackBar(
content: Text(e.toString())),
);
}); });
} }
: null, : null,
child: Text(app?.app.installedVersion == null child: Text(app?.app.installedVersion == null
? 'Install' ? app?.app.trackOnly == false
: 'Update'))), ? 'Install'
: 'Mark Installed'
: app?.app.trackOnly == false
? 'Update'
: 'Mark Updated'))),
const SizedBox(width: 16.0), const SizedBox(width: 16.0),
ElevatedButton( ElevatedButton(
onPressed: app?.downloadProgress != null onPressed: app?.downloadProgress != null
@ -251,9 +279,14 @@ class _AppPageState extends State<AppPage> {
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
return AlertDialog( return AlertDialog(
title: const Text('Remove App?'), title: Text(tr('removeAppQuestion')),
content: Text( content: Text(tr(
'This will remove \'${app?.installedInfo?.name ?? app?.app.name}\' from Obtainium.${app?.app.installedVersion != null ? '\n\nNote that while Obtainium will no longer track its updates, the App will remain installed.' : ''}'), 'xWillBeRemovedButRemainInstalled',
args: [
app?.installedInfo?.name ??
app?.app.name ??
tr('app')
])),
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
@ -267,12 +300,12 @@ class _AppPageState extends State<AppPage> {
count++ >= 2); count++ >= 2);
}); });
}, },
child: const Text('Remove')), child: Text(tr('remove'))),
TextButton( TextButton(
onPressed: () { onPressed: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
child: const Text('Cancel')) child: Text(tr('cancel')))
], ],
); );
}); });
@ -282,7 +315,7 @@ class _AppPageState extends State<AppPage> {
Theme.of(context).colorScheme.error, Theme.of(context).colorScheme.error,
surfaceTintColor: surfaceTintColor:
Theme.of(context).colorScheme.error), Theme.of(context).colorScheme.error),
child: const Text('Remove'), child: Text(tr('remove')),
), ),
])), ])),
if (app?.downloadProgress != null) if (app?.downloadProgress != null)

View File

@ -1,8 +1,11 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:obtainium/components/custom_app_bar.dart'; import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/components/generated_form_modal.dart'; import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/main.dart';
import 'package:obtainium/pages/app.dart'; import 'package:obtainium/pages/app.dart';
import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
@ -22,24 +25,24 @@ class AppsPageState extends State<AppsPage> {
AppsFilter? filter; AppsFilter? filter;
var updatesOnlyFilter = var updatesOnlyFilter =
AppsFilter(includeUptodate: false, includeNonInstalled: false); AppsFilter(includeUptodate: false, includeNonInstalled: false);
Set<String> selectedIds = {}; Set<App> selectedApps = {};
DateTime? refreshingSince; DateTime? refreshingSince;
clearSelected() { clearSelected() {
if (selectedIds.isNotEmpty) { if (selectedApps.isNotEmpty) {
setState(() { setState(() {
selectedIds.clear(); selectedApps.clear();
}); });
return true; return true;
} }
return false; return false;
} }
selectThese(List<String> appIds) { selectThese(List<App> apps) {
if (selectedIds.isEmpty) { if (selectedApps.isEmpty) {
setState(() { setState(() {
for (var a in appIds) { for (var a in apps) {
selectedIds.add(a); selectedApps.add(a);
} }
}); });
} }
@ -53,16 +56,16 @@ class AppsPageState extends State<AppsPage> {
var currentFilterIsUpdatesOnly = var currentFilterIsUpdatesOnly =
filter?.isIdenticalTo(updatesOnlyFilter) ?? false; filter?.isIdenticalTo(updatesOnlyFilter) ?? false;
selectedIds = selectedIds selectedApps = selectedApps
.where((element) => sortedApps.map((e) => e.app.id).contains(element)) .where((element) => sortedApps.map((e) => e.app).contains(element))
.toSet(); .toSet();
toggleAppSelected(String appId) { toggleAppSelected(App app) {
setState(() { setState(() {
if (selectedIds.contains(appId)) { if (selectedApps.contains(app)) {
selectedIds.remove(appId); selectedApps.remove(app);
} else { } else {
selectedIds.add(appId); selectedApps.add(app);
} }
}); });
} }
@ -120,20 +123,36 @@ class AppsPageState extends State<AppsPage> {
sortedApps = sortedApps.reversed.toList(); sortedApps = sortedApps.reversed.toList();
} }
var existingUpdates = appsProvider.getExistingUpdates(installedOnly: true); var existingUpdates = appsProvider.findExistingUpdates(installedOnly: true);
var existingUpdateIdsAllOrSelected = existingUpdates var existingUpdateIdsAllOrSelected = existingUpdates
.where((element) => selectedIds.isEmpty .where((element) => selectedApps.isEmpty
? sortedApps.where((a) => a.app.id == element).isNotEmpty ? sortedApps.where((a) => a.app.id == element).isNotEmpty
: selectedIds.contains(element)) : selectedApps.map((e) => e.id).contains(element))
.toList(); .toList();
var newInstallIdsAllOrSelected = appsProvider var newInstallIdsAllOrSelected = appsProvider
.getExistingUpdates(nonInstalledOnly: true) .findExistingUpdates(nonInstalledOnly: true)
.where((element) => selectedIds.isEmpty .where((element) => selectedApps.isEmpty
? sortedApps.where((a) => a.app.id == element).isNotEmpty ? sortedApps.where((a) => a.app.id == element).isNotEmpty
: selectedIds.contains(element)) : selectedApps.map((e) => e.id).contains(element))
.toList(); .toList();
List<String> trackOnlyUpdateIdsAllOrSelected = [];
existingUpdateIdsAllOrSelected = existingUpdateIdsAllOrSelected.where((id) {
if (appsProvider.apps[id]!.app.trackOnly) {
trackOnlyUpdateIdsAllOrSelected.add(id);
return false;
}
return true;
}).toList();
newInstallIdsAllOrSelected = newInstallIdsAllOrSelected.where((id) {
if (appsProvider.apps[id]!.app.trackOnly) {
trackOnlyUpdateIdsAllOrSelected.add(id);
return false;
}
return true;
}).toList();
if (settingsProvider.pinUpdates) { if (settingsProvider.pinUpdates) {
var temp = []; var temp = [];
sortedApps = sortedApps.where((sa) { sortedApps = sortedApps.where((sa) {
@ -146,6 +165,17 @@ class AppsPageState extends State<AppsPage> {
sortedApps = [...temp, ...sortedApps]; sortedApps = [...temp, ...sortedApps];
} }
var tempPinned = [];
var tempNotPinned = [];
for (var a in sortedApps) {
if (a.app.pinned) {
tempPinned.add(a);
} else {
tempNotPinned.add(a);
}
}
sortedApps = [...tempPinned, ...tempNotPinned];
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
body: RefreshIndicator( body: RefreshIndicator(
@ -155,9 +185,7 @@ class AppsPageState extends State<AppsPage> {
refreshingSince = DateTime.now(); refreshingSince = DateTime.now();
}); });
return appsProvider.checkUpdates().catchError((e) { return appsProvider.checkUpdates().catchError((e) {
ScaffoldMessenger.of(context).showSnackBar( showError(e, context);
SnackBar(content: Text(e.toString())),
);
}).whenComplete(() { }).whenComplete(() {
setState(() { setState(() {
refreshingSince = null; refreshingSince = null;
@ -165,7 +193,7 @@ class AppsPageState extends State<AppsPage> {
}); });
}, },
child: CustomScrollView(slivers: <Widget>[ child: CustomScrollView(slivers: <Widget>[
const CustomAppBar(title: 'Apps'), CustomAppBar(title: tr('appsString')),
if (appsProvider.loadingApps || sortedApps.isEmpty) if (appsProvider.loadingApps || sortedApps.isEmpty)
SliverFillRemaining( SliverFillRemaining(
child: Center( child: Center(
@ -173,8 +201,8 @@ class AppsPageState extends State<AppsPage> {
? const CircularProgressIndicator() ? const CircularProgressIndicator()
: Text( : Text(
appsProvider.apps.isEmpty appsProvider.apps.isEmpty
? 'No Apps' ? tr('noApps')
: 'No Apps for Filter', : tr('noAppsForFilter'),
style: Theme.of(context).textTheme.headlineMedium, style: Theme.of(context).textTheme.headlineMedium,
textAlign: TextAlign.center, textAlign: TextAlign.center,
))), ))),
@ -192,12 +220,20 @@ class AppsPageState extends State<AppsPage> {
SliverList( SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) { (BuildContext context, int index) {
String? changesUrl = SourceProvider()
.getSource(sortedApps[index].app.url)
.changeLogPageFromStandardUrl(sortedApps[index].app.url);
return ListTile( return ListTile(
selectedTileColor: tileColor: sortedApps[index].app.pinned
Theme.of(context).colorScheme.primary.withOpacity(0.1), ? Colors.grey.withOpacity(0.1)
selected: selectedIds.contains(sortedApps[index].app.id), : Colors.transparent,
selectedTileColor: Theme.of(context)
.colorScheme
.primary
.withOpacity(sortedApps[index].app.pinned ? 0.2 : 0.1),
selected: selectedApps.contains(sortedApps[index].app),
onLongPress: () { onLongPress: () {
toggleAppSelected(sortedApps[index].app.id); toggleAppSelected(sortedApps[index].app);
}, },
leading: sortedApps[index].installedInfo != null leading: sortedApps[index].installedInfo != null
? Image.memory( ? Image.memory(
@ -205,60 +241,68 @@ class AppsPageState extends State<AppsPage> {
gaplessPlayback: true, gaplessPlayback: true,
) )
: null, : null,
title: Text(sortedApps[index].installedInfo?.name ?? title: Text(
sortedApps[index].app.name), sortedApps[index].installedInfo?.name ??
subtitle: Text('By ${sortedApps[index].app.author}'), sortedApps[index].app.name,
trailing: sortedApps[index].downloadProgress != null style: TextStyle(
? Text( fontWeight: sortedApps[index].app.pinned
'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%') ? FontWeight.bold
: (sortedApps[index].app.installedVersion != null && : FontWeight.normal),
sortedApps[index].app.installedVersion != ),
sortedApps[index].app.latestVersion subtitle: Text(tr('byX', args: [sortedApps[index].app.author]),
? Column( style: TextStyle(
fontWeight: sortedApps[index].app.pinned
? FontWeight.bold
: FontWeight.normal)),
trailing: SingleChildScrollView(
reverse: true,
child: sortedApps[index].downloadProgress != null
? Text(tr('percentProgress', args: [
sortedApps[index]
.downloadProgress
?.toInt()
.toString() ??
'100'
]))
: (Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text(appsProvider.areDownloadsRunning() SizedBox(
? 'Please Wait...' width: 100,
: 'Update Available'), child: Text(
SourceProvider() '${sortedApps[index].app.installedVersion ?? tr('notInstalled')}${sortedApps[index].app.trackOnly == true ? ' ${tr('estimateInBrackets')}' : ''}',
.getSource(sortedApps[index].app.url) overflow: TextOverflow.fade,
.changeLogPageFromStandardUrl( textAlign: TextAlign.end,
sortedApps[index].app.url) == )),
null sortedApps[index].app.installedVersion != null &&
? const SizedBox() sortedApps[index].app.installedVersion !=
: GestureDetector( sortedApps[index].app.latestVersion
onTap: () { ? GestureDetector(
launchUrlString( onTap: changesUrl == null
SourceProvider() ? null
.getSource( : () {
sortedApps[index].app.url) launchUrlString(changesUrl,
.changeLogPageFromStandardUrl( mode: LaunchMode
sortedApps[index].app.url)!, .externalApplication);
mode: },
LaunchMode.externalApplication); child: appsProvider.areDownloadsRunning()
}, ? Text(tr('pleaseWait'))
child: const Text( : Text(
'See Changes', '${tr('updateAvailable')}${sortedApps[index].app.trackOnly ? ' ${tr('estimateInBracketsShort')}' : ''}',
style: TextStyle( style: TextStyle(
fontStyle: FontStyle.italic, fontStyle: FontStyle.italic,
decoration: decoration: changesUrl == null
TextDecoration.underline), ? TextDecoration.none
)), : TextDecoration
.underline),
))
: const SizedBox(),
], ],
) ))),
: SingleChildScrollView(
child: SizedBox(
width: 80,
child: Text(
sortedApps[index].app.installedVersion ??
'Not Installed',
overflow: TextOverflow.fade,
textAlign: TextAlign.end,
)))),
onTap: () { onTap: () {
if (selectedIds.isNotEmpty) { if (selectedApps.isNotEmpty) {
toggleAppSelected(sortedApps[index].app.id); toggleAppSelected(sortedApps[index].app);
} else { } else {
Navigator.push( Navigator.push(
context, context,
@ -276,25 +320,25 @@ class AppsPageState extends State<AppsPage> {
children: [ children: [
IconButton( IconButton(
onPressed: () { onPressed: () {
selectedIds.isEmpty selectedApps.isEmpty
? selectThese(sortedApps.map((e) => e.app.id).toList()) ? selectThese(sortedApps.map((e) => e.app).toList())
: clearSelected(); : clearSelected();
}, },
icon: Icon( icon: Icon(
selectedIds.isEmpty selectedApps.isEmpty
? Icons.select_all_outlined ? Icons.select_all_outlined
: Icons.deselect_outlined, : Icons.deselect_outlined,
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
tooltip: selectedIds.isEmpty tooltip: selectedApps.isEmpty
? 'Select All' ? tr('selectAll')
: 'Deselect ${selectedIds.length.toString()}'), : tr('deselectN', args: [selectedApps.length.toString()])),
const VerticalDivider(), const VerticalDivider(),
Expanded( Expanded(
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
selectedIds.isEmpty selectedApps.isEmpty
? const SizedBox() ? const SizedBox()
: IconButton( : IconButton(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
@ -303,71 +347,107 @@ class AppsPageState extends State<AppsPage> {
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
return GeneratedFormModal( return GeneratedFormModal(
title: 'Remove Selected Apps?', title: tr('removeSelectedAppsQuestion'),
items: const [], items: const [],
defaultValues: const [], defaultValues: const [],
initValid: true, initValid: true,
message: message: tr(
'${selectedIds.length} App${selectedIds.length == 1 ? '' : 's'} will be removed from Obtainium but remain installed. You still need to uninstall ${selectedIds.length == 1 ? 'it' : 'them'} manually.', 'xWillBeRemovedButRemainInstalled',
args: [
plural('apps', selectedApps.length)
]),
); );
}).then((values) { }).then((values) {
if (values != null) { if (values != null) {
appsProvider.removeApps(selectedIds.toList()); appsProvider.removeApps(
selectedApps.map((e) => e.id).toList());
} }
}); });
}, },
tooltip: 'Remove Selected Apps', tooltip: tr('removeSelectedApps'),
icon: const Icon(Icons.delete_outline_outlined), icon: const Icon(Icons.delete_outline_outlined),
), ),
IconButton( IconButton(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
onPressed: appsProvider.areDownloadsRunning() || onPressed: appsProvider.areDownloadsRunning() ||
(existingUpdateIdsAllOrSelected.isEmpty && (existingUpdateIdsAllOrSelected.isEmpty &&
newInstallIdsAllOrSelected.isEmpty) newInstallIdsAllOrSelected.isEmpty &&
trackOnlyUpdateIdsAllOrSelected.isEmpty)
? null ? null
: () { : () {
HapticFeedback.heavyImpact(); HapticFeedback.heavyImpact();
List<List<GeneratedFormItem>> formInputs = []; List<GeneratedFormItem> formInputs = [];
if (existingUpdateIdsAllOrSelected.isNotEmpty && List<String> defaultValues = [];
newInstallIdsAllOrSelected.isNotEmpty) { if (existingUpdateIdsAllOrSelected.isNotEmpty) {
formInputs.add([ formInputs.add(GeneratedFormItem(
GeneratedFormItem( label: tr('updateX', args: [
label: plural('apps',
'Update ${existingUpdateIdsAllOrSelected.length} App${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'}', existingUpdateIdsAllOrSelected.length)
type: FormItemType.bool) ]),
]); type: FormItemType.bool,
formInputs.add([ key: 'updates'));
GeneratedFormItem( defaultValues.add('true');
label: }
'Install ${newInstallIdsAllOrSelected.length} new App${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}', if (newInstallIdsAllOrSelected.isNotEmpty) {
type: FormItemType.bool) formInputs.add(GeneratedFormItem(
]); label: tr('installX', args: [
plural('apps',
newInstallIdsAllOrSelected.length)
]),
type: FormItemType.bool,
key: 'installs'));
defaultValues
.add(defaultValues.isEmpty ? 'true' : '');
}
if (trackOnlyUpdateIdsAllOrSelected.isNotEmpty) {
formInputs.add(GeneratedFormItem(
label: tr('markXTrackOnlyAsUpdated', args: [
plural('apps',
trackOnlyUpdateIdsAllOrSelected.length)
]),
type: FormItemType.bool,
key: 'trackonlies'));
defaultValues
.add(defaultValues.isEmpty ? 'true' : '');
} }
showDialog<List<String>?>( showDialog<List<String>?>(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
var totalApps = existingUpdateIdsAllOrSelected
.length +
newInstallIdsAllOrSelected.length +
trackOnlyUpdateIdsAllOrSelected.length;
return GeneratedFormModal( return GeneratedFormModal(
title: title: tr('changeX',
'Install${selectedIds.isEmpty ? ' ' : ' Selected '}Apps?', args: [plural('apps', totalApps)]),
message: items: formInputs.map((e) => [e]).toList(),
'${existingUpdateIdsAllOrSelected.length} update${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'} and ${newInstallIdsAllOrSelected.length} new install${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}.', defaultValues: defaultValues,
items: formInputs,
defaultValues: [
'true',
existingUpdateIdsAllOrSelected.isEmpty
? 'true'
: ''
],
initValid: true, initValid: true,
); );
}).then((values) { }).then((values) {
if (values != null) { if (values != null) {
if (values.isEmpty) {
values = defaultValues;
}
bool shouldInstallUpdates = bool shouldInstallUpdates =
values.isEmpty || values[0] == 'true'; findGeneratedFormValueByKey(
bool shouldInstallNew = values.isEmpty || formInputs, values, 'updates') ==
(values.length >= 2 && values[1] == 'true'); 'true';
settingsProvider bool shouldInstallNew =
.getInstallPermission() findGeneratedFormValueByKey(
formInputs, values, 'installs') ==
'true';
bool shouldMarkTrackOnlies =
findGeneratedFormValueByKey(formInputs,
values, 'trackonlies') ==
'true';
(() async {
if (shouldInstallNew ||
shouldInstallUpdates) {
await settingsProvider
.getInstallPermission();
}
})()
.then((_) { .then((_) {
List<String> toInstall = []; List<String> toInstall = [];
if (shouldInstallUpdates) { if (shouldInstallUpdates) {
@ -378,24 +458,27 @@ class AppsPageState extends State<AppsPage> {
toInstall toInstall
.addAll(newInstallIdsAllOrSelected); .addAll(newInstallIdsAllOrSelected);
} }
if (shouldMarkTrackOnlies) {
toInstall.addAll(
trackOnlyUpdateIdsAllOrSelected);
}
appsProvider appsProvider
.downloadAndInstallLatestApps( .downloadAndInstallLatestApps(toInstall,
toInstall, context) globalNavigatorKey.currentContext)
.catchError((e) { .catchError((e) {
ScaffoldMessenger.of(context).showSnackBar( showError(e, context);
SnackBar(content: Text(e.toString())),
);
}); });
}); });
} }
}); });
}, },
tooltip: tooltip: selectedApps.isEmpty
'Install/Update${selectedIds.isEmpty ? ' ' : ' Selected '}Apps', ? tr('installUpdateApps')
: tr('installUpdateSelectedApps'),
icon: const Icon( icon: const Icon(
Icons.file_download_outlined, Icons.file_download_outlined,
)), )),
selectedIds.isEmpty selectedApps.isEmpty
? const SizedBox() ? const SizedBox()
: IconButton( : IconButton(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
@ -423,11 +506,22 @@ class AppsPageState extends State<AppsPage> {
(BuildContext (BuildContext
ctx) { ctx) {
return AlertDialog( return AlertDialog(
title: Text( title: Text(tr(
'Mark ${selectedIds.length} Selected Apps as Updated?'), 'markXSelectedAppsAsUpdated',
content: args: [
const Text( selectedApps
'Only applies to installed but out of date Apps.'), .length
.toString()
])),
content: Text(
tr('onlyWorksWithNonEVDApps'),
style: const TextStyle(
fontWeight:
FontWeight
.bold,
fontStyle:
FontStyle.italic),
),
actions: [ actions: [
TextButton( TextButton(
onPressed: onPressed:
@ -435,17 +529,15 @@ class AppsPageState extends State<AppsPage> {
Navigator.of(context) Navigator.of(context)
.pop(); .pop();
}, },
child: const Text( child: Text(
'No')), tr('no'))),
TextButton( TextButton(
onPressed: onPressed:
() { () {
HapticFeedback HapticFeedback
.selectionClick(); .selectionClick();
appsProvider appsProvider
.saveApps(selectedIds.map((e) { .saveApps(selectedApps.map((a) {
var a =
appsProvider.apps[e]!.app;
if (a.installedVersion != if (a.installedVersion !=
null) { null) {
a.installedVersion = a.latestVersion; a.installedVersion = a.latestVersion;
@ -456,37 +548,104 @@ class AppsPageState extends State<AppsPage> {
Navigator.of(context) Navigator.of(context)
.pop(); .pop();
}, },
child: const Text( child: Text(
'Yes')) tr('yes')))
], ],
); );
}); }).whenComplete(() {
Navigator.of(
context)
.pop();
});
}, },
tooltip: tooltip:
'Mark Selected Apps as Updated', tr('markSelectedAppsUpdated'),
icon: const Icon(Icons.done)), icon: const Icon(Icons.done)),
IconButton(
onPressed: () {
var pinStatus = selectedApps
.where((element) =>
element.pinned)
.isEmpty;
appsProvider.saveApps(
selectedApps.map((e) {
e.pinned = pinStatus;
return e;
}).toList());
Navigator.of(context).pop();
},
tooltip: selectedApps
.where((element) =>
element.pinned)
.isEmpty
? tr('pinToTop')
: tr('unpinFromTop'),
icon: Icon(selectedApps
.where((element) =>
element.pinned)
.isEmpty
? Icons.bookmark_outline_rounded
: Icons
.bookmark_remove_outlined),
),
IconButton( IconButton(
onPressed: () { onPressed: () {
String urls = ''; String urls = '';
for (var id in selectedIds) { for (var a in selectedApps) {
urls += urls += '${a.url}\n';
'${appsProvider.apps[id]!.app.url}\n';
} }
urls = urls.substring( urls = urls.substring(
0, urls.length - 1); 0, urls.length - 1);
Share.share(urls, Share.share(urls,
subject: subject: tr(
'${selectedIds.length} Selected App URLs from Obtainium'); 'selectedAppURLsFromObtainium'));
Navigator.of(context).pop();
}, },
tooltip: 'Share Selected App URLs', tooltip: tr('shareSelectedAppURLs'),
icon: const Icon(Icons.share), icon: const Icon(Icons.share),
), ),
IconButton(
onPressed: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr(
'resetInstallStatusForSelectedAppsQuestion'),
items: const [],
defaultValues: const [],
initValid: true,
message: tr(
'installStatusOfXWillBeResetExplanation',
args: [
plural(
'app',
selectedApps
.length)
]),
);
}).then((values) {
if (values != null) {
appsProvider.saveApps(
selectedApps.map((e) {
e.installedVersion = null;
return e;
}).toList());
}
}).whenComplete(() {
Navigator.of(context).pop();
});
},
tooltip: tr('resetInstallStatus'),
icon: const Icon(
Icons.restore_page_outlined),
),
]), ]),
), ),
); );
}); });
}, },
tooltip: 'More', tooltip: tr('more'),
icon: const Icon(Icons.more_horiz), icon: const Icon(Icons.more_horiz),
), ),
], ],
@ -504,8 +663,8 @@ class AppsPageState extends State<AppsPage> {
}); });
}, },
tooltip: currentFilterIsUpdatesOnly tooltip: currentFilterIsUpdatesOnly
? 'Remove Out-of-Date App Filter' ? tr('removeOutdatedFilter')
: 'Show Out-of-Date Apps Only', : tr('showOutdatedOnly'),
icon: Icon( icon: Icon(
currentFilterIsUpdatesOnly currentFilterIsUpdatesOnly
? Icons.update_disabled_rounded ? Icons.update_disabled_rounded
@ -517,7 +676,7 @@ class AppsPageState extends State<AppsPage> {
? const SizedBox() ? const SizedBox()
: TextButton.icon( : TextButton.icon(
label: Text( label: Text(
filter == null ? 'Filter' : 'Filter *', filter == null ? tr('filter') : tr('filterActive'),
style: TextStyle( style: TextStyle(
fontWeight: filter == null fontWeight: filter == null
? FontWeight.normal ? FontWeight.normal
@ -528,22 +687,22 @@ class AppsPageState extends State<AppsPage> {
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
return GeneratedFormModal( return GeneratedFormModal(
title: 'Filter Apps', title: tr('filterApps'),
items: [ items: [
[ [
GeneratedFormItem( GeneratedFormItem(
label: 'App Name', required: false), label: tr('appName'), required: false),
GeneratedFormItem( GeneratedFormItem(
label: 'Author', required: false) label: tr('author'), required: false)
], ],
[ [
GeneratedFormItem( GeneratedFormItem(
label: 'Up to Date Apps', label: tr('upToDateApps'),
type: FormItemType.bool) type: FormItemType.bool)
], ],
[ [
GeneratedFormItem( GeneratedFormItem(
label: 'Non-Installed Apps', label: tr('nonInstalledApps'),
type: FormItemType.bool) type: FormItemType.bool)
] ]
], ],

View File

@ -1,4 +1,5 @@
import 'package:animations/animations.dart'; import 'package:animations/animations.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:obtainium/pages/add_app.dart'; import 'package:obtainium/pages/add_app.dart';
@ -25,12 +26,12 @@ class _HomePageState extends State<HomePage> {
List<int> selectedIndexHistory = []; List<int> selectedIndexHistory = [];
List<NavigationPageItem> pages = [ List<NavigationPageItem> pages = [
NavigationPageItem(tr('appsString'), Icons.apps,
AppsPage(key: GlobalKey<AppsPageState>())),
NavigationPageItem(tr('addApp'), Icons.add, const AddAppPage()),
NavigationPageItem( NavigationPageItem(
'Apps', Icons.apps, AppsPage(key: GlobalKey<AppsPageState>())), tr('importExport'), Icons.import_export, const ImportExportPage()),
NavigationPageItem('Add App', Icons.add, const AddAppPage()), NavigationPageItem(tr('settings'), Icons.settings, const SettingsPage())
NavigationPageItem(
'Import/Export', Icons.import_export, const ImportExportPage()),
NavigationPageItem('Settings', Icons.settings, const SettingsPage())
]; ];
@override @override
@ -92,7 +93,6 @@ class _HomePageState extends State<HomePage> {
return !(pages[0].widget.key as GlobalKey<AppsPageState>) return !(pages[0].widget.key as GlobalKey<AppsPageState>)
.currentState .currentState
?.clearSelected(); ?.clearSelected();
// return !appsPageKey.currentState?.clearSelected();
}); });
} }
} }

View File

@ -1,16 +1,18 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:obtainium/components/custom_app_bar.dart'; import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/components/generated_form_modal.dart'; import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:url_launcher/url_launcher_string.dart';
class ImportExportPage extends StatefulWidget { class ImportExportPage extends StatefulWidget {
const ImportExportPage({super.key}); const ImportExportPage({super.key});
@ -25,7 +27,6 @@ class _ImportExportPageState extends State<ImportExportPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider(); SourceProvider sourceProvider = SourceProvider();
var settingsProvider = context.read<SettingsProvider>();
var appsProvider = context.read<AppsProvider>(); var appsProvider = context.read<AppsProvider>();
var outlineButtonStyle = ButtonStyle( var outlineButtonStyle = ButtonStyle(
shape: MaterialStateProperty.all( shape: MaterialStateProperty.all(
@ -38,30 +39,11 @@ class _ImportExportPageState extends State<ImportExportPage> {
), ),
); );
Future<List<List<String>>> addApps(List<String> urls) async {
await settingsProvider.getInstallPermission();
List<dynamic> results = await sourceProvider.getApps(urls,
ignoreUrls: appsProvider.apps.values.map((e) => e.app.url).toList());
List<App> apps = results[0];
Map<String, dynamic> errorsMap = results[1];
for (var app in apps) {
if (appsProvider.apps.containsKey(app.id)) {
errorsMap.addAll({app.id: 'App already added'});
} else {
await appsProvider.saveApps([app]);
}
}
List<List<String>> errors =
errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList();
return errors;
}
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[ body: CustomScrollView(slivers: <Widget>[
const CustomAppBar(title: 'Import/Export'), CustomAppBar(title: tr('importExport')),
SliverFillRemaining( SliverFillRemaining(
hasScrollBody: false,
child: Padding( child: Padding(
padding: padding:
const EdgeInsets.symmetric(vertical: 8, horizontal: 16), const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
@ -81,15 +63,12 @@ class _ImportExportPageState extends State<ImportExportPage> {
appsProvider appsProvider
.exportApps() .exportApps()
.then((String path) { .then((String path) {
ScaffoldMessenger.of(context) showError(
.showSnackBar( tr('exportedTo', args: [path]),
SnackBar( context);
content: Text(
'Exported to $path')),
);
}); });
}, },
child: const Text('Obtainium Export'))), child: Text(tr('obtainiumExport')))),
const SizedBox( const SizedBox(
width: 16, width: 16,
), ),
@ -113,34 +92,30 @@ class _ImportExportPageState extends State<ImportExportPage> {
try { try {
jsonDecode(data); jsonDecode(data);
} catch (e) { } catch (e) {
throw 'Invalid input'; throw ObtainiumError(
tr('invalidInput'));
} }
appsProvider appsProvider
.importApps(data) .importApps(data)
.then((value) { .then((value) {
ScaffoldMessenger.of(context) showError(
.showSnackBar( tr('importedX', args: [
SnackBar( plural('apps', value)
content: Text( ]),
'$value App${value == 1 ? '' : 's'} Imported')), context);
);
}); });
} else { } else {
// User canceled the picker // User canceled the picker
} }
}).catchError((e) { }).catchError((e) {
ScaffoldMessenger.of(context) showError(e, context);
.showSnackBar(
SnackBar(
content: Text(e.toString())),
);
}).whenComplete(() { }).whenComplete(() {
setState(() { setState(() {
importInProgress = false; importInProgress = false;
}); });
}); });
}, },
child: const Text('Obtainium Import'))) child: Text(tr('obtainiumImport'))))
], ],
), ),
if (importInProgress) if (importInProgress)
@ -167,11 +142,11 @@ class _ImportExportPageState extends State<ImportExportPage> {
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
return GeneratedFormModal( return GeneratedFormModal(
title: 'Import from URL List', title: tr('importFromURLList'),
items: [ items: [
[ [
GeneratedFormItem( GeneratedFormItem(
label: 'App URL List', label: tr('appURLList'),
max: 7, max: 7,
additionalValidators: [ additionalValidators: [
(String? value) { (String? value) {
@ -188,7 +163,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
.getSource( .getSource(
lines[i]); lines[i]);
} catch (e) { } catch (e) {
return 'Line ${i + 1}: $e'; return '${tr('line')} ${i + 1}: $e';
} }
} }
} }
@ -206,14 +181,15 @@ class _ImportExportPageState extends State<ImportExportPage> {
setState(() { setState(() {
importInProgress = true; importInProgress = true;
}); });
addApps(urls).then((errors) { appsProvider
.addAppsByURL(urls)
.then((errors) {
if (errors.isEmpty) { if (errors.isEmpty) {
ScaffoldMessenger.of(context) showError(
.showSnackBar( tr('importedX', args: [
SnackBar( plural('apps', urls.length)
content: Text( ]),
'Imported ${urls.length} Apps')), context);
);
} else { } else {
showDialog( showDialog(
context: context, context: context,
@ -224,10 +200,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
}); });
} }
}).catchError((e) { }).catchError((e) {
ScaffoldMessenger.of(context) showError(e, context);
.showSnackBar(
SnackBar(content: Text(e.toString())),
);
}).whenComplete(() { }).whenComplete(() {
setState(() { setState(() {
importInProgress = false; importInProgress = false;
@ -236,10 +209,11 @@ class _ImportExportPageState extends State<ImportExportPage> {
} }
}); });
}, },
child: const Text( child: Text(
'Import from URL List', tr('importFromURLList'),
)), )),
...sourceProvider.massSources ...sourceProvider.sources
.where((element) => element.canSearch)
.map((source) => Column( .map((source) => Column(
crossAxisAlignment: crossAxisAlignment:
CrossAxisAlignment.stretch, CrossAxisAlignment.stretch,
@ -249,99 +223,210 @@ class _ImportExportPageState extends State<ImportExportPage> {
onPressed: importInProgress onPressed: importInProgress
? null ? null
: () { : () {
showDialog( () async {
context: context, var values = await showDialog<
builder: List<String>>(
(BuildContext ctx) { context: context,
return GeneratedFormModal( builder:
title: (BuildContext ctx) {
'Import ${source.name}', return GeneratedFormModal(
items: source title: tr('searchX',
.requiredArgs args: [
.map((e) => [ source.name
GeneratedFormItem( ]),
label: e) items: [
]) [
.toList(), GeneratedFormItem(
defaultValues: const [], label: tr(
); 'searchQuery'))
}).then((values) { ]
if (values != null) { ],
defaultValues: const [],
);
});
if (values != null &&
values[0].isNotEmpty) {
setState(() { setState(() {
importInProgress = true; importInProgress = true;
}); });
source var urlsWithDescriptions =
.getUrls(values) await source
.then((urls) { .search(values[0]);
showDialog<List<String>?>( if (urlsWithDescriptions
.isNotEmpty) {
var selectedUrls =
await showDialog<
List<
String>?>(
context: context, context: context,
builder: builder:
(BuildContext (BuildContext
ctx) { ctx) {
return UrlSelectionModal( return UrlSelectionModal(
urls: urls); urlsWithDescriptions:
}) urlsWithDescriptions,
.then((selectedUrls) { selectedByDefault:
if (selectedUrls != false,
null) { );
addApps(selectedUrls) });
.then((errors) { if (selectedUrls !=
if (errors null &&
.isEmpty) { selectedUrls
ScaffoldMessenger .isNotEmpty) {
.of(context) var errors =
.showSnackBar( await appsProvider
SnackBar( .addAppsByURL(
content: Text( selectedUrls);
'Imported ${selectedUrls.length} Apps')), if (errors.isEmpty) {
); // ignore: use_build_context_synchronously
} else { showError(
showDialog( tr('importedX',
context: args: [
context, plural(
builder: 'app',
(BuildContext selectedUrls
ctx) { .length)
return ImportErrorDialog( ]),
urlsLength: context);
selectedUrls
.length,
errors:
errors);
});
}
}).whenComplete(() {
setState(() {
importInProgress =
false;
});
});
} else { } else {
setState(() { showDialog(
importInProgress = context: context,
false; builder:
}); (BuildContext
ctx) {
return ImportErrorDialog(
urlsLength:
selectedUrls
.length,
errors:
errors);
});
} }
}); }
}).catchError((e) { } else {
setState(() { throw ObtainiumError(
importInProgress = tr('noResults'));
false; }
});
ScaffoldMessenger.of(
context)
.showSnackBar(
SnackBar(
content: Text(
e.toString())),
);
});
} }
}()
.catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
}); });
}, },
child: Text('Import ${source.name}')) child: Text(
tr('searchX', args: [source.name])))
])) ]))
.toList() .toList(),
...sourceProvider.massUrlSources
.map((source) => Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 8),
TextButton(
onPressed: importInProgress
? null
: () {
() async {
var values = await showDialog(
context: context,
builder:
(BuildContext ctx) {
return GeneratedFormModal(
title: tr('importX',
args: [
source.name
]),
items:
source
.requiredArgs
.map(
(e) => [
GeneratedFormItem(label: e)
])
.toList(),
defaultValues: const [],
);
});
if (values != null) {
setState(() {
importInProgress = true;
});
var urlsWithDescriptions =
await source
.getUrlsWithDescriptions(
values);
var selectedUrls =
await showDialog<
List<String>?>(
context: context,
builder:
(BuildContext
ctx) {
return UrlSelectionModal(
urlsWithDescriptions:
urlsWithDescriptions);
});
if (selectedUrls != null) {
var errors =
await appsProvider
.addAppsByURL(
selectedUrls);
if (errors.isEmpty) {
// ignore: use_build_context_synchronously
showError(
tr('importedX',
args: [
plural(
'app',
selectedUrls
.length)
]),
context);
} else {
showDialog(
context: context,
builder:
(BuildContext
ctx) {
return ImportErrorDialog(
urlsLength:
selectedUrls
.length,
errors:
errors);
});
}
}
}
}()
.catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
},
child: Text(
tr('importX', args: [source.name])))
]))
.toList(),
const Spacer(),
const Divider(
height: 32,
),
Text(tr('importedAppsIdDisclaimer'),
textAlign: TextAlign.center,
style: const TextStyle(
fontStyle: FontStyle.italic, fontSize: 12)),
const SizedBox(
height: 8,
)
], ],
))) )))
])); ]));
@ -364,16 +449,19 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( return AlertDialog(
scrollable: true, scrollable: true,
title: const Text('Import Errors'), title: Text(tr('importErrors')),
content: content:
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Text( Text(
'${widget.urlsLength - widget.errors.length} of ${widget.urlsLength} Apps imported.', tr('importedXOfYApps', args: [
(widget.urlsLength - widget.errors.length).toString(),
widget.urlsLength.toString()
]),
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'The following URLs had errors:', tr('followingURLsHadErrors'),
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
), ),
...widget.errors.map((e) { ...widget.errors.map((e) {
@ -396,7 +484,7 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> {
onPressed: () { onPressed: () {
Navigator.of(context).pop(null); Navigator.of(context).pop(null);
}, },
child: const Text('Okay')) child: Text(tr('okay')))
], ],
); );
} }
@ -404,21 +492,37 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> {
// ignore: must_be_immutable // ignore: must_be_immutable
class UrlSelectionModal extends StatefulWidget { class UrlSelectionModal extends StatefulWidget {
UrlSelectionModal({super.key, required this.urls}); UrlSelectionModal(
{super.key,
required this.urlsWithDescriptions,
this.selectedByDefault = true,
this.onlyOneSelectionAllowed = false});
List<String> urls; Map<String, String> urlsWithDescriptions;
bool selectedByDefault;
bool onlyOneSelectionAllowed;
@override @override
State<UrlSelectionModal> createState() => _UrlSelectionModalState(); State<UrlSelectionModal> createState() => _UrlSelectionModalState();
} }
class _UrlSelectionModalState extends State<UrlSelectionModal> { class _UrlSelectionModalState extends State<UrlSelectionModal> {
Map<String, bool> urlSelections = {}; Map<MapEntry<String, String>, bool> urlWithDescriptionSelections = {};
@override @override
void initState() { void initState() {
super.initState(); super.initState();
for (var url in widget.urls) { for (var url in widget.urlsWithDescriptions.entries) {
urlSelections.putIfAbsent(url, () => true); urlWithDescriptionSelections.putIfAbsent(url,
() => widget.selectedByDefault && !widget.onlyOneSelectionAllowed);
}
if (widget.selectedByDefault && widget.onlyOneSelectionAllowed) {
selectOnlyOne(widget.urlsWithDescriptions.entries.first.key);
}
}
selectOnlyOne(String url) {
for (var uwd in urlWithDescriptionSelections.keys) {
urlWithDescriptionSelections[uwd] = uwd.key == url;
} }
} }
@ -426,23 +530,56 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( return AlertDialog(
scrollable: true, scrollable: true,
title: const Text('Select URLs to Import'), title: Text(
widget.onlyOneSelectionAllowed ? tr('selectURL') : tr('selectURLs')),
content: Column(children: [ content: Column(children: [
...urlSelections.keys.map((url) { ...urlWithDescriptionSelections.keys.map((urlWithD) {
return Row(children: [ return Row(children: [
Checkbox( Checkbox(
value: urlSelections[url], value: urlWithDescriptionSelections[urlWithD],
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
urlSelections[url] = value ?? false; value ??= false;
if (value! && widget.onlyOneSelectionAllowed) {
selectOnlyOne(urlWithD.key);
} else {
urlWithDescriptionSelections[urlWithD] = value!;
}
}); });
}), }),
const SizedBox( const SizedBox(
width: 8, width: 8,
), ),
Expanded( Expanded(
child: Text( child: Column(
Uri.parse(url).path.substring(1), crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(
height: 8,
),
GestureDetector(
onTap: () {
launchUrlString(urlWithD.key,
mode: LaunchMode.externalApplication);
},
child: Text(
Uri.parse(urlWithD.key).path.substring(1),
style:
const TextStyle(decoration: TextDecoration.underline),
textAlign: TextAlign.start,
)),
Text(
urlWithD.value.length > 128
? '${urlWithD.value.substring(0, 128)}...'
: urlWithD.value,
style: const TextStyle(
fontStyle: FontStyle.italic, fontSize: 12),
),
const SizedBox(
height: 8,
)
],
)) ))
]); ]);
}) })
@ -452,15 +589,27 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
onPressed: () { onPressed: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
child: const Text('Cancel')), child: Text(tr('cancel'))),
TextButton( TextButton(
onPressed: () { onPressed:
Navigator.of(context).pop(urlSelections.keys urlWithDescriptionSelections.values.where((b) => b).isEmpty
.where((url) => urlSelections[url] ?? false) ? null
.toList()); : () {
}, Navigator.of(context).pop(urlWithDescriptionSelections
child: Text( .entries
'Import ${urlSelections.values.where((b) => b).length} URLs')) .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)
])))
], ],
); );
} }

View File

@ -1,9 +1,13 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:obtainium/components/custom_app_bar.dart'; import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/logs_provider.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
class SettingsPage extends StatefulWidget { class SettingsPage extends StatefulWidget {
@ -21,10 +25,147 @@ class _SettingsPageState extends State<SettingsPage> {
if (settingsProvider.prefs == null) { if (settingsProvider.prefs == null) {
settingsProvider.initializeSettings(); settingsProvider.initializeSettings();
} }
var themeDropdown = DropdownButtonFormField(
decoration: InputDecoration(labelText: tr('theme')),
value: settingsProvider.theme,
items: [
DropdownMenuItem(
value: ThemeSettings.dark,
child: Text(tr('dark')),
),
DropdownMenuItem(
value: ThemeSettings.light,
child: Text(tr('light')),
),
DropdownMenuItem(
value: ThemeSettings.system,
child: Text(tr('followSystem')),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.theme = value;
}
});
var colourDropdown = DropdownButtonFormField(
decoration: InputDecoration(labelText: tr('colour')),
value: settingsProvider.colour,
items: [
DropdownMenuItem(
value: ColourSettings.basic,
child: Text(tr('obtainium')),
),
DropdownMenuItem(
value: ColourSettings.materialYou,
child: Text(tr('materialYou')),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.colour = value;
}
});
var sortDropdown = DropdownButtonFormField(
decoration: InputDecoration(labelText: tr('appSortBy')),
value: settingsProvider.sortColumn,
items: [
DropdownMenuItem(
value: SortColumnSettings.authorName,
child: Text(tr('authorName')),
),
DropdownMenuItem(
value: SortColumnSettings.nameAuthor,
child: Text(tr('nameAuthor')),
),
DropdownMenuItem(
value: SortColumnSettings.added,
child: Text(tr('asAdded')),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.sortColumn = value;
}
});
var orderDropdown = DropdownButtonFormField(
decoration: InputDecoration(labelText: tr('appSortOrder')),
value: settingsProvider.sortOrder,
items: [
DropdownMenuItem(
value: SortOrderSettings.ascending,
child: Text(tr('ascending')),
),
DropdownMenuItem(
value: SortOrderSettings.descending,
child: Text(tr('descending')),
),
],
onChanged: (value) {
if (value != null) {
settingsProvider.sortOrder = value;
}
});
var intervalDropdown = DropdownButtonFormField(
decoration: InputDecoration(labelText: tr('bgUpdateCheckInterval')),
value: settingsProvider.updateInterval,
items: updateIntervals.map((e) {
int displayNum = (e < 60
? e
: e < 1440
? e / 60
: e / 1440)
.round();
String display = e == 0
? tr('neverManualOnly')
: (e < 60
? plural('minute', displayNum)
: e < 1440
? plural('hour', displayNum)
: plural('day', displayNum));
return DropdownMenuItem(value: e, child: Text(display));
}).toList(),
onChanged: (value) {
if (value != null) {
settingsProvider.updateInterval = value;
}
});
var sourceSpecificFields = sourceProvider.sources.map((e) {
if (e.additionalSourceSpecificSettingFormItems.isNotEmpty) {
return GeneratedForm(
items: e.additionalSourceSpecificSettingFormItems
.map((e) => [e])
.toList(),
onValueChanges: (values, valid, isBuilding) {
if (valid) {
for (var i = 0; i < values.length; i++) {
settingsProvider.setSettingString(
e.additionalSourceSpecificSettingFormItems[i].id,
values[i]);
}
}
},
defaultValues: e.additionalSourceSpecificSettingFormItems.map((e) {
return settingsProvider.getSettingString(e.id) ?? '';
}).toList());
} else {
return Container();
}
});
const height16 = SizedBox(
height: 16,
);
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[ body: CustomScrollView(slivers: <Widget>[
const CustomAppBar(title: 'Settings'), CustomAppBar(title: tr('settings')),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@ -34,120 +175,30 @@ class _SettingsPageState extends State<SettingsPage> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Appearance', tr('appearance'),
style: TextStyle( style: TextStyle(
color: Theme.of(context).colorScheme.primary), color: Theme.of(context).colorScheme.primary),
), ),
DropdownButtonFormField( themeDropdown,
decoration: height16,
const InputDecoration(labelText: 'Theme'), colourDropdown,
value: settingsProvider.theme, height16,
items: const [
DropdownMenuItem(
value: ThemeSettings.dark,
child: Text('Dark'),
),
DropdownMenuItem(
value: ThemeSettings.light,
child: Text('Light'),
),
DropdownMenuItem(
value: ThemeSettings.system,
child: Text('Follow System'),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.theme = value;
}
}),
const SizedBox(
height: 16,
),
DropdownButtonFormField(
decoration:
const InputDecoration(labelText: 'Colour'),
value: settingsProvider.colour,
items: const [
DropdownMenuItem(
value: ColourSettings.basic,
child: Text('Obtainium'),
),
DropdownMenuItem(
value: ColourSettings.materialYou,
child: Text('Material You'),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.colour = value;
}
}),
const SizedBox(
height: 16,
),
Row( Row(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Expanded(child: sortDropdown),
child: DropdownButtonFormField(
decoration: const InputDecoration(
labelText: 'App Sort By'),
value: settingsProvider.sortColumn,
items: const [
DropdownMenuItem(
value:
SortColumnSettings.authorName,
child: Text('Author/Name'),
),
DropdownMenuItem(
value:
SortColumnSettings.nameAuthor,
child: Text('Name/Author'),
),
DropdownMenuItem(
value: SortColumnSettings.added,
child: Text('As Added'),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.sortColumn = value;
}
})),
const SizedBox( const SizedBox(
width: 16, width: 16,
), ),
Expanded( Expanded(child: orderDropdown),
child: DropdownButtonFormField(
decoration: const InputDecoration(
labelText: 'App Sort Order'),
value: settingsProvider.sortOrder,
items: const [
DropdownMenuItem(
value: SortOrderSettings.ascending,
child: Text('Ascending'),
),
DropdownMenuItem(
value: SortOrderSettings.descending,
child: Text('Descending'),
),
],
onChanged: (value) {
if (value != null) {
settingsProvider.sortOrder = value;
}
})),
], ],
), ),
const SizedBox( height16,
height: 16,
),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Text('Show Source Webpage in App View'), Text(tr('showWebInAppView')),
Switch( Switch(
value: settingsProvider.showAppWebpage, value: settingsProvider.showAppWebpage,
onChanged: (value) { onChanged: (value) {
@ -155,13 +206,11 @@ class _SettingsPageState extends State<SettingsPage> {
}) })
], ],
), ),
const SizedBox( height16,
height: 16,
),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Text('Pin Updates to Top of Apps View'), Text(tr('pinUpdates')),
Switch( Switch(
value: settingsProvider.pinUpdates, value: settingsProvider.pinUpdates,
onChanged: (value) { onChanged: (value) {
@ -172,121 +221,133 @@ class _SettingsPageState extends State<SettingsPage> {
const Divider( const Divider(
height: 16, height: 16,
), ),
const SizedBox( height16,
height: 16,
),
Text( Text(
'Updates', tr('updates'),
style: TextStyle( style: TextStyle(
color: Theme.of(context).colorScheme.primary), color: Theme.of(context).colorScheme.primary),
), ),
DropdownButtonFormField( intervalDropdown,
decoration: const InputDecoration(
labelText:
'Background Update Checking Interval'),
value: settingsProvider.updateInterval,
items: updateIntervals.map((e) {
int displayNum = (e < 60
? e
: e < 1440
? e / 60
: e / 1440)
.round();
var displayUnit = (e < 60
? 'Minute'
: e < 1440
? 'Hour'
: 'Day');
String display = e == 0
? 'Never - Manual Only'
: '$displayNum $displayUnit${displayNum == 1 ? '' : 's'}';
return DropdownMenuItem(
value: e, child: Text(display));
}).toList(),
onChanged: (value) {
if (value != null) {
settingsProvider.updateInterval = value;
}
}),
const SizedBox(
height: 8,
),
Text(
'Longer intervals recommended for large App collections',
style: Theme.of(context)
.textTheme
.labelMedium!
.merge(const TextStyle(
fontStyle: FontStyle.italic)),
),
const Divider( const Divider(
height: 48, height: 48,
), ),
Text( Text(
'Source-Specific', tr('sourceSpecific'),
style: TextStyle( style: TextStyle(
color: Theme.of(context).colorScheme.primary), color: Theme.of(context).colorScheme.primary),
), ),
...sourceProvider.sources.map((e) { ...sourceSpecificFields,
if (e.moreSourceSettingsFormItems.isNotEmpty) {
return GeneratedForm(
items: e.moreSourceSettingsFormItems
.map((e) => [e])
.toList(),
onValueChanges: (values, valid) {
if (valid) {
for (var i = 0;
i < values.length;
i++) {
settingsProvider.setSettingString(
e.moreSourceSettingsFormItems[i]
.id,
values[i]);
}
}
},
defaultValues:
e.moreSourceSettingsFormItems.map((e) {
return settingsProvider
.getSettingString(e.id) ??
'';
}).toList());
} else {
return Container();
}
}),
], ],
))), ))),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Column( child: Column(
children: [ children: [
const SizedBox( const Divider(
height: 16, height: 32,
), ),
TextButton.icon( Row(
style: ButtonStyle( mainAxisAlignment: MainAxisAlignment.spaceAround,
foregroundColor: MaterialStateProperty.resolveWith<Color>( children: [
(Set<MaterialState> states) { TextButton.icon(
return Colors.grey; onPressed: () {
}), launchUrlString(settingsProvider.sourceUrl,
), mode: LaunchMode.externalApplication);
onPressed: () { },
launchUrlString(settingsProvider.sourceUrl, icon: const Icon(Icons.code),
mode: LaunchMode.externalApplication); label: Text(
}, tr('appSource'),
icon: const Icon(Icons.code), ),
label: Text( ),
'Source', TextButton.icon(
style: Theme.of(context).textTheme.bodySmall, onPressed: () {
), context.read<LogsProvider>().get().then((logs) {
), if (logs.isEmpty) {
const SizedBox( showError(ObtainiumError(tr('noLogs')), context);
height: 16, } else {
showDialog(
context: context,
builder: (BuildContext ctx) {
return const LogsDialog();
});
}
});
},
icon: const Icon(Icons.bug_report_outlined),
label: Text(tr('appLogs'))),
],
), ),
height16,
], ],
), ),
) )
])); ]));
} }
} }
class LogsDialog extends StatefulWidget {
const LogsDialog({super.key});
@override
State<LogsDialog> createState() => _LogsDialogState();
}
class _LogsDialogState extends State<LogsDialog> {
String? logString;
List<int> days = [7, 5, 4, 3, 2, 1];
@override
Widget build(BuildContext context) {
var logsProvider = context.read<LogsProvider>();
void filterLogs(int days) {
logsProvider
.get(after: DateTime.now().subtract(Duration(days: days)))
.then((value) {
setState(() {
String l = value.map((e) => e.toString()).join('\n\n');
logString = l.isNotEmpty ? l : tr('noLogs');
});
});
}
if (logString == null) {
filterLogs(days.first);
}
return AlertDialog(
scrollable: true,
title: Text(tr('appLogs')),
content: Column(
children: [
DropdownButtonFormField(
value: days.first,
items: days
.map((e) => DropdownMenuItem(
value: e,
child: Text(plural('day', e)),
))
.toList(),
onChanged: (d) {
filterLogs(d ?? 7);
}),
const SizedBox(
height: 32,
),
Text(logString ?? '')
],
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(tr('close'))),
TextButton(
onPressed: () {
Share.share(logString ?? '', subject: tr('appLogs'));
Navigator.of(context).pop();
},
child: Text(tr('share')))
],
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,112 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart';
const String logTable = 'logs';
const String idColumn = '_id';
const String levelColumn = 'level';
const String messageColumn = 'message';
const String timestampColumn = 'timestamp';
const String dbPath = 'logs.db';
enum LogLevels { debug, info, warning, error }
class Log {
int? id;
late LogLevels level;
late String message;
DateTime timestamp = DateTime.now();
Map<String, Object?> toMap() {
var map = <String, Object?>{
idColumn: id,
levelColumn: level.index,
messageColumn: message,
timestampColumn: timestamp.millisecondsSinceEpoch
};
return map;
}
Log(this.message, this.level);
Log.fromMap(Map<String, Object?> map) {
id = map[idColumn] as int;
level = LogLevels.values.elementAt(map[levelColumn] as int);
message = map[messageColumn] as String;
timestamp =
DateTime.fromMillisecondsSinceEpoch(map[timestampColumn] as int);
}
@override
String toString() {
return '${timestamp.toString()}: ${level.name}: $message';
}
}
class LogsProvider {
LogsProvider({bool runDefaultClear = true}) {
clear(before: DateTime.now().subtract(const Duration(days: 7)));
}
Database? db;
Future<Database> getDB() async {
db ??= await openDatabase(dbPath, version: 1,
onCreate: (Database db, int version) async {
await db.execute('''
create table if not exists $logTable (
$idColumn integer primary key autoincrement,
$levelColumn integer not null,
$messageColumn text not null,
$timestampColumn integer not null)
''');
});
return db!;
}
Future<Log> add(String message, {LogLevels level = LogLevels.info}) async {
Log l = Log(message, level);
l.id = await (await getDB()).insert(logTable, l.toMap());
if (kDebugMode) {
print(l);
}
return l;
}
Future<List<Log>> get({DateTime? before, DateTime? after}) async {
var where = getWhereDates(before: before, after: after);
return (await (await getDB())
.query(logTable, where: where.key, whereArgs: where.value))
.map((e) => Log.fromMap(e))
.toList();
}
Future<int> clear({DateTime? before, DateTime? after}) async {
var where = getWhereDates(before: before, after: after);
var res = await (await getDB())
.delete(logTable, where: where.key, whereArgs: where.value);
if (res > 0) {
add(plural('clearedNLogsBeforeXAfterY', res,
namedArgs: {'before': before.toString(), 'after': after.toString()},
name: 'n'));
}
return res;
}
}
MapEntry<String?, List<int>?> getWhereDates(
{DateTime? before, DateTime? after}) {
List<String> where = [];
List<int> whereArgs = [];
if (before != null) {
where.add('$timestampColumn < ?');
whereArgs.add(before.millisecondsSinceEpoch);
}
if (after != null) {
where.add('$timestampColumn > ?');
whereArgs.add(after.millisecondsSinceEpoch);
}
return whereArgs.isEmpty
? const MapEntry(null, null)
: MapEntry(where.join(' and '), whereArgs);
}

View File

@ -1,6 +1,7 @@
// Exposes functions that can be used to send notifications to the user // Exposes functions that can be used to send notifications to the user
// Contains a set of pre-defined ObtainiumNotification objects that should be used throughout the app // Contains a set of pre-defined ObtainiumNotification objects that should be used throughout the app
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
@ -12,40 +13,42 @@ class ObtainiumNotification {
late String channelName; late String channelName;
late String channelDescription; late String channelDescription;
Importance importance; Importance importance;
int? progPercent;
bool onlyAlertOnce;
ObtainiumNotification(this.id, this.title, this.message, this.channelCode, ObtainiumNotification(this.id, this.title, this.message, this.channelCode,
this.channelName, this.channelDescription, this.importance); this.channelName, this.channelDescription, this.importance,
{this.onlyAlertOnce = false, this.progPercent});
} }
class UpdateNotification extends ObtainiumNotification { class UpdateNotification extends ObtainiumNotification {
UpdateNotification(List<App> updates) UpdateNotification(List<App> updates)
: super( : super(
2, 2,
'Updates Available', tr('updatesAvailable'),
'', '',
'UPDATES_AVAILABLE', 'UPDATES_AVAILABLE',
'Updates Available', tr('updatesAvailable'),
'Notifies the user that updates are available for one or more Apps tracked by Obtainium', tr('updatesAvailableNotifDescription'),
Importance.max) { Importance.max) {
message = updates.length == 1 message = updates.isEmpty
? '${updates[0].name} has an update.' ? tr('noNewUpdates')
: '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} have updates.'; : updates.length == 1
? tr('xHasAnUpdate', args: [updates[0].name])
: plural('xAndNMoreUpdatesAvailable', updates.length - 1,
args: [updates[0].name, (updates.length - 1).toString()]);
} }
} }
class SilentUpdateNotification extends ObtainiumNotification { class SilentUpdateNotification extends ObtainiumNotification {
SilentUpdateNotification(List<App> updates) SilentUpdateNotification(List<App> updates)
: super( : super(3, tr('appsUpdated'), '', 'APPS_UPDATED', tr('appsUpdated'),
3, tr('appsUpdatedNotifDescription'), Importance.defaultImportance) {
'Apps Updated',
'',
'APPS_UPDATED',
'Apps Updated',
'Notifies the user that updates to one or more Apps were applied in the background',
Importance.defaultImportance) {
message = updates.length == 1 message = updates.length == 1
? '${updates[0].name} was updated to ${updates[0].latestVersion}.' ? tr('xWasUpdatedToY',
: '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} were updated.'; args: [updates[0].name, updates[0].latestVersion])
: plural('xAndNMoreUpdatesInstalled', updates.length - 1,
args: [updates[0].name, (updates.length - 1).toString()]);
} }
} }
@ -53,48 +56,56 @@ class ErrorCheckingUpdatesNotification extends ObtainiumNotification {
ErrorCheckingUpdatesNotification(String error) ErrorCheckingUpdatesNotification(String error)
: super( : super(
5, 5,
'Error Checking for Updates', tr('errorCheckingUpdates'),
error, error,
'BG_UPDATE_CHECK_ERROR', 'BG_UPDATE_CHECK_ERROR',
'Error Checking for Updates', tr('errorCheckingUpdates'),
'A notification that shows when background update checking fails', tr('errorCheckingUpdatesNotifDescription'),
Importance.high); Importance.high);
} }
class AppsRemovedNotification extends ObtainiumNotification { class AppsRemovedNotification extends ObtainiumNotification {
AppsRemovedNotification(List<List<String>> namedReasons) AppsRemovedNotification(List<List<String>> namedReasons)
: super( : super(6, tr('appsRemoved'), '', 'APPS_REMOVED', tr('appsRemoved'),
6, tr('appsRemovedNotifDescription'), Importance.max) {
'Apps Removed',
'',
'APPS_REMOVED',
'Apps Removed',
'Notifies the user that one or more Apps were removed due to errors while loading them',
Importance.max) {
message = ''; message = '';
for (var r in namedReasons) { for (var r in namedReasons) {
message += '${r[0]} was removed due to this error: ${r[1]}. \n'; message += '${tr('xWasRemovedDueToErrorY', args: [r[0], r[1]])} \n';
} }
message = message.trim(); message = message.trim();
} }
} }
class DownloadNotification extends ObtainiumNotification {
DownloadNotification(String appName, int progPercent)
: super(
appName.hashCode,
'Downloading $appName',
'',
'APP_DOWNLOADING',
'Downloading App',
'Notifies the user of the progress in downloading an App',
Importance.low,
onlyAlertOnce: true,
progPercent: progPercent);
}
final completeInstallationNotification = ObtainiumNotification( final completeInstallationNotification = ObtainiumNotification(
1, 1,
'Complete App Installation', tr('completeAppInstallation'),
'Obtainium must be open to install Apps', tr('obtainiumMustBeOpenToInstallApps'),
'COMPLETE_INSTALL', 'COMPLETE_INSTALL',
'Complete App Installation', tr('completeAppInstallation'),
'Asks the user to return to Obtanium to finish installing an App', tr('completeAppInstallationNotifDescription'),
Importance.max); Importance.max);
final checkingUpdatesNotification = ObtainiumNotification( final checkingUpdatesNotification = ObtainiumNotification(
4, 4,
'Checking for Updates', tr('checkingForUpdates'),
'', '',
'BG_UPDATE_CHECK', 'BG_UPDATE_CHECK',
'Checking for Updates', tr('checkingForUpdates'),
'Transient notification that appears when checking for updates', tr('checkingForUpdatesNotifDescription'),
Importance.min); Importance.min);
class NotificationsProvider { class NotificationsProvider {
@ -134,7 +145,9 @@ class NotificationsProvider {
String channelName, String channelName,
String channelDescription, String channelDescription,
Importance importance, Importance importance,
{bool cancelExisting = false}) async { {bool cancelExisting = false,
int? progPercent,
bool onlyAlertOnce = false}) async {
if (cancelExisting) { if (cancelExisting) {
await cancel(id); await cancel(id);
} }
@ -150,12 +163,18 @@ class NotificationsProvider {
channelDescription: channelDescription, channelDescription: channelDescription,
importance: importance, importance: importance,
priority: importanceToPriority[importance]!, priority: importanceToPriority[importance]!,
groupKey: 'dev.imranr.obtainium.$channelCode'))); groupKey: 'dev.imranr.obtainium.$channelCode',
progress: progPercent ?? 0,
maxProgress: 100,
showProgress: progPercent != null,
onlyAlertOnce: onlyAlertOnce)));
} }
Future<void> notify(ObtainiumNotification notif, Future<void> notify(ObtainiumNotification notif,
{bool cancelExisting = false}) => {bool cancelExisting = false}) =>
notifyRaw(notif.id, notif.title, notif.message, notif.channelCode, notifyRaw(notif.id, notif.title, notif.message, notif.channelCode,
notif.channelName, notif.channelDescription, notif.importance, notif.channelName, notif.channelDescription, notif.importance,
cancelExisting: cancelExisting); cancelExisting: cancelExisting,
onlyAlertOnce: notif.onlyAlertOnce,
progPercent: notif.progPercent);
} }

View File

@ -1,10 +1,15 @@
// Exposes functions used to save/load app settings // Exposes functions used to save/load app settings
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:obtainium/app_sources/github.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
String obtainiumTempId = 'imranr98_obtainium_${GitHub().host}';
String obtainiumId = 'dev.imranr.obtainium';
enum ThemeSettings { system, light, dark } enum ThemeSettings { system, light, dark }
enum ColourSettings { basic, materialYou } enum ColourSettings { basic, materialYou }
@ -55,7 +60,7 @@ class SettingsProvider with ChangeNotifier {
} }
int get updateInterval { int get updateInterval {
var min = prefs?.getInt('updateInterval') ?? 180; var min = prefs?.getInt('updateInterval') ?? 360;
if (!updateIntervals.contains(min)) { if (!updateIntervals.contains(min)) {
var temp = updateIntervals[0]; var temp = updateIntervals[0];
for (var i in updateIntervals) { for (var i in updateIntervals) {
@ -105,8 +110,7 @@ class SettingsProvider with ChangeNotifier {
while (!(await Permission.requestInstallPackages.isGranted)) { while (!(await Permission.requestInstallPackages.isGranted)) {
// Explicit request as InstallPlugin request sometimes bugged // Explicit request as InstallPlugin request sometimes bugged
Fluttertoast.showToast( Fluttertoast.showToast(
msg: 'Please allow Obtainium to install Apps', msg: tr('pleaseAllowInstallPerm'), toastLength: Toast.LENGTH_LONG);
toastLength: Toast.LENGTH_LONG);
if ((await Permission.requestInstallPackages.request()) == if ((await Permission.requestInstallPackages.request()) ==
PermissionStatus.granted) { PermissionStatus.granted) {
break; break;

View File

@ -3,15 +3,21 @@
import 'dart:convert'; import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:html/dom.dart'; import 'package:html/dom.dart';
import 'package:http/http.dart';
import 'package:obtainium/app_sources/apkmirror.dart';
import 'package:obtainium/app_sources/fdroid.dart'; import 'package:obtainium/app_sources/fdroid.dart';
import 'package:obtainium/app_sources/fdroidrepo.dart';
import 'package:obtainium/app_sources/github.dart'; import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/app_sources/gitlab.dart'; import 'package:obtainium/app_sources/gitlab.dart';
import 'package:obtainium/app_sources/izzyondroid.dart'; import 'package:obtainium/app_sources/izzyondroid.dart';
import 'package:obtainium/app_sources/mullvad.dart'; import 'package:obtainium/app_sources/mullvad.dart';
import 'package:obtainium/app_sources/signal.dart'; import 'package:obtainium/app_sources/signal.dart';
import 'package:obtainium/app_sources/sourceforge.dart'; import 'package:obtainium/app_sources/sourceforge.dart';
import 'package:obtainium/app_sources/steammobile.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/mass_app_sources/githubstars.dart'; import 'package:obtainium/mass_app_sources/githubstars.dart';
class AppNames { class AppNames {
@ -24,8 +30,9 @@ class AppNames {
class APKDetails { class APKDetails {
late String version; late String version;
late List<String> apkUrls; late List<String> apkUrls;
late AppNames names;
APKDetails(this.version, this.apkUrls); APKDetails(this.version, this.apkUrls, this.names);
} }
class App { class App {
@ -39,6 +46,8 @@ class App {
late int preferredApkIndex; late int preferredApkIndex;
late List<String> additionalData; late List<String> additionalData;
late DateTime? lastUpdateCheck; late DateTime? lastUpdateCheck;
bool pinned = false;
bool trackOnly = false;
App( App(
this.id, this.id,
this.url, this.url,
@ -49,11 +58,13 @@ class App {
this.apkUrls, this.apkUrls,
this.preferredApkIndex, this.preferredApkIndex,
this.additionalData, this.additionalData,
this.lastUpdateCheck); this.lastUpdateCheck,
this.pinned,
this.trackOnly);
@override @override
String toString() { String toString() {
return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls PREFERREDAPK: $preferredApkIndex ADDITIONALDATA: ${additionalData.toString()} LASTCHECK: ${lastUpdateCheck.toString()}'; return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls PREFERREDAPK: $preferredApkIndex ADDITIONALDATA: ${additionalData.toString()} LASTCHECK: ${lastUpdateCheck.toString()} PINNED $pinned';
} }
factory App.fromJson(Map<String, dynamic> json) => App( factory App.fromJson(Map<String, dynamic> json) => App(
@ -70,11 +81,15 @@ class App {
: List<String>.from(jsonDecode(json['apkUrls'])), : List<String>.from(jsonDecode(json['apkUrls'])),
json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int, json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int,
json['additionalData'] == null json['additionalData'] == null
? SourceProvider().getSource(json['url']).additionalDataDefaults ? SourceProvider()
.getSource(json['url'])
.additionalSourceAppSpecificDefaults
: List<String>.from(jsonDecode(json['additionalData'])), : List<String>.from(jsonDecode(json['additionalData'])),
json['lastUpdateCheck'] == null json['lastUpdateCheck'] == null
? null ? null
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck'])); : DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']),
json['pinned'] ?? false,
json['trackOnly'] ?? false);
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'id': id, 'id': id,
@ -86,16 +101,13 @@ class App {
'apkUrls': jsonEncode(apkUrls), 'apkUrls': jsonEncode(apkUrls),
'preferredApkIndex': preferredApkIndex, 'preferredApkIndex': preferredApkIndex,
'additionalData': jsonEncode(additionalData), 'additionalData': jsonEncode(additionalData),
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch 'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
'pinned': pinned,
'trackOnly': trackOnly
}; };
} }
escapeRegEx(String s) { // Ensure the input is starts with HTTPS and has no WWW
return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
return '\\${x[0]}';
});
}
preStandardizeUrl(String url) { preStandardizeUrl(String url) {
if (url.toLowerCase().indexOf('http://') != 0 && if (url.toLowerCase().indexOf('http://') != 0 &&
url.toLowerCase().indexOf('https://') != 0) { url.toLowerCase().indexOf('https://') != 0) {
@ -112,13 +124,6 @@ preStandardizeUrl(String url) {
return url; return url;
} }
const String couldNotFindReleases = 'Could not find a suitable release';
const String couldNotFindLatestVersion =
'Could not determine latest release version';
String notValidURL(String sourceName) {
return 'Not a valid $sourceName App URL';
}
const String noAPKFound = 'No APK found'; const String noAPKFound = 'No APK found';
List<String> getLinksFromParsedHTML( List<String> getLinksFromParsedHTML(
@ -132,23 +137,69 @@ List<String> getLinksFromParsedHTML(
.map((e) => '$prependToLinks${e.attributes['href']!}') .map((e) => '$prependToLinks${e.attributes['href']!}')
.toList(); .toList();
abstract class AppSource { class AppSource {
late String host; String? host;
String standardizeURL(String url); late String name;
bool enforceTrackOnly = false;
AppSource() {
name = runtimeType.toString();
}
String standardizeURL(String url) {
throw NotImplementedError();
}
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData); String standardUrl, List<String> additionalData,
AppNames getAppNames(String standardUrl); {bool trackOnly = false}) {
late List<List<GeneratedFormItem>> additionalDataFormItems; throw NotImplementedError();
late List<String> additionalDataDefaults; }
late List<GeneratedFormItem> moreSourceSettingsFormItems;
String? changeLogPageFromStandardUrl(String standardUrl); // Different Sources may need different kinds of additional data for Apps
Future<String> apkUrlPrefetchModifier(String apkUrl); List<List<GeneratedFormItem>> additionalSourceAppSpecificFormItems = [];
List<String> additionalSourceAppSpecificDefaults = [];
// Some additional data may be needed for Apps regardless of Source
final List<GeneratedFormItem> additionalAppSpecificSourceAgnosticFormItems = [
GeneratedFormItem(
label: tr('trackOnly'),
type: FormItemType.bool,
key: 'trackOnlyFormItemKey')
];
final List<String> additionalAppSpecificSourceAgnosticDefaults = [''];
// Some Sources may have additional settings at the Source level (not specific to Apps) - these use SettingsProvider
List<GeneratedFormItem> additionalSourceSpecificSettingFormItems = [];
String? changeLogPageFromStandardUrl(String standardUrl) {
return null;
}
Future<String> apkUrlPrefetchModifier(String apkUrl) async {
return apkUrl;
}
bool canSearch = false;
Future<Map<String, String>> search(String query) {
throw NotImplementedError();
}
String? tryInferringAppId(String standardUrl,
{List<String> additionalData = const []}) {
return null;
}
} }
abstract class MassAppSource { ObtainiumError getObtainiumHttpError(Response res) {
return ObtainiumError(res.reasonPhrase ??
tr('errorWithHttpStatusCode', args: [res.statusCode.toString()]));
}
abstract class MassAppUrlSource {
late String name; late String name;
late List<String> requiredArgs; late List<String> requiredArgs;
Future<List<String>> getUrls(List<String> args); Future<Map<String, String>> getUrlsWithDescriptions(List<String> args);
} }
class SourceProvider { class SourceProvider {
@ -161,31 +212,44 @@ class SourceProvider {
Mullvad(), Mullvad(),
Signal(), Signal(),
SourceForge(), SourceForge(),
// APKMirror() APKMirror(),
FDroidRepo(),
SteamMobile()
]; ];
// Add more mass source classes here so they are available via the service // Add more mass url source classes here so they are available via the service
List<MassAppSource> massSources = [GitHubStars()]; List<MassAppUrlSource> massUrlSources = [GitHubStars()];
AppSource getSource(String url) { AppSource getSource(String url) {
url = preStandardizeUrl(url); url = preStandardizeUrl(url);
AppSource? source; AppSource? source;
for (var s in sources) { for (var s in sources.where((element) => element.host != null)) {
if (url.toLowerCase().contains('://${s.host}')) { if (url.contains('://${s.host}')) {
source = s; source = s;
break; break;
} }
} }
if (source == null) { if (source == null) {
throw 'URL does not match a known source'; for (var s in sources.where((element) => element.host == null)) {
try {
s.standardizeURL(url);
source = s;
break;
} catch (e) {
//
}
}
}
if (source == null) {
throw UnsupportedURLError();
} }
return source; return source;
} }
bool doesSourceHaveRequiredAdditionalData(AppSource source) { bool ifSourceAppsRequireAdditionalData(AppSource source) {
for (var row in source.additionalDataFormItems) { for (var row in source.additionalSourceAppSpecificFormItems) {
for (var element in row) { for (var element in row) {
if (element.required) { if (element.required && element.opts == null) {
return true; return true;
} }
} }
@ -196,43 +260,67 @@ class SourceProvider {
String generateTempID(AppNames names, AppSource source) => String generateTempID(AppNames names, AppSource source) =>
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}'; '${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}';
bool isTempId(String id) {
List<String> parts = id.split('_');
if (parts.length < 3) {
return false;
}
for (int i = 0; i < parts.length - 1; i++) {
if (RegExp('.*[A-Z].*').hasMatch(parts[i])) {
// TODO: Look into RegEx for non-Latin characters
return false;
}
}
return true;
}
Future<App> getApp(AppSource source, String url, List<String> additionalData, Future<App> getApp(AppSource source, String url, List<String> additionalData,
{String name = '', String? id}) async { {String name = '',
String? id,
bool pinned = false,
bool trackOnly = false,
String? installedVersion}) async {
String standardUrl = source.standardizeURL(preStandardizeUrl(url)); String standardUrl = source.standardizeURL(preStandardizeUrl(url));
AppNames names = source.getAppNames(standardUrl); APKDetails apk = await source
APKDetails apk = .getLatestAPKDetails(standardUrl, additionalData, trackOnly: trackOnly);
await source.getLatestAPKDetails(standardUrl, additionalData); if (apk.apkUrls.isEmpty && !trackOnly) {
throw NoAPKError();
}
String apkVersion = apk.version.replaceAll('/', '-');
return App( return App(
id ?? generateTempID(names, source), id ??
source.tryInferringAppId(standardUrl,
additionalData: additionalData) ??
generateTempID(apk.names, source),
standardUrl, standardUrl,
names.author[0].toUpperCase() + names.author.substring(1), apk.names.author[0].toUpperCase() + apk.names.author.substring(1),
name.trim().isNotEmpty name.trim().isNotEmpty
? name ? name
: names.name[0].toUpperCase() + names.name.substring(1), : apk.names.name[0].toUpperCase() + apk.names.name.substring(1),
null, installedVersion,
apk.version.replaceAll('/', '-'), apkVersion,
apk.apkUrls, apk.apkUrls,
apk.apkUrls.length - 1, apk.apkUrls.length - 1,
additionalData, additionalData,
DateTime.now()); DateTime.now(),
pinned,
trackOnly);
} }
/// Returns a length 2 list, where the first element is a list of Apps and // Returns errors in [results, errors] instead of throwing them
/// the second is a Map<String, dynamic> of URLs and errors Future<List<dynamic>> getAppsByURLNaive(List<String> urls,
Future<List<dynamic>> getApps(List<String> urls,
{List<String> ignoreUrls = const []}) async { {List<String> ignoreUrls = const []}) async {
List<App> apps = []; List<App> apps = [];
Map<String, dynamic> errors = {}; Map<String, dynamic> errors = {};
for (var url in urls.where((element) => !ignoreUrls.contains(element))) { for (var url in urls.where((element) => !ignoreUrls.contains(element))) {
try { try {
var source = getSource(url); var source = getSource(url);
apps.add(await getApp(source, url, source.additionalDataDefaults)); apps.add(await getApp(
source, url, source.additionalSourceAppSpecificDefaults));
} catch (e) { } catch (e) {
errors.addAll(<String, dynamic>{url: e}); errors.addAll(<String, dynamic>{url: e});
} }
} }
return [apps, errors]; return [apps, errors];
} }
List<String> getSourceHosts() => sources.map((e) => e.host).toList();
} }

View File

@ -1,6 +1,13 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
android_alarm_manager_plus:
dependency: "direct main"
description:
name: android_alarm_manager_plus
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
animations: animations:
dependency: "direct main" dependency: "direct main"
description: description:
@ -14,7 +21,7 @@ packages:
name: archive name: archive
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.3.2" version: "3.3.5"
args: args:
dependency: transitive dependency: transitive
description: description:
@ -71,6 +78,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.16.0" version: "1.16.0"
convert:
dependency: transitive
description:
name: convert
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.1"
cross_file: cross_file:
dependency: transitive dependency: transitive
description: description:
@ -127,6 +141,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.5.4" version: "1.5.4"
easy_localization:
dependency: "direct main"
description:
name: easy_localization
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
easy_logger:
dependency: transitive
description:
name: easy_logger
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.2"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -154,7 +182,7 @@ packages:
name: file_picker name: file_picker
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "5.2.2" version: "5.2.3"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@ -166,14 +194,14 @@ packages:
name: flutter_fgbg name: flutter_fgbg
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.2.0" version: "0.2.2"
flutter_launcher_icons: flutter_launcher_icons:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: flutter_launcher_icons name: flutter_launcher_icons
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.10.0" version: "0.11.0"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -187,7 +215,7 @@ packages:
name: flutter_local_notifications name: flutter_local_notifications
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "12.0.3" version: "12.0.4"
flutter_local_notifications_linux: flutter_local_notifications_linux:
dependency: transitive dependency: transitive
description: description:
@ -202,6 +230,11 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.0.0" version: "6.0.0"
flutter_localizations:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_plugin_android_lifecycle: flutter_plugin_android_lifecycle:
dependency: transitive dependency: transitive
description: description:
@ -268,6 +301,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.3.1" version: "1.3.1"
intl:
dependency: transitive
description:
name: intl
url: "https://pub.dartlang.org"
source: hosted
version: "0.17.0"
js: js:
dependency: transitive dependency: transitive
description: description:
@ -316,7 +356,7 @@ packages:
name: mime name: mime
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.2" version: "1.0.3"
nested: nested:
dependency: transitive dependency: transitive
description: description:
@ -358,7 +398,7 @@ packages:
name: path_provider_android name: path_provider_android
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.20" version: "2.0.22"
path_provider_ios: path_provider_ios:
dependency: transitive dependency: transitive
description: description:
@ -450,6 +490,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.3" version: "2.1.3"
pointycastle:
dependency: transitive
description:
name: pointycastle
url: "https://pub.dartlang.org"
source: hosted
version: "3.6.2"
process: process:
dependency: transitive dependency: transitive
description: description:
@ -470,7 +517,7 @@ packages:
name: share_plus name: share_plus
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.1.0" version: "6.3.0"
share_plus_platform_interface: share_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -546,6 +593,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.9.0" version: "1.9.0"
sqflite:
dependency: "direct main"
description:
name: sqflite
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.1"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
url: "https://pub.dartlang.org"
source: hosted
version: "2.4.0+2"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
@ -567,6 +628,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
synchronized:
dependency: transitive
description:
name: synchronized
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0+3"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:
@ -601,14 +669,14 @@ packages:
name: url_launcher name: url_launcher
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.1.6" version: "6.1.7"
url_launcher_android: url_launcher_android:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_android name: url_launcher_android
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.0.21" version: "6.0.22"
url_launcher_ios: url_launcher_ios:
dependency: transitive dependency: transitive
description: description:
@ -657,7 +725,7 @@ packages:
name: uuid name: uuid
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.0.6" version: "3.0.7"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@ -699,14 +767,7 @@ packages:
name: win32 name: win32
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.0.1" version: "3.1.2"
workmanager:
dependency: "direct main"
description:
name: workmanager
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.1"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:

View File

@ -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 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 0.6.5+49 # When changing this, update the tag in main() accordingly version: 0.8.20+84 # When changing this, update the tag in main() accordingly
environment: environment:
sdk: '>=2.18.2 <3.0.0' sdk: '>=2.18.2 <3.0.0'
@ -42,7 +42,6 @@ dependencies:
provider: ^6.0.3 provider: ^6.0.3
http: ^0.13.5 http: ^0.13.5
webview_flutter: ^3.0.4 webview_flutter: ^3.0.4
workmanager: ^0.5.0
dynamic_color: ^1.5.4 dynamic_color: ^1.5.4
html: ^0.15.0 html: ^0.15.0
shared_preferences: ^2.0.15 shared_preferences: ^2.0.15
@ -56,12 +55,15 @@ dependencies:
share_plus: ^6.0.1 share_plus: ^6.0.1
installed_apps: ^1.3.1 installed_apps: ^1.3.1
package_archive_info: ^0.1.0 package_archive_info: ^0.1.0
android_alarm_manager_plus: ^2.1.0
sqflite: ^2.2.0+3
easy_localization: ^3.0.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_launcher_icons: ^0.10.0 flutter_launcher_icons: ^0.11.0
# The "flutter_lints" package below contains a set of recommended lints to # The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is # encourage good coding practices. The lint set provided by the package is
@ -88,9 +90,12 @@ flutter:
uses-material-design: true uses-material-design: true
# To add assets to your application, add an assets section, like this: # To add assets to your application, add an assets section, like this:
# assets: # - assets:
# - images/a_dot_burr.jpeg # - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg # - images/a_dot_ham.jpeg
assets:
- assets/translations/
# An image asset can refer to one or more resolution-specific "variants", see # An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware # https://flutter.dev/assets-and-images/#resolution-aware