Compare commits

...

579 Commits

Author SHA1 Message Date
5885ea57ad Merge pull request #519 from ImranR98/dev
Add Jenkins jobs as a Source (#514), switch to Apps page after App added (#508), Bugfixes (#510)
2023-04-30 17:05:28 -04:00
f8b326529f Add Jenkins to README 2023-04-30 17:03:54 -04:00
9f5f1174ba Increment version 2023-04-30 17:02:33 -04:00
779de58f74 Jenkins uses release dates only + APK delete bugfix 2023-04-30 17:00:00 -04:00
76e316422c Added Jenkins Source (#514) 2023-04-30 16:28:09 -04:00
36273fe02d Switch to apps tab after app added (#508) 2023-04-30 15:49:25 -04:00
03b592521c Fixed link sorting for HTML Source 2023-04-30 15:39:21 -04:00
a5ef47a060 Merge pull request #516 from gidano/main
Update hu.json
2023-04-30 14:40:26 -04:00
289c801fec Merge pull request #511 from ThePhoDit/es-translations
Spanish translations
2023-04-30 14:40:17 -04:00
73d04b1564 Update hu.json 2023-04-30 16:09:15 +02:00
9469d56144 Spanish translations 2023-04-30 11:37:07 +02:00
d063bca474 Merge pull request #506 from ImranR98/dev
Switched to synchronous install plugin (#99, #459)
2023-04-30 02:59:49 -04:00
7c592756fe Smarter APK caching (#459) 2023-04-30 02:47:53 -04:00
08586870fb Tweak use of attemptToCorrectInstallStatus 2023-04-30 02:28:14 -04:00
8b123acdcd Switched to synchronous install plugin 2023-04-30 02:23:53 -04:00
08aa04f812 Merge pull request #504 from ImranR98/dev
Less restrictive install permission requests (#495)
Add toggles to disable warnings (#495 
Fix a breaking bug on Add App page from the previous PR
2023-04-30 01:04:04 -04:00
dd19fcf6da Add warning popup toggles + fix breaking bug in add app 2023-04-30 00:58:32 -04:00
04b3c8ad7d Bugfix for prev. commit 2023-04-30 00:15:53 -04:00
81f66683d2 Less restrictive install permission requests (#488) 2023-04-30 00:02:23 -04:00
392554123b Added an (experimental) Source override option for URLs that work with an existing Source but use a custom host (#271, #393) (#502) 2023-04-29 23:50:12 -04:00
3e4d5c26ac Merge pull request #501 from LilligantMatsuri/main
Update zh.json
2023-04-29 23:37:50 -04:00
86b7f6fef3 Update zh.json
- Overhaul Simplified Chinese translation
- Add a missing string ("onlyWorksWithNonVersionDetectApps")
- Remove an invalid string ("onlyAppliesToInstalledAndOutdatedApps")
2023-04-29 22:10:00 +08:00
e1d914118f Merge pull request #496 from ImranR98/dev
Show "Reset Install Status" button on track-only apps
2023-04-28 22:46:13 -04:00
4a07cf9951 Show "Reset Install Status" button on track-only apps 2023-04-28 22:45:38 -04:00
ce44e200a5 Merge pull request #495 from ImranR98/dev
HTML Source Bugfix (#288) + Better Placeholder Icon Visibility (#490)
2023-04-28 22:25:30 -04:00
e8ebf53626 Increment version, update packages 2023-04-28 22:24:41 -04:00
cdd6a4124c Merge pull request #492 from gidano/main
Update hu.json
2023-04-28 22:23:26 -04:00
09c71e4e9f Increase icon placeholder opacity (#490) 2023-04-28 22:22:55 -04:00
28a996441c HTML Source Bugfix #288 2023-04-28 22:18:58 -04:00
396bf012c9 Update hu.json 2023-04-25 15:55:30 +02:00
02da24aa75 Merge pull request #487 from ImranR98/dev
Fixed null error for imported Apps (#476)
2023-04-23 03:23:38 -04:00
3c6e66ce12 Fixed null error for imported Apps (#476) 2023-04-23 03:22:56 -04:00
0213b542e3 Merge pull request #486 from ImranR98/dev
Several bugfixes and minor UI improvements + GitLab fallback support
2023-04-23 01:31:13 -04:00
b0e8a4a297 Merge pull request #480 from markus-gitdev/main
Update de.json
2023-04-23 01:22:57 -04:00
e72b33ebf2 Added fallback option to GitLab Source (#456) 2023-04-23 01:19:31 -04:00
283722319b More adaptive column spacing in apps list (#485) 2023-04-23 00:53:03 -04:00
b406bb5c6a Increment version, update packages 2023-04-23 00:01:49 -04:00
de2b7fa7a1 URL selection modal improvements (incl. #460) 2023-04-22 23:49:55 -04:00
be61220af4 Show version in changelog dialog (#482) 2023-04-22 22:57:54 -04:00
3e732a4317 Sort GitHub releases by date, remove codeberg redundancy 2023-04-22 22:42:59 -04:00
9f2db4e4e7 App page 'reset install status' button shows if appropriate 2023-04-22 21:40:15 -04:00
78141998f4 Attempt additional fix for #201 2023-04-21 15:54:17 -04:00
934f237e34 Update de.json
Correction of "removeAppQuestion"
2023-04-19 12:04:51 +02:00
1b2a9a39e3 Fix "reset install status" button being disabled 2023-04-19 02:05:31 -04:00
dc52fb6181 Merge pull request #473 from ImranR98/dev
Sourceforge apk url extraction bugfix
2023-04-15 15:19:08 -04:00
9e4ac397d8 Sourceforge apk url extraction bugfix 2023-04-15 15:18:32 -04:00
0ec944eae9 Merge pull request #472 from ImranR98/dev
Bugfix in getting APK name from URL (affected Sourceforge and potentially others)
2023-04-15 14:43:02 -04:00
ad250c30e4 Increment version 2023-04-15 14:41:38 -04:00
1090f15508 Sourceforge bugfix 2023-04-15 14:34:14 -04:00
666941350e APK name bugfix 2023-04-15 14:28:00 -04:00
eeadbce8b0 Merge pull request #466 from ImranR98/dev
Increment version, update packages
2023-04-13 23:06:22 -04:00
ce8aeff342 Increment version, update packages 2023-04-13 23:06:08 -04:00
0d8362a2ed Merge pull request #465 from Bnyro/amoled-theme
Add an amoled black theme
2023-04-13 22:59:54 -04:00
3b28143a4e Add an amoled black theme 2023-04-13 18:19:24 +02:00
537628f378 Merge pull request #451 from gidano/gidano/Obtainium-HU
Updated hu.json
2023-04-12 15:49:02 -04:00
c92d76df98 Merge pull request #453 from mehdijahann/main
Update fa.json
2023-04-12 15:48:51 -04:00
b6959e1a8b Merge pull request #457 from markus-gitdev/main
Update de.json
2023-04-12 15:48:42 -04:00
1bf648da60 Merge pull request #461 from ImranR98/dev
Fixed HTML relative link handling (#455), Fixed App name override and sort inconsistencies (#450)
2023-04-12 15:48:28 -04:00
6a1275e9e4 Sort no longer case-sensitive (#450) 2023-04-12 15:46:48 -04:00
df242b91ad Increment version, update packages 2023-04-12 15:39:32 -04:00
7ea75325bb App name overrides more consistent (#450) 2023-04-12 15:36:17 -04:00
0704dfe2ee Fixed relative link handling in HTML source 2023-04-12 15:17:08 -04:00
6275cbf114 HTML Source - handle relative URLs in literal .html pages 2023-04-12 14:50:54 -04:00
36b8ef6782 Update de.json
Translations for:
- groupByCategory
- autoApkFilterByArch
2023-04-11 13:10:03 +02:00
d274b9a428 Update fa.json 2023-04-10 16:54:29 +02:00
1c2980d1ac Updated hu.json 2023-04-09 08:57:12 +02:00
8f0aac057e Merge pull request #442 from bluefly000/japanese-translation
Update ja.json
2023-04-07 22:11:35 -04:00
e929920a48 Merge pull request #440 from atilluF/main
Update it.json
2023-04-07 22:11:27 -04:00
8ed254c7dd Merge pull request #446 from ImranR98/dev
Bugfix: GitHub/Codeberg fallback + no-prerel fail
2023-04-07 22:11:20 -04:00
46a00836df Bugfix: GitHub/Codeberg fallback + no-prerel fail 2023-04-07 22:10:55 -04:00
f144ffdded Update ja.json 2023-04-08 00:03:31 +09:00
d597d569e2 Update it.json 2023-04-07 13:19:12 +02:00
b62475de87 Merge pull request #439 from ImranR98/dev
Use app deep copy in places to avoid bugs
2023-04-07 02:20:32 -04:00
334ac8d3d6 Use app deep copy in places to avoid bugs 2023-04-07 01:54:14 -04:00
9193788356 Merge pull request #438 from ImranR98/dev
Better downloaded file naming (reduces conflicts)
2023-04-06 23:00:28 -04:00
8f75ddd43f Better download file naming (reduces conflicts) 2023-04-06 22:59:40 -04:00
a2edc86bfa Merge pull request #437 from ImranR98/dev
Added simple APK try auto-select by CPU arch #436
2023-04-06 22:37:55 -04:00
0804e680b2 Added simple APK try auto-select by CPU arch #436
Plus minor form switch UI fixes (overflow, spacing)
2023-04-06 22:37:24 -04:00
49affd1bd4 Merge pull request #434 from ImranR98/dev
Store APK names with URLs (#432)
2023-04-05 18:56:16 -04:00
202ce4f0d5 Store APK names with URLs (#432) 2023-04-05 18:50:19 -04:00
361a3e1bc2 Merge pull request #426 from ImranR98/dev
Increment version
2023-04-04 21:46:15 -04:00
f33a26d4f4 Increment version 2023-04-04 21:45:57 -04:00
7aaf56ec8c Merge pull request #425 from HRTK92/main
Add long-press URL copy and snackbar message
2023-04-04 21:44:35 -04:00
ed120016d9 Add long-press URL copy and snackbar message 2023-04-05 10:32:56 +09:00
e8cbac8657 Merge pull request #413 from gidano/Obtainium-HU
Done
2023-04-04 21:13:23 -04:00
b66c13d319 Merge pull request #424 from ImranR98/dev
Bugfix #392, Custom App Names #420, Archive Label in GitHub Search #421
2023-04-04 21:05:54 -04:00
782d055bc3 Added cloudflare.f-droid.org support 2023-04-04 20:21:24 -04:00
d557746965 Increment version 2023-04-04 20:00:36 -04:00
e6b05d50b9 Scrolling bugfix #392, custom name #420, search archive label #421 2023-04-04 19:59:35 -04:00
dea635fa6a Merge pull request #416 from ImranR98/dev
Added a source filter to the Apps page
2023-04-02 18:14:59 -04:00
682026ed0a Added a source filter to the Apps page 2023-04-02 18:14:43 -04:00
9fe8a200ef Done 2023-04-01 14:55:56 +02:00
210100da2b Merge pull request #412 from ImranR98/dev
Attempt to workaround export bug (#385)
2023-04-01 00:43:11 -04:00
d52660235b Attempt to workaround export bug (#385) 2023-04-01 00:42:44 -04:00
e386b5ab8a Merge pull request #411 from ImranR98/dev
Bugfix: App pinning not working (#410)
2023-04-01 00:24:56 -04:00
abf7be222d Bugfix: App pinning not working (#410) 2023-04-01 00:24:16 -04:00
4c5b9304c0 Merge pull request #409 from ImranR98/dev
Bugfix for prev. commit
2023-03-31 15:40:18 -04:00
4cfe6af044 Bugfix for prev. commit 2023-03-31 15:39:52 -04:00
3f0c4068dd Merge pull request #408 from ImranR98/dev
Bugfix #405 + general categories bugfixes
2023-03-31 15:37:11 -04:00
7981ca29c5 Bugfix #405 + general categories bugfixes 2023-03-31 15:36:51 -04:00
187efa8fc5 Merge pull request #406 from ImranR98/dev
Fixed Mullvad web scraping (again)
2023-03-31 09:25:50 -04:00
cd27ff7f2d Fixed Mullvad web scraping (again) 2023-03-31 09:24:15 -04:00
6f6a25511b Merge pull request #402 from ImranR98/dev
Added "Group by Category" setting
2023-03-30 23:41:06 -04:00
4e17bbcfd1 Added "Group by Category" setting 2023-03-30 23:40:32 -04:00
814e269d1d Merge pull request #401 from ImranR98/dev
Bugfix: "releaseDateAsVersion" resets to "noVersionDetection"
2023-03-30 17:45:24 -04:00
6b7d962b87 Bugfix: "releaseDateAsVersion" resets to "noVersionDetection"
Also 2 related UI fixes
2023-03-30 17:44:39 -04:00
9fba747802 Version detection improvements, Mullvad web scraping fix and changelog addition, code readability improvements, general tweaks/bugfixes (#400)
1. Apps that don't have "standard" versioning formats now automatically stop using version detection. This will prevent users from having to learn about this feature and enable it manually.
    - For such Apps, the "standard" version detection option is greyed out.
2. The Mullvad Source recently broke due to a slight change in their website design. This is now fixed.
    - Mullvad also now provides an in-app changelog via their official GitHub repo.
3. Code has been refactored for readability (specifically the version detection code and UI code for most screens).
4. Minor UI tweaks and bugfixes.
2023-03-30 17:27:36 -04:00
c7cd35b6a1 Merge pull request #388 from ImranR98/dev
Hide (non-functional) uninstall button for track-only Apps, attempt bugfix for cert error (#375)
2023-03-26 07:39:34 -04:00
a8a3fce33a Attempt to fix bug (#375) 2023-03-26 07:37:35 -04:00
3a38cedcf5 Bugfix: Don't show uninstall option for track-only apps 2023-03-25 22:55:40 -04:00
69ccefcf1a Merge pull request #382 from atilluF/main
Update it.json
2023-03-25 00:56:52 -04:00
d3932f317d Merge pull request #384 from ImranR98/dev
Bugfixes: "Return After Delete" (#359) and No Redirect if Navigating During App Addition
2023-03-25 00:56:16 -04:00
895deeead5 Increment version 2023-03-25 00:54:34 -04:00
4c04af3868 Bugfix: App add doesn't redirect if nav away during add 2023-03-25 00:54:12 -04:00
07c490bb0e Fixed "return after delete" bug (#359) 2023-03-25 00:47:26 -04:00
a081d553bb Update it.json 2023-03-24 20:11:03 +01:00
3bc5837999 Merge pull request #380 from ImranR98/dev
Bugfix: Infinite load on corrupt App JSON (#378)
2023-03-22 22:41:59 -04:00
9fbe524818 Bugfix: Infinite load on corrupt App JSON (#378) 2023-03-22 22:36:04 -04:00
c21a9d7292 Merge pull request #373 from ImranR98/dev
Rearranged Sources + Added (Non-Activated) WhatsApp Source (Website Currently Provides the Wrong Version)
2023-03-20 15:20:33 -04:00
9c6068b270 Added WhatsApp (not activated) + rearranged sources 2023-03-20 15:18:55 -04:00
cd86d6112b Merge pull request #371 from ImranR98/dev
Render Changelog as MarkDown (#369) (for some Sources) + VLC as a Source (#367)
2023-03-19 13:54:59 -04:00
1112c79c14 Increment version 2023-03-19 13:53:40 -04:00
08555bac75 Added VLC as a Source 2023-03-19 13:50:14 -04:00
6db31e2b24 Support for normal text changelogs (by Source) 2023-03-19 12:52:34 -04:00
48d2532323 Links in changelog openable 2023-03-19 12:49:43 -04:00
f1fc43a3e7 Don't show 'Changes' button if it doesn't do anything 2023-03-19 12:44:17 -04:00
280827d8ec Changelog now rendered as MarkDown 2023-03-19 12:38:57 -04:00
05ee0f9c48 Merge pull request #366 from ImranR98/dev
Open changelog inside App for supported Sources (#82)
2023-03-18 23:54:08 -04:00
ef06ae289e Open changelog inside App for supported Sources (#82) 2023-03-18 23:53:42 -04:00
bd0e322465 Updated README sources section 2023-03-18 23:20:04 -04:00
a93a2411fa Merge pull request #365 from ImranR98/dev
Added 2 Sources: Neutron Code #287, Telegram #290 (Official App, not Channels as Sources) + Tiny UI Bugfix
2023-03-18 23:12:05 -04:00
26e6eef72e Increment version 2023-03-18 23:10:14 -04:00
e49a6e311b Added Telegram App as a Source 2023-03-18 23:09:11 -04:00
53d3397651 Remove download notification when download fails 2023-03-18 22:58:37 -04:00
fe540f5e61 Added Neutron Code as a Source 2023-03-18 22:54:58 -04:00
234374224b Merge pull request #364 from ImranR98/dev
Made download start more responsive (#361, #327)  + very minor form UI spacing improvement
2023-03-17 23:10:59 -04:00
83390f648a Increment version, update packages 2023-03-17 23:10:18 -04:00
1143b6a546 Better form UI spacing 2023-03-17 23:03:38 -04:00
0f3e029312 Bugfix for previous commit 2023-03-17 22:49:12 -04:00
c0120f4e40 Made download start more responsive (#361, #327) 2023-03-17 16:48:06 -04:00
a0199f0ceb Merge pull request #349 from ImranR98/dev
Added FR menu option, increment version
2023-03-05 13:07:53 -05:00
0528936e5a Added FR menu option, increment version 2023-03-05 13:07:31 -05:00
4de98b2f36 Merge pull request #348 from sonalder-darlene/main
Add french translation 🇫🇷
2023-03-05 13:03:09 -05:00
dfb5f5596c Add a missing translation 2023-03-05 17:19:04 +00:00
2e706aac47 Add french translation
just added a fr.json with everything translated
2023-03-05 17:16:48 +00:00
24a600e595 Merge pull request #347 from ImranR98/dev
Bugfixes from prev. commit
2023-03-03 23:31:54 -05:00
1596a44ec5 Bugfixes from prev. commit 2023-03-03 23:31:21 -05:00
9ee2be76ca Merge pull request #346 from ImranR98/dev
Icon improvements (#267, #345)
2023-03-03 23:00:47 -05:00
83b770294d Icon improvements (#267, #345) 2023-03-03 23:00:14 -05:00
2679d5a1aa Merge pull request #340 from ImranR98/dev
UI Improvements (#330, #337)
2023-03-01 21:55:47 -05:00
e49c09c0ff Increment version 2023-03-01 21:55:04 -05:00
c9318ef2b5 Merge pull request #336 from markus-gitdev/main
Update de.json
2023-03-01 21:54:05 -05:00
2e88c8eede UI improvements
Update button for individual apps on list is now an icon.
Less clipping on small screens on apps list page.
2023-03-01 21:37:18 -05:00
8648c1bea7 Added icon for non-installed Apps 2023-03-01 20:20:34 -05:00
b22e2bab0c Update de.json
Updated the following strings to a German translation:
- importFromURLsInFile
- versionDetection
- standardVersionDetection
2023-03-01 08:20:04 +01:00
57f7bf44c2 Merge pull request #335 from ImranR98/dev
Language bugfix + package upgrades + incr. ver.
2023-02-27 19:01:20 -05:00
ce526d8d26 Language bugfix + package upgrades + incr. ver. 2023-02-27 19:00:50 -05:00
5f3eeb9971 Merge pull request #333 from ImranR98/dev
Increment version
2023-02-25 15:57:28 -05:00
e67a6b8627 Increment version 2023-02-25 15:57:04 -05:00
f8e99bb0cb Merge pull request #329 from bluefly000/japanese-translation
Update Japanese translation
2023-02-25 15:55:44 -05:00
09b5dd41d3 Merge pull request #331 from gidano/main
Update hu.json
2023-02-25 15:55:38 -05:00
b1bd36408c Merge pull request #332 from mehdijahann/main
Update fa.json
2023-02-25 15:55:26 -05:00
54d8dff32f Update fa.json 2023-02-25 20:52:22 +03:30
7b1416e28e Update hu.json 2023-02-25 11:17:17 +01:00
926e7b89ce Update Japanese translation 2023-02-25 13:57:07 +09:00
43d4f89d61 Merge pull request #328 from ImranR98/dev
Added "URLs in File" import (#326) + UI Improvements (#312, #325)
2023-02-24 23:12:02 -05:00
2190da162d Merge remote-tracking branch 'origin/main' into dev 2023-02-24 23:09:18 -05:00
f10bb5ac91 Increment version, upgrade packages 2023-02-24 23:02:32 -05:00
8e52f9666d UI Bugfix 2023-02-24 22:58:56 -05:00
a8a47bb153 Unified version detection setting 2023-02-24 22:48:30 -05:00
728dafcc28 Added "URLs in file (like OPML)" import 2023-02-24 21:54:27 -05:00
d53b21906c Merge pull request #324 from markus-gitdev/main
Update de.json
2023-02-24 18:26:39 -05:00
d6dcac0f97 Update de.json
Improve readability.
2023-02-24 10:00:15 +01:00
dae5a67652 Merge pull request #323 from ImranR98/dev
Bugfix
2023-02-23 18:31:59 -05:00
508fcccec9 Increment version, update packages 2023-02-23 18:31:08 -05:00
cc8a4c3760 Merge remote-tracking branch 'origin/main' into dev 2023-02-23 18:27:59 -05:00
814e2b7306 Merge pull request #311 from markus-gitdev/main
Update de.json
2023-02-23 18:27:20 -05:00
2e159c9886 Bugfix 2023-02-23 16:20:03 -05:00
b82d28f2a7 Update de.json
Update German translation.
2023-02-21 16:02:32 +01:00
3c61735706 Merge pull request #309 from ImranR98/dev
Release Filter Support for APKMirror (#307) + UI Bugfix (#303)
2023-02-19 18:50:12 -05:00
a2879f5bfa Increment version, update package 2023-02-19 18:48:21 -05:00
b57f023739 Added prev. rel. and regex title filter support to APKMirror 2023-02-19 18:46:30 -05:00
c376a7abec Longer version names bugfix for apps list UI 2023-02-19 18:20:28 -05:00
31c6cc3f6f Merge pull request #305 from atilluF/ita
Update Italian translation
2023-02-19 17:54:53 -05:00
8de8438aeb Merge pull request #302 from bluefly000/japanese-translation
Update Japanese translation
2023-02-19 17:54:47 -05:00
2b0225dd5b Merge pull request #306 from gidano/main
Update hu.json
2023-02-19 17:54:41 -05:00
f6af3a7998 Update hu.json 2023-02-19 15:12:06 +01:00
bd29d7bc10 Update it.json 2023-02-19 12:44:31 +01:00
ffb3516a4b Update Japanese translation 2023-02-19 15:41:14 +09:00
6a5e7942ee Merge pull request #301 from ImranR98/dev
App edit bugfixes
2023-02-18 21:39:55 -05:00
859158e84a App edit bugfixes 2023-02-18 21:39:26 -05:00
435116e10b Merge pull request #300 from ImranR98/dev
Release Date Support for Some Sources (#210 + #298) + UI Changes (#274) + Bugfix (#299)
2023-02-18 21:24:36 -05:00
a788d9d7cd Increment version 2023-02-18 21:22:36 -05:00
4be3478b97 Added release date support to APKMirror 2023-02-18 21:16:28 -05:00
fe0126095a Added release date support to third part f-droid repos 2023-02-18 21:03:22 -05:00
d5fdf28a98 Added release date support to Codeberg 2023-02-18 20:58:08 -05:00
f06d245e20 Added release date support to GitLab 2023-02-18 20:55:23 -05:00
2b4f94b407 Date sort bugfix 2023-02-18 20:49:45 -05:00
5f7e342e6b Added rel. date sort 2023-02-18 20:47:29 -05:00
191776d0d5 Initial release date support 2023-02-18 20:37:30 -05:00
ea81b0e66e Bugfix for different ID same URL Apps (#299) 2023-02-18 18:31:42 -05:00
86131ae3ce Merge pull request #297 from ImranR98/dev
Bugfixes (#292 and #293)
2023-02-16 22:40:38 -05:00
64ded1d720 Updated packages 2023-02-16 22:39:35 -05:00
a11c2f1d37 Increment version 2023-02-16 22:37:17 -05:00
890787f87f Fixed type errors and HTML APK filter 2023-02-16 22:36:53 -05:00
c5ff1de950 Merge pull request #291 from ImranR98/dev
Bugfixes (#286, #289)
2023-02-15 21:27:30 -05:00
56658abd60 Increment version 2023-02-15 21:26:55 -05:00
b60622e2cb Steam bugfix 2023-02-15 21:26:05 -05:00
e149f0b225 HTML Source bugfix 2023-02-15 21:05:14 -05:00
d9729f08c0 Merge pull request #278 from ImranR98/dev
Fixed breaking typo in fa.json
2023-02-12 19:32:25 -05:00
eda5c1bac6 Fixed breaking typo in fa.json 2023-02-12 19:32:05 -05:00
5574ea870b Merge pull request #277 from ImranR98/dev
Added FA to language menu
2023-02-12 19:26:09 -05:00
9f03234ac1 Added FA to language menu
(and renamed file for consistency)
2023-02-12 19:25:44 -05:00
b2503dd43d Merge pull request #276 from ImranR98/dev
Increment version
2023-02-12 19:20:18 -05:00
e01ca704bc Increment version 2023-02-12 19:19:59 -05:00
6aa4ace8e2 Merge pull request #275 from mehdijahann/main
Add FA(Persian) language
2023-02-12 19:19:14 -05:00
d762467a31 Update FA.json 2023-02-13 08:16:06 +09:00
b07cce8ecd Create FA.json 2023-02-13 07:07:30 +09:00
8002a946b2 Merge pull request #273 from ImranR98/dev
Reverse changes related to App ID changes (#270)
2023-02-12 14:37:21 -05:00
fd9aebc5b2 Reverse changes related to App ID changes (#270) 2023-02-12 14:36:54 -05:00
1be38d361f Merge pull request #272 from ImranR98/dev
No longer blocking App ID changes in updates
2023-02-12 14:20:04 -05:00
32c40ae7b3 No longer blocking App ID changes in updates 2023-02-12 14:16:19 -05:00
07223d81c7 Merge pull request #268 from gidano/main
Updated hu.json
2023-02-11 12:02:41 -05:00
78baee7265 Updated hu.json 2023-02-11 11:41:52 +01:00
348c33dfe9 Merge pull request #266 from rollingmoai/patch-1
Add installation badges
2023-02-10 20:36:38 -05:00
c408d70ae6 Merge pull request #260 from atilluF/ita
Update Italian translation
2023-02-10 20:23:13 -05:00
3ae4e7cc8a Merge pull request #259 from bluefly000/japanese-translation
Update Japanese translation
2023-02-10 20:23:06 -05:00
dab0f2bb72 Add installation badges 2023-02-09 22:13:16 +08:00
4baf6bcd3b Update it.json 2023-02-05 12:10:59 +01:00
db4517aa13 Update Japanese translation 2023-02-05 12:23:30 +09:00
55d4d1f978 Merge pull request #258 from ImranR98/dev
Removed unused commented code
2023-02-04 20:06:57 -05:00
f89ac5965f Removed unused commented code 2023-02-04 20:06:22 -05:00
d5ebaa161f Merge pull request #257 from ImranR98/dev
Remove unused class
2023-02-04 20:05:39 -05:00
a4c014a8bf Remove unused class 2023-02-04 20:05:20 -05:00
bbaa42fb01 Merge pull request #256 from ImranR98/dev
Bugfixes (#254, #252), App Uninstall and Settings Feature (#257)
2023-02-04 19:46:24 -05:00
4fe311bc03 Update packages, increment version 2023-02-04 19:44:18 -05:00
ea68b97ff7 Mark updated feature more clear 2023-02-04 19:30:41 -05:00
6e0f6b528e Added App settings button 2023-02-04 19:11:28 -05:00
a2c227931e Added uninstall option 2023-02-04 18:58:14 -05:00
15ad3bb439 Removed unnecessary repetitive log 2023-02-04 17:21:39 -05:00
b03d7fba1a Fix permission error on Android 10 #252 2023-02-04 17:07:59 -05:00
31c491d7c5 Fix prev. commit. 2023-02-04 16:50:33 -05:00
71c80f11f5 'Fix' for GlobalKey error #254 2023-02-04 12:30:34 -05:00
eef4d33431 Merge pull request #246 from ImranR98/dev
Bugfixes for #242 and #245 + Various UI Improvements
2023-01-29 17:35:18 -05:00
d56342e907 Merge pull request #243 from bluefly000/japanese-translation
Update Japanese translation
2023-01-29 17:32:09 -05:00
c72c0fdb57 Increment version 2023-01-29 17:31:19 -05:00
ffe29009ed URL select modal now works when tapping text 2023-01-29 17:29:41 -05:00
60e3b68ebd Search allows option changes (no direct add) 2023-01-29 17:23:35 -05:00
ee4d0f259f Generated form bugfix (initState not running) - #245 2023-01-29 17:07:11 -05:00
0ecfbef0a0 Update Japanese translation 2023-01-29 17:28:54 +09:00
1b60e75ca7 Added delay after Obtainium install prompt 2023-01-28 20:59:17 -05:00
abcfa389e8 Merge pull request #241 from ImranR98/dev
Updated screenshots
2023-01-28 00:47:26 -05:00
a64bd67ef1 Updated screenshots 2023-01-28 00:46:54 -05:00
4252c2711b Merge pull request #240 from ImranR98/dev
APK RegEx Filter, Increased GitHub/Codeberg Release Range, UI Tweaks
Addresses #237, #238.
2023-01-28 00:17:10 -05:00
52913b0450 Slight UI tweak 2023-01-28 00:15:52 -05:00
427b0ed8d2 Changed a string 2023-01-28 00:13:03 -05:00
a85d6d4f08 Increment version, remove comment 2023-01-28 00:11:40 -05:00
05f712603c GitHub & Codeberg - get first 100 releases (not 30) 2023-01-28 00:08:17 -05:00
fa2a80e34c APK RegEx Filter + Updated Packages 2023-01-28 00:04:57 -05:00
f43e5a2ff1 Merge pull request #235 from ImranR98/dev
Increment version, update packages
2023-01-22 19:55:35 -05:00
b72aa8273e Increment version, update packages 2023-01-22 19:55:14 -05:00
520f186e4a Merge pull request #234 from p1gp1g/themed-icon
Add themed icon for Android 13
2023-01-22 19:53:43 -05:00
sim
e1e97672cf Add themed icon for Android 13 2023-01-23 01:09:14 +01:00
1494bcd013 Merge pull request #232 from ImranR98/dev
GitHub (and Codeberg) bugfix (#231)
2023-01-20 12:49:35 -05:00
3457a0a12f GitHub (and Codeberg) bugfix (#231) 2023-01-20 12:48:55 -05:00
b165400a6e Merge pull request #229 from ImranR98/dev
Increment version
2023-01-15 11:45:05 -05:00
c47bf937f1 Increment version 2023-01-15 11:44:45 -05:00
2e19a8c04c Merge pull request #228 from gidano/main
Update hu.json
2023-01-15 11:42:28 -05:00
05d4da86ec Update hu.json 2023-01-15 17:39:57 +01:00
e9d1b04d54 Merge pull request #227 from ImranR98/dev
Increment version, upgrade packages
2023-01-15 11:20:45 -05:00
cff5334c25 Increment version, upgrade packages 2023-01-15 11:20:30 -05:00
a55346fc22 Merge pull request #226 from bluefly000/japanese-translation
Update Japanese translation
2023-01-15 11:19:01 -05:00
885df678e5 Update Japanese translation 2023-01-13 13:34:57 +09:00
bf7b0c5702 Merge pull request #225 from ImranR98/dev
2 New Sources: Codeberg and HTML Fallback
2023-01-12 22:33:50 -05:00
2972da4609 Upgraded packages 2023-01-12 22:28:47 -05:00
b8567af98e Increment version 2023-01-12 22:24:52 -05:00
ea62c68b40 Added the HTML fallback Source 2023-01-12 22:23:53 -05:00
08a5af0449 Added Codeberg as a Source + search UI bugfix 2023-01-12 20:57:53 -05:00
36f327c16e Merge pull request #220 from ImranR98/dev
- Obtainium would skip installing APKs that had the same [`versionCode`](https://developer.android.com/studio/publish/versioning#versioningsettings) since this number should be different for each new build of an App.
    - However, there are enough Apps that don't do this (#149, #219) so Obtainium now installs updates even if the `versionCode` has not changed.
- The GitHub release title filter has also been improved so that it filters by `tag_name` instead of `title` for releases with empty titles (as it seems like GitHub automatically displays the tag as the title in such cases).
2023-01-07 16:58:58 -05:00
768213cb34 Increment version 2023-01-07 16:50:01 -05:00
e888fb7120 Don't skip installing same-versionCode updates 2023-01-07 16:49:38 -05:00
1fb68dd674 GitHub release filter bugfix 2023-01-07 16:18:26 -05:00
5c4bb8f84c Merge pull request #217 from ImranR98/dev
Bugfixes and UI Tweaks (#213, #215, #216)
2023-01-06 21:11:58 -05:00
1c8e759494 Increment version + updated packages 2023-01-06 21:11:13 -05:00
081c2a07d2 Categories re-added on import (#213) 2023-01-06 21:10:04 -05:00
02751fe8fa Made GitHub PATs hidden (password field) (#215) 2023-01-06 20:57:26 -05:00
95f3362a84 Apps bottom bar tweaks (#216) 2023-01-06 20:47:22 -05:00
b68cf5a1be Increment version 2023-01-02 02:05:35 -05:00
4eb7499591 Merge pull request #211 from RanTranslations/main
assets: Update Simplified Chinese
2023-01-02 02:04:34 -05:00
98fafe2aa4 assets: Update Simplified Chinese 2023-01-02 11:18:27 +08:00
9bac74aadd Icon fixed in readme 2022-12-28 06:42:42 -05:00
0a93117bf0 Merge pull request #208 from ImranR98/dev
Tiny bugfix + increment version
2022-12-28 06:31:42 -05:00
451cc41c45 Tiny bugfix + increment version 2022-12-28 06:30:58 -05:00
3b449d0982 Merge pull request #207 from ImranR98/dev
Categorization Improvements
2022-12-27 22:39:17 -05:00
1863f55372 Increment build num 2022-12-27 22:38:07 -05:00
0c4b8ac79d Made notif icon white for consistency on some OS skins 2022-12-27 22:37:49 -05:00
e287087753 Increment build number 2022-12-27 21:15:06 -05:00
82bcc46d42 Fixed search error on Add App page (#202) 2022-12-27 21:14:11 -05:00
1f26188ec6 Potential fix for rangeError for no URL Apps (#201) 2022-12-27 21:00:46 -05:00
794c3e1a81 Increment version 2022-12-27 20:42:21 -05:00
16369b4adf App page with Webview now on par with no webview
+ ratelimit error bugfix
2022-12-27 20:41:44 -05:00
8f16f745be Added categorize in multi select menu 2022-12-27 20:15:56 -05:00
8ddeb3d776 Apps now support multiple categories 2022-12-27 19:37:13 -05:00
21cf9c98d9 Merge pull request #200 from ImranR98/dev
Fixed export error on Android SDK <= 28
2022-12-25 22:30:47 -05:00
358f910d19 Increment version 2022-12-25 22:30:01 -05:00
7a3d74bd05 Fixed export error on Android SDK <= 28 2022-12-25 22:29:39 -05:00
6f27f64699 Merge pull request #199 from ImranR98/dev
UI improvements
2022-12-25 21:56:36 -05:00
3341fecb68 Increment version 2022-12-25 21:53:26 -05:00
d3bce63ca4 Updated plugins 2022-12-25 21:53:06 -05:00
8aa8b6b698 Added selection count on Apps page 2022-12-25 21:52:21 -05:00
3d6c9bbf98 Added category multi-select to Apps filter
+ UI tweaks and bugfixes
2022-12-25 21:41:51 -05:00
7af0a8628c Slightly thicker category color indicator on apps page 2022-12-25 20:31:20 -05:00
4573ce6bcf Added category select to add app page 2022-12-25 20:30:36 -05:00
e29d38fa32 Adding an existing category no longer overwrites it 2022-12-25 20:04:47 -05:00
dc82431235 App page now scrollable when categories overflow 2022-12-25 19:58:58 -05:00
424b0028bf Merge pull request #198 from gidano/main
Update hu.json
2022-12-25 15:36:26 -05:00
46fba9e0a4 Update hu.json 2022-12-25 11:14:15 +01:00
b40be7569b Bugfix (#197) 2022-12-24 23:17:03 -05:00
a173be11eb Merge pull request #193 from ImranR98/dev
Track-only source bugfix +  better http errors
2022-12-23 23:53:08 -05:00
0c97b25d99 Track-only source bugfix + better http errors
+ increment version
2022-12-23 23:52:32 -05:00
f836fd20d8 Increment version 2022-12-22 17:43:08 -05:00
2f6917592d Merge pull request #190 from atilluF/Ita-TL
Update it.json
2022-12-22 17:39:47 -05:00
b864fef3ad Update it.json
New strings + fixes
2022-12-22 22:48:53 +01:00
8e487592b3 Increment version 2022-12-22 11:58:00 -05:00
e9a44746a5 Merge pull request #184 from gidano/main
Updated hu.json
2022-12-22 11:57:16 -05:00
9123737bf3 Merge branch 'main' into main 2022-12-22 14:58:25 +01:00
12f70951c2 Merge pull request #186 from bluefly000/japanese-translation
Update Japanese translation
2022-12-22 08:03:03 -05:00
c1d56f89f0 Merge pull request #187 from markus-gitdev/main
Update DE translation
2022-12-22 08:02:57 -05:00
4dfd29f5de Merge pull request #189 from ImranR98/dev
Bugfixes
2022-12-22 08:02:21 -05:00
226cfa25e0 Increment version 2022-12-22 08:01:52 -05:00
4e0c655538 F-Droid repo URL matching made more general (#188) 2022-12-22 08:01:26 -05:00
45a23e9025 Language fix for #185 2022-12-22 07:57:21 -05:00
1e5aa0999a Update DE translation
Update german translation to match newly added localized strings
2022-12-22 10:21:04 +01:00
beeec356e5 Update Japanese translation 2022-12-22 18:03:20 +09:00
01fa9a2e96 Updated hu.json 2022-12-22 09:18:32 +01:00
0da7a36f1a Merge pull request #183 from ImranR98/dev
Better Category UI + Language Setting
2022-12-22 03:14:43 -05:00
ed2a4e674f Added language setting (mostly working) - #165 2022-12-22 03:13:55 -05:00
0f6a683faa Increment version 2022-12-22 02:26:13 -05:00
fa4d46b622 Bugfix es+ new category picker on App page 2022-12-22 02:13:21 -05:00
a3f9947f28 Finished new category editor (needs to be used) 2022-12-22 01:24:35 -05:00
6977858b99 Started work on new unified category selector/editor 2022-12-21 23:54:36 -05:00
2ff6acb701 Merge pull request #182 from ImranR98/dev
Broke `GeneratedFormItem` into sub-types + bugfix
2022-12-21 18:26:15 -05:00
0c2d6ce84d Increment version 2022-12-21 18:23:55 -05:00
9072862862 Broke GeneratedFormItem into sub-types
Prep for "chips" input type
2022-12-21 18:23:25 -05:00
3cbaac2f5d Increment version 2022-12-21 15:08:18 -05:00
0f8871efcb Merge pull request #179 from bluefly000/japanese-translation
Update Japanese translation
2022-12-21 15:07:33 -05:00
ee216cbbba Merge pull request #181 from ImranR98/dev
Bugfix (#178) + translation typos
2022-12-21 15:07:26 -05:00
ebe5b79dc5 Bugfix (#178) + translation typos 2022-12-21 15:06:54 -05:00
60014c864c Update Japanese translation 2022-12-21 21:48:32 +09:00
070b6033bd Merge pull request #177 from ImranR98/dev
Added very basic categorization support
2022-12-21 04:25:45 -05:00
626bebbe5a Localized new strings 2022-12-21 04:24:17 -05:00
118460ccb9 Added category filter 2022-12-21 04:15:39 -05:00
26f953dbb0 Category displayed on App/Apps pages
+ category save bugfix
2022-12-21 03:57:08 -05:00
99d7595f2d Added category add/remove (no recolour/rename for now) 2022-12-21 03:08:56 -05:00
e2f99c5e71 Increment version 2022-12-20 21:12:50 -05:00
1f582d239b Merge pull request #175 from RanTranslations/main
assests: Update Simplified Chinese
2022-12-20 21:10:48 -05:00
5e6b00718e Merge pull request #176 from bluefly000/main
Update Japanese translation
2022-12-20 21:10:26 -05:00
56594e6b19 Update Japanese translation 2022-12-21 10:44:18 +09:00
bbcc3ff9b3 assests: Update Simplified Chinese 2022-12-21 09:07:31 +08:00
ee66c53320 Updated plugins 2022-12-20 19:06:49 -05:00
b7d581f8b0 GitHub prereleases now not included by default 2022-12-20 18:48:54 -05:00
ead63ba21d Translation typos 2022-12-20 18:41:02 -05:00
c69404363f Increment version 2022-12-20 18:34:47 -05:00
99d0bd2461 Merge pull request #173 from atilluF/main
Fix Italian translation
2022-12-20 18:33:55 -05:00
54efda3eea Merge pull request #171 from markus-gitdev/main
Improving german translation
2022-12-20 18:33:47 -05:00
d76d68329c Merge pull request #174 from ImranR98/dev
"Additional Settings" related code changes for maintainability + other changes
2022-12-20 18:32:46 -05:00
b151eb27e1 Translations + bugfix 2022-12-20 18:19:44 -05:00
6a21045e5b Progress 2022-12-20 18:00:22 -05:00
6aedd9ce37 Update it.json (small fixes) 2022-12-20 18:48:33 +01:00
f319639a99 Merge branch 'ImranR98:main' into main 2022-12-20 08:58:43 +01:00
92e6798809 Update de.json
Abbreviating some texts to provide a better appearance.
2022-12-20 08:56:46 +01:00
9a129d41df Added migration code for additionalData (NOTHING TESTED) 2022-12-19 20:14:54 -05:00
0c2654a226 More fixes to prev commit 2022-12-19 19:58:12 -05:00
afc8e41171 Made defaultvallue part of formitem 2022-12-19 19:48:37 -05:00
1fe9e4f91e Started switching additionaldata to map 2022-12-19 19:34:43 -05:00
dbd6dec0a6 Merge remote-tracking branch 'origin/main' into dev 2022-12-19 16:19:59 -05:00
d068db2a57 Increment version 2022-12-19 16:19:38 -05:00
dd5c5fd2bc Merge pull request #169 from markus-gitdev/main
German translation
2022-12-19 16:12:32 -05:00
ac9dadd9d0 Merge pull request #168 from gidano/main
HU Text correction
2022-12-19 16:11:50 -05:00
bb0540b644 German translation
Initial version of german translation
2022-12-19 10:05:21 +01:00
819334021a HU Text correction 2022-12-19 08:06:59 +01:00
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
63034dd3f9 Added 'no version detection' option 2022-12-18 02:46:25 -05: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
76e98feeb7 Increment version 2022-11-02 20:24:12 -04:00
03da23f77a Addresses #90 2022-11-02 20:23:40 -04:00
9b99e2b302 Addresses #88, #89 2022-11-02 20:07:46 -04:00
e746ca890a Obtainium now installs last (#84) 2022-10-31 17:41:25 -04:00
9c00a7da14 Increment version 2022-10-30 13:09:56 -04:00
4df0dd64ad Addresses #77 (version string overflow) 2022-10-30 13:09:36 -04:00
7cf7ffe0de Fixed icon size on App page (#78) 2022-10-30 12:48:26 -04:00
b1953435af Added progress toasts when adding Apps 2022-10-30 12:44:30 -04:00
fc7d7d11d6 Addresses #79 + other GitHub bugfix 2022-10-30 12:22:32 -04:00
9ef26b3a4a F-Droid bugfixes (#73, #74, #75) + UI tweak 2022-10-29 22:57:21 -04:00
27ee6b9e88 Bugfix: Mass install not working 2022-10-29 18:59:27 -04:00
d1a3529036 Switched to package names as app ids (#69)
* Fixes #14 (although detection is disabled in background processes due to the bug described in #60)
    * Added App icons and basic installed detection
    * Real Package Names Used as IDs + App Icons (INCONVENIENT FOR PREVIOUS VERSION USERS)
    * Switch to using extracted names (no custom names)

* Fixes #57

* Fixes #67

* Fixes #64

* Fixes #61

* Commented out APKMirror and added code to remove their Apps

* Updated README

* Switched to Flutter stable (causes some UI elements to switch back to the old material design style, but this will be fixed in later Flutter releases)

* BG task silently retries on network errors

* Updated screenshots
2022-10-29 13:13:28 -04:00
a954a627fd Fixed 2 issues:
- Rate-limit regression in previous release
- Update notifications not sent when >1 apps have errors
2022-10-11 18:39:53 -04:00
52ce5b19c4 More informative errors for mass update checking 2022-10-11 11:53:20 -04:00
03f0b6cf05 Fixed sort order (was reversed asc/desc)
Also changed default sort to nameAuthor ascending
2022-10-09 15:26:51 -04:00
5d8d0de8de Slightly more efficient JSON importing (tiny difference) 2022-10-08 17:31:08 -04:00
07f6d4ad2c Fixed custom App name issue 2022-10-08 12:26:08 -04:00
dfbb4e19a5 Added more Mass App Actions 2022-10-07 21:15:19 -04:00
f5fda2ca90 Updated some plugins 2022-10-07 19:23:25 -04:00
661dc1626c Increment version 2022-10-07 19:08:24 -04:00
dde3fc20fb Back to old install plugin (dealbreaker in new one) 2022-10-07 19:06:02 -04:00
017b867d8d Added APKMirror (Phew!) 2022-10-07 17:24:45 -04:00
1cb1c124eb UI Tweak 2022-10-07 13:02:25 -04:00
fdeb852c7b More changelog urls added 2022-10-07 12:58:10 -04:00
67f50ba776 Added 'See Changes' button in app list (GitHub only) 2022-10-07 12:51:53 -04:00
a0968caa5c Tweaked update checking, fixed an issue on App page 2022-10-07 12:22:16 -04:00
e3e945d13b Bugfix - Obtainium doesn't update with other Apps 2022-10-01 00:29:15 -04:00
61f7f171b1 Upgraded a package 2022-09-30 23:23:23 -04:00
de07583161 Fixed issue with backgorund task not starting 2022-09-30 23:21:35 -04:00
49b9a65053 Updated version 2022-09-30 15:37:32 -04:00
aebc8aed76 Clearer GitHub PAT instructions 2022-09-30 15:33:24 -04:00
3958425c22 Removed outdated comment 2022-09-29 23:28:49 -04:00
0a560871cb Fixed update checking on App page 2022-09-29 23:20:57 -04:00
fbe4f0b49e Added GitHub PAT support 2022-09-29 21:27:54 -04:00
e2440a38c4 App name now editable on App page 2022-09-29 16:45:24 -04:00
496a10a444 Added pull-to-refresh on App page when no webpage shown 2022-09-29 16:35:16 -04:00
b8bb8d1f4b Bugfix for F-Droid URL parsing 2022-09-29 10:15:57 -04:00
af033f42cb Updated modules 2022-09-28 22:43:24 -04:00
e706661062 Added URL selection menu for mass imports 2022-09-28 22:33:55 -04:00
1a68b8abe6 Improved GitHub starred import + other tweaks 2022-09-28 21:36:21 -04:00
15c0ed04d1 BG Updates *should* work now 2022-09-28 21:17:42 -04:00
dd193d62f2 Update checking improvements (#38)
Still no auto retry for rate-limit. Instead, rate-limit errors are ignored and the unchecked Apps have to wait until the next cycle. Even this needs more testing before release.
2022-09-27 23:20:39 -04:00
77e1768f3b Bugfix 2022-09-25 11:46:25 -04:00
da9e5aed5e Apps page UI improvements 2022-09-25 11:32:57 -04:00
136628c9e6 Removed an unused import 2022-09-25 03:22:22 -04:00
a916167be3 Added basic SourceForge support 2022-09-25 03:21:57 -04:00
420cf487d4 Basic custom App name support (only when adding) 2022-09-25 02:39:41 -04:00
12855370b0 Merge pull request #31 from ImranR98/apps-list-improvements
Added
- Multi select on the Apps page with share, delete, and install actions - #23 
- (Related to above) Ability to filter and update all out of date Apps - #27 
- Notifying users to return to the App to complete installs is less buggy thanks to the new installer plugin - #24
2022-09-25 02:01:51 -04:00
33fed1cb2f Reduced dependece on fgbg thanks to new install plugin 2022-09-25 01:56:24 -04:00
33238b56a9 Added IconButton tootlips 2022-09-25 01:43:51 -04:00
428c208de4 Added share option, saveApp -> saveApps 2022-09-25 01:41:50 -04:00
9a4b0301be Updated version, standardized quotes, deleted test_page 2022-09-25 00:21:41 -04:00
f58d26524c Done w/ filter and multi select stuff 2022-09-25 00:12:02 -04:00
45e5544c5b Added apps list selection (actions incomplete) 2022-09-24 21:10:29 -04:00
0a9373e65a More work on silent updates (not working in BG) 2022-09-24 18:43:05 -04:00
b65c6e1d41 Bugfixes + started work on silent udates 2022-09-24 15:00:47 -04:00
22dd8253a9 Tiny bugfix with setting visual persistance 2022-09-24 02:49:37 -04:00
18198bbdfe Tiny bugfix in default source-specific options 2022-09-24 02:39:04 -04:00
cf3c86abb8 Updated version 2022-09-24 02:16:58 -04:00
570e376742 Tiny UI tweak 2022-09-24 02:15:08 -04:00
32ae5e8175 Added error reporting on forgeround update check 2022-09-24 02:10:56 -04:00
cbf5057c17 Changed App tile layout 2022-09-24 02:08:21 -04:00
2cfe62142a Added Apps search 2022-09-24 01:57:45 -04:00
d03486fc5d Adds Source-specific options + other changes (#26)
* Started work on dynamic forms

* dynamic form progress (switch doesn't work)

* dynamic forms work

* Gen. form improvements, source specific data (untested)

* Gen form bugfix

* Removed redundant generated modal code

* Added custom validators to gen. forms

* Progress on source options (incomplete), gen form bugfixes

* Tweaks, more

* More

* Progress

* Changed a default

* Additional options done!
2022-09-24 00:36:32 -04:00
224e435bbb Moved App Sources into separate files 2022-09-22 19:35:15 -04:00
90fa0e06ce Fixed App webpage scrolling issue 2022-09-18 13:59:26 -04:00
77 changed files with 10148 additions and 1808 deletions

1
.gitignore vendored
View File

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

View File

@ -1,4 +1,4 @@
# ![Obtainium Icon](./android/app/src/main/res/drawable/ic_notification.png) Obtainium
# ![Obtainium Icon](./assets/graphics/icon_small.png) Obtainium
Get Android App Updates Directly From the Source.
@ -9,13 +9,32 @@ Motivation: [Side Of Burritos - You should use this instead of F-Droid | How to
Currently supported App sources:
- [GitHub](https://github.com/)
- [GitLab](https://gitlab.com/)
- [Codeberg](https://codeberg.org/)
- [F-Droid](https://f-droid.org/)
- [IzzyOnDroid](https://android.izzysoft.de/)
- [Mullvad](https://mullvad.net/en/)
- [Signal](https://signal.org/)
- [SourceForge](https://sourceforge.net/)
- [APKMirror](https://apkmirror.com/) (Track-Only)
- Third Party F-Droid Repos
- Jenkins Jobs
- [Steam](https://store.steampowered.com/mobile)
- [Telegram App](https://telegram.org)
- [VLC](https://www.videolan.org/vlc/download-android.html)
- [Neutron Code](https://neutroncode.com)
- "HTML" (Fallback)
- Any other URL that returns an HTML page with links to APK files (if multiple, the last file alphabetically is picked)
## Installation
[<img src="https://github.com/machiav3lli/oandbackupx/blob/034b226cea5c1b30eb4f6a6f313e4dadcbb0ece4/badge_github.png"
alt="Get it on GitHub"
height="80">](https://github.com/ImranR98/Obtainium/releases)
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png"
alt="Get it on IzzyOnDroid"
height="80">](https://apt.izzysoft.de/fdroid/index/apk/dev.imranr.obtainium)
## Limitations
- App installs are assumed to have succeeded; failures and cancelled installs cannot be detected.
- 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.
@ -23,4 +42,4 @@ Currently supported App sources:
| <img src="./assets/screenshots/1.apps.png" alt="Apps Page" /> | <img src="./assets/screenshots/2.dark_theme.png" alt="Dark Theme" /> | <img src="./assets/screenshots/3.material_you.png" alt="Material You" /> |
| ------------------------------------------------------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------- |
| <img src="./assets/screenshots/4.app.png" alt="App Page" /> | <img src="./assets/screenshots/5.apk_picker.png" alt="Multiple APK Support" /> | <img src="./assets/screenshots/6.apk_install.png" alt="App Installation" /> |
| <img src="./assets/screenshots/4.app.png" alt="App Page" /> | <img src="./assets/screenshots/5.app_opts.png" alt="App Options" /> | <img src="./assets/screenshots/6.app_webview.png" alt="App Web View" /> |

View File

@ -3,7 +3,8 @@
<application
android:label="Obtainium"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:requestLegacyExternalStorage="true">
<activity
android:name=".MainActivity"
android:exported="true"
@ -24,13 +25,49 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action
android:name="com.android_package_installer.content.SESSION_API_PACKAGE_INSTALLED"
android:exported="false"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
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>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="dev.imranr.obtainium"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"/>
</provider>
</application>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29"/>
</manifest>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

View File

@ -0,0 +1,46 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt"
android:viewportWidth="142.129"
android:viewportHeight="142.129"
android:width="503.6066dp"
android:height="503.6066dp">
<group
android:translateX="-30.39437"
android:translateY="-54.68043">
<path
android:pathData="M109.8808 153.22596c-0.73146 -0.38777 -5.00657 -2.75679 -25.032416 -13.87149 -5.57273 -3.09297 -10.93823 -6.06723 -11.92332 -6.60948 -2.23728 -1.23152 -2.58105 -1.53456 -2.58105 -2.27528 0 -0.3879 0.89293 -2.87231 2.98561 -8.30689 1.64209 -4.2644 3.09426 -8.0014 3.22705 -8.30444 0.3024 -0.69008 0.78972 -1.27621 1.26573 -1.52236 0.44558 -0.23042 11.58052 -4.29685 12.14814 -4.43644 0.61355 -0.1509 1.1428 0.13977 1.45487 0.79901 0.14976 0.31638 0.77213 1.94934 1.38303 3.6288 0.6109 1.67945 1.52036 4.16275 2.02104 5.51844 1.14709 3.10604 1.18992 3.54589 0.3912 4.01771 -0.2117 0.12505 -1.58874 0.66539 -3.06009 1.20075 -1.47136 0.53536 -2.87533 1.08982 -3.11993 1.23213 -0.56422 0.32826 -0.64913 0.83523 -0.20815 1.24273 0.17523 0.16193 3.00434 1.77571 6.28691 3.58618 9.174936 5.06035 8.665596 4.83136 9.277626 4.17097 0.29987 -0.32356 5.78141 -14.266 6.09596 -15.50521 0.1344 -0.5295 0.11969 -0.60308 -0.16695 -0.83519 -0.39165 -0.31714 -0.335 -0.33071 -3.93797 0.9431 -3.56937 1.26192 -3.90926 1.28864 -4.38744 0.34488 -0.25108 -0.49556 -4.095796 -11.05481 -4.334456 -11.90432 -0.15438 -0.5495 0.0344 -1.0717 0.49701 -1.37482 0.19228 -0.12598 2.990116 -1.19935 6.217406 -2.38526 4.78924 -1.75986 6.0081 -2.15842 6.63117 -2.16837 0.8037 -0.0128 0.90917 0.0424 15.64514 8.19599 1.02104 0.56495 1.56579 1.15961 1.56579 1.70925 0 0.21814 -3.6538 9.91011 -8.11957 21.53771 -6.2982 16.39877 -8.19916 21.21114 -8.4744 21.45338 -0.46789 0.41179 -0.8512 0.39392 -1.74794 -0.0815z"
android:strokeWidth="0.139">
<aapt:attr
name="android:fillColor">
<gradient
android:startX="76.74697"
android:startY="113.4246"
android:endX="110.6445"
android:endY="152.5006"
android:tileMode="clamp">
<item
android:color="#9B58DC"
android:offset="0" />
<item
android:color="#321C92"
android:offset="1" />
</gradient>
</aapt:attr>
<aapt:attr
name="android:strokeColor">
<gradient
android:startX="76.74697"
android:startY="113.4246"
android:endX="110.6445"
android:endY="152.5006"
android:tileMode="clamp">
<item
android:color="#9B58DC"
android:offset="0" />
<item
android:color="#321C92"
android:offset="1" />
</gradient>
</aapt:attr>
</path>
</group>
</vector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@ -2,4 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 918 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-path path="Android/data/dev.imranr.obtainium/" name="files_root" />
<external-path path="." name="external_storage_root" />
<external-path name="external_files" path="."/>
</paths>

View File

@ -0,0 +1,30 @@
-----BEGIN CERTIFICATE-----
MIIFFjCCAv6gAwIBAgIRAJErCErPDBinU/bWLiWnX1owDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjAwOTA0MDAwMDAw
WhcNMjUwOTE1MTYwMDAwWjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
RW5jcnlwdDELMAkGA1UEAxMCUjMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
AoIBAQC7AhUozPaglNMPEuyNVZLD+ILxmaZ6QoinXSaqtSu5xUyxr45r+XXIo9cP
R5QUVTVXjJ6oojkZ9YI8QqlObvU7wy7bjcCwXPNZOOftz2nwWgsbvsCUJCWH+jdx
sxPnHKzhm+/b5DtFUkWWqcFTzjTIUu61ru2P3mBw4qVUq7ZtDpelQDRrK9O8Zutm
NHz6a4uPVymZ+DAXXbpyb/uBxa3Shlg9F8fnCbvxK/eG3MHacV3URuPMrSXBiLxg
Z3Vms/EY96Jc5lP/Ooi2R6X/ExjqmAl3P51T+c8B5fWmcBcUr2Ok/5mzk53cU6cG
/kiFHaFpriV1uxPMUgP17VGhi9sVAgMBAAGjggEIMIIBBDAOBgNVHQ8BAf8EBAMC
AYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMBIGA1UdEwEB/wQIMAYB
Af8CAQAwHQYDVR0OBBYEFBQusxe3WFbLrlAJQOYfr52LFMLGMB8GA1UdIwQYMBaA
FHm0WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcw
AoYWaHR0cDovL3gxLmkubGVuY3Iub3JnLzAnBgNVHR8EIDAeMBygGqAYhhZodHRw
Oi8veDEuYy5sZW5jci5vcmcvMCIGA1UdIAQbMBkwCAYGZ4EMAQIBMA0GCysGAQQB
gt8TAQEBMA0GCSqGSIb3DQEBCwUAA4ICAQCFyk5HPqP3hUSFvNVneLKYY611TR6W
PTNlclQtgaDqw+34IL9fzLdwALduO/ZelN7kIJ+m74uyA+eitRY8kc607TkC53wl
ikfmZW4/RvTZ8M6UK+5UzhK8jCdLuMGYL6KvzXGRSgi3yLgjewQtCPkIVz6D2QQz
CkcheAmCJ8MqyJu5zlzyZMjAvnnAT45tRAxekrsu94sQ4egdRCnbWSDtY7kh+BIm
lJNXoB1lBMEKIq4QDUOXoRgffuDghje1WrG9ML+Hbisq/yFOGwXD9RiX8F6sw6W4
avAuvDszue5L3sz85K+EC4Y/wFVDNvZo4TYXao6Z0f+lQKc0t8DQYzk1OXVu8rp2
yJMC6alLbBfODALZvYH7n7do1AZls4I9d1P4jnkDrQoxB3UqQ9hVl3LEKQ73xF1O
yK5GhDDX8oVfGKF5u+decIsH4YaTw7mP3GFxJSqv3+0lUFJoi5Lc5da149p90Ids
hCExroL1+7mryIkXPeFM5TgO9r0rvZaBFOvV2z0gp35Z0+L4WPlbuEjN/lxPFin+
HlUjr8gRsI3qfJOQFy/9rKIJR0Y/8Omwt/8oTWgy1mdeHmmjk7j1nYsvC9JSQ6Zv
MldlTTKB3zhThV1+XWYp6rjd5JW1zbVWEkLNxE7GJThEUG3szgBVGP7pSWTUTsqX
nLRbwHOoq7hHwg==
-----END CERTIFICATE-----

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

78
assets/graphics/icon.svg Normal file
View File

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="142.12897mm"
height="142.12897mm"
viewBox="0 0 142.12897 142.12897"
version="1.1"
id="svg5"
xml:space="preserve"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="icon.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="2.4175295"
inkscape:cx="371.03994"
inkscape:cy="273.62644"
inkscape:window-width="2256"
inkscape:window-height="1427"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" /><defs
id="defs2"><linearGradient
inkscape:collect="always"
id="linearGradient3657"><stop
style="stop-color:#9b58dc;stop-opacity:1;"
offset="0"
id="stop3653" /><stop
style="stop-color:#321c92;stop-opacity:1;"
offset="1"
id="stop3655" /></linearGradient><linearGradient
inkscape:collect="always"
id="linearGradient945"><stop
style="stop-color:#9b58dc;stop-opacity:1;"
offset="0"
id="stop941" /><stop
style="stop-color:#321c92;stop-opacity:1;"
offset="1"
id="stop943" /></linearGradient><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient945"
id="linearGradient947"
x1="76.787094"
y1="113.40435"
x2="110.68458"
y2="152.48038"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-0.04012535,0.02025786)" /><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient3657"
id="linearGradient3659"
x1="76.787094"
y1="113.40435"
x2="110.68458"
y2="152.48038"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-0.04012535,0.02025786)" /></defs><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-30.394373,-54.680428)"><path
style="fill:url(#linearGradient3659);fill-opacity:1;stroke:url(#linearGradient947);stroke-width:0.139;stroke-dasharray:none"
d="m 109.8808,153.22596 c -0.73146,-0.38777 -5.00657,-2.75679 -25.032416,-13.87149 -5.57273,-3.09297 -10.93823,-6.06723 -11.92332,-6.60948 -2.23728,-1.23152 -2.58105,-1.53456 -2.58105,-2.27528 0,-0.3879 0.89293,-2.87231 2.98561,-8.30689 1.64209,-4.2644 3.09426,-8.0014 3.22705,-8.30444 0.3024,-0.69008 0.78972,-1.27621 1.26573,-1.52236 0.44558,-0.23042 11.58052,-4.29685 12.14814,-4.43644 0.61355,-0.1509 1.1428,0.13977 1.45487,0.79901 0.14976,0.31638 0.77213,1.94934 1.38303,3.6288 0.6109,1.67945 1.52036,4.16275 2.02104,5.51844 1.14709,3.10604 1.18992,3.54589 0.3912,4.01771 -0.2117,0.12505 -1.58874,0.66539 -3.06009,1.20075 -1.47136,0.53536 -2.87533,1.08982 -3.11993,1.23213 -0.56422,0.32826 -0.64913,0.83523 -0.20815,1.24273 0.17523,0.16193 3.00434,1.77571 6.28691,3.58618 9.174936,5.06035 8.665596,4.83136 9.277626,4.17097 0.29987,-0.32356 5.78141,-14.266 6.09596,-15.50521 0.1344,-0.5295 0.11969,-0.60308 -0.16695,-0.83519 -0.39165,-0.31714 -0.335,-0.33071 -3.93797,0.9431 -3.56937,1.26192 -3.90926,1.28864 -4.38744,0.34488 -0.25108,-0.49556 -4.095796,-11.05481 -4.334456,-11.90432 -0.15438,-0.5495 0.0344,-1.0717 0.49701,-1.37482 0.19228,-0.12598 2.990116,-1.19935 6.217406,-2.38526 4.78924,-1.75986 6.0081,-2.15842 6.63117,-2.16837 0.8037,-0.0128 0.90917,0.0424 15.64514,8.19599 1.02104,0.56495 1.56579,1.15961 1.56579,1.70925 0,0.21814 -3.6538,9.91011 -8.11957,21.53771 -6.2982,16.39877 -8.19916,21.21114 -8.4744,21.45338 -0.46789,0.41179 -0.8512,0.39392 -1.74794,-0.0815 z"
id="path239" /></g></svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

279
assets/translations/de.json Normal file
View File

@ -0,0 +1,279 @@
{
"invalidURLForSource": "Keine gültige {} App-URL",
"noReleaseFound": "Keine passende Version gefunden",
"noVersionFound": "Release-Version nicht ermittelbar",
"urlMatchesNoSource": "URL stimmt mit keiner bekannten Quelle überein",
"cantInstallOlderVersion": "Installation einer älteren App-Version nicht möglich",
"appIdMismatch": "Die heruntergeladene Paket-ID stimmt nicht mit der vorhandenen App-ID überein",
"functionNotImplemented": "Diese Klasse hat diese Funktion nicht implementiert",
"placeholder": "Platzhalter",
"someErrors": "Es traten einige Fehler auf",
"unexpectedError": "Unerwarteter Fehler",
"ok": "Okay",
"and": "und",
"startedBgUpdateTask": "Hintergrundaktualisierungsprüfung gestartet",
"bgUpdateIgnoreAfterIs": "Hintergrundaktualisierung 'ignoreAfter' ist {}",
"startedActualBGUpdateCheck": "Überprüfung der Hintergrundaktualisierung gestartet",
"bgUpdateTaskFinished": "Hintergrundaktualisierungsprüfung abgeschlossen",
"firstRun": "Dies ist der erste Start von Obtainium überhaupt",
"settingUpdateCheckIntervalTo": "Aktualisierungsintervall auf {} stellen",
"githubPATLabel": "GitHub Personal Access Token (Erhöht das Ratenlimit)",
"githubPATHint": "PAT muss in diesem Format sein: Benutzername:Token",
"githubPATFormat": "Benutzername:Token",
"githubPATLinkText": "Über GitHub PATs",
"includePrereleases": "Vorabversionen einbeziehen",
"fallbackToOlderReleases": "Fallback auf ältere Versionen",
"filterReleaseTitlesByRegEx": "Release-Titel nach regulärem Ausdruck\nfiltern",
"invalidRegEx": "Ungültiger regulärer Ausdruck",
"noDescription": "Keine Beschreibung",
"cancel": "Abbrechen",
"continue": "Weiter",
"requiredInBrackets": "(Benötigt)",
"dropdownNoOptsError": "FEHLER: DROPDOWN MUSS MINDESTENS EINE OPTION HABEN",
"colour": "Farbe",
"githubStarredRepos": "GitHub Starred Repos",
"uname": "Benutzername",
"wrongArgNum": "Falsche Anzahl von Argumenten übermittelt",
"xIsTrackOnly": "{} ist nur zur Nachverfolgung",
"source": "Quelle",
"app": "App",
"appsFromSourceAreTrackOnly": "Apps aus dieser Quelle sind 'Nur Nachverfolgen'.",
"youPickedTrackOnly": "Sie haben die Option 'Nur Nachverfolgen' gewählt.",
"trackOnlyAppDescription": "Die App wird auf Updates überwacht, aber Obtainium wird sie nicht herunterladen oder installieren.",
"cancelled": "Abgebrochen",
"appAlreadyAdded": "App bereits hinzugefügt",
"alreadyUpToDateQuestion": "App bereits auf dem neuesten Stand?",
"addApp": "App hinzufügen",
"appSourceURL": "Quell-URL der App",
"error": "Fehler",
"add": "Hinzufügen",
"searchSomeSourcesLabel": "Suche (nur bestimmte Quellen)",
"search": "Suchen",
"additionalOptsFor": "Zusatzoptionen für {}",
"supportedSourcesBelow": "Unterstützte Quellen:",
"trackOnlyInBrackets": "(Nur Nachverfolgen)",
"searchableInBrackets": "(Durchsuchbar)",
"appsString": "Apps",
"noApps": "Keine Apps",
"noAppsForFilter": "Keine Apps für ausgewählten Filter",
"byX": "Von {}",
"percentProgress": "Fortschritt: {}%",
"pleaseWait": "Bitte warten",
"updateAvailable": "Aktualisierung verfügbar",
"estimateInBracketsShort": "(ca.)",
"notInstalled": "Nicht installiert",
"estimateInBrackets": "(Ungefähr)",
"selectAll": "Alle auswählen",
"deselectN": "{} abgewählt",
"xWillBeRemovedButRemainInstalled": "{} wird aus Obtainium entfernt, bleibt aber auf dem Gerät installiert.",
"removeSelectedAppsQuestion": "Ausgewählte Apps entfernen?",
"removeSelectedApps": "Ausgewählte Apps entfernen",
"updateX": "Aktualisiere {}",
"installX": "Installiere {}",
"markXTrackOnlyAsUpdated": "Markiere {}\n(Nur Nachverfolgen)\nals aktualisiert",
"changeX": "Ändern {}",
"installUpdateApps": "Apps installieren/aktualisieren",
"installUpdateSelectedApps": "Ausgewählte Apps installieren/aktualisieren",
"markXSelectedAppsAsUpdated": "Markiere {} ausgewählte Apps als aktuell?",
"no": "Nein",
"yes": "Ja",
"markSelectedAppsUpdated": "Markiere ausgewählte Apps als aktuell",
"pinToTop": "Oben anheften",
"unpinFromTop": "'Oben anheften' aufheben",
"resetInstallStatusForSelectedAppsQuestion": "Installationsstatus für ausgewählte Apps zurücksetzen?",
"installStatusOfXWillBeResetExplanation": "Der Installationsstatus der ausgewählten Apps wird zurückgesetzt. Dies kann hilfreich sein, wenn die in Obtainium angezeigte App-Version aufgrund fehlgeschlagener Aktualisierungen oder anderer Probleme falsch ist.",
"shareSelectedAppURLs": "Ausgewählte App-URLs teilen",
"resetInstallStatus": "Installationsstatus zurücksetzen",
"more": "Mehr",
"removeOutdatedFilter": "App-Filter 'Nicht aktuell' entfernen",
"showOutdatedOnly": "Nur nicht aktuelle Apps anzeigen",
"filter": "Filter",
"filterActive": "Filter *",
"filterApps": "Apps filtern",
"appName": "App Name",
"author": "Autor",
"upToDateApps": "Apps mit aktueller Version",
"nonInstalledApps": "Nicht installierte Apps",
"importExport": "Import/Export",
"settings": "Einstellungen",
"exportedTo": "Exportiert zu {}",
"obtainiumExport": "Obtainium Export",
"invalidInput": "Ungültige Eingabe",
"importedX": "Importiert {}",
"obtainiumImport": "Obtainium Import",
"importFromURLList": "Importieren aus URL-Liste",
"searchQuery": "Suchanfrage",
"appURLList": "App URL-Liste",
"line": "Linie",
"searchX": "Suche {}",
"noResults": "Keine Ergebnisse gefunden",
"importX": "Import {}",
"importedAppsIdDisclaimer": "Importierte Apps werden möglicherweise fälschlicherweise als \"Nicht installiert\" angezeigt. Um dies zu beheben, installieren Sie sie erneut über Obtainium. Dies hat keine Auswirkungen auf App-Daten. Es betrifft nur URL- und Drittanbieter-Importmethoden.",
"importErrors": "Importfehler",
"importedXOfYApps": "{} von {} Apps importiert.",
"followingURLsHadErrors": "Bei folgenden URLs traten Fehler auf:",
"okay": "Okay",
"selectURL": "URL auswählen",
"selectURLs": "URLs auswählen",
"pick": "Auswählen",
"theme": "Theme",
"dark": "Dunkel",
"light": "Hell",
"followSystem": "System folgen",
"obtainium": "Obtainium",
"materialYou": "Material You",
"useBlackTheme": "Use pure black dark theme",
"appSortBy": "App sortieren nach",
"authorName": "Autor/Name",
"nameAuthor": "Name/Autor",
"asAdded": "Wie hinzugefügt",
"appSortOrder": "App Sortierung nach",
"ascending": "Aufsteigend",
"descending": "Absteigend",
"bgUpdateCheckInterval": "Prüfintervall für Hintergrundaktualisierung",
"neverManualOnly": "Nie - nur manuell",
"appearance": "Aussehen",
"showWebInAppView": "Quellwebseite in der App-Ansicht anzeigen",
"pinUpdates": "Apps mit Aktualisierungen oben anheften",
"updates": "Aktualisierungen",
"sourceSpecific": "Quellenspezifisch",
"appSource": "App-Quelle",
"noLogs": "Keine Protokolle",
"appLogs": "App Protokolle",
"close": "Schließen",
"share": "Teilen",
"appNotFound": "App nicht gefunden",
"obtainiumExportHyphenatedLowercase": "obtainium-export",
"pickAnAPK": "APK auswählen",
"appHasMoreThanOnePackage": "{} verfügt über mehr als ein Paket:",
"deviceSupportsXArch": "Ihr Gerät unterstützt die CPU-Architektur {}.",
"deviceSupportsFollowingArchs": "Ihr Gerät unterstützt die folgenden CPU-Architekturen:",
"warning": "Warnung",
"sourceIsXButPackageFromYPrompt": "Die App-Quelle ist '{}', aber das Release-Paket stammt von '{}'. Fortfahren?",
"updatesAvailable": "Aktualisierungen verfügbar",
"updatesAvailableNotifDescription": "Benachrichtigt den Nutzer, dass Aktualisierungen für eine oder mehrere von Obtainium verfolgte Apps verfügbar sind",
"noNewUpdates": "Keine neuen Aktualisierungen.",
"xHasAnUpdate": "{} hat eine Aktualisierung.",
"appsUpdated": "Apps aktualisiert",
"appsUpdatedNotifDescription": "Benachrichtigt den Benutzer, dass Aktualisierungen für eine oder mehrere Apps im Hintergrund durchgeführt wurden",
"xWasUpdatedToY": "{} wurde auf {} aktualisiert.",
"errorCheckingUpdates": "Fehler beim Prüfen auf Aktualisierungen",
"errorCheckingUpdatesNotifDescription": "Eine Benachrichtigung, die angezeigt wird, wenn die Prüfung der Hintergrundaktualisierung fehlschlägt",
"appsRemoved": "Apps entfernt",
"appsRemovedNotifDescription": "Benachrichtigt den Benutzer, dass eine oder mehrere Apps aufgrund von Fehlern beim Laden entfernt wurden",
"xWasRemovedDueToErrorY": "{} wurde aufgrund des folgenden Fehlers entfernt: {}",
"completeAppInstallation": "App Installation abschließen",
"obtainiumMustBeOpenToInstallApps": "Obtainium muss geöffnet sein, um Apps zu installieren",
"completeAppInstallationNotifDescription": "Aufforderung an den Benutzer, zu Obtainium zurückzukehren, um die Installation einer App abzuschließen",
"checkingForUpdates": "Nach Aktualisierungen suchen",
"checkingForUpdatesNotifDescription": "Vorübergehende Benachrichtigung, die bei der Suche nach Aktualisierungen angezeigt wird",
"pleaseAllowInstallPerm": "Bitte erlauben Sie Obtainium die Installation von Apps",
"trackOnly": "Nur Nachverfolgen",
"errorWithHttpStatusCode": "Fehler {}",
"versionCorrectionDisabled": "Versionskorrektur deaktiviert (Plugin scheint nicht zu funktionieren)",
"unknown": "Unbekannt",
"none": "Keine",
"never": "Nie",
"latestVersionX": "Neueste Version: {}",
"installedVersionX": "Installierte Version: {}",
"lastUpdateCheckX": "Letzte Aktualisierungsprüfung: {}",
"remove": "Entfernen",
"yesMarkUpdated": "Ja, als aktualisiert markieren",
"fdroid": "F-Droid Official",
"appIdOrName": "App ID oder Name",
"appWithIdOrNameNotFound": "Es wurde keine App mit dieser ID oder diesem Namen gefunden",
"reposHaveMultipleApps": "Repos können mehrere Apps enthalten",
"fdroidThirdPartyRepo": "F-Droid Third-Party Repo",
"steam": "Steam",
"steamMobile": "Steam Mobile",
"steamChat": "Steam Chat",
"install": "Installieren",
"markInstalled": "Als Installiert markieren",
"update": "Aktualisieren",
"markUpdated": "Als Aktuell markieren",
"additionalOptions": "Zusätzliche Optionen",
"disableVersionDetection": "Versionsermittlung deaktivieren",
"noVersionDetectionExplanation": "Diese Option sollte nur für Apps verwendet werden, bei denen die Versionserkennung nicht korrekt funktioniert.",
"downloadingX": "Lade {} herunter",
"downloadNotifDescription": "Benachrichtigt den Nutzer über den Fortschritt beim Herunterladen einer App",
"noAPKFound": "Keine APK gefunden",
"noVersionDetection": "Keine Versionserkennung",
"categorize": "Kategorisieren",
"categories": "Kategorien",
"category": "Kategorie",
"noCategory": "Keine Kategorie",
"noCategories": "Keine Kategorien",
"deleteCategoriesQuestion": "Kategorien löschen?",
"categoryDeleteWarning": "Alle Apps in gelöschten Kategorien werden auf nicht kategorisiert gesetzt.",
"addCategory": "Kategorie hinzufügen",
"label": "Bezeichnung",
"language": "Sprache",
"copiedToClipboard": "Copied to Clipboard",
"storagePermissionDenied": "Speicherberechtigung verweigert",
"selectedCategorizeWarning": "Dadurch werden alle bestehenden Kategorieeinstellungen für die ausgewählten Apps ersetzt.",
"filterAPKsByRegEx": "APKs nach regulärem Ausdruck filtern",
"removeFromObtainium": "Aus Obtainium entfernen",
"uninstallFromDevice": "Vom Gerät deinstallieren",
"onlyWorksWithNonVersionDetectApps": "Funktioniert nur bei Apps mit deaktivierter Versionserkennung.",
"releaseDateAsVersion": "Veröffentlichungsdatum als Version verwenden",
"releaseDateAsVersionExplanation": "Diese Option sollte nur für Apps verwendet werden, bei denen die Versionserkennung nicht korrekt funktioniert, aber ein Veröffentlichungsdatum verfügbar ist.",
"changes": "Änderungen",
"releaseDate": "Veröffentlichungsdatum",
"importFromURLsInFile": "Importieren von URLs aus Datei ( z.B. OPML)",
"versionDetection": "Versionserkennung",
"standardVersionDetection": "Standardversionserkennung",
"groupByCategory": "Nach Kategorie gruppieren",
"autoApkFilterByArch": "Nach Möglichkeit versuchen, APKs nach CPU-Architektur zu filtern",
"overrideSource": "Override Source",
"dontShowAgain": "Don't show this again",
"dontShowTrackOnlyWarnings": "Don't Show the 'Track-Only' Warning",
"dontShowAPKOriginWarnings": "Don't Show APK Origin Warnings",
"removeAppQuestion": {
"one": "App entfernen?",
"other": "Apps entfernen?"
},
"tooManyRequestsTryAgainInMinutes": {
"one": "Zu viele Anfragen (Rate begrenzt) - versuchen Sie es in {} Minute erneut",
"other": "Zu viele Anfragen (Rate begrenzt) - versuchen Sie es in {} Minuten erneut"
},
"bgUpdateGotErrorRetryInMinutes": {
"one": "Bei der Aktualisierungsprüfung im Hintergrund wurde ein {} festgestellt, eine erneute Prüfung wird in {} Minute geplant",
"other": "Bei der Aktualisierungsprüfung im Hintergrund wurde ein {} festgestellt, eine erneute Prüfung wird in {} Minuten geplant"
},
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
"one": "Hintergrundaktualisierungsprüfung fand {} Aktualisierung - benachrichtigt den Benutzer, falls erforderlich",
"other": "Hintergrundaktualisierungsprüfung fand {} Aktualisierungen - benachrichtigt den Benutzer, falls erforderlich"
},
"apps": {
"one": "{} App",
"other": "{} Apps"
},
"url": {
"one": "{} URL",
"other": "{} URLs"
},
"minute": {
"one": "{} Minute",
"other": "{} Minutes"
},
"hour": {
"one": "{} Stunde",
"other": "{} Stunden"
},
"day": {
"one": "{} Tag",
"other": "{} Tage"
},
"clearedNLogsBeforeXAfterY": {
"one": "{n} Protokoll gelöscht (vorher = {vorher}, nachher = {nachher})",
"other": "{n} Protokolle gelöscht (vorher = {vorher}, nachher = {nachher})"
},
"xAndNMoreUpdatesAvailable": {
"one": "{} und 1 weitere App haben Aktualisierungen.",
"other": "{} und {} weitere Apps haben Aktualisierungen."
},
"xAndNMoreUpdatesInstalled": {
"one": "{} und 1 weitere Anwendung wurden aktualisiert.",
"other": "{} und {} weitere Anwendungen wurden aktualisiert."
}
}

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

@ -0,0 +1,279 @@
{
"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",
"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",
"useBlackTheme": "Use pure black dark theme",
"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": "Updates",
"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",
"yesMarkUpdated": "Yes, Mark as Updated",
"fdroid": "F-Droid Official",
"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",
"install": "Install",
"markInstalled": "Mark Installed",
"update": "Update",
"markUpdated": "Mark Updated",
"additionalOptions": "Additional Options",
"disableVersionDetection": "Disable Version Detection",
"noVersionDetectionExplanation": "This option should only be used for Apps where version detection does not work correctly.",
"downloadingX": "Downloading {}",
"downloadNotifDescription": "Notifies the user of the progress in downloading an App",
"noAPKFound": "No APK found",
"noVersionDetection": "No version detection",
"categorize": "Categorize",
"categories": "Categories",
"category": "Category",
"noCategory": "No Category",
"noCategories": "No Categories",
"deleteCategoriesQuestion": "Delete Categories?",
"categoryDeleteWarning": "All Apps in deleted categories will be set to uncategorized.",
"addCategory": "Add Category",
"label": "Label",
"language": "Language",
"copiedToClipboard": "Copied to Clipboard",
"storagePermissionDenied": "Storage permission denied",
"selectedCategorizeWarning": "This will replace any existing category settings for the selected Apps.",
"filterAPKsByRegEx": "Filter APKs by Regular Expression",
"removeFromObtainium": "Remove from Obtainium",
"uninstallFromDevice": "Uninstall from Device",
"onlyWorksWithNonVersionDetectApps": "Only works for Apps with version detection disabled.",
"releaseDateAsVersion": "Use Release Date as Version",
"releaseDateAsVersionExplanation": "This option should only be used for Apps where version detection does not work correctly, but a release date is available.",
"changes": "Changes",
"releaseDate": "Release Date",
"importFromURLsInFile": "Import from URLs in File (like OPML)",
"versionDetection": "Version Detection",
"standardVersionDetection": "Standard version detection",
"groupByCategory": "Group by Category",
"autoApkFilterByArch": "Attempt to filter APKs by CPU architecture if possible",
"overrideSource": "Override Source",
"dontShowAgain": "Don't show this again",
"dontShowTrackOnlyWarnings": "Don't Show 'Track-Only' Warnings",
"dontShowAPKOriginWarnings": "Don't Show APK Origin Warnings",
"removeAppQuestion": {
"one": "Remove App?",
"other": "Remove Apps?"
},
"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."
}
}

279
assets/translations/es.json Normal file
View File

@ -0,0 +1,279 @@
{
"invalidURLForSource": "URL de la aplicación {} no válida",
"noReleaseFound": "No se ha podido encontrar una versión válida",
"noVersionFound": "No se ha podido determinar la versión de la publicación",
"urlMatchesNoSource": "La URL no coincide con ninguna fuente conocida",
"cantInstallOlderVersion": "No se puede instalar una versión previa de la aplicación",
"appIdMismatch": "La ID del paquete descargado no coincide con la ID de la aplicación instalada",
"functionNotImplemented": "Esta clase no ha implementado esta función",
"placeholder": "Espacio reservado",
"someErrors": "Han ocurrido algunos errores",
"unexpectedError": "Error Inesperado",
"ok": "Correcto",
"and": "y",
"startedBgUpdateTask": "Empezada la tarea de comprobación de actualizaciones en segundo plano",
"bgUpdateIgnoreAfterIs": "El parámetro ignoreAfter de la actualización en segundo plano es {}",
"startedActualBGUpdateCheck": "Ha comenzado la comprobación de actualizaciones en segundo plano",
"bgUpdateTaskFinished": "Ha finalizado la comprobación de actualizaciones en segundo plano",
"firstRun": "Esta es la primera ejecución de Obtainium",
"settingUpdateCheckIntervalTo": "Cambiando intervalo de actualización a {}",
"githubPATLabel": "Token de Acceso Personal de GitHub (Reduce tiempos de espera)",
"githubPATHint": "El TAP debe tener este formato: nombre_de_usuario:token",
"githubPATFormat": "nombre_de_usuario:token",
"githubPATLinkText": "Sobre los TAP de GitHub",
"includePrereleases": "Incluir versiones preliminares",
"fallbackToOlderReleases": "Retorceder a versiones previas",
"filterReleaseTitlesByRegEx": "Filtra Títulos de Versiones mediantes Expresiones Regulares",
"invalidRegEx": "Expresión regular inválida",
"noDescription": "Sin descripción",
"cancel": "Cancelar",
"continue": "Continuar",
"requiredInBrackets": "(Requerido)",
"dropdownNoOptsError": "ERROR: EL DESPLEGABLE DEBE TENER AL MENOS UNA OPCIÓN",
"colour": "Color",
"githubStarredRepos": "Repositorios favoritos de GitHub",
"uname": "Nombre de usuario",
"wrongArgNum": "Número de argumentos provistos inválido",
"xIsTrackOnly": "{} es de 'Solo Seguimiento'",
"source": "Origen",
"app": "Aplicación",
"appsFromSourceAreTrackOnly": "Las aplicaciones de este origen son de 'Solo Seguimiento'.",
"youPickedTrackOnly": "Debes seleccionar la opción de 'Solo Seguimiento'.",
"trackOnlyAppDescription": "Se monitorizará la aplicación en busca de actualizaciones, pero Obtainium no será capaz de descargarla o acutalizarla.",
"cancelled": "Cancelado",
"appAlreadyAdded": "Aplicación ya añadida",
"alreadyUpToDateQuestion": "¿Aplicación ya actualizada?",
"addApp": "Añadir Aplicación",
"appSourceURL": "URL de Origen de la Aplicación",
"error": "Error",
"add": "Añadir",
"searchSomeSourcesLabel": "Buscar (Solo Algunas Fuentes)",
"search": "Buscar",
"additionalOptsFor": "Opciones Adicionales para {}",
"supportedSourcesBelow": "Fuentes Soportadas:",
"trackOnlyInBrackets": "(Solo Seguimiento)",
"searchableInBrackets": "(Soporta Búsquedas)",
"appsString": "Aplicaciones",
"noApps": "Sin Aplicaciones",
"noAppsForFilter": "Sin Aplicaciones para Filtrar",
"byX": "Por {}",
"percentProgress": "Progreso: {}%",
"pleaseWait": "Por favor, espere",
"updateAvailable": "Actualización Disponible",
"estimateInBracketsShort": "(Aprox.)",
"notInstalled": "No Instalado",
"estimateInBrackets": "(Aproximado)",
"selectAll": "Seleccionar Todo",
"deselectN": "Deseleccionar {}",
"xWillBeRemovedButRemainInstalled": "{} será borrada de Obtainium pero continuará instalada en el dispositivo.",
"removeSelectedAppsQuestion": "¿Borrar aplicaciones seleccionadas?",
"removeSelectedApps": "Borrar Aplicaciones Seleccionadas",
"updateX": "Actualizar {}",
"installX": "Instalar {}",
"markXTrackOnlyAsUpdated": "Marcar {}\n(Solo Seguimient)\ncomo Actualizada",
"changeX": "Cambiar {}",
"installUpdateApps": "Instalar/Actualizar Aplicaciones",
"installUpdateSelectedApps": "Instalar/Actualizar Aplicaciones Seleccionadas",
"markXSelectedAppsAsUpdated": "¿Marcar {} Aplicaciones Seleccionadas como Actualizadas?",
"no": "No",
"yes": "Sí",
"markSelectedAppsUpdated": "Marcar Aplicaciones Seleccionadas como Actualizadas",
"pinToTop": "Fijar arriba",
"unpinFromTop": "Desfijar de arriba",
"resetInstallStatusForSelectedAppsQuestion": "¿Restuarar Estado de Instalación para las Aplicaciones Seleccionadas?",
"installStatusOfXWillBeResetExplanation": "El estado de instalación de las aplicaciones seleccionadas será restaurado.\n\nEsto puede ser de utilidad cuando la versión de la aplicación mostrada en Obtainium es incorrecta por actualizaciones fallidas u otros motivos.",
"shareSelectedAppURLs": "Compartir URLs de las Aplicaciones Seleccionadas",
"resetInstallStatus": "Restaurar Estado de Instalación",
"more": "Más",
"removeOutdatedFilter": "Elimiar Filtro de Aplicaciones Desactualizado",
"showOutdatedOnly": "Mostrar solo Aplicaciones Desactualizadas",
"filter": "Filtrar",
"filterActive": "Filtrar *",
"filterApps": "Filtrar Actualizaciones",
"appName": "Nombre de la Aplicación",
"author": "Autor",
"upToDateApps": "Aplicaciones Actualizadas",
"nonInstalledApps": "Aplicaciones No Instaladas",
"importExport": "Importar/Exportar",
"settings": "Ajustes",
"exportedTo": "Exportado a {}",
"obtainiumExport": "Exportar Obtainium",
"invalidInput": "Input incorrecto",
"importedX": "Importado {}",
"obtainiumImport": "Importar Obtainium",
"importFromURLList": "Importar desde lista de URLs",
"searchQuery": "Consulta de Búsqueda",
"appURLList": "Lista de URLs de Aplicaciones",
"line": "Línea",
"searchX": "Buscar {}",
"noResults": "Resultados no encontrados",
"importX": "Importar {}",
"importedAppsIdDisclaimer": "Las Aplicaciones Importadas pueden mostrarse incorrectamente como \"No Instalada\".\nPara arreglar esto, reinstálalas a través de Obtainium.\nEsto no debería afectar a los datos de las aplicaciones.\n\nSolo afecta a las URLs y a los métodos de importación mediante terceros.",
"importErrors": "Import Errors",
"importedXOfYApps": "{} de {} Aplicaciones importadas.",
"followingURLsHadErrors": "Las siguientes URLs tuvieron problemas:",
"okay": "Correcto",
"selectURL": "Seleccionar URL",
"selectURLs": "Seleccionar URLs",
"pick": "Escoger",
"theme": "Tema",
"dark": "Oscuro",
"light": "Claro",
"followSystem": "Seguir al Sistema",
"obtainium": "Obtainium",
"materialYou": "Material You",
"useBlackTheme": "Usar tema oscuro con negros puros",
"appSortBy": "Ordenar Aplicaciones Por",
"authorName": "Autor/Nombre",
"nameAuthor": "Nombre/Autor",
"asAdded": "Según se Añadieron",
"appSortOrder": "Orden de Clasificación de Aplicaciones",
"ascending": "Ascendente",
"descending": "Descendente",
"bgUpdateCheckInterval": "Intervalo de Comprobación de Actualizaciones en Segundo Plano",
"neverManualOnly": "Nunca - Solo Manual",
"appearance": "Apariencia",
"showWebInAppView": "Mostrar Vista de la Web de Origen",
"pinUpdates": "Fijar Actualizaciones en la Parte Superior de la Vista de Aplicaciones",
"updates": "Actualizaciones",
"sourceSpecific": "Fuente Específica",
"appSource": "Fuente de la Aplicación",
"noLogs": "Sin Logs",
"appLogs": "Logs de la Aplicación",
"close": "Cerrar",
"share": "Compartir",
"appNotFound": "Aplicación no encontrada",
"obtainiumExportHyphenatedLowercase": "obtainium-export",
"pickAnAPK": "Elige una APK",
"appHasMoreThanOnePackage": "{} tiene más de un paquete:",
"deviceSupportsXArch": "Tu dispositivo soporta las siguientes arquitecturas de procesador: {}.",
"deviceSupportsFollowingArchs": "Tu dispositivo soporta las siguientes arquitecturas de procesador:",
"warning": "Aviso",
"sourceIsXButPackageFromYPrompt": "La fuente de la aplicación es '{}' pero el paquete de la actualización viene de '{}'. ¿Desea continuar?",
"updatesAvailable": "Actualizaciones Disponibles",
"updatesAvailableNotifDescription": "Notifica al usuario de que hay actualizaciones para una o más aplicaciones monitorizadas por Obtainium",
"noNewUpdates": "No hay nuevas actualizaciones.",
"xHasAnUpdate": "{} tiene una actualización.",
"appsUpdated": "Aplicaciones Actualizadas",
"appsUpdatedNotifDescription": "Notifica al usuario de que una o más aplicaciones han sido actualizadas en segundo plano",
"xWasUpdatedToY": "{} ha sido actualizada a {}.",
"errorCheckingUpdates": "Error Buscando Actualizaciones",
"errorCheckingUpdatesNotifDescription": "Una notificación que muestra cuándo la comprobación de actualizaciones en segundo plano falla",
"appsRemoved": "Aplicaciones Eliminadas",
"appsRemovedNotifDescription": "Notifica al usuario que una o más aplicaciones fueron eliminadas por problemas al cargarlas",
"xWasRemovedDueToErrorY": "{} ha sido eliminada por: {}",
"completeAppInstallation": "Instalación Completa de la Aplicación",
"obtainiumMustBeOpenToInstallApps": "Obtainium debe estar abierta para instalar aplicaciones",
"completeAppInstallationNotifDescription": "Pide al usuario volver a Obtainium para teminar de instalar una aplicación",
"checkingForUpdates": "Buscando Actualizaciones",
"checkingForUpdatesNotifDescription": "Notificación temporal que aparece al buscar actualizaciones",
"pleaseAllowInstallPerm": "Por favor, permite a Obtainium instalar aplicaciones",
"trackOnly": "Solo Seguimiento",
"errorWithHttpStatusCode": "Error {}",
"versionCorrectionDisabled": "Corrección de versiones desactivada (el plugin parece no funcionar)",
"unknown": "Desconocido",
"none": "Ninguno",
"never": "Nunca",
"latestVersionX": "Última Versión: {}",
"installedVersionX": "Versión Instalada: {}",
"lastUpdateCheckX": "Última Comprobación: {}",
"remove": "Eliminar",
"yesMarkUpdated": "Sí, Marcar como Actualizada",
"fdroid": "Repositorio oficial de F-Droid",
"appIdOrName": "ID o Nombre de la Aplicación",
"appWithIdOrNameNotFound": "No se han encontrado aplicaciones con esa ID o nombre",
"reposHaveMultipleApps": "Los repositorios pueden contener varias aplicaciones",
"fdroidThirdPartyRepo": "Rpositorios de terceros de F-Droid",
"steam": "Steam",
"steamMobile": "Steam Mobile",
"steamChat": "Steam Chat",
"install": "Instalar",
"markInstalled": "Marcar como Instalda",
"update": "Actualizar",
"markUpdated": "Marcar como Actualizada",
"additionalOptions": "Opciones Adicionales",
"disableVersionDetection": "Descativar Detección de Versiones",
"noVersionDetectionExplanation": "Esta opción solo se debe usar en aplicaciones en las que la deteción de versiones pueda no funcionar correctamente.",
"downloadingX": "Descargando {}",
"downloadNotifDescription": "Notifica al usuario de progreso de descarga de una aplicación",
"noAPKFound": "APK no encontrada",
"noVersionDetection": "Sin detección de versiones",
"categorize": "Catogorizar",
"categories": "Categorías",
"category": "Categoría",
"noCategory": "Sin Categoría",
"noCategories": "Sin Categorías",
"deleteCategoriesQuestion": "¿Borrar Categorías?",
"categoryDeleteWarning": "Todas las aplicaciones en las categorías borradas serán margadas como 'Sin Categoría'.",
"addCategory": "Añadir Categoría",
"label": "Nombre",
"language": "Idioma",
"copiedToClipboard": "Copiado al Portapapeles",
"storagePermissionDenied": "Permiso de Almacenamiento rechazado",
"selectedCategorizeWarning": "Esto reemplazará cualquier ajuste de categoría para las aplicaicones seleccionadas.",
"filterAPKsByRegEx": "Filtrar APKs mediante Expresiones Regulares",
"removeFromObtainium": "Eliminar de Obtainium",
"uninstallFromDevice": "Desinstalar del Dispositivo",
"onlyWorksWithNonVersionDetectApps": "Solo funciona para aplicaciones con la detección de versiones desactivada.",
"releaseDateAsVersion": "Usar Fecha de Publicación como Versión",
"releaseDateAsVersionExplanation": "Esta opción solo se debería usar con aplicaciones en las que la detección de versiones no funciona pero hay disponible una fecha de publicación.",
"changes": "Cambios",
"releaseDate": "Fecha de Publicación",
"importFromURLsInFile": "Importar de URls en un Archivo (como OPML)",
"versionDetection": "Detección de Versiones",
"standardVersionDetection": "Detección de versiones estándar",
"groupByCategory": "Agrupar por Categoría",
"autoApkFilterByArch": "Tratar de filtrar las APKs mediante arquitecturas de procesador si es posible",
"overrideSource": "Sobrescribir Fuente",
"dontShowAgain": "No mostrar de nuevo",
"dontShowTrackOnlyWarnings": "No mostrar avisos de 'Solo Seguimiento'",
"dontShowAPKOriginWarnings": "No mostrar avisos de las fuentes de las APks",
"removeAppQuestion": {
"one": "¿Eliminar Aplicación?",
"other": "¿Eliminar Aplicaciones?"
},
"tooManyRequestsTryAgainInMinutes": {
"one": "Muchas peticiones (limitado) - prueba de nuevo en {} minuto",
"other": "Muchas peticiones (limitado) - prueba de nuevo en {} minutos"
},
"bgUpdateGotErrorRetryInMinutes": {
"one": "La comprobación de actualizaciones en segundo plano se ha encontrado un {}, se volverá a probar en {} minuto",
"other": "La comprobación de actualizaciones en segundo plano se ha encontrado un {}, se volverá a probar en {} minutos"
},
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
"one": "La comprobación de actualizaciones en segundo plano ha encontrado {} actualización - se notificará al usuario si es necesario",
"other": "La comprobación de actualizaciones en segundo plano ha encontrado {} actualizaciones - se notificará al usuario si es necesario"
},
"apps": {
"one": "{} Aplicación",
"other": "{} Aplicaciones"
},
"url": {
"one": "{} URL",
"other": "{} URLs"
},
"minute": {
"one": "{} Minuto",
"other": "{} Minutos"
},
"hour": {
"one": "{} Hora",
"other": "{} Horas"
},
"day": {
"one": "{} Día",
"other": "{} Días"
},
"clearedNLogsBeforeXAfterY": {
"one": "Borrado {n} log (previo a = {before}, posterior a = {after})",
"other": "Borrados {n} logs (previos a = {before}, posteriores a = {after})"
},
"xAndNMoreUpdatesAvailable": {
"one": "{} y 1 aplicación más tiene actualizaciones.",
"other": "{} y {} aplicaciones más tiene actualizaciones."
},
"xAndNMoreUpdatesInstalled": {
"one": "{} y 1 aplicación más han sido actualizadas.",
"other": "{} y {} aplicaciones más han sido actualizadas."
}
}

279
assets/translations/fa.json Normal file
View File

@ -0,0 +1,279 @@
{
"invalidURLForSource": "آدرس اینترنتی برنامه {} معتبر نیست",
"noReleaseFound": "نسخه مناسبی پیدا نشد",
"noVersionFound": "نمی توان نسخه منتشر شده را تعیین کرد",
"urlMatchesNoSource": "آدرس اینترنتی با منبع شناخته شده مطابقت ندارد",
"cantInstallOlderVersion": "نمی توان نسخه قدیمی یک برنامه را نصب کرد",
"appIdMismatch": "شناسه بسته دانلود شده با شناسه برنامه موجود مطابقت ندارد",
"functionNotImplemented": "این کلاس این تابع را پیاده سازی نکرده است",
"placeholder": "نگهدارنده مکان",
"someErrors": "برخی از خطاها رخ داده است",
"unexpectedError": "خطای غیرمنتظره",
"ok": "باشه",
"and": "و",
"startedBgUpdateTask": "شروع بررسی بروزرسانی BG",
"bgUpdateIgnoreAfterIs": "نادیده گرفتن بروزرسانی BG بعد از {} است",
"startedActualBGUpdateCheck": "بررسی به‌روزرسانی واقعی BG آغاز شد",
"bgUpdateTaskFinished": "کار بررسی به‌روزرسانی BG تمام شد",
"firstRun": "این اولین اجرای Obtainium است",
"settingUpdateCheckIntervalTo": "تنظیم فاصله به‌روزرسانی روی {}",
"githubPATLabel": "توکن دسترسی شخصی گیت هاب(محدودیت نرخ را افزایش میدهد)",
"githubPATHint": "PAT باید در این قالب باشد: username:token",
"githubPATFormat": "username:token",
"githubPATLinkText": "درباره گیتهاب PATs",
"includePrereleases": "شامل نسخه های اولیه",
"fallbackToOlderReleases": "بازگشت به نسخه های قدیمی تر",
"filterReleaseTitlesByRegEx": "عناوین انتشار را با بیان منظم فیلتر کنید",
"invalidRegEx": "عبارت منظم نامعتبر است",
"noDescription": "بدون توضیحات",
"cancel": "لغو",
"continue": "ادامه دهید",
"requiredInBrackets": "(ضروری)",
"dropdownNoOptsError": "خطا: کشویی باید حداقل یک گزینه داشته باشد",
"colour": "رنگ",
"githubStarredRepos": "مخازن ستاره دار گیتهاب",
"uname": "نام کاربری",
"wrongArgNum": "تعداد آرگومان های ارائه شده اشتباه است",
"xIsTrackOnly": "{} فقط ردیابی",
"source": "منبع",
"app": "برنامه",
"appsFromSourceAreTrackOnly": "برنامه‌های این منبع «فقط ردیابی» هستند",
"youPickedTrackOnly": "شما گزینه ی «فقط ردیابی» را انتخاب کرده اید",
"trackOnlyAppDescription": "برنامه برای به روز رسانی ها ردیابی می شود، اما Obtainium قادر به دانلود یا نصب آن نخواهد بود.",
"cancelled": "لغو شد",
"appAlreadyAdded": "برنامه قبلاً اضافه شده است",
"alreadyUpToDateQuestion": "برنامه از قبل به روز شده است؟",
"addApp": "افزودن برنامه",
"appSourceURL": "آدرس اینترنتی منبع برنامه",
"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": "علامت {}\n(فقط ردیابی)\nبروز شده",
"changeX": "تغییر دادن {}",
"installUpdateApps": "نصب/به‌روزرسانی برنامه‌ها",
"installUpdateSelectedApps": "برنامه‌های انتخابی را نصب/به‌روزرسانی کنید",
"markXSelectedAppsAsUpdated": "{} برنامه های انتخابی را به عنوان به روز علامت گذاری کنید؟",
"no": "خیر",
"yes": "بله",
"markSelectedAppsUpdated": "برنامه های انتخاب شده را به عنوان به روز علامت گذاری کنید",
"pinToTop": "پین به بالا",
"unpinFromTop": "برداشتن پین از بالا",
"resetInstallStatusForSelectedAppsQuestion": "وضعیت نصب برنامه‌های انتخابی بازنشانی شود؟",
"installStatusOfXWillBeResetExplanation": "وضعیت نصب برنامه‌های انتخاب‌شده بازنشانی می‌شود.\n\nاگر نسخه برنامه نشان‌داده‌شده در Obtainium به دلیل به‌روزرسانی‌های ناموفق یا مشکلات دیگر نادرست باشد، می‌تواند کمک کند.",
"shareSelectedAppURLs": "اشتراک گذاری آدرس اینترنتی برنامه های انتخاب شده",
"resetInstallStatus": "بازنشانی وضعیت نصب",
"more": "بیشتر",
"removeOutdatedFilter": "فیلتر برنامه قدیمی را حذف کنید",
"showOutdatedOnly": "فقط برنامه های قدیمی را نشان دهید",
"filter": "فیلتر",
"filterActive": "فیلتر *",
"filterApps": "فیلتر کردن برنامه ها",
"appName": "نام برنامه",
"author": "سازنده",
"upToDateApps": "برنامه های به روز",
"nonInstalledApps": "برنامه های نصب نشده",
"importExport": "وادر کردن/صادر کردن",
"settings": "تنظیمات",
"exportedTo": "صادر کردن به{}",
"obtainiumExport": "صادرکردن Obtainium",
"invalidInput": "ورودی نامعتبر",
"importedX": "وارد شده {}",
"obtainiumImport": "واردکردن Obtainium",
"importFromURLList": "وارد کردن از فهرست آدرس اینترنتی",
"searchQuery": "جستجوی سوال",
"appURLList": "فهرست آدرس اینترنتی برنامه",
"line": "خط",
"searchX": "جستجو {}",
"noResults": "نتیجه ای پیدا نشد",
"importX": "وارد کردن {}",
"importedAppsIdDisclaimer": "ممکن است برنامه‌های وارد شده به اشتباه به‌عنوان \"نصب نشده\" نشان داده شوند.\nبرای رفع این مشکل، آنها را دوباره از طریق Obtainium نصب کنید.\nاین نباید روی داده‌های برنامه تأثیر بگذارد.\n\nفقط بر روی آدرس اینترنتی و روش‌های وارد کردن شخص ثالث تأثیر می‌گذارد.",
"importErrors": "خطاهای وارد کردن",
"importedXOfYApps": "{} از {} برنامه وارد شد.",
"followingURLsHadErrors": "آدرس های اینترنتی زیر دارای خطا بودند:",
"okay": "باشه",
"selectURL": "آدرس اینترنتی انتخاب شده",
"selectURLs": "آدرس های اینترنتی انتخاب شده",
"pick": "انتخاب",
"theme": "تم",
"dark": "تاریک",
"light": "روشن",
"followSystem": "هماهنگ با سیستم",
"obtainium": "Obtainium",
"materialYou": "Material You",
"useBlackTheme": "Use pure black dark theme",
"appSortBy": "مرتب سازی برنامه بر اساس",
"authorName": "سازنده/اسم",
"nameAuthor": "اسم/سازنده",
"asAdded": "همانطور که اضافه شد",
"appSortOrder": "ترتیب مرتب سازی برنامه",
"ascending": "صعودی",
"descending": "نزولی",
"bgUpdateCheckInterval": "فاصله بررسی به‌روزرسانی در پس‌زمینه",
"neverManualOnly": "هرگز - فقط دستی",
"appearance": "ظاهر",
"showWebInAppView": "نمایش صفحه وب منبع در نمای برنامه",
"pinUpdates": "به‌روزرسانی‌ها را به نمای بالای برنامه‌ها پین کنید",
"updates": "به روز رسانی ها",
"sourceSpecific": "منبع خاص",
"appSource": "منبع برنامه",
"noLogs": "بدون گزارش",
"appLogs": "گزارش های برنامه",
"close": "بستن",
"share": "اشتراک گذاری",
"appNotFound": "برنامه پیدا نشد",
"obtainiumExportHyphenatedLowercase": "صادر کردن-obtainium",
"pickAnAPK": "یک APK انتخاب کنید",
"appHasMoreThanOnePackage": "{} بیش از یک بسته دارد:",
"deviceSupportsXArch": "دستگاه شما از معماری پردازنده {} پشتیبانی میکند",
"deviceSupportsFollowingArchs": "دستگاه شما از معماری های پردازنده زیر پشتیبانی می کند:",
"warning": "اخطار",
"sourceIsXButPackageFromYPrompt": "منبع برنامه \"{}\" است اما بسته انتشار از \"{}\" آمده است. ادامه هید؟",
"updatesAvailable": "بروزرسانی در دسترس ",
"updatesAvailableNotifDescription": "به کاربر اطلاع می دهد که به روز رسانی برای یک یا چند برنامه ردیابی شده توسط Obtainium در دسترس است",
"noNewUpdates": "به روز رسانی جدیدی وجود ندارد.",
"xHasAnUpdate": "{} یک به روز رسانی دارد.",
"appsUpdated": "برنامه ها به روز شدند",
"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": "حذف",
"yesMarkUpdated": "بله، علامت گذاری به عنوان به روز شده",
"fdroid": "F-Droid Official",
"appIdOrName": "شناسه یا نام برنامه",
"appWithIdOrNameNotFound": "هیچ برنامه ای با آن شناسه یا نام یافت نشد",
"reposHaveMultipleApps": "مخازن ممکن است شامل چندین برنامه باشد",
"fdroidThirdPartyRepo": "مخازن شخص ثالث F-Droid",
"steam": "Steam",
"steamMobile": "Steam Mobile",
"steamChat": "Steam Chat",
"install": "نصب",
"markInstalled": "علامت گذاری به عنوان نصب شده",
"update": "به روز رسانی",
"markUpdated": "علامت گذاری به روز شد",
"additionalOptions": "گزینه های اضافی",
"disableVersionDetection": "غیرفعال کردن تشخیص نسخه",
"noVersionDetectionExplanation": "این گزینه فقط باید برای برنامه هایی استفاده شود که تشخیص نسخه به درستی کار نمی کند.",
"downloadingX": "در حال دانلود {}",
"downloadNotifDescription": "کاربر را از پیشرفت دانلود یک برنامه مطلع می کند",
"noAPKFound": "APK پیدا نشد فایل",
"noVersionDetection": "بدون تشخیص نسخه",
"categorize": "دسته بندی کردن",
"categories": "دسته بندی ها",
"category": "دسته بندی",
"noCategory": "بدون دسته بندی",
"noCategories": "بدون دسته بندی ها",
"deleteCategoriesQuestion": "دسته بندی ها حذف شوند؟",
"categoryDeleteWarning": "همه برنامه‌ها در دسته‌های حذف شده روی دسته‌بندی نشده تنظیم می‌شوند.",
"addCategory": "اضافه کردن دسته",
"label": "برچسب",
"language": "زبان",
"copiedToClipboard": "در کلیپ بورد کپی شد",
"storagePermissionDenied": "مجوز ذخیره سازی رد شد",
"selectedCategorizeWarning": "این جایگزین تنظیمات دسته بندی موجود برای برنامه های انتخابی می شود.",
"filterAPKsByRegEx": "فایل‌های APK را با نظم فیلتر کنید",
"removeFromObtainium": "از Obtainium حذف کنید",
"uninstallFromDevice": "حذف نصب از دستگاه",
"onlyWorksWithNonVersionDetectApps": "فقط برای برنامه‌هایی کار می‌کند که تشخیص نسخه غیرفعال است.",
"releaseDateAsVersion": "از تاریخ انتشار به عنوان نسخه استفاده کنید",
"releaseDateAsVersionExplanation": "این گزینه فقط باید برای برنامه هایی استفاده شود که تشخیص نسخه به درستی کار نمی کند، اما تاریخ انتشار در دسترس است.",
"changes": "تغییرات",
"releaseDate": "تاریخ انتشار",
"importFromURLsInFile": "وارد کردن از آدرس های اینترنتی موجود در فایل (مانند OPML)",
"versionDetection": "تشخیص نسخه",
"standardVersionDetection": "تشخیص نسخه استاندارد",
"groupByCategory": "گروه بر اساس دسته",
"autoApkFilterByArch": "در صورت امکان سعی کنید APKها را بر اساس معماری CPU فیلتر کنید",
"overrideSource": "Override Source",
"dontShowAgain": "Don't show this again",
"dontShowTrackOnlyWarnings": "Don't Show the 'Track-Only' Warning",
"dontShowAPKOriginWarnings": "Don't Show APK Origin Warnings",
"removeAppQuestion": {
"one": "برنامه حذف شود؟",
"other": "برنامه ها حذف شوند؟"
},
"tooManyRequestsTryAgainInMinutes": {
"one": "درخواست‌های بسیار زیاد (نرخ محدود) - {} دقیقه دیگر دوباره امتحان کنید",
"other": "درخواست های بسیار زیاد (نرخ محدود) - بعد از {} دقیقه دوباره امتحان کنید"
},
"bgUpdateGotErrorRetryInMinutes": {
"one": "بررسی به‌روزرسانی BG با یک {} مواجه شد، یک بررسی مجدد را در {} دقیقه برنامه‌ریزی می‌کند",
"other": "بررسی به‌روزرسانی BG با {} مواجه شد، یک بررسی مجدد را در {} دقیقه برنامه‌ریزی می‌کند"
},
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
"one": "بررسی به‌روزرسانی BG پیدا شد {} به‌روزرسانی - در صورت نیاز به کاربر اطلاع می‌دهد",
"other": "بررسی به‌روزرسانی BG {} به‌روزرسانی‌های یافت شده - در صورت نیاز به کاربر اطلاع می‌دهد"
},
"apps": {
"one": "برنامه {}",
"other": "{} برنامه ها"
},
"url": {
"one": "{} آدرس اینترنتی",
"other": "{} آدرس های اینترنتی"
},
"minute": {
"one": "{} دقیقه",
"other": "{} دقیقه"
},
"hour": {
"one": "{} ساعت",
"other": "{} ساعت"
},
"day": {
"one": "{} روز",
"other": "{} روز"
},
"clearedNLogsBeforeXAfterY": {
"one": "گزارش {n} پاک شد (قبل از = {پیش از}، بعد = {بعد})",
"other": "{n} گزارش پاک شد (قبل از = {پیش از}، بعد = {بعد})"
},
"xAndNMoreUpdatesAvailable": {
"one": "{} و 1 برنامه دیگر به‌روزرسانی دارند.",
"other": "{} و {} برنامه دیگر به روز رسانی دارند."
},
"xAndNMoreUpdatesInstalled": {
"one": "{} و 1 برنامه دیگر به روز شدند.",
"other": "{} و {} برنامه دیگر به روز شدند."
}
}

279
assets/translations/fr.json Normal file
View File

@ -0,0 +1,279 @@
{
"invalidURLForSource": "URL d'application {} invalide",
"noReleaseFound": "Impossible de trouver une version appropriée",
"noVersionFound": "Impossible de déterminer la version de la version",
"urlMatchesNoSource": "L'URL ne correspond pas à une source connue",
"cantInstallOlderVersion": "Impossible d'installer une ancienne version d'une application",
"appIdMismatch": "L'ID de paquet téléchargé ne correspond pas à l'ID de l'application existante",
"functionNotImplemented": "Cette classe n'a pas implémenté cette fonction",
"placeholder": "Espace réservé",
"someErrors": "Des erreurs se sont produites",
"unexpectedError": "Erreur inattendue",
"ok": "Okay",
"and": "et",
"startedBgUpdateTask": "Démarrage de la tâche de vérification de mise à jour en arrière-plan",
"bgUpdateIgnoreAfterIs": "Mise à jour en arrière-plan est ignoré après {}",
"startedActualBGUpdateCheck": "Démarrage de la vérification de la mise à jour en arrière-plan",
"bgUpdateTaskFinished": "Tâche de vérification de la mise à jour en arrière-plan terminée",
"firstRun": "Il s'agit de la toute première exécution d'Obtainium",
"settingUpdateCheckIntervalTo": "Définition de l'intervalle de mise à jour sur {}",
"githubPATLabel": "Jeton d'Accès Personnel GitHub (Augmente la limite de débit)",
"githubPATHint": "Le JAP doit être dans ce format : username:token",
"githubPATFormat": "username:token",
"githubPATLinkText": "À propos des JAP GitHub",
"includePrereleases": "Inclure les avant-premières",
"fallbackToOlderReleases": "Retour aux anciennes versions",
"filterReleaseTitlesByRegEx": "Filtrer les titres de version par expression régulière",
"invalidRegEx": "Expression régulière invalide",
"noDescription": "Pas de description",
"cancel": "Annuler",
"continue": "Continuer",
"requiredInBrackets": "(Requis)",
"dropdownNoOptsError": "ERREUR : LE DÉROULEMENT DOIT AVOIR AU MOINS UNE OPT",
"colour": "Couleur",
"githubStarredRepos": "Dépôts étoilés GitHub",
"uname": "Nom d'utilisateur",
"wrongArgNum": "Mauvais nombre d'arguments fournis",
"xIsTrackOnly": "{} est en 'Suivi uniquement'",
"source": "Source",
"app": "Application",
"appsFromSourceAreTrackOnly": "Les applications de cette source sont en 'Suivi uniquement'.",
"youPickedTrackOnly": "Vous avez sélectionné l'option 'Suivi uniquement'.",
"trackOnlyAppDescription": "L'application sera suivie pour les mises à jour, mais Obtainium ne pourra pas la télécharger ou l'installer.",
"cancelled": "Annulé",
"appAlreadyAdded": "Application déjà ajoutée",
"alreadyUpToDateQuestion": "Application déjà à jour ?",
"addApp": "Ajouter une application",
"appSourceURL": "URL de la source de l'application",
"error": "Erreur",
"add": "Ajoutée",
"searchSomeSourcesLabel": "Rechercher (certaines sources uniquement)",
"search": "Rechercher",
"additionalOptsFor": "Options supplémentaires pour {}",
"supportedSourcesBelow": "Sources prises en charge :",
"trackOnlyInBrackets": "(Suivi uniquement)",
"searchableInBrackets": "(Recherchable)",
"appsString": "Applications",
"noApps": "Aucune application",
"noAppsForFilter": "Aucune application pour le filtre",
"byX": "Par {}",
"percentProgress": "Progrès: {}%",
"pleaseWait": "Veuillez patienter",
"updateAvailable": "Mise à jour disponible",
"estimateInBracketsShort": "(Est.)",
"notInstalled": "Pas installé",
"estimateInBrackets": "(Estimation)",
"selectAll": "Tout sélectionner",
"deselectN": "Déselectionner {}",
"xWillBeRemovedButRemainInstalled": "{} sera supprimé d'Obtainium mais restera installé sur l'appareil.",
"removeSelectedAppsQuestion": "Supprimer les applications sélectionnées ?",
"removeSelectedApps": "Supprimer les applications sélectionnées",
"updateX": "Mise à jour {}",
"installX": "Installer {}",
"markXTrackOnlyAsUpdated": "Marquer {}\n(Suivi uniquement)\nas mis à jour",
"changeX": "Changer {}",
"installUpdateApps": "Installer/Mettre à jour les applications",
"installUpdateSelectedApps": "Installer/Mettre à jour les applications sélectionnées",
"markXSelectedAppsAsUpdated": "Marquer {} les applications sélectionnées comme mises à jour ?",
"no": "Non",
"yes": "Oui",
"markSelectedAppsUpdated": "Marquer les applications sélectionnées comme mises à jour",
"pinToTop": "Épingler en haut",
"unpinFromTop": "Détacher du haut",
"resetInstallStatusForSelectedAppsQuestion": "Réinitialiser l'état d'installation des applications sélectionnées ?",
"installStatusOfXWillBeResetExplanation": "L'état d'installation de toutes les applications sélectionnées sera réinitialisé.\n\nCela peut aider lorsque la version de l'application affichée dans Obtainium est incorrecte en raison d'échecs de mises à jour ou d'autres problèmes.",
"shareSelectedAppURLs": "Partager les URL d'application sélectionnées",
"resetInstallStatus": "Réinitialiser le statut d'installation",
"more": "Plus",
"removeOutdatedFilter": "Supprimer le filtre d'application obsolète",
"showOutdatedOnly": "Afficher uniquement les applications obsolètes",
"filter": "Filtre",
"filterActive": "Filtre *",
"filterApps": "Filtrer les applications",
"appName": "Nom de l'application",
"author": "Auteur",
"upToDateApps": "Applications à jour",
"nonInstalledApps": "Applications non installées",
"importExport": "Importer/Exporter",
"settings": "Paramètres",
"exportedTo": "Exporté vers {}",
"obtainiumExport": "Exportation d'Obtainium",
"invalidInput": "Entrée invalide",
"importedX": "Importé {}",
"obtainiumImport": "Importation d'Obtainium",
"importFromURLList": "Importer à partir de la liste d'URL",
"searchQuery": "Requête de recherche",
"appURLList": "Liste d'URL d'application",
"line": "Queue",
"searchX": "Rechercher {}",
"noResults": "Aucun résultat trouvé",
"importX": "Importer {}",
"importedAppsIdDisclaimer": "Les applications importées peuvent s'afficher à tort comme \"Non installées\".\nPour résoudre ce problème, réinstallez-les via Obtainium.\nCela ne devrait pas affecter les données de l'application.\n\nN'affecte que les URL et les méthodes d'importation tierces.",
"importErrors": "Erreurs d'importation",
"importedXOfYApps": "{} sur {} applications importées.",
"followingURLsHadErrors": "Les URL suivantes comportaient des erreurs :",
"okay": "Okay",
"selectURL": "Sélectionnez l'URL",
"selectURLs": "Sélectionnez les URLs",
"pick": "Prendre",
"theme": "Thème",
"dark": "Sombre",
"light": "Clair",
"followSystem": "Suivre le système",
"obtainium": "Obtainium",
"materialYou": "Material You",
"useBlackTheme": "Use pure black dark theme",
"appSortBy": "Applications triées par",
"authorName": "Auteur/Nom",
"nameAuthor": "Nom/Auteur",
"asAdded": "Comme ajouté",
"appSortOrder": "Ordre de tri des applications",
"ascending": "Ascendant",
"descending": "Descendanr",
"bgUpdateCheckInterval": "Intervalle de vérification des mises à jour en arrière-plan",
"neverManualOnly": "Jamais - Manuel uniquement",
"appearance": "Apparence",
"showWebInAppView": "Afficher la page Web source dans la vue de l'application",
"pinUpdates": "Épingler les mises à jour dans la vue Top des applications",
"updates": "Mises à jour",
"sourceSpecific": "Spécifique à la source",
"appSource": "Source de l'application",
"noLogs": "Aucun journal",
"appLogs": "Journaux d'application",
"close": "Fermer",
"share": "Partager",
"appNotFound": "Application introuvable",
"obtainiumExportHyphenatedLowercase": "obtainium-export",
"pickAnAPK": "Choisissez un APK",
"appHasMoreThanOnePackage": "{} a plus d'un paquet :",
"deviceSupportsXArch": "Votre appareil prend en charge l'architecture de processeur {}.",
"deviceSupportsFollowingArchs": "Votre appareil prend en charge les architectures CPU suivantes :",
"warning": "Avertissement",
"sourceIsXButPackageFromYPrompt": "La source de l'application est '{}' mais le paquet de version provient de '{}'. Continuer?",
"updatesAvailable": "Mises à jour disponibles",
"updatesAvailableNotifDescription": "Avertit l'utilisateur que des mises à jour sont disponibles pour une ou plusieurs applications suivies par Obtainium",
"noNewUpdates": "Aucune nouvelle mise à jour.",
"xHasAnUpdate": "{} a une mise à jour.",
"appsUpdated": "Applications mises à jour",
"appsUpdatedNotifDescription": "Avertit l'utilisateur que les mises à jour d'une ou plusieurs applications ont été appliquées en arrière-plan",
"xWasUpdatedToY": "{} a été mis à jour pour {}.",
"errorCheckingUpdates": "Erreur lors de la vérification des mises à jour",
"errorCheckingUpdatesNotifDescription": "Une notification qui s'affiche lorsque la vérification de la mise à jour en arrière-plan échoue",
"appsRemoved": "Applications supprimées",
"appsRemovedNotifDescription": "Avertit l'utilisateur qu'une ou plusieurs applications ont été supprimées en raison d'erreurs lors de leur chargement",
"xWasRemovedDueToErrorY": "{} a été supprimé en raison de cette erreur : {}",
"completeAppInstallation": "Installation complète de l'application",
"obtainiumMustBeOpenToInstallApps": "Obtainium doit être ouvert pour installer des applications",
"completeAppInstallationNotifDescription": "Demande à l'utilisateur de retourner sur Obtainium pour terminer l'installation d'une application",
"checkingForUpdates": "Vérification des mises à jour",
"checkingForUpdatesNotifDescription": "Notification transitoire qui apparaît lors de la recherche de mises à jour",
"pleaseAllowInstallPerm": "Veuillez autoriser Obtainium à installer des applications",
"trackOnly": "Suivi uniquement",
"errorWithHttpStatusCode": "Erreur {}",
"versionCorrectionDisabled": "Correction de version désactivée (le plugin ne semble pas fonctionner)",
"unknown": "Inconnu",
"none": "Aucun",
"never": "Jamais",
"latestVersionX": "Dernière version: {}",
"installedVersionX": "Version installée : {}",
"lastUpdateCheckX": "Vérification de la dernière mise à jour : {}",
"remove": "Retirer",
"yesMarkUpdated": "Oui, marquer comme mis à jour",
"fdroid": "F-Droid Official",
"appIdOrName": "ID ou nom de l'application",
"appWithIdOrNameNotFound": "Aucune application n'a été trouvée avec cet identifiant ou ce nom",
"reposHaveMultipleApps": "Les dépôts peuvent contenir plusieurs applications",
"fdroidThirdPartyRepo": "Dépôt tiers F-Droid",
"steam": "Steam",
"steamMobile": "Steam Mobile",
"steamChat": "Steam Chat",
"install": "Installer",
"markInstalled": "Marquer installée",
"update": "Mettre à jour",
"markUpdated": "Marquer à jour",
"additionalOptions": "Options additionelles",
"disableVersionDetection": "Désactiver la détection de version",
"noVersionDetectionExplanation": "Cette option ne doit être utilisée que pour les applications où la détection de version ne fonctionne pas correctement.",
"downloadingX": "Téléchargement {}",
"downloadNotifDescription": "Avertit l'utilisateur de la progression du téléchargement d'une application",
"noAPKFound": "Aucun APK trouvé",
"noVersionDetection": "Pas de détection de version",
"categorize": "Catégoriser",
"categories": "Catégories",
"category": "Catégorie",
"noCategory": "No Category",
"noCategories": "Aucune catégorie",
"deleteCategoriesQuestion": "Supprimer les catégories ?",
"categoryDeleteWarning": "Toutes les applications dans les catégories supprimées seront définies sur non catégorisées.",
"addCategory": "Ajouter une catégorie",
"label": "Étiquette",
"language": "Langue",
"copiedToClipboard": "Copied to Clipboard",
"storagePermissionDenied": "Autorisation de stockage refusée",
"selectedCategorizeWarning": "Cela remplacera tous les paramètres de catégorie existants pour les applications sélectionnées.",
"filterAPKsByRegEx": "Filtrer les APK par expression régulière",
"removeFromObtainium": "Supprimer d'Obtainium",
"uninstallFromDevice": "Désinstaller de l'appareil",
"onlyWorksWithNonVersionDetectApps": "Fonctionne uniquement pour les applications avec la détection de version désactivée.",
"releaseDateAsVersion": "Utiliser la date de sortie comme version",
"releaseDateAsVersionExplanation": "Cette option ne doit être utilisée que pour les applications où la détection de version ne fonctionne pas correctement, mais une date de sortie est disponible.",
"changes": "Changements",
"releaseDate": "Date de sortie",
"importFromURLsInFile": "Importer à partir d'URL dans un fichier (comme OPML)",
"versionDetection": "Détection des versions",
"standardVersionDetection": "Détection de version standard",
"groupByCategory": "Group by Category",
"autoApkFilterByArch": "Attempt to filter APKs by CPU architecture if possible",
"overrideSource": "Override Source",
"dontShowAgain": "Don't show this again",
"dontShowTrackOnlyWarnings": "Don't Show the 'Track-Only' Warning",
"dontShowAPKOriginWarnings": "Don't Show APK Origin Warnings",
"removeAppQuestion": {
"one": "Supprimer l'application ?",
"other": "Supprimer les applications ?"
},
"tooManyRequestsTryAgainInMinutes": {
"one": "Trop de demandes (taux limité) - réessayez dans {} minute",
"other": "Trop de demandes (taux limité) - réessayez dans {} minutes"
},
"bgUpdateGotErrorRetryInMinutes": {
"one": "La vérification de la mise à jour en arrière-plan a rencontré un {}, planifiera une nouvelle tentative de vérification dans {} minute",
"other": "La vérification de la mise à jour en arrière-plan a rencontré un {}, planifiera une nouvelle tentative de vérification dans {} minutes"
},
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
"one": "La vérification des mises à jour en arrière-plan trouvée {} mise à jour - avertira l'utilisateur si nécessaire",
"other": "La vérification des mises à jour en arrière-plan a trouvé {} mises à jour - avertira l'utilisateur si nécessaire"
},
"apps": {
"one": "{} Application",
"other": "{} Applications"
},
"url": {
"one": "{} URL",
"other": "{} URLs"
},
"minute": {
"one": "{} Minute",
"other": "{} Minutes"
},
"hour": {
"one": "{} Heure",
"other": "{} Heures"
},
"day": {
"one": "{} Jour",
"other": "{} Jours"
},
"clearedNLogsBeforeXAfterY": {
"one": "{n} journal effacé (avant = {before}, après = {after})",
"other": "{n} journaux effacés (avant = {before}, après = {after})"
},
"xAndNMoreUpdatesAvailable": {
"one": "{} et 1 autre application ont des mises à jour.",
"other": "{} et {} autres applications ont des mises à jour."
},
"xAndNMoreUpdatesInstalled": {
"one": "{} et 1 autre application ont été mises à jour.",
"other": "{} et {} autres applications ont été mises à jour."
}
}

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

@ -0,0 +1,278 @@
{
"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 háttérfrissí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 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ötelező)",
"dropdownNoOptsError": "HIBA: A LEDOBÁST LEGALÁBB EGY OPCIÓHOZ KELL RENDELNI",
"colour": "Szín",
"githubStarredRepos": "GitHub Csillagos Repo-k",
"uname": "Felh.név",
"wrongArgNum": "Rossz számú argumentumot adott meg",
"xIsTrackOnly": "A(z) {} csak nyomonkö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": "Fejlesztő: {}",
"percentProgress": "Folyamat: {}%",
"pleaseWait": "Kis türelmet",
"updateAvailable": "Frissítés érhető el",
"estimateInBracketsShort": "(Becsült)",
"notInstalled": "Nem telepített",
"estimateInBrackets": "(Becslés)",
"selectAll": "Mindet kiválaszt",
"deselectN": "Törölje {} kijelölését",
"xWillBeRemovedButRemainInstalled": "A(z) {} el lesz távolítva az Obtainiumból, de továbbra is telepítve marad az eszközön.",
"removeSelectedAppsQuestion": "Eltávolítja a kiválasztott appokat?",
"removeSelectedApps": "Távolítsa el a kiválasztott appokat",
"updateX": "Frissítés: {}",
"installX": "Telepítés: {}",
"markXTrackOnlyAsUpdated": "Jelölje meg: {}\n(Csak nyomon követhető)\nmint Frissített",
"changeX": "Változás {}",
"installUpdateApps": "Appok telepítése/frissítése",
"installUpdateSelectedApps": "Telepítse/frissítse a kiválasztott appokat",
"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 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 app szűrőt",
"showOutdatedOnly": "Csak az elavult appok 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ét",
"light": "Világos",
"followSystem": "Rendszer szerint",
"obtainium": "Obtainium",
"materialYou": "Material You",
"useBlackTheme": "Használjon tiszta fekete sötét témát",
"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és időköze",
"neverManualOnly": "Soha csak manuális",
"appearance": "Megjelenés",
"showWebInAppView": "Forrás megjelenítése az Appok nézetben",
"pinUpdates": "Frissítések kitűzése az App nézet tetejére",
"updates": "Frissítések",
"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": "A(z) {} 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": "A(z) {} frissítést kapott.",
"appsUpdated": "Alkalmazások frissítve",
"appsUpdatedNotifDescription": "Értesíti a felhasználót, hogy egy/több app frissítése megtö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 app telepí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",
"yesMarkUpdated": "Igen, megjelölés frissítettként",
"fdroid": "F-Droid Official",
"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",
"install": "Telepít",
"markInstalled": "Telepítettnek jelöl",
"update": "Frissít",
"markUpdated": "Frissítettnek jelöl",
"additionalOptions": "További lehetőségek",
"disableVersionDetection": "Verzió érzékelés letiltása",
"noVersionDetectionExplanation": "Ezt a beállítást csak olyan alkalmazásoknál szabad használni, ahol a verzióérzékelés nem működik megfelelően.",
"downloadingX": "{} letöltés",
"downloadNotifDescription": "Értesíti a felhasználót az app letöltésének előrehaladásáról",
"noAPKFound": "Nem található APK",
"noVersionDetection": "Nincs verzió érzékelés",
"categorize": "Kategorizálás",
"categories": "Kategóriák",
"category": "Kategória",
"noCategory": "Nincs kategória",
"deleteCategoryQuestion": "Törli a kategóriát?",
"categoryDeleteWarning": "A(z) {} összes app kategorizálatlan állapotba kerül.",
"addCategory": "Új kategória",
"label": "Címke",
"language": "Nyelv",
"copiedToClipboard": "Másolva a vágólapra",
"storagePermissionDenied": "Tárhely engedély megtagadva",
"selectedCategorizeWarning": "Ez felváltja a kiválasztott alkalmazások meglévő kategória-beállításait.",
"filterAPKsByRegEx": "Az APK-k szűrése reguláris kifejezéssel",
"removeFromObtainium": "Eltávolítás az Obtainiumból",
"uninstallFromDevice": "Eltávolítás a készülékről",
"onlyWorksWithNonVersionDetectApps": "Csak azoknál az alkalmazásoknál működik, amelyeknél a verzióérzékelés le van tiltva.",
"releaseDateAsVersion": "Használja a Kiadás dátumát, mint verziót",
"releaseDateAsVersionExplanation": "Ezt a beállítást csak olyan alkalmazásoknál szabad használni, ahol a verzió érzékelése nem működik megfelelően, de elérhető a kiadás dátuma.",
"changes": "Változtatások",
"releaseDate": "Kiadás dátuma",
"importFromURLsInFile": "Importálás fájlban található URL-ből (mint pl. OPML)",
"versionDetection": "Verzió érzékelés",
"standardVersionDetection": "Alapért. verzió érzékelés",
"groupByCategory": "Csoportosítás Kategória alapján",
"autoApkFilterByArch": "Ha lehetséges, próbálja CPU architektúra szerint szűrni az APK-kat",
"overrideSource": "Forrás felülbírálása",
"dontShowAgain": "Ne mutassa ezt újra",
"dontShowTrackOnlyWarnings": "Ne jelenítsen meg 'Csak nyomon követés' figyelmeztetést",
"dontShowAPKOriginWarnings": "Ne jelenítsen meg az APK eredetére vonatkozó figyelmeztetéseket",
"removeAppQuestion": {
"one": "Eltávolítja az alkalmazást?",
"other": "Eltávolítja az alkalmazást?"
},
"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."
}
}

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

@ -0,0 +1,279 @@
{
"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": "Fissato intervallo di aggiornamento a {}",
"githubPATLabel": "GitHub Personal Access Token (diminuisce 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 precedenti",
"filterReleaseTitlesByRegEx": "Filtra release con espressioni regolari",
"invalidRegEx": "Espressione regolare non valida",
"noDescription": "Descrizione assente",
"cancel": "Annulla",
"continue": "Continua",
"requiredInBrackets": "(richiesto)",
"dropdownNoOptsError": "ERRORE: LA TENDINA DEVE AVERE ALMENO UN'OPZIONE",
"colour": "Colore",
"githubStarredRepos": "repository stellati da GitHub",
"uname": "Username",
"wrongArgNum": "Numero di argomenti forniti errato",
"xIsTrackOnly": "{} è in modalità Solo-Monitoraggio",
"source": "Fonte",
"app": "App",
"appsFromSourceAreTrackOnly": "Le App da questa fonte sono in modalità 'Solo-Monitoraggio'.",
"youPickedTrackOnly": "È stata selezionata 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": "L'App è già aggiornata?",
"addApp": "Aggiungi App",
"appSourceURL": "URL della fonte dell'App",
"error": "Errore",
"add": "Aggiungi",
"searchSomeSourcesLabel": "Cerca (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": "Di {}",
"percentProgress": "Progresso: {}%",
"pleaseWait": "In attesa",
"updateAvailable": "Aggiornamento disponibile",
"estimateInBracketsShort": "(prev.)",
"notInstalled": "Non installato",
"estimateInBrackets": "(previsto)",
"selectAll": "Seleziona tutto",
"deselectN": "Deseleziona {}",
"xWillBeRemovedButRemainInstalled": "Verà effettuata la rimozione di {}, ma non la disinstallazione.",
"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 App",
"installUpdateSelectedApps": "Installa/Aggiorna le App selezionate",
"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 non aggiornate",
"showOutdatedOnly": "Mostra solo le App non aggiornate",
"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 su {}",
"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 sistema",
"obtainium": "Obtainium",
"materialYou": "Material You",
"useBlackTheme": "Use pure black dark theme",
"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 pagina web dell'App se selezionata",
"pinUpdates": "Fissa aggiornamenti disponibili in alto",
"updates": "Aggiornamenti",
"sourceSpecific": "Specifiche 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 dispositivo in uso supporta l'architettura {} della CPU.",
"deviceSupportsFollowingArchs": "Il dispositivo in uso 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": "Notifica all'utente che sono disponibili gli aggiornamenti di una o più App monitorate da Obtainium",
"noNewUpdates": "Nessun nuovo aggiornamento.",
"xHasAnUpdate": "Aggiornamento disponibile per {}",
"appsUpdated": "App aggiornate",
"appsUpdatedNotifDescription": "Notifica all'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": "Notifica all'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",
"yesMarkUpdated": "Sì, contrassegna come aggiornato",
"fdroid": "F-Droid Official",
"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 F-Droid di terze parti",
"steam": "Steam",
"steamMobile": "Steam Mobile",
"steamChat": "Steam Chat",
"install": "Installa",
"markInstalled": "Contrassegna come installato",
"update": "Aggiorna",
"markUpdated": "Contrassegna come aggiornato",
"additionalOptions": "Opzioni aggiuntive",
"disableVersionDetection": "Disattiva il rilevamento della versione",
"noVersionDetectionExplanation": "Questa opzione dovrebbe essere usata solo per le App la cui versione non viene rilevata correttamente.",
"downloadingX": "Scaricamento di {} in corso",
"downloadNotifDescription": "Notifica all'utente lo stato di avanzamento del download di un'App",
"noAPKFound": "Nessun APK trovato",
"noVersionDetection": "Disattiva rilevamento di versione",
"categorize": "Aggiungi a categoria",
"categories": "Categorie",
"category": "Categoria",
"noCategory": "Nessuna categoria",
"noCategories": "Nessuna categoria",
"deleteCategoriesQuestion": "Eliminare le categorie?",
"categoryDeleteWarning": "Tutte le App nelle categorie eliminate saranno impostate come non categorizzate.",
"addCategory": "Aggiungi categoria",
"label": "Etichetta",
"language": "Lingua",
"copiedToClipboard": "Copiato negli appunti",
"storagePermissionDenied": "Accesso ai file non autorizzato",
"selectedCategorizeWarning": "Ciò sostituirà le impostazioni di categoria esistenti per le App selezionate.",
"filterAPKsByRegEx": "Filtra file APK con espressioni regolari",
"removeFromObtainium": "Rimuovi da Obtainium",
"uninstallFromDevice": "Disinstalla dal dispositivo",
"onlyWorksWithNonVersionDetectApps": "Funziona solo per le App con il rilevamento della versione disattivato.",
"releaseDateAsVersion": "Usa data di rilascio come versione",
"releaseDateAsVersionExplanation": "Questa opzione dovrebbe essere usata solo per le App in cui il rilevamento della versione non funziona correttamente, ma è disponibile una data di rilascio.",
"changes": "Novità",
"releaseDate": "Data di rilascio",
"importFromURLsInFile": "Importa da URL in file (come OPML)",
"versionDetection": "Rilevamento di versione",
"standardVersionDetection": "Rilevamento di versione standard",
"groupByCategory": "Raggruppa per categoria",
"autoApkFilterByArch": "Tenta di filtrare gli APK in base all'architettura della CPU, se possibile",
"overrideSource": "Override Source",
"dontShowAgain": "Don't show this again",
"dontShowTrackOnlyWarnings": "Don't Show the 'Track-Only' Warning",
"dontShowAPKOriginWarnings": "Don't Show APK Origin Warnings",
"removeAppQuestion": {
"one": "Rimuovere l'App?",
"other": "Rimuovere le App?"
},
"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 {}, nuovo tentativo tra {} minuto",
"other": "Il controllo degli aggiornamenti in background ha incontrato un {}, nuovo tentativo tra {} minuti"
},
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
"one": "Il controllo degli aggiornamenti in background ha trovato {} aggiornamento - notificherà l'utente se necessario",
"other": "Il controllo degli aggiornamenti in background ha trovato {} aggiornamenti - notificherà l'utente 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."
}
}

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

@ -0,0 +1,279 @@
{
"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つのオプションが必要です",
"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": "選択したアプリのインストール/アップデート",
"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",
"useBlackTheme": "Use pure black dark theme",
"appSortBy": "アプリの並び方",
"authorName": "作者名/アプリ名",
"nameAuthor": "アプリ名/作者名",
"asAdded": "追加順",
"appSortOrder": "並び順",
"ascending": "昇順",
"descending": "降順",
"bgUpdateCheckInterval": "バックグラウンドでのアップデート確認の間隔",
"neverManualOnly": "手動",
"appearance": "外観",
"showWebInAppView": "アプリページにソースのWebページを表示する",
"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": "しない",
"latestVersionX": "最新のバージョン: {}",
"installedVersionX": "インストールされたバージョン: {}",
"lastUpdateCheckX": "最終アップデート確認: {}",
"remove": "削除",
"yesMarkUpdated": "はい、アップデート済みとしてマークします",
"fdroid": "F-Droid Official",
"appIdOrName": "アプリのIDまたは名前",
"appWithIdOrNameNotFound": "そのIDや名前を持つアプリは見つかりませんでした",
"reposHaveMultipleApps": "リポジトリには複数のアプリが含まれることがあります",
"fdroidThirdPartyRepo": "F-Droid サードパーティリポジトリ",
"steam": "Steam",
"steamMobile": "Steam Mobile",
"steamChat": "Steam Chat",
"install": "インストール",
"markInstalled": "インストール済みとしてマークする",
"update": "アップデート",
"markUpdated": "アップデート済みとしてマークする",
"additionalOptions": "追加のオプション",
"disableVersionDetection": "バージョン検出を無効にする",
"noVersionDetectionExplanation": "このオプションは、バージョン検出が正しく機能しないアプリにのみ使用する必要があります。",
"downloadingX": "{} をダウンロード中",
"downloadNotifDescription": "アプリのダウンロード状況を通知する",
"noAPKFound": "APKが見つかりません",
"noVersionDetection": "バージョン検出を行わない",
"categorize": "カテゴライズ",
"categories": "カテゴリ",
"category": "カテゴリ",
"noCategory": "カテゴリなし",
"noCategories": "カテゴリなし",
"deleteCategoriesQuestion": "カテゴリを削除しますか?",
"categoryDeleteWarning": "削除されたカテゴリ内のアプリは未分類に設定されます。",
"addCategory": "カテゴリを追加",
"label": "ラベル",
"language": "言語",
"copiedToClipboard": "クリップボードにコピーしました",
"storagePermissionDenied": "ストレージ権限が拒否されました",
"selectedCategorizeWarning": "これにより、選択したアプリの既存のカテゴリ設定がすべて置き換えられます。",
"filterAPKsByRegEx": "正規表現でAPKを絞り込む",
"removeFromObtainium": "Obtainiumから削除する",
"uninstallFromDevice": "デバイスからアンインストールする",
"onlyWorksWithNonVersionDetectApps": "バージョン検出を無効にしているアプリにのみ動作します。",
"releaseDateAsVersion": "リリース日をバージョンとして使用する",
"releaseDateAsVersionExplanation": "このオプションは、バージョン検出が正しく機能しないアプリで、リリース日が利用可能な場合にのみ使用する必要があります。",
"changes": "変更点",
"releaseDate": "リリース日",
"importFromURLsInFile": "ファイルOPMLなど内のURLからインポート",
"versionDetection": "バージョン検出",
"standardVersionDetection": "標準のバージョン検出",
"groupByCategory": "カテゴリ別にグループ化する",
"autoApkFilterByArch": "可能であればCPUアーキテクチャによるAPKのフィルタリングを試みる",
"overrideSource": "Override Source",
"dontShowAgain": "Don't show this again",
"dontShowTrackOnlyWarnings": "Don't Show the 'Track-Only' Warning",
"dontShowAPKOriginWarnings": "Don't Show APK Origin Warnings",
"removeAppQuestion": {
"one": "アプリを削除しますか?",
"other": "アプリを削除しますか?"
},
"tooManyRequestsTryAgainInMinutes": {
"one": "リクエストが多すぎます(レート制限)- {}分後に再試行してください",
"other": "リクエストが多すぎます(レート制限)- {}分後に再試行してください"
},
"bgUpdateGotErrorRetryInMinutes": {
"one": "バックグラウンドでのアップデート確認で {} の問題が発生, {} 分後に再試行します",
"other": "バックグラウンドでのアップデート確認で {} の問題が発生, {} 分後に再試行します"
},
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
"one": "バックグラウンドでのアップデート確認で {} 個のアップデートを発見 - 必要に応じてユーザーに通知します",
"other": "バックグラウンドでのアップデート確認で {} 個のアップデートを発見 - 必要に応じてユーザーに通知します"
},
"apps": {
"one": "{}個のアプリ",
"other": "{}個のアプリ"
},
"url": {
"one": "{}個のURL",
"other": "{}個のURL"
},
"minute": {
"one": "{}分",
"other": "{}分"
},
"hour": {
"one": "{}時間",
"other": "{}時間"
},
"day": {
"one": "{}日",
"other": "{}日"
},
"clearedNLogsBeforeXAfterY": {
"one": "{n}個のログをクリアしました (前 = {before}, 後 = {after})",
"other": "{n}個のログをクリアしました (前 = {before}, 後 = {after})"
},
"xAndNMoreUpdatesAvailable": {
"one": "{} とさらに {} 個のアプリのアップデートが利用可能です",
"other": "{} とさらに {} 個のアプリのアップデートが利用可能です"
},
"xAndNMoreUpdatesInstalled": {
"one": "{} とさらに {} 個のアプリがアップデートされました",
"other": "{} とさらに {} 個のアプリがアップデートされました"
}
}

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

@ -0,0 +1,279 @@
{
"invalidURLForSource": "无效的 {} URL",
"noReleaseFound": "找不到合适的发行版",
"noVersionFound": "无法确定发行版本号",
"urlMatchesNoSource": "URL 与已知的来源不符",
"cantInstallOlderVersion": "无法安装旧版本的应用",
"appIdMismatch": "所下载 APK 的应用 ID 与现有应用不一致",
"functionNotImplemented": "该类未实现此功能",
"placeholder": "占位符",
"someErrors": "出现了一些错误",
"unexpectedError": "意外错误",
"ok": "好的",
"and": "和",
"startedBgUpdateTask": "后台更新检查任务已启动",
"bgUpdateIgnoreAfterIs": "后台更新检查间隔为 {}",
"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": "将 {}\n仅追踪\n标记为已更新",
"changeX": "更改 {}",
"installUpdateApps": "安装/更新应用",
"installUpdateSelectedApps": "安装/更新选中的应用",
"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 重新安装这些应用来解决此问题。",
"importErrors": "导入错误",
"importedXOfYApps": "已导入 {} 中的 {} 个应用。",
"followingURLsHadErrors": "下列 URL 存在错误:",
"okay": "好的",
"selectURL": "选择 URL",
"selectURLs": "选择 URL",
"pick": "选择",
"theme": "主题",
"dark": "深色",
"light": "浅色",
"followSystem": "跟随系统",
"obtainium": "Obtainium",
"materialYou": "Material You",
"useBlackTheme": "使用纯黑深色主题",
"appSortBy": "排序依据",
"authorName": "作者 / 应用名称",
"nameAuthor": "应用名称 / 作者",
"asAdded": "添加次序",
"appSortOrder": "顺序",
"ascending": "升序",
"descending": "降序",
"bgUpdateCheckInterval": "后台更新检查间隔",
"neverManualOnly": "手动",
"appearance": "外观",
"showWebInAppView": "在应用详情页显示来源网页",
"pinUpdates": "将待更新应用置顶",
"updates": "更新",
"sourceSpecific": "来源相关",
"appSource": "源代码",
"noLogs": "无日志",
"appLogs": "日志",
"close": "关闭",
"share": "分享",
"appNotFound": "未找到应用",
"obtainiumExportHyphenatedLowercase": "obtainium-导出",
"pickAnAPK": "选择一个 APK 文件",
"appHasMoreThanOnePackage": "{} 有多个架构可用:",
"deviceSupportsXArch": "您的设备支持 {} 架构。",
"deviceSupportsFollowingArchs": "您的设备支持下列架构:",
"warning": "警告",
"sourceIsXButPackageFromYPrompt": "此应用的来源是“{}”,但 APK 文件来自“{}”。是否继续?",
"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": "删除",
"yesMarkUpdated": "是的,标记为已更新",
"fdroid": "F-Droid Official",
"appIdOrName": "应用 ID 或名称",
"appWithIdOrNameNotFound": "未找到符合此 ID 或名称的应用",
"reposHaveMultipleApps": "存储库中可能包含多个应用",
"fdroidThirdPartyRepo": "F-Droid 第三方存储库",
"steam": "Steam",
"steamMobile": "Steam Mobile",
"steamChat": "Steam Chat",
"install": "安装",
"markInstalled": "标记为已安装",
"update": "更新",
"markUpdated": "标记为已更新",
"additionalOptions": "附加选项",
"disableVersionDetection": "禁用版本检测",
"noVersionDetectionExplanation": "此选项应该仅用于无法进行版本检测的应用。",
"downloadingX": "正在下载 {}",
"downloadNotifDescription": "提示应用的下载进度",
"noAPKFound": "未找到 APK 文件",
"noVersionDetection": "禁用版本检测",
"categorize": "分类",
"categories": "类别",
"category": "类别",
"noCategory": "无类别",
"noCategories": "无类别",
"deleteCategoriesQuestion": "是否删除选中的类别?",
"categoryDeleteWarning": "被删除类别下的应用将恢复为未分类状态。",
"addCategory": "添加类别",
"label": "标签",
"language": "语言",
"copiedToClipboard": "已复制至剪贴板",
"storagePermissionDenied": "已拒绝授予存储权限",
"selectedCategorizeWarning": "这将覆盖选中应用当前的类别设置。",
"filterAPKsByRegEx": "使用正则表达式筛选 APK 文件",
"removeFromObtainium": "从 Obtainium 中删除",
"uninstallFromDevice": "从设备中卸载",
"onlyWorksWithNonVersionDetectApps": "仅适用于禁用版本检测的应用。",
"releaseDateAsVersion": "将发行日期作为版本号",
"releaseDateAsVersionExplanation": "此选项应该仅用于无法进行版本检测但能够获取发行日期的应用。",
"changes": "更新日志",
"releaseDate": "发行日期",
"importFromURLsInFile": "从文件中的 URL 导入(如 OPML",
"versionDetection": "版本检测",
"standardVersionDetection": "常规版本检测",
"groupByCategory": "按类别分组显示",
"autoApkFilterByArch": "如果可能,尝试按 CPU 架构筛选 APK 文件",
"overrideSource": "Override Source",
"dontShowAgain": "Don't show this again",
"dontShowTrackOnlyWarnings": "Don't Show the 'Track-Only' Warning",
"dontShowAPKOriginWarnings": "Don't Show APK Origin Warnings",
"removeAppQuestion": {
"one": "是否删除应用?",
"other": "是否删除应用?"
},
"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": "{} 和另外 1 个应用可以更新了。",
"other": "{} 和另外 {} 个应用可以更新了。"
},
"xAndNMoreUpdatesInstalled": {
"one": "{} 和另外 1 个应用已更新。",
"other": "{} 和另外 {} 个应用已更新。"
}
}

View File

@ -0,0 +1,106 @@
import 'dart:io';
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 APKMirror extends AppSource {
APKMirror() {
host = 'apkmirror.com';
enforceTrackOnly = true;
additionalSourceAppSpecificSettingFormItems = [
[
GeneratedFormSwitch('fallbackToOlderReleases',
label: tr('fallbackToOlderReleases'), defaultValue: true)
],
[
GeneratedFormTextField('filterReleaseTitlesByRegEx',
label: tr('filterReleaseTitlesByRegEx'),
required: false,
additionalValidators: [
(value) {
return regExValidator(value);
}
])
]
];
}
@override
String sourceSpecificStandardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/apk/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw InvalidURLError(name);
}
return url.substring(0, match.end);
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) =>
'$standardUrl/#whatsnew';
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
bool fallbackToOlderReleases =
additionalSettings['fallbackToOlderReleases'] == true;
String? regexFilter =
(additionalSettings['filterReleaseTitlesByRegEx'] as String?)
?.isNotEmpty ==
true
? additionalSettings['filterReleaseTitlesByRegEx']
: null;
Response res = await get(Uri.parse('$standardUrl/feed'));
if (res.statusCode == 200) {
var items = parse(res.body).querySelectorAll('item');
dynamic targetRelease;
for (int i = 0; i < items.length; i++) {
if (!fallbackToOlderReleases && i > 0) break;
String? nameToFilter = items[i].querySelector('title')?.innerHtml;
if (regexFilter != null &&
nameToFilter != null &&
!RegExp(regexFilter).hasMatch(nameToFilter.trim())) {
continue;
}
targetRelease = items[i];
break;
}
String? titleString = targetRelease?.querySelector('title')?.innerHtml;
String? dateString = targetRelease
?.querySelector('pubDate')
?.innerHtml
.split(' ')
.sublist(0, 5)
.join(' ');
DateTime? releaseDate =
dateString != null ? HttpDate.parse('$dateString GMT') : null;
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),
releaseDate: releaseDate);
} else {
throw getObtainiumHttpError(res);
}
}
AppNames getAppNames(String standardUrl) {
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
return AppNames(names[1], names[2]);
}
}

View File

@ -0,0 +1,79 @@
import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:http/http.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';
class Codeberg extends AppSource {
Codeberg() {
host = 'codeberg.org';
additionalSourceSpecificSettingFormItems = [];
additionalSourceAppSpecificSettingFormItems = [
[
GeneratedFormSwitch('includePrereleases',
label: tr('includePrereleases'), defaultValue: false)
],
[
GeneratedFormSwitch('fallbackToOlderReleases',
label: tr('fallbackToOlderReleases'), defaultValue: true)
],
[
GeneratedFormTextField('filterReleaseTitlesByRegEx',
label: tr('filterReleaseTitlesByRegEx'),
required: false,
additionalValidators: [
(value) {
return regExValidator(value);
}
])
]
];
canSearch = true;
}
var gh = GitHub();
@override
String sourceSpecificStandardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw InvalidURLError(name);
}
return url.substring(0, match.end);
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) =>
'$standardUrl/releases';
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
return gh.getLatestAPKDetailsCommon(
'https://$host/api/v1/repos${standardUrl.substring('https://$host'.length)}/releases?per_page=100',
standardUrl,
additionalSettings);
}
AppNames getAppNames(String standardUrl) {
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
return AppNames(names[0], names[1]);
}
@override
Future<Map<String, String>> search(String query) async {
return gh.searchCommon(
query,
'https://$host/api/v1/repos/search?q=${Uri.encodeQueryComponent(query)}&limit=100',
'data');
}
}

View File

@ -0,0 +1,71 @@
import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:http/http.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class FDroid extends AppSource {
FDroid() {
host = 'f-droid.org';
name = tr('fdroid');
}
@override
String sourceSpecificStandardizeURL(String url) {
RegExp standardUrlRegExB =
RegExp('^https?://$host/+[^/]+/+packages/+[^/]+');
RegExpMatch? match = standardUrlRegExB.firstMatch(url.toLowerCase());
if (match != null) {
url =
'https://${Uri.parse(url.substring(0, match.end)).host}/packages/${Uri.parse(url).pathSegments.last}';
}
RegExp standardUrlRegExA = RegExp('^https?://$host/+packages/+[^/]+');
match = standardUrlRegExA.firstMatch(url.toLowerCase());
if (match == null) {
throw InvalidURLError(name);
}
return url.substring(0, match.end);
}
@override
String? tryInferringAppId(String standardUrl,
{Map<String, dynamic> additionalSettings = const {}}) {
return Uri.parse(standardUrl).pathSegments.last;
}
APKDetails getAPKUrlsFromFDroidPackagesAPIResponse(
Response res, String apkUrlPrefix, String standardUrl) {
if (res.statusCode == 200) {
List<dynamic> releases = jsonDecode(res.body)['packages'] ?? [];
if (releases.isEmpty) {
throw NoReleasesError();
}
String? latestVersion = releases[0]['versionName'];
if (latestVersion == null) {
throw NoVersionError();
}
List<String> apkUrls = releases
.where((element) => element['versionName'] == latestVersion)
.map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk')
.toList();
return APKDetails(latestVersion, getApkUrlsFromUrls(apkUrls),
AppNames(name, Uri.parse(standardUrl).pathSegments.last));
} else {
throw getObtainiumHttpError(res);
}
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
String? appId = tryInferringAppId(standardUrl);
String host = Uri.parse(standardUrl).host;
return getAPKUrlsFromFDroidPackagesAPIResponse(
await get(Uri.parse('https://$host/api/v1/packages/$appId')),
'https://$host/repo/$appId',
standardUrl);
}
}

View File

@ -0,0 +1,79 @@
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');
additionalSourceAppSpecificSettingFormItems = [
[
GeneratedFormTextField('appIdOrName',
label: tr('appIdOrName'),
hint: tr('reposHaveMultipleApps'),
required: true)
]
];
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
String? appIdOrName = additionalSettings['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;
String? added = releases[0].querySelector('added')?.innerHtml;
DateTime? releaseDate = added != null ? DateTime.parse(added) : null;
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, getApkUrlsFromUrls(apkUrls),
AppNames(authorName, appName),
releaseDate: releaseDate);
} else {
throw getObtainiumHttpError(res);
}
}
}

258
lib/app_sources/github.dart Normal file
View File

@ -0,0 +1,258 @@
import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:url_launcher/url_launcher_string.dart';
class GitHub extends AppSource {
GitHub() {
host = 'github.com';
additionalSourceSpecificSettingFormItems = [
GeneratedFormTextField('github-creds',
label: tr('githubPATLabel'),
password: true,
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),
))
])
];
additionalSourceAppSpecificSettingFormItems = [
[
GeneratedFormSwitch('includePrereleases',
label: tr('includePrereleases'), defaultValue: false)
],
[
GeneratedFormSwitch('fallbackToOlderReleases',
label: tr('fallbackToOlderReleases'), defaultValue: true)
],
[
GeneratedFormTextField('filterReleaseTitlesByRegEx',
label: tr('filterReleaseTitlesByRegEx'),
required: false,
additionalValidators: [
(value) {
return regExValidator(value);
}
])
]
];
canSearch = true;
}
@override
String sourceSpecificStandardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw InvalidURLError(name);
}
return url.substring(0, match.end);
}
Future<String> getCredentialPrefixIfAny() async {
SettingsProvider settingsProvider = SettingsProvider();
await settingsProvider.initializeSettings();
String? creds = settingsProvider
.getSettingString(additionalSourceSpecificSettingFormItems[0].key);
return creds != null && creds.isNotEmpty ? '$creds@' : '';
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) =>
'$standardUrl/releases';
Future<APKDetails> getLatestAPKDetailsCommon(String requestUrl,
String standardUrl, Map<String, dynamic> additionalSettings,
{Function(Response)? onHttpErrorCode}) async {
bool includePrereleases = additionalSettings['includePrereleases'] == true;
bool fallbackToOlderReleases =
additionalSettings['fallbackToOlderReleases'] == true;
String? regexFilter =
(additionalSettings['filterReleaseTitlesByRegEx'] as String?)
?.isNotEmpty ==
true
? additionalSettings['filterReleaseTitlesByRegEx']
: null;
Response res = await get(Uri.parse(requestUrl));
if (res.statusCode == 200) {
var releases = jsonDecode(res.body) as List<dynamic>;
List<MapEntry<String, String>> getReleaseAPKUrls(dynamic release) =>
(release['assets'] as List<dynamic>?)
?.map((e) {
return e['name'] != null && e['browser_download_url'] != null
? MapEntry(e['name'] as String,
e['browser_download_url'] as String)
: const MapEntry('', '');
})
.where((element) => element.key.toLowerCase().endsWith('.apk'))
.toList() ??
[];
DateTime? getReleaseDateFromRelease(dynamic rel) =>
rel?['published_at'] != null
? DateTime.parse(rel['published_at'])
: null;
releases.sort((a, b) {
// See #478
if (a == b) {
return 0;
} else if (a == null) {
return -1;
} else if (b == null) {
return 1;
} else {
return getReleaseDateFromRelease(a)!
.compareTo(getReleaseDateFromRelease(b)!);
}
});
releases = releases.reversed.toList();
dynamic targetRelease;
var prerrelsSkipped = 0;
for (int i = 0; i < releases.length; i++) {
if (!fallbackToOlderReleases && i > prerrelsSkipped) break;
if (!includePrereleases && releases[i]['prerelease'] == true) {
prerrelsSkipped++;
continue;
}
if (releases[i]['draft'] == true) {
// Draft releases not supported
continue;
}
var nameToFilter = releases[i]['name'] as String?;
if (nameToFilter == null || nameToFilter.trim().isEmpty) {
// Some leave titles empty so tag is used
nameToFilter = releases[i]['tag_name'] as String;
}
if (regexFilter != null &&
!RegExp(regexFilter).hasMatch(nameToFilter.trim())) {
continue;
}
var apkUrls = getReleaseAPKUrls(releases[i]);
if (apkUrls.isEmpty && additionalSettings['trackOnly'] != true) {
continue;
}
targetRelease = releases[i];
targetRelease['apkUrls'] = apkUrls;
break;
}
if (targetRelease == null) {
throw NoReleasesError();
}
String? version = targetRelease['tag_name'];
DateTime? releaseDate = getReleaseDateFromRelease(targetRelease);
if (version == null) {
throw NoVersionError();
}
var changeLog = targetRelease['body'].toString();
return APKDetails(
version,
targetRelease['apkUrls'] as List<MapEntry<String, String>>,
getAppNames(standardUrl),
releaseDate: releaseDate,
changeLog: changeLog.isEmpty ? null : changeLog);
} else {
if (onHttpErrorCode != null) {
onHttpErrorCode(res);
}
throw getObtainiumHttpError(res);
}
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
return getLatestAPKDetailsCommon(
'https://${await getCredentialPrefixIfAny()}api.$host/repos${standardUrl.substring('https://$host'.length)}/releases?per_page=100',
standardUrl,
additionalSettings, onHttpErrorCode: (Response res) {
rateLimitErrorCheck(res);
});
}
AppNames getAppNames(String standardUrl) {
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
return AppNames(names[0], names[1]);
}
Future<Map<String, String>> searchCommon(
String query, String requestUrl, String rootProp,
{Function(Response)? onHttpErrorCode}) async {
Response res = await get(Uri.parse(requestUrl));
if (res.statusCode == 200) {
Map<String, String> urlsWithDescriptions = {};
for (var e in (jsonDecode(res.body)[rootProp] as List<dynamic>)) {
urlsWithDescriptions.addAll({
e['html_url'] as String:
((e['archived'] == true ? '[ARCHIVED] ' : '') +
(e['description'] != null
? e['description'] as String
: tr('noDescription')))
});
}
return urlsWithDescriptions;
} else {
if (onHttpErrorCode != null) {
onHttpErrorCode(res);
}
throw getObtainiumHttpError(res);
}
}
@override
Future<Map<String, String>> search(String query) async {
return searchCommon(
query,
'https://${await getCredentialPrefixIfAny()}api.$host/search/repositories?q=${Uri.encodeQueryComponent(query)}&per_page=100',
'items', onHttpErrorCode: (Response res) {
rateLimitErrorCheck(res);
});
}
rateLimitErrorCheck(Response res) {
if (res.headers['x-ratelimit-remaining'] == '0') {
throw RateLimitError(
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
60000000)
.round());
}
}
}

View File

@ -0,0 +1,96 @@
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:easy_localization/easy_localization.dart';
class GitLab extends AppSource {
GitLab() {
host = 'gitlab.com';
additionalSourceAppSpecificSettingFormItems = [
[
GeneratedFormSwitch('fallbackToOlderReleases',
label: tr('fallbackToOlderReleases'), defaultValue: true)
]
];
}
@override
String sourceSpecificStandardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw InvalidURLError(name);
}
return url.substring(0, match.end);
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) =>
'$standardUrl/-/releases';
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
bool fallbackToOlderReleases =
additionalSettings['fallbackToOlderReleases'] == true;
Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom'));
if (res.statusCode == 200) {
var standardUri = Uri.parse(standardUrl);
var parsedHtml = parse(res.body);
var apkDetailsList = parsedHtml.querySelectorAll('entry').map((entry) {
var entryContent = parse(
parseFragment(entry.querySelector('content')!.innerHtml).text);
var apkUrls = [
...getLinksFromParsedHTML(
entryContent,
RegExp(
'^${standardUri.path.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
return '\\${x[0]}';
})}/uploads/[^/]+/[^/]+\\.apk\$',
caseSensitive: false),
standardUri.origin),
// GitLab releases may contain links to externally hosted APKs
...getLinksFromParsedHTML(entryContent,
RegExp('/[^/]+\\.apk\$', caseSensitive: false), '')
.where((element) => Uri.parse(element).host != '')
.toList()
];
var entryId = entry.querySelector('id')?.innerHtml;
var version =
entryId == null ? null : Uri.parse(entryId).pathSegments.last;
var releaseDateString = entry.querySelector('updated')?.innerHtml;
DateTime? releaseDate = releaseDateString != null
? DateTime.parse(releaseDateString)
: null;
if (version == null) {
throw NoVersionError();
}
return APKDetails(version, getApkUrlsFromUrls(apkUrls),
GitHub().getAppNames(standardUrl),
releaseDate: releaseDate);
});
if (apkDetailsList.isEmpty) {
throw NoReleasesError();
}
if (fallbackToOlderReleases) {
if (additionalSettings['trackOnly'] != true) {
apkDetailsList =
apkDetailsList.where((e) => e.apkUrls.isNotEmpty).toList();
}
if (apkDetailsList.isEmpty) {
throw NoReleasesError();
}
}
return apkDetailsList.first;
} else {
throw getObtainiumHttpError(res);
}
}
}

123
lib/app_sources/html.dart Normal file
View File

@ -0,0 +1,123 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class HTML extends AppSource {
@override
String sourceSpecificStandardizeURL(String url) {
return url;
}
int compareAlphaNumeric(String a, String b) {
List<String> aParts = _splitAlphaNumeric(a);
List<String> bParts = _splitAlphaNumeric(b);
for (int i = 0; i < aParts.length && i < bParts.length; i++) {
String aPart = aParts[i];
String bPart = bParts[i];
bool aIsNumber = _isNumeric(aPart);
bool bIsNumber = _isNumeric(bPart);
if (aIsNumber && bIsNumber) {
int aNumber = int.parse(aPart);
int bNumber = int.parse(bPart);
int cmp = aNumber.compareTo(bNumber);
if (cmp != 0) {
return cmp;
}
} else if (!aIsNumber && !bIsNumber) {
int cmp = aPart.compareTo(bPart);
if (cmp != 0) {
return cmp;
}
} else {
// Alphanumeric strings come before numeric strings
return aIsNumber ? 1 : -1;
}
}
return aParts.length.compareTo(bParts.length);
}
List<String> _splitAlphaNumeric(String s) {
List<String> parts = [];
StringBuffer sb = StringBuffer();
bool isNumeric = _isNumeric(s[0]);
sb.write(s[0]);
for (int i = 1; i < s.length; i++) {
bool currentIsNumeric = _isNumeric(s[i]);
if (currentIsNumeric == isNumeric) {
sb.write(s[i]);
} else {
parts.add(sb.toString());
sb.clear();
sb.write(s[i]);
isNumeric = currentIsNumeric;
}
}
parts.add(sb.toString());
return parts;
}
bool _isNumeric(String s) {
return s.codeUnitAt(0) >= 48 && s.codeUnitAt(0) <= 57;
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
var uri = Uri.parse(standardUrl);
Response res = await get(uri);
if (res.statusCode == 200) {
List<String> links = parse(res.body)
.querySelectorAll('a')
.map((element) => element.attributes['href'] ?? '')
.where((element) => element.toLowerCase().endsWith('.apk'))
.toList();
links.sort(
(a, b) => compareAlphaNumeric(a.split('/').last, b.split('/').last));
if (additionalSettings['apkFilterRegEx'] != null) {
var reg = RegExp(additionalSettings['apkFilterRegEx']);
links = links.where((element) => reg.hasMatch(element)).toList();
}
if (links.isEmpty) {
throw NoReleasesError();
}
var rel = links.last;
var apkName = rel.split('/').last;
var version = apkName.substring(0, apkName.length - 4);
List<String> apkUrls = [rel].map((e) {
try {
Uri.parse(e).origin;
return e;
} catch (err) {
// is relative
}
var currPathSegments = uri.path
.split('/')
.where((element) => element.trim().isNotEmpty)
.toList();
if (e.startsWith('/') || currPathSegments.isEmpty) {
return '${uri.origin}/$e';
} else if (e.split('/').length == 1) {
return '${uri.origin}/${currPathSegments.join('/')}/$e';
} else {
return '${uri.origin}/${currPathSegments.sublist(0, currPathSegments.length - 1).join('/')}/$e';
}
}).toList();
return APKDetails(
version, getApkUrlsFromUrls(apkUrls), AppNames(uri.host, tr('app')));
} else {
throw getObtainiumHttpError(res);
}
}
}

View File

@ -0,0 +1,39 @@
import 'package:http/http.dart';
import 'package:obtainium/app_sources/fdroid.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class IzzyOnDroid extends AppSource {
IzzyOnDroid() {
host = 'android.izzysoft.de';
}
@override
String sourceSpecificStandardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw InvalidURLError(name);
}
return url.substring(0, match.end);
}
@override
String? tryInferringAppId(String standardUrl,
{Map<String, dynamic> additionalSettings = const {}}) {
return FDroid().tryInferringAppId(standardUrl);
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
String? appId = tryInferringAppId(standardUrl);
return FDroid().getAPKUrlsFromFDroidPackagesAPIResponse(
await get(
Uri.parse('https://apt.izzysoft.de/fdroid/api/v1/packages/$appId')),
'https://android.izzysoft.de/frepo/$appId',
standardUrl);
}
}

View File

@ -0,0 +1,70 @@
import 'dart:convert';
import 'package:http/http.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class Jenkins extends AppSource {
Jenkins() {
overrideVersionDetectionFormDefault('releaseDateAsVersion', true);
}
@override
String trimJobUrl(String url) {
RegExp standardUrlRegEx = RegExp('.*/job/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url);
if (match == null) {
throw InvalidURLError(name);
}
return url.substring(0, match.end);
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) =>
'$standardUrl/-/releases';
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
standardUrl = trimJobUrl(standardUrl);
Response res =
await get(Uri.parse('$standardUrl/lastSuccessfulBuild/api/json'));
if (res.statusCode == 200) {
var json = jsonDecode(res.body);
var releaseDate = json['timestamp'] == null
? null
: DateTime.fromMillisecondsSinceEpoch(json['timestamp'] as int);
var version =
json['number'] == null ? null : (json['number'] as int).toString();
if (version == null) {
throw NoVersionError();
}
var apkUrls = (json['artifacts'] as List<dynamic>)
.map((e) {
var path = (e['relativePath'] as String?);
if (path != null && path.isNotEmpty) {
path = '$standardUrl/lastSuccessfulBuild/artifact/$path';
}
return path == null
? const MapEntry<String, String>('', '')
: MapEntry<String, String>(
(e['fileName'] ?? e['relativePath']) as String, path);
})
.where((url) =>
url.value.isNotEmpty && url.key.toLowerCase().endsWith('.apk'))
.toList();
if (apkUrls.isEmpty) {
throw NoAPKError();
}
return APKDetails(
version,
apkUrls,
releaseDate: releaseDate,
AppNames(Uri.parse(standardUrl).host, standardUrl.split('/').last));
} else {
throw getObtainiumHttpError(res);
}
}
}

View File

@ -0,0 +1,68 @@
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class Mullvad extends AppSource {
Mullvad() {
host = 'mullvad.net';
}
@override
String sourceSpecificStandardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw InvalidURLError(name);
}
return url.substring(0, match.end);
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) =>
'https://github.com/mullvad/mullvadvpn-app/blob/master/CHANGELOG.md';
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
Response res = await get(Uri.parse('$standardUrl/en/download/android'));
if (res.statusCode == 200) {
var versions = parse(res.body)
.querySelectorAll('p')
.map((e) => e.innerHtml)
.where((p) => p.contains('Latest version: '))
.map((e) {
var match = RegExp('[0-9]+(\\.[0-9]+)*').firstMatch(e);
if (match == null) {
return '';
} else {
return e.substring(match.start, match.end);
}
})
.where((element) => element.isNotEmpty)
.toList();
if (versions.isEmpty) {
throw NoVersionError();
}
String? changeLog;
try {
changeLog = (await GitHub().getLatestAPKDetails(
'https://github.com/mullvad/mullvadvpn-app',
{'fallbackToOlderReleases': true}))
.changeLog;
} catch (e) {
// Ignore
}
return APKDetails(
versions[0],
getApkUrlsFromUrls(['https://mullvad.net/download/app/apk/latest']),
AppNames(name, 'Mullvad-VPN'),
changeLog: changeLog);
} else {
throw getObtainiumHttpError(res);
}
}
}

View File

@ -0,0 +1,111 @@
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class NeutronCode extends AppSource {
NeutronCode() {
host = 'neutroncode.com';
}
@override
String sourceSpecificStandardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/downloads/file/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw InvalidURLError(name);
}
return url.substring(0, match.end);
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) => standardUrl;
String monthNameToNumberString(String s) {
switch (s.toLowerCase()) {
case 'january':
return '01';
case 'february':
return '02';
case 'march':
return '03';
case 'april':
return '04';
case 'may':
return '05';
case 'june':
return '06';
case 'july':
return '07';
case 'august':
return '08';
case 'september':
return '09';
case 'october':
return '10';
case 'november':
return '11';
case 'december':
return '12';
default:
throw ArgumentError('Invalid month name: $s');
}
}
customDateParse(String dateString) {
List<String> parts = dateString.split(' ');
if (parts.length != 3) {
return null;
}
String result = '';
for (var s in parts.reversed) {
try {
try {
int.parse(s);
result += '$s-';
} catch (e) {
result += '${monthNameToNumberString(s)}-';
}
} catch (e) {
return null;
}
}
return result.substring(0, result.length - 1);
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
Response res = await get(Uri.parse(standardUrl));
if (res.statusCode == 200) {
var http = parse(res.body);
var name = http.querySelector('.pd-title')?.innerHtml;
var filename = http.querySelector('.pd-filename .pd-float')?.innerHtml;
if (filename == null) {
throw NoReleasesError();
}
var version =
http.querySelector('.pd-version-txt')?.nextElementSibling?.innerHtml;
if (version == null) {
throw NoVersionError();
}
String? apkUrl = 'https://$host/download/$filename';
var dateStringOriginal =
http.querySelector('.pd-date-txt')?.nextElementSibling?.innerHtml;
var dateString = dateStringOriginal != null
? (customDateParse(dateStringOriginal))
: null;
var changeLogElements = http.querySelectorAll('.pd-fdesc p');
return APKDetails(version, getApkUrlsFromUrls([apkUrl]),
AppNames(runtimeType.toString(), name ?? standardUrl.split('/').last),
releaseDate: dateString != null ? DateTime.parse(dateString) : null,
changeLog: changeLogElements.isNotEmpty
? changeLogElements.last.innerHtml
: null);
} else {
throw getObtainiumHttpError(res);
}
}
}

View File

@ -0,0 +1,37 @@
import 'dart:convert';
import 'package:http/http.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class Signal extends AppSource {
Signal() {
host = 'signal.org';
}
@override
String sourceSpecificStandardizeURL(String url) {
return 'https://$host';
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
Response res =
await get(Uri.parse('https://updates.$host/android/latest.json'));
if (res.statusCode == 200) {
var json = jsonDecode(res.body);
String? apkUrl = json['url'];
List<String> apkUrls = apkUrl == null ? [] : [apkUrl];
String? version = json['versionName'];
if (version == null) {
throw NoVersionError();
}
return APKDetails(
version, getApkUrlsFromUrls(apkUrls), AppNames(name, 'Signal'));
} else {
throw getObtainiumHttpError(res);
}
}
}

View File

@ -0,0 +1,61 @@
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class SourceForge extends AppSource {
SourceForge() {
host = 'sourceforge.net';
}
@override
String sourceSpecificStandardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/projects/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw InvalidURLError(name);
}
return url.substring(0, match.end);
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
Response res = await get(Uri.parse('$standardUrl/rss?path=/'));
if (res.statusCode == 200) {
var parsedHtml = parse(res.body);
var allDownloadLinks =
parsedHtml.querySelectorAll('guid').map((e) => e.innerHtml).toList();
getVersion(String url) {
try {
var tokens = url.split('/');
var fi = tokens.indexOf('files');
return tokens[tokens[fi + 2] == 'download' ? fi - 1 : fi + 1];
} catch (e) {
return null;
}
}
String? version = getVersion(allDownloadLinks[0]);
if (version == null) {
throw NoVersionError();
}
var apkUrlListAllReleases = allDownloadLinks
.where((element) => element.toLowerCase().endsWith('.apk/download'))
.toList();
var apkUrlList =
apkUrlListAllReleases // This can be used skipped for fallback support later
.where((element) => getVersion(element) == version)
.toList();
return APKDetails(
version,
getApkUrlsFromUrls(apkUrlList),
AppNames(
name, standardUrl.substring(standardUrl.lastIndexOf('/') + 1)));
} else {
throw getObtainiumHttpError(res);
}
}
}

View File

@ -0,0 +1,62 @@
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');
additionalSourceAppSpecificSettingFormItems = [
[
GeneratedFormDropdown('app', apks.entries.toList(),
label: tr('app'), defaultValue: apks.entries.toList()[0].key)
]
];
}
final apks = {'steam': tr('steamMobile'), 'steam-chat-app': tr('steamChat')};
@override
String sourceSpecificStandardizeURL(String url) {
return 'https://$host';
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
Response res = await get(Uri.parse('https://$host/mobile'));
if (res.statusCode == 200) {
var apkNamePrefix = additionalSettings['app'] as String?;
if (apkNamePrefix == null) {
throw NoReleasesError();
}
String apkInURLRegexPattern =
'/$apkNamePrefix-([0-9]+\\.)*[0-9]+\\.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, getApkUrlsFromUrls(apkUrls),
AppNames(name, apks[apkNamePrefix]!));
} else {
throw getObtainiumHttpError(res);
}
}
}

View File

@ -0,0 +1,41 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class TelegramApp extends AppSource {
TelegramApp() {
host = 'telegram.org';
name = 'Telegram ${tr('app')}';
}
@override
String sourceSpecificStandardizeURL(String url) {
return 'https://$host';
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
Response res = await get(Uri.parse('https://t.me/s/TAndroidAPK'));
if (res.statusCode == 200) {
var http = parse(res.body);
var messages =
http.querySelectorAll('.tgme_widget_message_text.js-message_text');
var version = messages.isNotEmpty
? messages.last.innerHtml.split('\n').first.trim().split(' ').first
: null;
if (version == null) {
throw NoVersionError();
}
String? apkUrl = 'https://telegram.org/dl/android/apk';
return APKDetails(version, getApkUrlsFromUrls([apkUrl]),
AppNames('Telegram', 'Telegram'));
} else {
throw getObtainiumHttpError(res);
}
}
}

63
lib/app_sources/vlc.dart Normal file
View File

@ -0,0 +1,63 @@
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/app_sources/html.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class VLC extends AppSource {
VLC() {
host = 'videolan.org';
}
@override
String sourceSpecificStandardizeURL(String url) {
return 'https://$host';
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
Response res = await get(
Uri.parse('https://www.videolan.org/vlc/download-android.html'));
if (res.statusCode == 200) {
var dwUrlBase = 'get.videolan.org/vlc-android';
var dwLinks = parse(res.body)
.querySelectorAll('a')
.where((element) =>
element.attributes['href']?.contains(dwUrlBase) ?? false)
.toList();
String? version = dwLinks.isNotEmpty
? dwLinks.first.attributes['href']
?.split('/')
.where((s) => s.isNotEmpty)
.last
: null;
if (version == null) {
throw NoVersionError();
}
String? targetUrl = 'https://$dwUrlBase/$version/';
Response res2 = await get(Uri.parse(targetUrl));
String mirrorDwBase =
'https://plug-mirror.rcac.purdue.edu/vlc/vlc-android/$version/';
List<String> apkUrls = [];
if (res2.statusCode == 200) {
apkUrls = parse(res2.body)
.querySelectorAll('a')
.map((e) => e.attributes['href'])
.where((h) =>
h != null && h.isNotEmpty && h.toLowerCase().endsWith('.apk'))
.map((e) => mirrorDwBase + e!)
.toList();
} else {
throw getObtainiumHttpError(res2);
}
return APKDetails(
version, getApkUrlsFromUrls(apkUrls), AppNames('VideoLAN', 'VLC'));
} else {
throw getObtainiumHttpError(res);
}
}
}

View File

@ -0,0 +1,75 @@
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class WhatsApp extends AppSource {
WhatsApp() {
host = 'whatsapp.com';
}
@override
String sourceSpecificStandardizeURL(String url) {
return 'https://$host';
}
@override
Future<String> apkUrlPrefetchModifier(String apkUrl) async {
Response res = await get(Uri.parse('https://www.whatsapp.com/android'));
if (res.statusCode == 200) {
var targetLinks = parse(res.body)
.querySelectorAll('a')
.map((e) => e.attributes['href'])
.where((e) => e != null)
.where((e) =>
e!.contains('scontent.whatsapp.net') &&
e.contains('WhatsApp.apk'))
.toList();
if (targetLinks.isEmpty) {
throw NoAPKError();
}
return targetLinks[0]!;
} else {
throw getObtainiumHttpError(res);
}
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
Response res = await get(Uri.parse('https://www.whatsapp.com/android'));
if (res.statusCode == 200) {
var targetElements = parse(res.body)
.querySelectorAll('p')
.where((element) => element.innerHtml.contains('Version '))
.toList();
if (targetElements.isEmpty) {
throw NoVersionError();
}
var vLines = targetElements[0]
.innerHtml
.split('\n')
.where((element) => element.contains('Version '))
.toList();
if (vLines.isEmpty) {
throw NoVersionError();
}
var versionMatch = RegExp('[0-9]+(\\.[0-9]+)+').firstMatch(vLines[0]);
if (versionMatch == null) {
throw NoVersionError();
}
String version =
vLines[0].substring(versionMatch.start, versionMatch.end);
return APKDetails(
version,
getApkUrlsFromUrls([
'https://www.whatsapp.com/android?v=$version&=thisIsaPlaceholder&a=realURLPrefetchedAtDownloadTime'
]),
AppNames('Meta', 'WhatsApp'));
} else {
throw getObtainiumHttpError(res);
}
}
}

View File

@ -0,0 +1,510 @@
import 'dart:math';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:obtainium/components/generated_form_modal.dart';
abstract class GeneratedFormItem {
late String key;
late String label;
late List<Widget> belowWidgets;
late dynamic defaultValue;
List<dynamic> additionalValidators;
dynamic ensureType(dynamic val);
GeneratedFormItem(this.key,
{this.label = 'Input',
this.belowWidgets = const [],
this.defaultValue,
this.additionalValidators = const []});
}
class GeneratedFormTextField extends GeneratedFormItem {
late bool required;
late int max;
late String? hint;
late bool password;
GeneratedFormTextField(String key,
{String label = 'Input',
List<Widget> belowWidgets = const [],
String defaultValue = '',
List<String? Function(String? value)> additionalValidators = const [],
this.required = true,
this.max = 1,
this.hint,
this.password = false})
: super(key,
label: label,
belowWidgets: belowWidgets,
defaultValue: defaultValue,
additionalValidators: additionalValidators);
@override
String ensureType(val) {
return val.toString();
}
}
class GeneratedFormDropdown extends GeneratedFormItem {
late List<MapEntry<String, String>>? opts;
List<String>? disabledOptKeys;
GeneratedFormDropdown(
String key,
this.opts, {
String label = 'Input',
List<Widget> belowWidgets = const [],
String defaultValue = '',
this.disabledOptKeys,
List<String? Function(String? value)> additionalValidators = const [],
}) : super(key,
label: label,
belowWidgets: belowWidgets,
defaultValue: defaultValue,
additionalValidators: additionalValidators);
@override
String ensureType(val) {
return val.toString();
}
}
class GeneratedFormSwitch extends GeneratedFormItem {
GeneratedFormSwitch(
String key, {
String label = 'Input',
List<Widget> belowWidgets = const [],
bool defaultValue = false,
List<String? Function(bool value)> additionalValidators = const [],
}) : super(key,
label: label,
belowWidgets: belowWidgets,
defaultValue: defaultValue,
additionalValidators: additionalValidators);
@override
bool ensureType(val) {
return val == true || val == 'true';
}
}
class GeneratedFormTagInput extends GeneratedFormItem {
late MapEntry<String, String>? deleteConfirmationMessage;
late bool singleSelect;
late WrapAlignment alignment;
late String emptyMessage;
late bool showLabelWhenNotEmpty;
GeneratedFormTagInput(String key,
{String label = 'Input',
List<Widget> belowWidgets = const [],
Map<String, MapEntry<int, bool>> defaultValue = const {},
List<String? Function(Map<String, MapEntry<int, bool>> value)>
additionalValidators = const [],
this.deleteConfirmationMessage,
this.singleSelect = false,
this.alignment = WrapAlignment.start,
this.emptyMessage = 'Input',
this.showLabelWhenNotEmpty = true})
: super(key,
label: label,
belowWidgets: belowWidgets,
defaultValue: defaultValue,
additionalValidators: additionalValidators);
@override
Map<String, MapEntry<int, bool>> ensureType(val) {
return val is Map<String, MapEntry<int, bool>> ? val : {};
}
}
typedef OnValueChanges = void Function(
Map<String, dynamic> values, bool valid, bool isBuilding);
class GeneratedForm extends StatefulWidget {
const GeneratedForm(
{super.key, required this.items, required this.onValueChanges});
final List<List<GeneratedFormItem>> items;
final OnValueChanges onValueChanges;
@override
State<GeneratedForm> createState() => _GeneratedFormState();
}
// Generates a random light color
// Courtesy of ChatGPT 😭 (with a bugfix 🥳)
Color generateRandomLightColor() {
// Create a random number generator
final Random random = Random();
// Generate random hue, saturation, and value values
final double hue = random.nextDouble() * 360;
final double saturation = 0.5 + random.nextDouble() * 0.5;
final double value = 0.9 + random.nextDouble() * 0.1;
// Create a HSV color with the random values
return HSVColor.fromAHSV(1.0, hue, saturation, value).toColor();
}
class _GeneratedFormState extends State<GeneratedForm> {
final _formKey = GlobalKey<FormState>();
Map<String, dynamic> values = {};
late List<List<Widget>> formInputs;
List<List<Widget>> rows = [];
String? initKey;
// If any value changes, call this to update the parent with value and validity
void someValueChanged({bool isBuilding = false}) {
Map<String, dynamic> returnValues = values;
var valid = true;
for (int r = 0; r < widget.items.length; r++) {
for (int i = 0; i < widget.items[r].length; i++) {
if (formInputs[r][i] is TextFormField) {
var fieldState =
(formInputs[r][i].key as GlobalKey<FormFieldState>).currentState;
if (fieldState != null) {
valid = valid && fieldState.isValid;
}
}
}
}
widget.onValueChanges(returnValues, valid, isBuilding);
}
initForm() {
initKey = widget.key.toString();
// Initialize form values as all empty
values.clear();
for (var row in widget.items) {
for (var e in row) {
values[e.key] = e.defaultValue;
}
}
// Dynamically create form inputs
formInputs = widget.items.asMap().entries.map((row) {
return row.value.asMap().entries.map((e) {
var formItem = e.value;
if (formItem is GeneratedFormTextField) {
final formFieldKey = GlobalKey<FormFieldState>();
return TextFormField(
obscureText: formItem.password,
autocorrect: !formItem.password,
enableSuggestions: !formItem.password,
key: formFieldKey,
initialValue: values[formItem.key],
autovalidateMode: AutovalidateMode.onUserInteraction,
onChanged: (value) {
setState(() {
values[formItem.key] = value;
someValueChanged();
});
},
decoration: InputDecoration(
helperText: formItem.label + (formItem.required ? ' *' : ''),
hintText: formItem.hint),
minLines: formItem.max <= 1 ? null : formItem.max,
maxLines: formItem.max <= 1 ? 1 : formItem.max,
validator: (value) {
if (formItem.required &&
(value == null || value.trim().isEmpty)) {
return '${formItem.label} ${tr('requiredInBrackets')}';
}
for (var validator in formItem.additionalValidators) {
String? result = validator(value);
if (result != null) {
return result;
}
}
return null;
},
);
} else if (formItem is GeneratedFormDropdown) {
if (formItem.opts!.isEmpty) {
return Text(tr('dropdownNoOptsError'));
}
return DropdownButtonFormField(
decoration: InputDecoration(labelText: formItem.label),
value: values[formItem.key],
items: formItem.opts!.map((e2) {
var enabled =
formItem.disabledOptKeys?.contains(e2.key) != true;
return DropdownMenuItem(
value: e2.key,
enabled: enabled,
child: Opacity(
opacity: enabled ? 1 : 0.5, child: Text(e2.value)));
}).toList(),
onChanged: (value) {
setState(() {
values[formItem.key] = value ?? formItem.opts!.first.key;
someValueChanged();
});
});
} else {
return Container(); // Some input types added in build
}
}).toList();
}).toList();
someValueChanged(isBuilding: true);
}
@override
void initState() {
super.initState();
initForm();
}
@override
Widget build(BuildContext context) {
if (widget.key.toString() != initKey) {
initForm();
}
for (var r = 0; r < formInputs.length; r++) {
for (var e = 0; e < formInputs[r].length; e++) {
if (widget.items[r][e] is GeneratedFormSwitch) {
formInputs[r][e] = Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(child: Text(widget.items[r][e].label)),
const SizedBox(
width: 8,
),
Switch(
value: values[widget.items[r][e].key],
onChanged: (value) {
setState(() {
values[widget.items[r][e].key] = value;
someValueChanged();
});
})
],
);
} else if (widget.items[r][e] is GeneratedFormTagInput) {
formInputs[r][e] =
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
if ((values[widget.items[r][e].key]
as Map<String, MapEntry<int, bool>>?)
?.isNotEmpty ==
true &&
(widget.items[r][e] as GeneratedFormTagInput)
.showLabelWhenNotEmpty)
Column(
crossAxisAlignment:
(widget.items[r][e] as GeneratedFormTagInput).alignment ==
WrapAlignment.center
? CrossAxisAlignment.center
: CrossAxisAlignment.stretch,
children: [
Text(widget.items[r][e].label),
const SizedBox(
height: 8,
),
],
),
Wrap(
alignment:
(widget.items[r][e] as GeneratedFormTagInput).alignment,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
(values[widget.items[r][e].key]
as Map<String, MapEntry<int, bool>>?)
?.isEmpty ==
true
? Text(
(widget.items[r][e] as GeneratedFormTagInput)
.emptyMessage,
)
: const SizedBox.shrink(),
...(values[widget.items[r][e].key]
as Map<String, MapEntry<int, bool>>?)
?.entries
.map((e2) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: ChoiceChip(
label: Text(e2.key),
backgroundColor: Color(e2.value.key).withAlpha(50),
selectedColor: Color(e2.value.key),
visualDensity: VisualDensity.compact,
selected: e2.value.value,
onSelected: (value) {
setState(() {
(values[widget.items[r][e].key] as Map<String,
MapEntry<int, bool>>)[e2.key] =
MapEntry(
(values[widget.items[r][e].key] as Map<
String,
MapEntry<int, bool>>)[e2.key]!
.key,
value);
if ((widget.items[r][e]
as GeneratedFormTagInput)
.singleSelect &&
value == true) {
for (var key in (values[
widget.items[r][e].key]
as Map<String, MapEntry<int, bool>>)
.keys) {
if (key != e2.key) {
(values[widget.items[r][e].key] as Map<
String,
MapEntry<int, bool>>)[key] =
MapEntry(
(values[widget.items[r][e].key]
as Map<
String,
MapEntry<int,
bool>>)[key]!
.key,
false);
}
}
}
someValueChanged();
});
},
));
}) ??
[const SizedBox.shrink()],
(values[widget.items[r][e].key]
as Map<String, MapEntry<int, bool>>?)
?.values
.where((e) => e.value)
.isNotEmpty ==
true
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: IconButton(
onPressed: () {
fn() {
setState(() {
var temp = values[widget.items[r][e].key]
as Map<String, MapEntry<int, bool>>;
temp.removeWhere((key, value) => value.value);
values[widget.items[r][e].key] = temp;
someValueChanged();
});
}
if ((widget.items[r][e] as GeneratedFormTagInput)
.deleteConfirmationMessage !=
null) {
var message =
(widget.items[r][e] as GeneratedFormTagInput)
.deleteConfirmationMessage!;
showDialog<Map<String, dynamic>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: message.key,
message: message.value,
items: const []);
}).then((value) {
if (value != null) {
fn();
}
});
} else {
fn();
}
},
icon: const Icon(Icons.remove),
visualDensity: VisualDensity.compact,
tooltip: tr('remove'),
))
: const SizedBox.shrink(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: IconButton(
onPressed: () {
showDialog<Map<String, dynamic>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: widget.items[r][e].label,
items: [
[
GeneratedFormTextField('label',
label: tr('label'))
]
]);
}).then((value) {
String? label = value?['label'];
if (label != null) {
setState(() {
var temp = values[widget.items[r][e].key]
as Map<String, MapEntry<int, bool>>?;
temp ??= {};
if (temp[label] == null) {
var singleSelect = (widget.items[r][e]
as GeneratedFormTagInput)
.singleSelect;
var someSelected = temp.entries
.where((element) => element.value.value)
.isNotEmpty;
temp[label] = MapEntry(
generateRandomLightColor().value,
!(someSelected && singleSelect));
values[widget.items[r][e].key] = temp;
someValueChanged();
}
});
}
});
},
icon: const Icon(Icons.add),
visualDensity: VisualDensity.compact,
tooltip: tr('add'),
)),
],
)
]);
}
}
}
rows.clear();
formInputs.asMap().entries.forEach((rowInputs) {
if (rowInputs.key > 0) {
rows.add([
SizedBox(
height: widget.items[rowInputs.key - 1][0] is GeneratedFormSwitch
? 8
: 25,
)
]);
}
List<Widget> rowItems = [];
rowInputs.value.asMap().entries.forEach((rowInput) {
if (rowInput.key > 0) {
rowItems.add(const SizedBox(
width: 20,
));
}
rowItems.add(Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
rowInput.value,
...widget.items[rowInputs.key][rowInput.key].belowWidgets
])));
});
rows.add(rowItems);
});
return Form(
key: _formKey,
child: Column(
children: [
...rows.map((row) => Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [...row.map((e) => e)],
))
],
));
}
}

View File

@ -1,80 +1,87 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class GeneratedFormItem {
late String message;
late bool required;
late int lines;
GeneratedFormItem(this.message, this.required, this.lines);
}
import 'package:obtainium/components/generated_form.dart';
class GeneratedFormModal extends StatefulWidget {
const GeneratedFormModal(
{super.key, required this.title, required this.items});
{super.key,
required this.title,
required this.items,
this.initValid = false,
this.message = '',
this.additionalWidgets = const [],
this.singleNullReturnButton});
final String title;
final List<GeneratedFormItem> items;
final String message;
final List<List<GeneratedFormItem>> items;
final bool initValid;
final List<Widget> additionalWidgets;
final String? singleNullReturnButton;
@override
State<GeneratedFormModal> createState() => _GeneratedFormModalState();
}
class _GeneratedFormModalState extends State<GeneratedFormModal> {
final _formKey = GlobalKey<FormState>();
Map<String, dynamic> values = {};
bool valid = false;
final urlInputController = TextEditingController();
@override
void initState() {
super.initState();
valid = widget.initValid || widget.items.isEmpty;
}
@override
Widget build(BuildContext context) {
final formInputs = widget.items.map((e) {
final controller = TextEditingController();
return [
controller,
TextFormField(
decoration: InputDecoration(helperText: e.message),
controller: controller,
minLines: e.lines <= 1 ? null : e.lines,
maxLines: e.lines <= 1 ? 1 : e.lines,
validator: e.required
? (value) {
if (value == null || value.isEmpty) {
return '${e.message} (required)';
}
return null;
}
: null,
)
];
}).toList();
return AlertDialog(
scrollable: true,
title: Text(widget.title),
content: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [...formInputs.map((e) => e[1] as Widget)],
)),
content:
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
if (widget.message.isNotEmpty) Text(widget.message),
if (widget.message.isNotEmpty)
const SizedBox(
height: 16,
),
GeneratedForm(
items: widget.items,
onValueChanges: (values, valid, isBuilding) {
if (isBuilding) {
this.values = values;
this.valid = valid;
} else {
setState(() {
this.values = values;
this.valid = valid;
});
}
}),
if (widget.additionalWidgets.isNotEmpty) ...widget.additionalWidgets
]),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(null);
},
child: const Text('Cancel')),
TextButton(
onPressed: () {
if (_formKey.currentState?.validate() == true) {
HapticFeedback.selectionClick();
Navigator.of(context).pop(formInputs
.map((e) => (e[0] as TextEditingController).value.text)
.toList());
}
},
child: const Text('Continue'))
child: Text(widget.singleNullReturnButton == null
? tr('cancel')
: widget.singleNullReturnButton!)),
widget.singleNullReturnButton == null
? TextButton(
onPressed: !valid
? null
: () {
if (valid) {
HapticFeedback.selectionClick();
Navigator.of(context).pop(values);
}
},
child: Text(tr('continue')))
: const SizedBox.shrink()
],
);
}
}
// TODO: Add support for larger textarea so this can be used for text/json imports

126
lib/custom_errors.dart Normal file
View File

@ -0,0 +1,126 @@
import 'package:android_package_installer/android_package_installer.dart';
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 extends ObtainiumError {
late int remainingMinutes;
RateLimitError(this.remainingMinutes)
: super(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('noAPKFound'));
}
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 InstallError extends ObtainiumError {
InstallError(int code)
: super(PackageInstallerStatus.byCode(code).name.substring(7));
}
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,108 +1,250 @@
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/pages/home.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/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
import 'package:workmanager/workmanager.dart';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart';
import 'package: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.12.2';
const String currentReleaseTag =
'v0.2.4-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
const int bgUpdateCheckAlarmId = 666;
const supportedLocales = [
Locale('en'),
Locale('zh'),
Locale('it'),
Locale('ja'),
Locale('hu'),
Locale('de'),
Locale('fa'),
Locale('fr')
];
const fallbackLocale = Locale('en');
const localeDir = 'assets/translations';
final globalNavigatorKey = GlobalKey<NavigatorState>();
Future<void> loadTranslations() async {
// See easy_localization/issues/210
await EasyLocalizationController.initEasyLocation();
var s = SettingsProvider();
await s.initializeSettings();
var forceLocale = s.forcedLocale;
final controller = EasyLocalizationController(
saveLocale: true,
forceLocale: forceLocale != null ? Locale(forceLocale) : null,
fallbackLocale: fallbackLocale,
supportedLocales: supportedLocales,
assetLoader: const RootBundleAssetLoader(),
useOnlyLangCode: true,
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')
void bgTaskCallback() {
// Background update checking process
Workmanager().executeTask((task, taskName) async {
var notificationsProvider = NotificationsProvider();
await notificationsProvider.notify(checkingUpdatesNotification);
Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
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.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds)
: null;
logs.add(tr('bgUpdateIgnoreAfterIs', args: [ignoreAfter.toString()]));
var notificationsProvider = NotificationsProvider();
await notificationsProvider.notify(checkingUpdatesNotification);
try {
var appsProvider = AppsProvider();
await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id);
await appsProvider.loadApps();
List<String> existingUpdateIds =
appsProvider.findExistingUpdates(installedOnly: true);
DateTime nextIgnoreAfter = DateTime.now();
String? err;
try {
var appsProvider = AppsProvider();
await notificationsProvider
.cancel(ErrorCheckingUpdatesNotification('').id);
await appsProvider.loadApps();
List<App> updates = await appsProvider.checkUpdates();
if (updates.isNotEmpty) {
notificationsProvider.notify(UpdateNotification(updates),
cancelExisting: true);
}
return Future.value(true);
logs.add(tr('startedActualBGUpdateCheck'));
await appsProvider.checkUpdates(
ignoreAppsCheckedAfter: ignoreAfter, throwErrorsForRetry: true);
} catch (e) {
notificationsProvider.notify(
ErrorCheckingUpdatesNotification(e.toString()),
cancelExisting: true);
return Future.value(false);
} finally {
await notificationsProvider.cancel(checkingUpdatesNotification.id);
if (e is RateLimitError || e is SocketException) {
var remainingMinutes = e is RateLimitError ? e.remainingMinutes : 15;
logs.add(plural('bgUpdateGotErrorRetryInMinutes', remainingMinutes,
args: [e.toString(), remainingMinutes.toString()]));
AndroidAlarmManager.oneShot(Duration(minutes: remainingMinutes),
Random().nextInt(pow(2, 31) as int), bgUpdateCheck, params: {
'ignoreAfterMicroseconds': nextIgnoreAfter.microsecondsSinceEpoch
});
} else {
err = e.toString();
}
}
});
List<App> newUpdates = appsProvider
.findExistingUpdates(installedOnly: true)
.where((id) => !existingUpdateIds.contains(id))
.map((e) => appsProvider.apps[e]!.app)
.toList();
// TODO: This silent update code doesn't work yet
// List<String> silentlyUpdated = await appsProvider
// .downloadAndInstallLatestApp(
// [...newUpdates.map((e) => e.id), ...existingUpdateIds], null);
// if (silentlyUpdated.isNotEmpty) {
// newUpdates = newUpdates
// .where((element) => !silentlyUpdated.contains(element.id))
// .toList();
// notificationsProvider.notify(
// SilentUpdateNotification(
// silentlyUpdated.map((e) => appsProvider.apps[e]!.app).toList()),
// cancelExisting: true);
// }
logs.add(
plural('bgCheckFoundUpdatesWillNotifyIfNeeded', newUpdates.length));
if (newUpdates.isNotEmpty) {
notificationsProvider.notify(UpdateNotification(newUpdates));
}
if (err != null) {
throw err;
}
} catch (e) {
notificationsProvider
.notify(ErrorCheckingUpdatesNotification(e.toString()));
} finally {
logs.add(tr('bgUpdateTaskFinished'));
await notificationsProvider.cancel(checkingUpdatesNotification.id);
}
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt! >= 29) {
try {
ByteData data =
await PlatformAssetBundle().load('assets/ca/lets-encrypt-r3.pem');
SecurityContext.defaultContext
.setTrustedCertificatesBytes(data.buffer.asUint8List());
} catch (e) {
// Already added, do nothing (see #375)
}
await EasyLocalization.ensureInitialized();
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) {
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
}
Workmanager().initialize(
bgTaskCallback,
);
await AndroidAlarmManager.initialize();
runApp(MultiProvider(
providers: [
ChangeNotifierProvider(
create: (context) => AppsProvider(
shouldLoadApps: true,
shouldCheckUpdatesAfterLoad: true,
shouldDeleteAPKs: true)),
ChangeNotifierProvider(create: (context) => AppsProvider()),
ChangeNotifierProvider(create: (context) => SettingsProvider()),
Provider(create: (context) => NotificationsProvider())
Provider(create: (context) => NotificationsProvider()),
Provider(create: (context) => LogsProvider())
],
child: const MyApp(),
child: EasyLocalization(
supportedLocales: supportedLocales,
path: localeDir,
fallbackLocale: fallbackLocale,
useOnlyLangCode: true,
child: const Obtainium()),
));
}
var defaultThemeColour = Colors.deepPurple;
class MyApp extends StatelessWidget {
const MyApp({super.key});
class Obtainium extends StatefulWidget {
const Obtainium({super.key});
@override
State<Obtainium> createState() => _ObtainiumState();
}
class _ObtainiumState extends State<Obtainium> {
var existingUpdateInterval = -1;
@override
Widget build(BuildContext context) {
SettingsProvider settingsProvider = context.watch<SettingsProvider>();
AppsProvider appsProvider = context.read<AppsProvider>();
LogsProvider logs = context.read<LogsProvider>();
if (settingsProvider.prefs == null) {
settingsProvider.initializeSettings();
} else {
// Register the background update task according to the user's setting
if (settingsProvider.updateInterval > 0) {
Workmanager().registerPeriodicTask('bg-update-check', 'bg-update-check',
frequency: Duration(minutes: settingsProvider.updateInterval),
initialDelay: Duration(minutes: settingsProvider.updateInterval),
constraints: Constraints(networkType: NetworkType.connected),
existingWorkPolicy: ExistingWorkPolicy.replace);
} else {
Workmanager().cancelByUniqueName('bg-update-check');
}
bool isFirstRun = settingsProvider.checkAndFlipFirstRun();
if (isFirstRun) {
logs.add(tr('firstRun'));
// If this is the first run, ask for notification permissions and add Obtainium to the Apps list
Permission.notification.request();
appsProvider.saveApp(App(
'imranr98_obtainium_${GitHub().host}',
'https://github.com/ImranR98/Obtainium',
'ImranR98',
'Obtainium',
currentReleaseTag,
currentReleaseTag,
[],
0));
appsProvider.saveApps([
App(
obtainiumId,
'https://github.com/ImranR98/Obtainium',
'ImranR98',
'Obtainium',
currentReleaseTag,
currentReleaseTag,
[],
0,
{'includePrereleases': true},
null,
false)
], onlyIfExists: false);
}
if (!supportedLocales
.map((e) => e.languageCode)
.contains(context.locale.languageCode) ||
settingsProvider.forcedLocale == null &&
context.deviceLocale.languageCode !=
context.locale.languageCode) {
settingsProvider.resetLocaleSafe(context);
}
// Register the background update task according to the user's setting
if (existingUpdateInterval != settingsProvider.updateInterval) {
if (existingUpdateInterval != -1) {
logs.add(tr('settingUpdateCheckIntervalTo',
args: [settingsProvider.updateInterval.toString()]));
}
existingUpdateInterval = settingsProvider.updateInterval;
if (existingUpdateInterval == 0) {
AndroidAlarmManager.cancel(bgUpdateCheckAlarmId);
} else {
AndroidAlarmManager.periodic(
Duration(minutes: existingUpdateInterval),
bgUpdateCheckAlarmId,
bgUpdateCheck,
rescheduleOnReboot: true,
wakeup: true);
}
}
}
@ -121,8 +263,20 @@ class MyApp extends StatelessWidget {
darkColorScheme = ColorScheme.fromSeed(
seedColor: defaultThemeColour, brightness: Brightness.dark);
}
// set the background and surface colors to pure black in the amoled theme
if (settingsProvider.useBlackTheme) {
darkColorScheme = darkColorScheme
.copyWith(background: Colors.black, surface: Colors.black)
.harmonized();
}
return MaterialApp(
title: 'Obtainium',
localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales,
locale: context.locale,
navigatorKey: globalNavigatorKey,
theme: ThemeData(
useMaterial3: true,
colorScheme: settingsProvider.theme == ThemeSettings.dark

View File

@ -0,0 +1,54 @@
import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:http/http.dart';
import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class GitHubStars implements MassAppUrlSource {
@override
late String name = tr('githubStarredRepos');
@override
late List<String> requiredArgs = [tr('uname')];
Future<Map<String, String>> getOnePageOfUserStarredUrlsWithDescriptions(
String username, int page) async {
Response res = await get(Uri.parse(
'https://${await GitHub().getCredentialPrefixIfAny()}api.github.com/users/$username/starred?per_page=100&page=$page'));
if (res.statusCode == 200) {
Map<String, String> urlsWithDescriptions = {};
for (var e in (jsonDecode(res.body) as List<dynamic>)) {
urlsWithDescriptions.addAll({
e['html_url'] as String: e['description'] != null
? e['description'] as String
: tr('noDescription')
});
}
return urlsWithDescriptions;
} else {
var gh = GitHub();
gh.rateLimitErrorCheck(res);
throw getObtainiumHttpError(res);
}
}
@override
Future<Map<String, String>> getUrlsWithDescriptions(List<String> args) async {
if (args.length != requiredArgs.length) {
throw ObtainiumError(tr('wrongArgNum'));
}
Map<String, String> urlsWithDescriptions = {};
var page = 1;
while (true) {
var pageUrls =
await getOnePageOfUserStarredUrlsWithDescriptions(args[0], page++);
urlsWithDescriptions.addAll(pageUrls);
if (pageUrls.length < 100) {
break;
}
}
return urlsWithDescriptions;
}
}

View File

@ -1,7 +1,15 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/app_sources/html.dart';
import 'package:obtainium/components/custom_app_bar.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/import_export.dart';
import 'package:obtainium/pages/settings.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
@ -16,143 +24,455 @@ class AddAppPage extends StatefulWidget {
}
class _AddAppPageState extends State<AddAppPage> {
final _formKey = GlobalKey<FormState>();
final urlInputController = TextEditingController();
bool gettingAppInfo = false;
bool searching = false;
String userInput = '';
String searchQuery = '';
String? pickedSourceOverride;
AppSource? pickedSource;
Map<String, dynamic> additionalSettings = {};
bool additionalSettingsValid = true;
List<String> pickedCategories = [];
int searchnum = 0;
SourceProvider sourceProvider = SourceProvider();
@override
Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider();
AppsProvider appsProvider = context.read<AppsProvider>();
bool doingSomething = gettingAppInfo || searching;
changeUserInput(String input, bool valid, bool isBuilding,
{bool isSearch = false}) {
userInput = input;
if (!isBuilding) {
setState(() {
if (isSearch) {
searchnum++;
}
var prevHost = pickedSource?.host;
try {
var naturalSource =
valid ? sourceProvider.getSource(userInput) : null;
if (naturalSource != null &&
naturalSource.runtimeType.toString() !=
HTML().runtimeType.toString()) {
// If input has changed to match a regular source, reset the override
pickedSourceOverride = null;
}
} catch (e) {
// ignore
}
var source = valid
? sourceProvider.getSource(userInput,
overrideSource: pickedSourceOverride)
: null;
if (pickedSource.runtimeType != source.runtimeType ||
(prevHost != null && prevHost != source?.host)) {
pickedSource = source;
additionalSettings = source != null
? getDefaultValuesFromFormItems(
source.combinedAppSpecificSettingFormItems)
: {};
additionalSettingsValid = source != null
? !sourceProvider.ifRequiredAppSpecificSettingsExist(source)
: true;
}
});
}
}
Future<bool> getTrackOnlyConfirmationIfNeeded(
bool userPickedTrackOnly, SettingsProvider settingsProvider,
{bool ignoreHideSetting = false}) async {
var useTrackOnly = userPickedTrackOnly || pickedSource!.enforceTrackOnly;
if (useTrackOnly &&
(!settingsProvider.hideTrackOnlyWarning || ignoreHideSetting)) {
// ignore: use_build_context_synchronously
var values = await showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
initValid: true,
title: tr('xIsTrackOnly', args: [
pickedSource!.enforceTrackOnly ? tr('source') : tr('app')
]),
items: [
[GeneratedFormSwitch('hide', label: tr('dontShowAgain'))]
],
message:
'${pickedSource!.enforceTrackOnly ? tr('appsFromSourceAreTrackOnly') : tr('youPickedTrackOnly')}\n\n${tr('trackOnlyAppDescription')}',
);
});
if (values != null) {
settingsProvider.hideTrackOnlyWarning = values['hide'] == true;
}
return useTrackOnly && values != null;
} else {
return true;
}
}
getReleaseDateAsVersionConfirmationIfNeeded(
bool userPickedTrackOnly) async {
return (!(additionalSettings['versionDetection'] ==
'releaseDateAsVersion' &&
// ignore: use_build_context_synchronously
await showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr('releaseDateAsVersion'),
items: const [],
message: tr('releaseDateAsVersionExplanation'),
);
}) ==
null));
}
addApp({bool resetUserInputAfter = false}) async {
setState(() {
gettingAppInfo = true;
});
try {
var settingsProvider = context.read<SettingsProvider>();
var userPickedTrackOnly = additionalSettings['trackOnly'] == true;
App? app;
if ((await getTrackOnlyConfirmationIfNeeded(
userPickedTrackOnly, settingsProvider)) &&
(await getReleaseDateAsVersionConfirmationIfNeeded(
userPickedTrackOnly))) {
var trackOnly = pickedSource!.enforceTrackOnly || userPickedTrackOnly;
app = await sourceProvider.getApp(
pickedSource!, userInput, additionalSettings,
trackOnlyOverride: trackOnly,
overrideSource: pickedSourceOverride);
// Only download the APK here if you need to for the package ID
if (sourceProvider.isTempId(app) &&
app.additionalSettings['trackOnly'] != true) {
// ignore: use_build_context_synchronously
var apkUrl = await appsProvider.confirmApkUrl(app, context);
if (apkUrl == null) {
throw ObtainiumError(tr('cancelled'));
}
app.preferredApkIndex =
app.apkUrls.map((e) => e.value).toList().indexOf(apkUrl.value);
// 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.additionalSettings['trackOnly'] == true ||
app.additionalSettings['versionDetection'] !=
'standardVersionDetection') {
app.installedVersion = app.latestVersion;
}
app.categories = pickedCategories;
await appsProvider.saveApps([app], onlyIfExists: false);
}
if (app != null) {
Navigator.push(globalNavigatorKey.currentContext ?? context,
MaterialPageRoute(builder: (context) => AppPage(appId: app!.id)));
}
} catch (e) {
showError(e, context);
} finally {
setState(() {
gettingAppInfo = false;
if (resetUserInputAfter) {
changeUserInput('', false, true);
}
});
}
}
Widget getUrlInputRow() => Row(
children: [
Expanded(
child: GeneratedForm(
key: Key(searchnum.toString()),
items: [
[
GeneratedFormTextField('appSourceURL',
label: tr('appSourceURL'),
defaultValue: userInput,
additionalValidators: [
(value) {
try {
sourceProvider
.getSource(value ?? '',
overrideSource: pickedSourceOverride)
.standardizeUrl(value ?? '');
} catch (e) {
return e is String
? e
: e is ObtainiumError
? e.toString()
: tr('error');
}
return null;
}
])
]
],
onValueChanges: (values, valid, isBuilding) {
changeUserInput(
values['appSourceURL']!, valid, isBuilding);
})),
const SizedBox(
width: 16,
),
gettingAppInfo
? const CircularProgressIndicator()
: ElevatedButton(
onPressed: doingSomething ||
pickedSource == null ||
(pickedSource!.combinedAppSpecificSettingFormItems
.isNotEmpty &&
!additionalSettingsValid)
? null
: () {
HapticFeedback.selectionClick();
addApp();
},
child: Text(tr('add')))
],
);
runSearch() async {
setState(() {
searching = true;
});
try {
var results = await 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
? []
// ignore: use_build_context_synchronously
: 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, false, isSearch: true);
}
} catch (e) {
showError(e, context);
} finally {
setState(() {
searching = false;
});
}
}
Widget getHTMLSourceOverrideDropdown() => Column(children: [
Row(
children: [
Expanded(
child: GeneratedForm(
items: [
[
GeneratedFormDropdown(
'overrideSource',
defaultValue: HTML().runtimeType.toString(),
[
...sourceProvider.sources.map(
(s) => MapEntry(s.runtimeType.toString(), s.name))
],
label: tr('overrideSource'))
]
],
onValueChanges: (values, valid, isBuilding) {
fn() {
pickedSourceOverride = (values['overrideSource'] == null ||
values['overrideSource'] == '')
? null
: values['overrideSource'];
}
if (!isBuilding) {
setState(() {
fn();
});
} else {
fn();
}
changeUserInput(userInput, valid, isBuilding);
},
))
],
),
const SizedBox(
height: 25,
),
]);
bool shouldShowSearchBar() =>
sourceProvider.sources.where((e) => e.canSearch).isNotEmpty &&
pickedSource == null &&
userInput.isEmpty;
Widget getSearchBarRow() => Row(
children: [
Expanded(
child: GeneratedForm(
items: [
[
GeneratedFormTextField('searchSomeSources',
label: tr('searchSomeSourcesLabel'), required: false),
]
],
onValueChanges: (values, valid, isBuilding) {
if (values.isNotEmpty && valid && !isBuilding) {
setState(() {
searchQuery = values['searchSomeSources']!.trim();
});
}
}),
),
const SizedBox(
width: 16,
),
ElevatedButton(
onPressed: searchQuery.isEmpty || doingSomething
? null
: () {
runSearch();
},
child: Text(tr('search')))
],
);
Widget getAdditionalOptsCol() => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Divider(
height: 64,
),
Text(
tr('additionalOptsFor',
args: [pickedSource?.name ?? tr('source')]),
style: TextStyle(color: Theme.of(context).colorScheme.primary)),
const SizedBox(
height: 16,
),
if (pickedSourceOverride != null ||
pickedSource.runtimeType.toString() ==
HTML().runtimeType.toString())
getHTMLSourceOverrideDropdown(),
GeneratedForm(
key: Key(pickedSource.runtimeType.toString()),
items: pickedSource!.combinedAppSpecificSettingFormItems,
onValueChanges: (values, valid, isBuilding) {
if (!isBuilding) {
setState(() {
additionalSettings = values;
additionalSettingsValid = valid;
});
}
}),
Column(
children: [
const SizedBox(
height: 16,
),
CategoryEditorSelector(
alignment: WrapAlignment.start,
onSelected: (categories) {
pickedCategories = categories;
}),
],
),
],
);
Widget getSourcesListWidget() => Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(
height: 48,
),
Text(
tr('supportedSourcesBelow'),
),
const SizedBox(
height: 8,
),
...sourceProvider.sources
.map((e) => GestureDetector(
onTap: e.host != null
? () {
launchUrlString('https://${e.host}',
mode: LaunchMode.externalApplication);
}
: null,
child: Text(
'${e.name}${e.enforceTrackOnly ? ' ${tr('trackOnlyInBrackets')}' : ''}${e.canSearch ? ' ${tr('searchableInBrackets')}' : ''}',
style: TextStyle(
decoration: e.host != null
? TextDecoration.underline
: TextDecoration.none,
fontStyle: FontStyle.italic),
)))
.toList()
]);
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[
const CustomAppBar(title: 'Add App'),
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
decoration: const InputDecoration(
hintText:
'https://github.com/Author/Project',
helperText: 'Enter the App source URL'),
controller: urlInputController,
validator: (value) {
if (value == null ||
value.isEmpty ||
Uri.tryParse(value) == null) {
return 'Please enter a supported source URL';
}
return null;
},
),
Padding(
padding:
const EdgeInsets.symmetric(vertical: 16.0),
child: ElevatedButton(
onPressed: gettingAppInfo
? null
: () {
HapticFeedback.selectionClick();
if (_formKey.currentState!
.validate()) {
setState(() {
gettingAppInfo = true;
});
sourceProvider
.getApp(urlInputController
.value.text)
.then((app) {
var appsProvider =
context.read<AppsProvider>();
var settingsProvider = context
.read<SettingsProvider>();
if (appsProvider.apps
.containsKey(app.id)) {
throw 'App already added';
}
settingsProvider
.getInstallPermission()
.then((_) {
appsProvider
.saveApp(app)
.then((_) {
urlInputController.clear();
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'),
),
),
],
),
body: CustomScrollView(shrinkWrap: true, slivers: <Widget>[
CustomAppBar(title: tr('addApp')),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
getUrlInputRow(),
const SizedBox(
height: 16,
),
if (shouldShowSearchBar())
const SizedBox(
height: 16,
),
Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Text(
'Supported Sources:',
// style: TextStyle(fontWeight: FontWeight.bold),
// style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(
height: 8,
),
...sourceProvider
.getSourceHosts()
.map((e) => GestureDetector(
onTap: () {
launchUrlString('https://$e',
mode:
LaunchMode.externalApplication);
},
child: Text(
e,
style: const TextStyle(
decoration:
TextDecoration.underline,
fontStyle: FontStyle.italic),
)))
.toList()
]),
if (gettingAppInfo)
const LinearProgressIndicator()
else
Container(),
],
)),
))
if (shouldShowSearchBar()) getSearchBarRow(),
if (pickedSource != null)
getAdditionalOptsCol()
else
getSourcesListWidget(),
const SizedBox(
height: 8,
),
])),
)
]));
}
}

View File

@ -1,8 +1,14 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/components/custom_app_bar.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/settings.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:provider/provider.dart';
@ -17,227 +23,448 @@ class AppPage extends StatefulWidget {
}
class _AppPageState extends State<AppPage> {
AppInMemory? prevApp;
@override
Widget build(BuildContext context) {
var appsProvider = context.watch<AppsProvider>();
var settingsProvider = context.watch<SettingsProvider>();
AppInMemory? app = appsProvider.apps[widget.appId];
if (app?.app.installedVersion != null) {
appsProvider.getUpdate(app!.app.id);
getUpdate(String id) {
appsProvider.checkUpdate(id).catchError((e) {
showError(e, context);
});
}
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[
CustomAppBar(title: '${app?.app.name}'),
SliverFillRemaining(
child: settingsProvider.showAppWebpage
? WebView(
initialUrl: app?.app.url,
javascriptMode: JavascriptMode.unrestricted,
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
app?.app.name ?? 'App',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.displayLarge,
),
Text(
'By ${app?.app.author ?? 'Unknown'}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(
height: 32,
),
GestureDetector(
onTap: () {
if (app?.app.url != null) {
launchUrlString(app?.app.url ?? '',
mode: LaunchMode.externalApplication);
}
},
child: Text(
app?.app.url ?? '',
textAlign: TextAlign.center,
style: const TextStyle(
decoration: TextDecoration.underline,
fontStyle: FontStyle.italic,
fontSize: 12),
)),
const SizedBox(
height: 32,
),
Text(
'Latest Version: ${app?.app.latestVersion ?? 'Unknown'}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
Text(
'Installed Version: ${app?.app.installedVersion ?? 'None'}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
],
bool areDownloadsRunning = appsProvider.areDownloadsRunning();
var sourceProvider = SourceProvider();
AppInMemory? app = appsProvider.apps[widget.appId]?.deepCopy();
var source = app != null
? sourceProvider.getSource(app.app.url,
overrideSource: app.app.overrideSource)
: null;
if (!areDownloadsRunning && prevApp == null && app != null) {
prevApp = app;
getUpdate(app.app.id);
}
var trackOnly = app?.app.additionalSettings['trackOnly'] == true;
bool isVersionDetectionStandard =
app?.app.additionalSettings['versionDetection'] ==
'standardVersionDetection';
getInfoColumn() => Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
GestureDetector(
onTap: () {
if (app?.app.url != null) {
launchUrlString(app?.app.url ?? '',
mode: LaunchMode.externalApplication);
}
},
onLongPress: () {
Clipboard.setData(ClipboardData(text: app?.app.url ?? ''));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(tr('copiedToClipboard')),
));
},
child: Text(
app?.app.url ?? '',
textAlign: TextAlign.center,
style: const TextStyle(
decoration: TextDecoration.underline,
fontStyle: FontStyle.italic,
fontSize: 12),
)),
const SizedBox(
height: 32,
),
Text(
tr('latestVersionX',
args: [app?.app.latestVersion ?? tr('unknown')]),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
Text(
'${tr('installedVersionX', args: [
app?.app.installedVersion ?? tr('none')
])}${trackOnly ? ' ${tr('estimateInBrackets')}\n\n${tr('xIsTrackOnly', args: [
tr('app')
])}' : ''}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
if (app?.app.installedVersion != null &&
!isVersionDetectionStandard)
Column(
children: [
const SizedBox(
height: 4,
),
Text(
tr('noVersionDetection'),
style: Theme.of(context).textTheme.labelSmall,
)
],
),
const SizedBox(
height: 32,
),
Text(
tr('lastUpdateCheckX', args: [
app?.app.lastUpdateCheck == null
? tr('never')
: '\n${app?.app.lastUpdateCheck?.toLocal()}'
]),
textAlign: TextAlign.center,
style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12),
),
const SizedBox(
height: 48,
),
CategoryEditorSelector(
alignment: WrapAlignment.center,
preselected: app?.app.categories != null
? app!.app.categories.toSet()
: {},
onSelected: (categories) {
if (app != null) {
app.app.categories = categories;
appsProvider.saveApps([app.app]);
}
}),
],
);
getFullInfoColumn() => Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 125),
app?.installedInfo != null
? Row(mainAxisAlignment: MainAxisAlignment.center, children: [
Image.memory(
app!.installedInfo!.icon!,
height: 150,
gaplessPlayback: true,
)
])
: Container(),
const SizedBox(
height: 25,
),
Text(
app?.name ?? tr('app'),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.displayLarge,
),
Text(
tr('byX', args: [app?.app.author ?? tr('unknown')]),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(
height: 8,
),
Text(
app?.app.id ?? '',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelSmall,
),
app?.app.releaseDate == null
? const SizedBox.shrink()
: Text(
app!.app.releaseDate.toString(),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelSmall,
),
const SizedBox(
height: 32,
),
getInfoColumn(),
const SizedBox(height: 150)
],
);
getAppWebView() => app != null
? WebViewWidget(
controller: WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Theme.of(context).colorScheme.background)
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onWebResourceError: (WebResourceError error) {
if (error.isForMainFrame == true) {
showError(
ObtainiumError(error.description, unexpected: true),
context);
}
},
),
),
]),
bottomSheet: Padding(
padding: EdgeInsets.fromLTRB(
0, 0, 0, MediaQuery.of(context).padding.bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
if (app?.app.installedVersion != app?.app.latestVersion)
IconButton(
onPressed: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
title: Text(
'App Already ${app?.app.installedVersion == null ? 'Installed' : 'Updated'}?'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('No')),
TextButton(
onPressed: () {
HapticFeedback.selectionClick();
var updatedApp = app?.app;
if (updatedApp != null) {
updatedApp.installedVersion =
updatedApp.latestVersion;
appsProvider
.saveApp(updatedApp);
}
Navigator.of(context).pop();
},
child: const Text(
'Yes, Mark as Installed'))
],
);
});
},
tooltip: 'Mark as Installed',
icon: const Icon(Icons.done))
else
IconButton(
onPressed: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
title: const Text('App Not Installed?'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('No')),
TextButton(
onPressed: () {
HapticFeedback.selectionClick();
var updatedApp = app?.app;
if (updatedApp != null) {
updatedApp.installedVersion =
null;
appsProvider
.saveApp(updatedApp);
}
Navigator.of(context).pop();
},
child: const Text(
'Yes, Mark as Not Installed'))
],
);
});
},
tooltip: 'Mark as Not Installed',
icon: const Icon(Icons.no_cell_outlined)),
const SizedBox(width: 16.0),
Expanded(
child: ElevatedButton(
onPressed: (app?.app.installedVersion == null ||
appsProvider
.checkAppObjectForUpdate(
app!.app)) &&
!appsProvider.areDownloadsRunning()
? () {
HapticFeedback.heavyImpact();
appsProvider
.downloadAndInstallLatestApp(
[app!.app.id],
context).then((res) {
if (res && mounted) {
Navigator.of(context).pop();
}
});
}
: null,
child: Text(app?.app.installedVersion == null
? 'Install'
: 'Update'))),
const SizedBox(width: 16.0),
ElevatedButton(
onPressed: app?.downloadProgress != null
? null
: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
title: const Text('Remove App?'),
content: Text(
'This will remove \'${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.' : ''}'),
actions: [
TextButton(
onPressed: () {
HapticFeedback
.selectionClick();
appsProvider
.removeApp(app!.app.id)
.then((_) {
int count = 0;
Navigator.of(context)
.popUntil((_) =>
count++ >= 2);
});
},
child: const Text('Remove')),
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Cancel'))
],
);
});
},
style: TextButton.styleFrom(
foregroundColor:
Theme.of(context).colorScheme.error,
surfaceTintColor:
Theme.of(context).colorScheme.error),
child: const Text('Remove'),
)
..loadRequest(Uri.parse(app.app.url)))
: Container();
showMarkUpdatedDialog() {
return showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
title: Text(tr('alreadyUpToDateQuestion')),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(tr('no'))),
TextButton(
onPressed: () {
HapticFeedback.selectionClick();
var updatedApp = app?.app;
if (updatedApp != null) {
updatedApp.installedVersion = updatedApp.latestVersion;
appsProvider.saveApps([updatedApp]);
}
Navigator.of(context).pop();
},
child: Text(tr('yesMarkUpdated')))
],
);
});
}
showAdditionalOptionsDialog() async {
return await showDialog<Map<String, dynamic>?>(
context: context,
builder: (BuildContext ctx) {
var items =
(source?.combinedAppSpecificSettingFormItems ?? []).map((row) {
row = row.map((e) {
if (app?.app.additionalSettings[e.key] != null) {
e.defaultValue = app?.app.additionalSettings[e.key];
}
return e;
}).toList();
return row;
}).toList();
items = items.map((row) {
row = row.map((e) {
if (e.key == 'versionDetection' && e is GeneratedFormDropdown) {
e.disabledOptKeys ??= [];
if (app?.app.installedVersion != null &&
app?.app.additionalSettings['versionDetection'] !=
'releaseDateAsVersion' &&
!appsProvider.isVersionDetectionPossible(app)) {
e.disabledOptKeys!.add('standardVersionDetection');
}
if (app?.app.releaseDate == null) {
e.disabledOptKeys!.add('releaseDateAsVersion');
}
}
return e;
}).toList();
return row;
}).toList();
return GeneratedFormModal(
title: tr('additionalOptions'), items: items);
});
}
handleAdditionalOptionChanges(Map<String, dynamic>? values) {
if (app != null && values != null) {
Map<String, dynamic> originalSettings = app.app.additionalSettings;
app.app.additionalSettings = values;
if (source?.enforceTrackOnly == true) {
app.app.additionalSettings['trackOnly'] = true;
// ignore: use_build_context_synchronously
showError(tr('appsFromSourceAreTrackOnly'), context);
}
if (app.app.additionalSettings['versionDetection'] ==
'releaseDateAsVersion') {
if (originalSettings['versionDetection'] != 'releaseDateAsVersion') {
if (app.app.releaseDate != null) {
bool isUpdated =
app.app.installedVersion == app.app.latestVersion;
app.app.latestVersion =
app.app.releaseDate!.microsecondsSinceEpoch.toString();
if (isUpdated) {
app.app.installedVersion = app.app.latestVersion;
}
}
}
} else if (originalSettings['versionDetection'] ==
'releaseDateAsVersion') {
app.app.installedVersion =
app.installedInfo?.versionName ?? app.app.installedVersion;
}
appsProvider.saveApps([app.app]).then((value) {
getUpdate(app.app.id);
});
}
}
getResetInstallStatusButton() => TextButton(
onPressed: app?.app == null
? null
: () {
app!.app.installedVersion = null;
appsProvider.saveApps([app.app]);
},
child: Text(
tr('resetInstallStatus'),
textAlign: TextAlign.center,
));
getInstallOrUpdateButton() => TextButton(
onPressed: (app?.app.installedVersion == null ||
app?.app.installedVersion != app?.app.latestVersion) &&
!areDownloadsRunning
? () async {
try {
HapticFeedback.heavyImpact();
var res = await appsProvider.downloadAndInstallLatestApps(
[app!.app.id], globalNavigatorKey.currentContext);
if (res.isNotEmpty && mounted) {
Navigator.of(context).pop();
}
} catch (e) {
showError(e, context);
}
}
: null,
child: Text(app?.app.installedVersion == null
? !trackOnly
? tr('install')
: tr('markInstalled')
: !trackOnly
? tr('update')
: tr('markUpdated')));
getBottomSheetMenu() => Padding(
padding:
EdgeInsets.fromLTRB(0, 0, 0, MediaQuery.of(context).padding.bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
if (app?.app.installedVersion != null &&
app?.app.installedVersion != app?.app.latestVersion &&
!isVersionDetectionStandard &&
!trackOnly)
IconButton(
onPressed: app?.downloadProgress != null
? null
: showMarkUpdatedDialog,
tooltip: tr('markUpdated'),
icon: const Icon(Icons.done)),
if (source != null &&
source.combinedAppSpecificSettingFormItems.isNotEmpty)
IconButton(
onPressed: app?.downloadProgress != null
? null
: () async {
var values =
await showAdditionalOptionsDialog();
handleAdditionalOptionChanges(values);
},
tooltip: tr('additionalOptions'),
icon: const Icon(Icons.edit)),
if (app != null && app.installedInfo != null)
IconButton(
onPressed: () {
appsProvider.openAppSettings(app.app.id);
},
icon: const Icon(Icons.settings),
tooltip: tr('settings'),
),
])),
if (app?.downloadProgress != null)
Padding(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 0),
child: LinearProgressIndicator(
value: app!.downloadProgress! / 100))
],
)),
);
if (app != null && settingsProvider.showAppWebpage)
IconButton(
onPressed: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
scrollable: true,
content: getInfoColumn(),
title: Text(
'${app.name} ${tr('byX', args: [
app.app.author
])}'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(tr('continue')))
],
);
});
},
icon: const Icon(Icons.more_horiz),
tooltip: tr('more')),
const SizedBox(width: 16.0),
Expanded(
child: (!isVersionDetectionStandard || trackOnly) &&
app?.app.installedVersion != null &&
app?.app.installedVersion ==
app?.app.latestVersion
? getResetInstallStatusButton()
: getInstallOrUpdateButton()),
const SizedBox(width: 16.0),
Expanded(
child: TextButton(
onPressed: app?.downloadProgress != null
? null
: () {
appsProvider.removeAppsWithModal(
context, [app!.app]).then((value) {
if (value == true) {
Navigator.of(context).pop();
}
});
},
style: TextButton.styleFrom(
foregroundColor:
Theme.of(context).colorScheme.error,
surfaceTintColor:
Theme.of(context).colorScheme.error),
child: Text(tr('remove')),
)),
])),
if (app?.downloadProgress != null)
Padding(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 0),
child: LinearProgressIndicator(
value: app!.downloadProgress! / 100))
],
));
return Scaffold(
appBar: settingsProvider.showAppWebpage ? AppBar() : null,
backgroundColor: Theme.of(context).colorScheme.surface,
body: RefreshIndicator(
child: settingsProvider.showAppWebpage
? getAppWebView()
: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Column(children: [getFullInfoColumn()])),
],
),
onRefresh: () async {
if (app != null) {
getUpdate(app.app.id);
}
}),
bottomSheet: getBottomSheetMenu());
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,13 @@
import 'package:animations/animations.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/pages/add_app.dart';
import 'package:obtainium/pages/apps.dart';
import 'package:obtainium/pages/import_export.dart';
import 'package:obtainium/pages/settings.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:provider/provider.dart';
class HomePage extends StatefulWidget {
const HomePage({super.key});
@ -23,17 +26,52 @@ class NavigationPageItem {
class _HomePageState extends State<HomePage> {
List<int> selectedIndexHistory = [];
int prevAppCount = -1;
List<NavigationPageItem> pages = [
NavigationPageItem('Apps', Icons.apps, const AppsPage()),
NavigationPageItem('Add App', Icons.add, const AddAppPage()),
NavigationPageItem(tr('appsString'), Icons.apps,
AppsPage(key: GlobalKey<AppsPageState>())),
NavigationPageItem(tr('addApp'), Icons.add, const AddAppPage()),
NavigationPageItem(
'Import/Export', Icons.import_export, const ImportExportPage()),
NavigationPageItem('Settings', Icons.settings, const SettingsPage())
tr('importExport'), Icons.import_export, const ImportExportPage()),
NavigationPageItem(tr('settings'), Icons.settings, const SettingsPage())
];
@override
Widget build(BuildContext context) {
AppsProvider appsProvider = context.watch<AppsProvider>();
switchToPage(int index) async {
if (index == 0) {
while ((pages[0].widget.key as GlobalKey<AppsPageState>).currentState !=
null) {
// Avoid duplicate GlobalKey error
await Future.delayed(const Duration(microseconds: 1));
}
setState(() {
selectedIndexHistory.clear();
});
} else if (selectedIndexHistory.isEmpty ||
(selectedIndexHistory.isNotEmpty &&
selectedIndexHistory.last != index)) {
setState(() {
int existingInd = selectedIndexHistory.indexOf(index);
if (existingInd >= 0) {
selectedIndexHistory.removeAt(existingInd);
}
selectedIndexHistory.add(index);
});
}
}
if (prevAppCount >= 0 &&
appsProvider.apps.length > prevAppCount &&
selectedIndexHistory.isNotEmpty &&
selectedIndexHistory.last == 1) {
switchToPage(0);
}
prevAppCount = appsProvider.apps.length;
return WillPopScope(
child: Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
@ -61,21 +99,9 @@ class _HomePageState extends State<HomePage> {
.map((e) =>
NavigationDestination(icon: Icon(e.icon), label: e.title))
.toList(),
onDestinationSelected: (int index) {
onDestinationSelected: (int index) async {
HapticFeedback.selectionClick();
setState(() {
if (index == 0) {
selectedIndexHistory.clear();
} else if (selectedIndexHistory.isEmpty ||
(selectedIndexHistory.isNotEmpty &&
selectedIndexHistory.last != index)) {
int existingInd = selectedIndexHistory.indexOf(index);
if (existingInd >= 0) {
selectedIndexHistory.removeAt(existingInd);
}
selectedIndexHistory.add(index);
}
});
await switchToPage(index);
},
selectedIndex:
selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last,
@ -88,7 +114,9 @@ class _HomePageState extends State<HomePage> {
});
return false;
}
return true;
return !(pages[0].widget.key as GlobalKey<AppsPageState>)
.currentState
?.clearSelected();
});
}
}

View File

@ -1,15 +1,19 @@
import 'dart:convert';
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/components/custom_app_bar.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/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:provider/provider.dart';
import 'package:file_picker/file_picker.dart';
import 'package:url_launcher/url_launcher_string.dart';
class ImportExportPage extends StatefulWidget {
const ImportExportPage({super.key});
@ -24,8 +28,9 @@ class _ImportExportPageState extends State<ImportExportPage> {
@override
Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider();
var settingsProvider = context.read<SettingsProvider>();
var appsProvider = context.read<AppsProvider>();
var settingsProvider = context.read<SettingsProvider>();
var outlineButtonStyle = ButtonStyle(
shape: MaterialStateProperty.all(
StadiumBorder(
@ -37,29 +42,258 @@ class _ImportExportPageState extends State<ImportExportPage> {
),
);
Future<List<List<String>>> addApps(List<String> urls) async {
await settingsProvider.getInstallPermission();
List<dynamic> results = await sourceProvider.getApps(urls);
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.saveApp(app);
urlListImport({String? initValue, bool overrideInitValid = false}) {
showDialog<Map<String, dynamic>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
initValid: overrideInitValid,
title: tr('importFromURLList'),
items: [
[
GeneratedFormTextField('appURLList',
defaultValue: initValue ?? '',
label: tr('appURLList'),
max: 7,
additionalValidators: [
(dynamic value) {
if (value != null && value.isNotEmpty) {
var lines = value.trim().split('\n');
for (int i = 0; i < lines.length; i++) {
try {
sourceProvider.getSource(lines[i]);
} catch (e) {
return '${tr('line')} ${i + 1}: $e';
}
}
}
return null;
}
])
]
],
);
}).then((values) {
if (values != null) {
var urls = (values['appURLList'] as String).split('\n');
setState(() {
importInProgress = true;
});
appsProvider.addAppsByURL(urls).then((errors) {
if (errors.isEmpty) {
showError(tr('importedX', args: [plural('apps', urls.length)]),
context);
} else {
showDialog(
context: context,
builder: (BuildContext ctx) {
return ImportErrorDialog(
urlsLength: urls.length, errors: errors);
});
}
}).catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
}
}
List<List<String>> errors =
errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList();
return errors;
});
}
runObtainiumExport() {
HapticFeedback.selectionClick();
appsProvider.exportApps().then((String path) {
showError(tr('exportedTo', args: [path]), context);
}).catchError((e) {
showError(e, context);
});
}
runObtainiumImport() {
HapticFeedback.selectionClick();
FilePicker.platform.pickFiles().then((result) {
setState(() {
importInProgress = true;
});
if (result != null) {
String data = File(result.files.single.path!).readAsStringSync();
try {
jsonDecode(data);
} catch (e) {
throw ObtainiumError(tr('invalidInput'));
}
appsProvider.importApps(data).then((value) {
var cats = settingsProvider.categories;
appsProvider.apps.forEach((key, value) {
for (var c in value.app.categories) {
if (!cats.containsKey(c)) {
cats[c] = generateRandomLightColor().value;
}
}
});
appsProvider.addMissingCategories(settingsProvider);
showError(tr('importedX', args: [plural('apps', value)]), context);
});
} else {
// User canceled the picker
}
}).catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
}
runUrlImport() {
FilePicker.platform.pickFiles().then((result) {
if (result != null) {
urlListImport(
overrideInitValid: true,
initValue: RegExp('https?://[^"]+')
.allMatches(
File(result.files.single.path!).readAsStringSync())
.map((e) => e.input.substring(e.start, e.end))
.toSet()
.toList()
.where((url) {
try {
sourceProvider.getSource(url);
return true;
} catch (e) {
return false;
}
}).join('\n'));
}
});
}
runSourceSearch(AppSource source) {
() async {
var values = await showDialog<Map<String, dynamic>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr('searchX', args: [source.name]),
items: [
[
GeneratedFormTextField('searchQuery',
label: tr('searchQuery'))
]
],
);
});
if (values != null &&
(values['searchQuery'] as String?)?.isNotEmpty == true) {
setState(() {
importInProgress = true;
});
var urlsWithDescriptions =
await source.search(values['searchQuery'] as String);
if (urlsWithDescriptions.isNotEmpty) {
var selectedUrls =
// ignore: use_build_context_synchronously
await showDialog<List<String>?>(
context: context,
builder: (BuildContext ctx) {
return UrlSelectionModal(
urlsWithDescriptions: urlsWithDescriptions,
selectedByDefault: false,
);
});
if (selectedUrls != null && selectedUrls.isNotEmpty) {
var errors = await appsProvider.addAppsByURL(selectedUrls);
if (errors.isEmpty) {
// ignore: use_build_context_synchronously
showError(
tr('importedX', args: [plural('app', selectedUrls.length)]),
context);
} else {
// ignore: use_build_context_synchronously
showDialog(
context: context,
builder: (BuildContext ctx) {
return ImportErrorDialog(
urlsLength: selectedUrls.length, errors: errors);
});
}
}
} else {
throw ObtainiumError(tr('noResults'));
}
}
}()
.catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
}
runMassSourceImport(MassAppUrlSource source) {
() async {
var values = await showDialog<Map<String, dynamic>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr('importX', args: [source.name]),
items: source.requiredArgs
.map((e) => [GeneratedFormTextField(e, label: e)])
.toList(),
);
});
if (values != null) {
setState(() {
importInProgress = true;
});
var urlsWithDescriptions = await source.getUrlsWithDescriptions(
values.values.map((e) => e.toString()).toList());
var selectedUrls =
// ignore: use_build_context_synchronously
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 {
// ignore: use_build_context_synchronously
showDialog(
context: context,
builder: (BuildContext ctx) {
return ImportErrorDialog(
urlsLength: selectedUrls.length, errors: errors);
});
}
}
}
}()
.catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
}
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[
const CustomAppBar(title: 'Import/Export'),
CustomAppBar(title: tr('importExport')),
SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
@ -74,20 +308,8 @@ class _ImportExportPageState extends State<ImportExportPage> {
onPressed: appsProvider.apps.isEmpty ||
importInProgress
? null
: () {
HapticFeedback.selectionClick();
appsProvider
.exportApps()
.then((String path) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: Text(
'Exported to $path')),
);
});
},
child: const Text('Obtainium Export'))),
: runObtainiumExport,
child: Text(tr('obtainiumExport')))),
const SizedBox(
width: 16,
),
@ -96,49 +318,8 @@ class _ImportExportPageState extends State<ImportExportPage> {
style: outlineButtonStyle,
onPressed: importInProgress
? null
: () {
HapticFeedback.selectionClick();
FilePicker.platform
.pickFiles()
.then((result) {
setState(() {
importInProgress = true;
});
if (result != null) {
String data = File(
result.files.single.path!)
.readAsStringSync();
try {
jsonDecode(data);
} catch (e) {
throw 'Invalid input';
}
appsProvider
.importApps(data)
.then((value) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: Text(
'$value App${value == 1 ? '' : 's'} Imported')),
);
});
} else {
// User canceled the picker
}
}).catchError((e) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: Text(e.toString())),
);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
},
child: const Text('Obtainium Import')))
: runObtainiumImport,
child: Text(tr('obtainiumImport'))))
],
),
if (importInProgress)
@ -154,64 +335,28 @@ class _ImportExportPageState extends State<ImportExportPage> {
],
)
else
const Divider(
height: 32,
Column(
children: [
const Divider(
height: 32,
),
TextButton(
onPressed:
importInProgress ? null : urlListImport,
child: Text(
tr('importFromURLList'),
)),
const SizedBox(height: 8),
TextButton(
onPressed:
importInProgress ? null : runUrlImport,
child: Text(
tr('importFromURLsInFile'),
)),
],
),
TextButton(
onPressed: importInProgress
? null
: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: 'Import from URL List',
items: [
GeneratedFormItem(
'App URL List', true, 7)
],
);
}).then((values) {
if (values != null) {
var urls =
(values[0] as String).split('\n');
setState(() {
importInProgress = true;
});
addApps(urls).then((errors) {
if (errors.isEmpty) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: Text(
'Imported ${urls.length} Apps')),
);
} else {
showDialog(
context: context,
builder: (BuildContext ctx) {
return ImportErrorDialog(
urlsLength: urls.length,
errors: errors);
});
}
}).catchError((e) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(content: Text(e.toString())),
);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
}
});
},
child: const Text(
'Import from URL List',
)),
...sourceProvider.massSources
...sourceProvider.sources
.where((element) => element.canSearch)
.map((source) => Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
@ -221,73 +366,39 @@ class _ImportExportPageState extends State<ImportExportPage> {
onPressed: importInProgress
? null
: () {
showDialog(
context: context,
builder:
(BuildContext ctx) {
return GeneratedFormModal(
title:
'Import ${source.name}',
items: source
.requiredArgs
.map((e) =>
GeneratedFormItem(
e,
true,
1))
.toList());
}).then((values) {
if (values != null) {
source
.getUrls(values)
.then((urls) {
setState(() {
importInProgress = true;
});
addApps(urls)
.then((errors) {
if (errors.isEmpty) {
ScaffoldMessenger.of(
context)
.showSnackBar(
SnackBar(
content: Text(
'Imported ${urls.length} Apps')),
);
} else {
showDialog(
context: context,
builder:
(BuildContext
ctx) {
return ImportErrorDialog(
urlsLength: urls
.length,
errors:
errors);
});
}
}).whenComplete(() {
setState(() {
importInProgress =
false;
});
});
}).catchError((e) {
ScaffoldMessenger.of(
context)
.showSnackBar(
SnackBar(
content: Text(
e.toString())),
);
});
}
});
runSourceSearch(source);
},
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
: () {
runMassSourceImport(source);
},
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,
)
],
)))
]));
@ -310,16 +421,19 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> {
Widget build(BuildContext context) {
return AlertDialog(
scrollable: true,
title: const Text('Import Errors'),
title: Text(tr('importErrors')),
content:
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
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,
),
const SizedBox(height: 16),
Text(
'The following URLs had errors:',
tr('followingURLsHadErrors'),
style: Theme.of(context).textTheme.bodyLarge,
),
...widget.errors.map((e) {
@ -342,7 +456,175 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> {
onPressed: () {
Navigator.of(context).pop(null);
},
child: const Text('Okay'))
child: Text(tr('okay')))
],
);
}
}
// ignore: must_be_immutable
class UrlSelectionModal extends StatefulWidget {
UrlSelectionModal(
{super.key,
required this.urlsWithDescriptions,
this.selectedByDefault = true,
this.onlyOneSelectionAllowed = false});
Map<String, String> urlsWithDescriptions;
bool selectedByDefault;
bool onlyOneSelectionAllowed;
@override
State<UrlSelectionModal> createState() => _UrlSelectionModalState();
}
class _UrlSelectionModalState extends State<UrlSelectionModal> {
Map<MapEntry<String, String>, bool> urlWithDescriptionSelections = {};
@override
void initState() {
super.initState();
for (var url in widget.urlsWithDescriptions.entries) {
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;
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
scrollable: true,
title: Text(
widget.onlyOneSelectionAllowed ? tr('selectURL') : tr('selectURLs')),
content: Column(children: [
...urlWithDescriptionSelections.keys.map((urlWithD) {
selectThis(bool? value) {
setState(() {
value ??= false;
if (value! && widget.onlyOneSelectionAllowed) {
selectOnlyOne(urlWithD.key);
} else {
urlWithDescriptionSelections[urlWithD] = value!;
}
});
}
var urlLink = 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,
));
var descriptionText = Text(
urlWithD.value.length > 128
? '${urlWithD.value.substring(0, 128)}...'
: urlWithD.value,
style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12),
);
var selectedUrlsWithDs = urlWithDescriptionSelections.entries
.where((e) => e.value)
.toList();
var singleSelectTile = ListTile(
title: urlLink,
subtitle: GestureDetector(
onTap: () {
setState(() {
selectOnlyOne(urlWithD.key);
});
},
child: descriptionText,
),
leading: Radio<String>(
value: urlWithD.key,
groupValue: selectedUrlsWithDs.isEmpty
? null
: selectedUrlsWithDs.first.key.key,
onChanged: (value) {
setState(() {
selectOnlyOne(urlWithD.key);
});
},
),
);
var multiSelectTile = Row(children: [
Checkbox(
value: urlWithDescriptionSelections[urlWithD],
onChanged: (value) {
selectThis(value);
}),
const SizedBox(
width: 8,
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(
height: 8,
),
urlLink,
GestureDetector(
onTap: () {
selectThis(
!(urlWithDescriptionSelections[urlWithD] ?? false));
},
child: descriptionText,
),
const SizedBox(
height: 8,
)
],
))
]);
return widget.onlyOneSelectionAllowed
? singleSelectTile
: multiSelectTile;
})
]),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(tr('cancel'))),
TextButton(
onPressed:
urlWithDescriptionSelections.values.where((b) => b).isEmpty
? null
: () {
Navigator.of(context).pop(urlWithDescriptionSelections
.entries
.where((entry) => entry.value)
.map((e) => e.key.key)
.toList());
},
child: Text(widget.onlyOneSelectionAllowed
? tr('pick')
: tr('importX', args: [
plural(
'url',
urlWithDescriptionSelections.values
.where((b) => b)
.length)
])))
],
);
}

View File

@ -1,7 +1,17 @@
import 'dart:math';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/main.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/logs_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher_string.dart';
class SettingsPage extends StatefulWidget {
@ -11,141 +21,240 @@ class SettingsPage extends StatefulWidget {
State<SettingsPage> createState() => _SettingsPageState();
}
// Generates a random light color
// Courtesy of ChatGPT 😭 (with a bugfix 🥳)
Color generateRandomLightColor() {
// Create a random number generator
final Random random = Random();
// Generate random hue, saturation, and value values
final double hue = random.nextDouble() * 360;
final double saturation = 0.5 + random.nextDouble() * 0.5;
final double value = 0.9 + random.nextDouble() * 0.1;
// Create a HSV color with the random values
return HSVColor.fromAHSV(1.0, hue, saturation, value).toColor();
}
class _SettingsPageState extends State<SettingsPage> {
@override
Widget build(BuildContext context) {
SettingsProvider settingsProvider = context.watch<SettingsProvider>();
SourceProvider sourceProvider = SourceProvider();
if (settingsProvider.prefs == null) {
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(
isExpanded: true,
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')),
),
DropdownMenuItem(
value: SortColumnSettings.releaseDate,
child: Text(tr('releaseDate')),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.sortColumn = value;
}
});
var orderDropdown = DropdownButtonFormField(
isExpanded: true,
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 localeDropdown = DropdownButtonFormField(
decoration: InputDecoration(labelText: tr('language')),
value: settingsProvider.forcedLocale,
items: [
DropdownMenuItem(
value: null,
child: Text(tr('followSystem')),
),
...supportedLocales.map((e) => DropdownMenuItem(
value: e.toLanguageTag(),
child: Text(e.toLanguageTag().toUpperCase()),
))
],
onChanged: (value) {
settingsProvider.forcedLocale = value;
if (value != null) {
context.setLocale(Locale(value));
} else {
settingsProvider.resetLocaleSafe(context);
}
});
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.defaultValue = settingsProvider.getSettingString(e.key);
return [e];
}).toList(),
onValueChanges: (values, valid, isBuilding) {
if (valid && !isBuilding) {
values.forEach((key, value) {
settingsProvider.setSettingString(key, value);
});
}
});
} else {
return Container();
}
});
const height16 = SizedBox(
height: 16,
);
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[
const CustomAppBar(title: 'Settings'),
SliverFillRemaining(
hasScrollBody: true,
CustomAppBar(title: tr('settings')),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: settingsProvider.prefs == null
? Container()
? const SizedBox()
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Appearance',
tr('appearance'),
style: TextStyle(
color: Theme.of(context).colorScheme.primary),
),
DropdownButtonFormField(
decoration:
const InputDecoration(labelText: 'Theme'),
value: settingsProvider.theme,
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,
themeDropdown,
height16,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(tr('useBlackTheme')),
Switch(
value: settingsProvider.useBlackTheme,
onChanged: (value) {
settingsProvider.useBlackTheme = value;
})
],
),
colourDropdown,
height16,
Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
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;
}
})),
Expanded(child: sortDropdown),
const SizedBox(
width: 16,
),
Expanded(
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;
}
})),
Expanded(child: orderDropdown),
],
),
const SizedBox(
height: 16,
),
height16,
localeDropdown,
height16,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Show Source Webpage in App View'),
Text(tr('showWebInAppView')),
Switch(
value: settingsProvider.showAppWebpage,
onChanged: (value) {
@ -153,84 +262,261 @@ class _SettingsPageState extends State<SettingsPage> {
})
],
),
height16,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(tr('pinUpdates')),
Switch(
value: settingsProvider.pinUpdates,
onChanged: (value) {
settingsProvider.pinUpdates = value;
})
],
),
height16,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(tr('groupByCategory')),
Switch(
value: settingsProvider.groupByCategory,
onChanged: (value) {
settingsProvider.groupByCategory = value;
})
],
),
height16,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(tr('dontShowTrackOnlyWarnings')),
Switch(
value:
settingsProvider.hideTrackOnlyWarning,
onChanged: (value) {
settingsProvider.hideTrackOnlyWarning =
value;
})
],
),
height16,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(tr('dontShowAPKOriginWarnings')),
Switch(
value:
settingsProvider.hideAPKOriginWarning,
onChanged: (value) {
settingsProvider.hideAPKOriginWarning =
value;
})
],
),
const Divider(
height: 16,
),
const SizedBox(
height: 16,
),
height16,
Text(
'More',
tr('updates'),
style: TextStyle(
color: Theme.of(context).colorScheme.primary),
),
DropdownButtonFormField(
decoration: const InputDecoration(
labelText:
'Background Update Checking Interval'),
value: settingsProvider.updateInterval,
items: const [
DropdownMenuItem(
value: 15,
child: Text('15 Minutes'),
),
DropdownMenuItem(
value: 30,
child: Text('30 Minutes'),
),
DropdownMenuItem(
value: 60,
child: Text('1 Hour'),
),
DropdownMenuItem(
value: 360,
child: Text('6 Hours'),
),
DropdownMenuItem(
value: 720,
child: Text('12 Hours'),
),
DropdownMenuItem(
value: 1440,
child: Text('1 Day'),
),
DropdownMenuItem(
value: 0,
child: Text('Never - Manual Only'),
),
],
onChanged: (value) {
if (value != null) {
settingsProvider.updateInterval = value;
}
}),
const Spacer(),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton.icon(
style: ButtonStyle(
foregroundColor:
MaterialStateProperty.resolveWith<
Color>((Set<MaterialState> states) {
return Colors.grey;
}),
),
onPressed: () {
launchUrlString(settingsProvider.sourceUrl,
mode: LaunchMode.externalApplication);
},
icon: const Icon(Icons.code),
label: Text(
'Source',
style:
Theme.of(context).textTheme.bodySmall,
),
)
],
intervalDropdown,
const Divider(
height: 48,
),
Text(
tr('sourceSpecific'),
style: TextStyle(
color: Theme.of(context).colorScheme.primary),
),
...sourceSpecificFields,
const Divider(
height: 48,
),
Text(
tr('categories'),
style: TextStyle(
color: Theme.of(context).colorScheme.primary),
),
height16,
const CategoryEditorSelector(
showLabelWhenNotEmpty: false,
)
],
)))
))),
SliverToBoxAdapter(
child: Column(
children: [
const Divider(
height: 32,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
TextButton.icon(
onPressed: () {
launchUrlString(settingsProvider.sourceUrl,
mode: LaunchMode.externalApplication);
},
icon: const Icon(Icons.code),
label: Text(
tr('appSource'),
),
),
TextButton.icon(
onPressed: () {
context.read<LogsProvider>().get().then((logs) {
if (logs.isEmpty) {
showError(ObtainiumError(tr('noLogs')), context);
} 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')))
],
);
}
}
class CategoryEditorSelector extends StatefulWidget {
final void Function(List<String> categories)? onSelected;
final bool singleSelect;
final Set<String> preselected;
final WrapAlignment alignment;
final bool showLabelWhenNotEmpty;
const CategoryEditorSelector(
{super.key,
this.onSelected,
this.singleSelect = false,
this.preselected = const {},
this.alignment = WrapAlignment.start,
this.showLabelWhenNotEmpty = true});
@override
State<CategoryEditorSelector> createState() => _CategoryEditorSelectorState();
}
class _CategoryEditorSelectorState extends State<CategoryEditorSelector> {
Map<String, MapEntry<int, bool>> storedValues = {};
@override
Widget build(BuildContext context) {
var settingsProvider = context.watch<SettingsProvider>();
var appsProvider = context.watch<AppsProvider>();
storedValues = settingsProvider.categories.map((key, value) => MapEntry(
key,
MapEntry(value,
storedValues[key]?.value ?? widget.preselected.contains(key))));
return GeneratedForm(
items: [
[
GeneratedFormTagInput('categories',
label: tr('categories'),
emptyMessage: tr('noCategories'),
defaultValue: storedValues,
alignment: widget.alignment,
deleteConfirmationMessage: MapEntry(
tr('deleteCategoriesQuestion'),
tr('categoryDeleteWarning')),
singleSelect: widget.singleSelect,
showLabelWhenNotEmpty: widget.showLabelWhenNotEmpty)
]
],
onValueChanges: ((values, valid, isBuilding) {
if (!isBuilding) {
storedValues =
values['categories'] as Map<String, MapEntry<int, bool>>;
settingsProvider.setCategories(
storedValues.map((key, value) => MapEntry(key, value.key)),
appsProvider: appsProvider);
if (widget.onSelected != null) {
widget.onSelected!(storedValues.keys
.where((k) => storedValues[k]!.value)
.toList());
}
}
}));
}
}

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
// 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:obtainium/providers/source_provider.dart';
@ -12,24 +13,42 @@ class ObtainiumNotification {
late String channelName;
late String channelDescription;
Importance importance;
int? progPercent;
bool onlyAlertOnce;
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 {
UpdateNotification(List<App> updates)
: super(
2,
'Updates Available',
tr('updatesAvailable'),
'',
'UPDATES_AVAILABLE',
'Updates Available',
'Notifies the user that updates are available for one or more Apps tracked by Obtainium',
tr('updatesAvailable'),
tr('updatesAvailableNotifDescription'),
Importance.max) {
message = updates.isEmpty
? tr('noNewUpdates')
: updates.length == 1
? tr('xHasAnUpdate', args: [updates[0].finalName])
: plural('xAndNMoreUpdatesAvailable', updates.length - 1,
args: [updates[0].finalName, (updates.length - 1).toString()]);
}
}
class SilentUpdateNotification extends ObtainiumNotification {
SilentUpdateNotification(List<App> updates)
: super(3, tr('appsUpdated'), '', 'APPS_UPDATED', tr('appsUpdated'),
tr('appsUpdatedNotifDescription'), Importance.defaultImportance) {
message = updates.length == 1
? '${updates[0].name} has an update.'
: '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} have updates.';
? tr('xWasUpdatedToY',
args: [updates[0].finalName, updates[0].latestVersion])
: plural('xAndNMoreUpdatesInstalled', updates.length - 1,
args: [updates[0].finalName, (updates.length - 1).toString()]);
}
}
@ -37,30 +56,56 @@ class ErrorCheckingUpdatesNotification extends ObtainiumNotification {
ErrorCheckingUpdatesNotification(String error)
: super(
5,
'Error Checking for Updates',
tr('errorCheckingUpdates'),
error,
'BG_UPDATE_CHECK_ERROR',
'Error Checking for Updates',
'A notification that shows when background update checking fails',
tr('errorCheckingUpdates'),
tr('errorCheckingUpdatesNotifDescription'),
Importance.high);
}
class AppsRemovedNotification extends ObtainiumNotification {
AppsRemovedNotification(List<List<String>> namedReasons)
: super(6, tr('appsRemoved'), '', 'APPS_REMOVED', tr('appsRemoved'),
tr('appsRemovedNotifDescription'), Importance.max) {
message = '';
for (var r in namedReasons) {
message += '${tr('xWasRemovedDueToErrorY', args: [r[0], r[1]])} \n';
}
message = message.trim();
}
}
class DownloadNotification extends ObtainiumNotification {
DownloadNotification(String appName, int progPercent)
: super(
appName.hashCode,
tr('downloadingX', args: [appName]),
'',
'APP_DOWNLOADING',
tr('downloadingX', args: [tr('app')]),
tr('downloadNotifDescription'),
Importance.low,
onlyAlertOnce: true,
progPercent: progPercent);
}
final completeInstallationNotification = ObtainiumNotification(
1,
'Complete App Installation',
'Obtainium must be open to install Apps',
tr('completeAppInstallation'),
tr('obtainiumMustBeOpenToInstallApps'),
'COMPLETE_INSTALL',
'Complete App Installation',
'Asks the user to return to Obtanium to finish installing an App',
tr('completeAppInstallation'),
tr('completeAppInstallationNotifDescription'),
Importance.max);
final checkingUpdatesNotification = ObtainiumNotification(
4,
'Checking for Updates',
tr('checkingForUpdates'),
'',
'BG_UPDATE_CHECK',
'Checking for Updates',
'Transient notification that appears when checking for updates',
tr('checkingForUpdates'),
tr('checkingForUpdatesNotifDescription'),
Importance.min);
class NotificationsProvider {
@ -100,7 +145,9 @@ class NotificationsProvider {
String channelName,
String channelDescription,
Importance importance,
{bool cancelExisting = false}) async {
{bool cancelExisting = false,
int? progPercent,
bool onlyAlertOnce = false}) async {
if (cancelExisting) {
await cancel(id);
}
@ -116,12 +163,18 @@ class NotificationsProvider {
channelDescription: channelDescription,
importance: 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,
{bool cancelExisting = false}) =>
notifyRaw(notif.id, notif.title, notif.message, notif.channelCode,
notif.channelName, notif.channelDescription, notif.importance,
cancelExisting: cancelExisting);
cancelExisting: cancelExisting,
onlyAlertOnce: notif.onlyAlertOnce,
progPercent: notif.progPercent);
}

View File

@ -1,18 +1,38 @@
// Exposes functions used to save/load app settings
import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/main.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:permission_handler/permission_handler.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 ColourSettings { basic, materialYou }
enum SortColumnSettings { added, nameAuthor, authorName }
enum SortColumnSettings { added, nameAuthor, authorName, releaseDate }
enum SortOrderSettings { ascending, descending }
const maxAPIRateLimitMinutes = 30;
const minUpdateIntervalMinutes = maxAPIRateLimitMinutes + 30;
const maxUpdateIntervalMinutes = 4320;
List<int> updateIntervals = [15, 30, 60, 120, 180, 360, 720, 1440, 4320, 0]
.where((element) =>
(element >= minUpdateIntervalMinutes &&
element <= maxUpdateIntervalMinutes) ||
element == 0)
.toList();
class SettingsProvider with ChangeNotifier {
SharedPreferences? prefs;
@ -44,8 +64,27 @@ class SettingsProvider with ChangeNotifier {
notifyListeners();
}
bool get useBlackTheme {
return prefs?.getBool('useBlackTheme') ?? false;
}
set useBlackTheme(bool useBlackTheme) {
prefs?.setBool('useBlackTheme', useBlackTheme);
notifyListeners();
}
int get updateInterval {
return prefs?.getInt('updateInterval') ?? 1440;
var min = prefs?.getInt('updateInterval') ?? 360;
if (!updateIntervals.contains(min)) {
var temp = updateIntervals[0];
for (var i in updateIntervals) {
if (min > i && i != 0) {
temp = i;
}
}
min = temp;
}
return min;
}
set updateInterval(int min) {
@ -54,8 +93,8 @@ class SettingsProvider with ChangeNotifier {
}
SortColumnSettings get sortColumn {
return SortColumnSettings
.values[prefs?.getInt('sortColumn') ?? SortColumnSettings.added.index];
return SortColumnSettings.values[
prefs?.getInt('sortColumn') ?? SortColumnSettings.nameAuthor.index];
}
set sortColumn(SortColumnSettings s) {
@ -65,7 +104,7 @@ class SettingsProvider with ChangeNotifier {
SortOrderSettings get sortOrder {
return SortOrderSettings.values[
prefs?.getInt('sortOrder') ?? SortOrderSettings.descending.index];
prefs?.getInt('sortOrder') ?? SortOrderSettings.ascending.index];
}
set sortOrder(SortOrderSettings s) {
@ -81,25 +120,130 @@ class SettingsProvider with ChangeNotifier {
return result;
}
Future<void> getInstallPermission() async {
Future<bool> getInstallPermission({bool enforce = false}) async {
while (!(await Permission.requestInstallPackages.isGranted)) {
// Explicit request as InstallPlugin request sometimes bugged
Fluttertoast.showToast(
msg: 'Please allow Obtainium to install Apps',
toastLength: Toast.LENGTH_LONG);
msg: tr('pleaseAllowInstallPerm'), toastLength: Toast.LENGTH_LONG);
if ((await Permission.requestInstallPackages.request()) ==
PermissionStatus.granted) {
break;
return true;
}
if (!enforce) {
return false;
}
}
return true;
}
bool get showAppWebpage {
return prefs?.getBool('showAppWebpage') ?? true;
return prefs?.getBool('showAppWebpage') ?? false;
}
set showAppWebpage(bool show) {
prefs?.setBool('showAppWebpage', show);
notifyListeners();
}
bool get pinUpdates {
return prefs?.getBool('pinUpdates') ?? true;
}
set pinUpdates(bool show) {
prefs?.setBool('pinUpdates', show);
notifyListeners();
}
bool get groupByCategory {
return prefs?.getBool('groupByCategory') ?? false;
}
set groupByCategory(bool show) {
prefs?.setBool('groupByCategory', show);
notifyListeners();
}
bool get hideTrackOnlyWarning {
return prefs?.getBool('hideTrackOnlyWarning') ?? false;
}
set hideTrackOnlyWarning(bool show) {
prefs?.setBool('hideTrackOnlyWarning', show);
notifyListeners();
}
bool get hideAPKOriginWarning {
return prefs?.getBool('hideAPKOriginWarning') ?? false;
}
set hideAPKOriginWarning(bool show) {
prefs?.setBool('hideAPKOriginWarning', show);
notifyListeners();
}
String? getSettingString(String settingId) {
return prefs?.getString(settingId);
}
void setSettingString(String settingId, String value) {
prefs?.setString(settingId, value);
notifyListeners();
}
Map<String, int> get categories =>
Map<String, int>.from(jsonDecode(prefs?.getString('categories') ?? '{}'));
void setCategories(Map<String, int> cats, {AppsProvider? appsProvider}) {
if (appsProvider != null) {
List<App> changedApps = appsProvider
.getAppValues()
.map((a) {
var n1 = a.app.categories.length;
a.app.categories.removeWhere((c) => !cats.keys.contains(c));
return n1 > a.app.categories.length ? a.app : null;
})
.where((element) => element != null)
.map((e) => e as App)
.toList();
if (changedApps.isNotEmpty) {
appsProvider.saveApps(changedApps);
}
}
prefs?.setString('categories', jsonEncode(cats));
notifyListeners();
}
String? get forcedLocale {
var fl = prefs?.getString('forcedLocale');
return supportedLocales
.where((element) => element.toLanguageTag() == fl)
.isNotEmpty
? fl
: null;
}
set forcedLocale(String? fl) {
if (fl == null) {
prefs?.remove('forcedLocale');
} else if (supportedLocales
.where((element) => element.toLanguageTag() == fl)
.isNotEmpty) {
prefs?.setString('forcedLocale', fl);
}
notifyListeners();
}
bool setEqual(Set<String> a, Set<String> b) =>
a.length == b.length && a.union(b).length == a.length;
void resetLocaleSafe(BuildContext context) {
if (context.supportedLocales
.map((e) => e.languageCode)
.contains(context.deviceLocale.languageCode)) {
context.resetLocale();
} else {
context.setLocale(context.fallbackLocale!);
context.deleteSaveLocale();
}
}
}

View File

@ -3,9 +3,29 @@
import 'dart:convert';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:html/dom.dart';
import 'package:http/http.dart';
import 'package:html/parser.dart';
import 'package:obtainium/app_sources/apkmirror.dart';
import 'package:obtainium/app_sources/codeberg.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/gitlab.dart';
import 'package:obtainium/app_sources/izzyondroid.dart';
import 'package:obtainium/app_sources/html.dart';
import 'package:obtainium/app_sources/jenkins.dart';
import 'package:obtainium/app_sources/mullvad.dart';
import 'package:obtainium/app_sources/neutroncode.dart';
import 'package:obtainium/app_sources/signal.dart';
import 'package:obtainium/app_sources/sourceforge.dart';
import 'package:obtainium/app_sources/steammobile.dart';
import 'package:obtainium/app_sources/telegramapp.dart';
import 'package:obtainium/app_sources/vlc.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/mass_app_sources/githubstars.dart';
class AppNames {
late String author;
@ -16,9 +36,113 @@ class AppNames {
class APKDetails {
late String version;
late List<String> apkUrls;
late List<MapEntry<String, String>> apkUrls;
late AppNames names;
late DateTime? releaseDate;
late String? changeLog;
APKDetails(this.version, this.apkUrls);
APKDetails(this.version, this.apkUrls, this.names,
{this.releaseDate, this.changeLog});
}
stringMapListTo2DList(List<MapEntry<String, String>> mapList) =>
mapList.map((e) => [e.key, e.value]).toList();
assumed2DlistToStringMapList(List<dynamic> arr) =>
arr.map((e) => MapEntry(e[0] as String, e[1] as String)).toList();
// App JSON schema has changed multiple times over the many versions of Obtainium
// This function takes an App JSON and modifies it if needed to conform to the latest (current) version
appJSONCompatibilityModifiers(Map<String, dynamic> json) {
var source = SourceProvider()
.getSource(json['url'], overrideSource: json['overrideSource']);
var formItems = source.combinedAppSpecificSettingFormItems
.reduce((value, element) => [...value, ...element]);
Map<String, dynamic> additionalSettings =
getDefaultValuesFromFormItems([formItems]);
if (json['additionalSettings'] != null) {
additionalSettings.addEntries(
Map<String, dynamic>.from(jsonDecode(json['additionalSettings']))
.entries);
}
// If needed, migrate old-style additionalData to newer-style additionalSettings (V1)
if (json['additionalData'] != null) {
List<String> temp = List<String>.from(jsonDecode(json['additionalData']));
temp.asMap().forEach((i, value) {
if (i < formItems.length) {
if (formItems[i] is GeneratedFormSwitch) {
additionalSettings[formItems[i].key] = value == 'true';
} else {
additionalSettings[formItems[i].key] = value;
}
}
});
additionalSettings['trackOnly'] =
json['trackOnly'] == 'true' || json['trackOnly'] == true;
additionalSettings['noVersionDetection'] =
json['noVersionDetection'] == 'true' || json['trackOnly'] == true;
}
// Convert bool style version detection options to dropdown style
if (additionalSettings['noVersionDetection'] == true) {
additionalSettings['versionDetection'] = 'noVersionDetection';
if (additionalSettings['releaseDateAsVersion'] == true) {
additionalSettings['versionDetection'] = 'releaseDateAsVersion';
additionalSettings.remove('releaseDateAsVersion');
}
if (additionalSettings['noVersionDetection'] != null) {
additionalSettings.remove('noVersionDetection');
}
if (additionalSettings['releaseDateAsVersion'] != null) {
additionalSettings.remove('releaseDateAsVersion');
}
}
// Ensure additionalSettings are correctly typed
for (var item in formItems) {
if (additionalSettings[item.key] != null) {
additionalSettings[item.key] =
item.ensureType(additionalSettings[item.key]);
}
}
int preferredApkIndex =
json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int;
if (preferredApkIndex < 0) {
preferredApkIndex = 0;
}
json['preferredApkIndex'] = preferredApkIndex;
// apkUrls can either be old list or new named list apkUrls
List<MapEntry<String, String>> apkUrls = [];
if (json['apkUrls'] != null) {
var apkUrlJson = jsonDecode(json['apkUrls']);
try {
apkUrls = getApkUrlsFromUrls(List<String>.from(apkUrlJson));
} catch (e) {
apkUrls = assumed2DlistToStringMapList(List<dynamic>.from(apkUrlJson));
apkUrls = List<dynamic>.from(apkUrlJson)
.map((e) => MapEntry(e[0] as String, e[1] as String))
.toList();
}
json['apkUrls'] = jsonEncode(stringMapListTo2DList(apkUrls));
}
// Arch based APK filter option should be disabled if it previously did not exist
if (additionalSettings['autoApkFilterByArch'] == null) {
additionalSettings['autoApkFilterByArch'] = false;
}
json['additionalSettings'] = jsonEncode(additionalSettings);
// F-Droid no longer needs cloudflare exception since override can be used - migrate apps appropriately
// This allows us to reverse the changes made for issue #418 (support cloudflare.f-droid)
// While not causing problems for existing apps from that source that were added in a previous version
var overrideSourceWasUndefined = !json.keys.contains('overrideSource');
if ((json['url'] as String).startsWith('https://cloudflare.f-droid.org')) {
json['overrideSource'] = FDroid().runtimeType.toString();
} else if (overrideSourceWasUndefined) {
// Similar to above, but for third-party F-Droid repos
RegExpMatch? match = RegExp('^https?://.+/fdroid/([^/]+(/|\\?)|[^/]+\$)')
.firstMatch(json['url'] as String);
if (match != null) {
json['overrideSource'] = FDroidRepo().runtimeType.toString();
}
}
return json;
}
class App {
@ -28,17 +152,66 @@ class App {
late String name;
String? installedVersion;
late String latestVersion;
List<String> apkUrls = [];
List<MapEntry<String, String>> apkUrls = [];
late int preferredApkIndex;
App(this.id, this.url, this.author, this.name, this.installedVersion,
this.latestVersion, this.apkUrls, this.preferredApkIndex);
late Map<String, dynamic> additionalSettings;
late DateTime? lastUpdateCheck;
bool pinned = false;
List<String> categories;
late DateTime? releaseDate;
late String? changeLog;
late String? overrideSource;
App(
this.id,
this.url,
this.author,
this.name,
this.installedVersion,
this.latestVersion,
this.apkUrls,
this.preferredApkIndex,
this.additionalSettings,
this.lastUpdateCheck,
this.pinned,
{this.categories = const [],
this.releaseDate,
this.changeLog,
this.overrideSource});
@override
String toString() {
return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls';
return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls PREFERREDAPK: $preferredApkIndex ADDITIONALSETTINGS: ${additionalSettings.toString()} LASTCHECK: ${lastUpdateCheck.toString()} PINNED $pinned';
}
factory App.fromJson(Map<String, dynamic> json) => App(
String? get overrideName =>
additionalSettings['appName']?.toString().trim().isNotEmpty == true
? additionalSettings['appName']
: null;
String get finalName {
return overrideName ?? name;
}
App deepCopy() => App(
id,
url,
author,
name,
installedVersion,
latestVersion,
apkUrls,
preferredApkIndex,
Map.from(additionalSettings),
lastUpdateCheck,
pinned,
categories: categories,
changeLog: changeLog,
releaseDate: releaseDate,
overrideSource: overrideSource);
factory App.fromJson(Map<String, dynamic> json) {
json = appJSONCompatibilityModifiers(json);
return App(
json['id'] as String,
json['url'] as String,
json['author'] as String,
@ -47,11 +220,27 @@ class App {
? null
: json['installedVersion'] as String,
json['latestVersion'] as String,
List<String>.from(jsonDecode(json['apkUrls'])),
json['preferredApkIndex'] == null
? 0
: json['preferredApkIndex'] as int,
);
assumed2DlistToStringMapList(jsonDecode(json['apkUrls'])),
json['preferredApkIndex'] as int,
jsonDecode(json['additionalSettings']) as Map<String, dynamic>,
json['lastUpdateCheck'] == null
? null
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']),
json['pinned'] ?? false,
categories: json['categories'] != null
? (json['categories'] as List<dynamic>)
.map((e) => e.toString())
.toList()
: json['category'] != null
? [json['category'] as String]
: [],
releaseDate: json['releaseDate'] == null
? null
: DateTime.fromMicrosecondsSinceEpoch(json['releaseDate']),
changeLog:
json['changeLog'] == null ? null : json['changeLog'] as String,
overrideSource: json['overrideSource']);
}
Map<String, dynamic> toJson() => {
'id': id,
@ -60,22 +249,40 @@ class App {
'name': name,
'installedVersion': installedVersion,
'latestVersion': latestVersion,
'apkUrls': jsonEncode(apkUrls),
'preferredApkIndex': preferredApkIndex
'apkUrls': jsonEncode(stringMapListTo2DList(apkUrls)),
'preferredApkIndex': preferredApkIndex,
'additionalSettings': jsonEncode(additionalSettings),
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
'pinned': pinned,
'categories': categories,
'releaseDate': releaseDate?.microsecondsSinceEpoch,
'changeLog': changeLog,
'overrideSource': overrideSource
};
}
escapeRegEx(String s) {
return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
return "\\${x[0]}";
});
// Ensure the input is starts with HTTPS and has no WWW
preStandardizeUrl(String url) {
var firstDotIndex = url.indexOf('.');
if (!(firstDotIndex >= 0 && firstDotIndex != url.length - 1)) {
throw UnsupportedURLError();
}
if (url.toLowerCase().indexOf('http://') != 0 &&
url.toLowerCase().indexOf('https://') != 0) {
url = 'https://$url';
}
if (url.toLowerCase().indexOf('https://www.') == 0) {
url = 'https://${url.substring(12)}';
}
url = url
.split('/')
.where((e) => e.isNotEmpty)
.join('/')
.replaceFirst(':/', '://');
return url;
}
const String couldNotFindReleases = 'Unable to fetch release info';
const String couldNotFindLatestVersion =
'Could not determine latest release version';
const String notValidURL = 'Not a valid URL';
const String noAPKFound = 'No APK found';
String noAPKFound = tr('noAPKFound');
List<String> getLinksFromParsedHTML(
Document dom, RegExp hrefPattern, String prependToLinks) =>
@ -88,410 +295,324 @@ List<String> getLinksFromParsedHTML(
.map((e) => '$prependToLinks${e.attributes['href']!}')
.toList();
Map<String, dynamic> getDefaultValuesFromFormItems(
List<List<GeneratedFormItem>> items) {
return Map.fromEntries(items
.map((row) => row.map((el) => MapEntry(el.key, el.defaultValue ?? '')))
.reduce((value, element) => [...value, ...element]));
}
List<MapEntry<String, String>> getApkUrlsFromUrls(List<String> urls) =>
urls.map((e) {
var segments = e.split('/').where((el) => el.trim().isNotEmpty);
var apkSegs = segments.where((s) => s.toLowerCase().endsWith('.apk'));
return MapEntry(apkSegs.isNotEmpty ? apkSegs.last : segments.last, e);
}).toList();
abstract class AppSource {
late String host;
String standardizeURL(String url);
Future<APKDetails> getLatestAPKDetails(String standardUrl);
AppNames getAppNames(String standardUrl);
}
String? host;
bool hostChanged = false;
late String name;
bool enforceTrackOnly = false;
bool changeLogIfAnyIsMarkDown = true;
class GitHub implements AppSource {
@override
late String host = 'github.com';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL;
}
return url.substring(0, match.end);
AppSource() {
name = runtimeType.toString();
}
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
Response res = await get(Uri.parse(
'https://api.$host/repos${standardUrl.substring('https://$host'.length)}/releases'));
if (res.statusCode == 200) {
var releases = jsonDecode(res.body) as List<dynamic>;
// Right now, the latest non-prerelease version is picked
// If none exists, the latest prerelease version is picked
// In the future, the user could be given a choice
var nonPrereleaseReleases =
releases.where((element) => element['prerelease'] != true).toList();
var latestRelease = nonPrereleaseReleases.isNotEmpty
? nonPrereleaseReleases[0]
: releases.isNotEmpty
? releases[0]
: null;
if (latestRelease == null) {
throw couldNotFindReleases;
}
List<dynamic>? assets = latestRelease['assets'];
List<String>? apkUrlList = assets
?.map((e) {
return e['browser_download_url'] != null
? e['browser_download_url'] as String
: '';
})
.where((element) => element.toLowerCase().endsWith('.apk'))
.toList();
if (apkUrlList == null || apkUrlList.isEmpty) {
throw noAPKFound;
}
String? version = latestRelease['tag_name'];
if (version == null) {
throw couldNotFindLatestVersion;
}
return APKDetails(version, apkUrlList);
} else {
if (res.headers['x-ratelimit-remaining'] == '0') {
throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes';
}
throw couldNotFindReleases;
}
overrideVersionDetectionFormDefault(String vd, bool disableStandard) {
additionalAppSpecificSourceAgnosticSettingFormItems =
additionalAppSpecificSourceAgnosticSettingFormItems.map((e) {
return e.map((e2) {
if (e2.key == 'versionDetection') {
var item = e2 as GeneratedFormDropdown;
item.defaultValue = vd;
if (disableStandard) {
item.disabledOptKeys = ['standardVersionDetection'];
}
}
return e2;
}).toList();
}).toList();
}
@override
AppNames getAppNames(String standardUrl) {
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
return AppNames(names[0], names[1]);
String standardizeUrl(String url) {
url = preStandardizeUrl(url);
if (!hostChanged) {
url = sourceSpecificStandardizeURL(url);
}
return url;
}
String sourceSpecificStandardizeURL(String url) {
throw NotImplementedError();
}
Future<APKDetails> getLatestAPKDetails(
String standardUrl, Map<String, dynamic> additionalSettings) {
throw NotImplementedError();
}
// Different Sources may need different kinds of additional data for Apps
List<List<GeneratedFormItem>> additionalSourceAppSpecificSettingFormItems =
[];
// Some additional data may be needed for Apps regardless of Source
List<List<GeneratedFormItem>>
additionalAppSpecificSourceAgnosticSettingFormItems = [
[
GeneratedFormSwitch(
'trackOnly',
label: tr('trackOnly'),
)
],
[
GeneratedFormDropdown(
'versionDetection',
[
MapEntry(
'standardVersionDetection', tr('standardVersionDetection')),
MapEntry('releaseDateAsVersion', tr('releaseDateAsVersion')),
MapEntry('noVersionDetection', tr('noVersionDetection'))
],
label: tr('versionDetection'),
defaultValue: 'standardVersionDetection')
],
[
GeneratedFormTextField('apkFilterRegEx',
label: tr('filterAPKsByRegEx'),
required: false,
additionalValidators: [
(value) {
return regExValidator(value);
}
])
],
[
GeneratedFormSwitch('autoApkFilterByArch',
label: tr('autoApkFilterByArch'), defaultValue: true)
],
[GeneratedFormTextField('appName', label: tr('appName'), required: false)]
];
// Previous 2 variables combined into one at runtime for convenient usage
List<List<GeneratedFormItem>> get combinedAppSpecificSettingFormItems {
return [
...additionalSourceAppSpecificSettingFormItems,
...additionalAppSpecificSourceAgnosticSettingFormItems
];
}
// 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,
{Map<String, dynamic> additionalSettings = const {}}) {
return null;
}
}
class GitLab implements AppSource {
@override
late String host = 'gitlab.com';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL;
}
return url.substring(0, match.end);
}
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom'));
if (res.statusCode == 200) {
var standardUri = Uri.parse(standardUrl);
var parsedHtml = parse(res.body);
var entry = parsedHtml.querySelector('entry');
var entryContent =
parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
var apkUrlList = [
...getLinksFromParsedHTML(
entryContent,
RegExp(
'^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$',
caseSensitive: false),
standardUri.origin),
// GitLab releases may contain links to externally hosted APKs
...getLinksFromParsedHTML(entryContent,
RegExp('/[^/]+\\.apk\$', caseSensitive: false), '')
.where((element) => Uri.parse(element).host != '')
.toList()
];
if (apkUrlList.isEmpty) {
throw noAPKFound;
}
var entryId = entry?.querySelector('id')?.innerHtml;
var version =
entryId == null ? null : Uri.parse(entryId).pathSegments.last;
if (version == null) {
throw couldNotFindLatestVersion;
}
return APKDetails(version, apkUrlList);
} else {
throw couldNotFindReleases;
}
}
@override
AppNames getAppNames(String standardUrl) {
// Same as GitHub
return GitHub().getAppNames(standardUrl);
}
ObtainiumError getObtainiumHttpError(Response res) {
return ObtainiumError(res.reasonPhrase ??
tr('errorWithHttpStatusCode', args: [res.statusCode.toString()]));
}
class Signal implements AppSource {
@override
late String host = 'signal.org';
@override
String standardizeURL(String url) {
return 'https://$host';
}
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
Response res =
await get(Uri.parse('https://updates.$host/android/latest.json'));
if (res.statusCode == 200) {
var json = jsonDecode(res.body);
String? apkUrl = json['url'];
if (apkUrl == null) {
throw noAPKFound;
}
String? version = json['versionName'];
if (version == null) {
throw couldNotFindLatestVersion;
}
return APKDetails(version, [apkUrl]);
} else {
throw couldNotFindReleases;
}
}
@override
AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal');
abstract class MassAppUrlSource {
late String name;
late List<String> requiredArgs;
Future<Map<String, String>> getUrlsWithDescriptions(List<String> args);
}
class FDroid implements AppSource {
@override
late String host = 'f-droid.org';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/packages/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL;
}
return url.substring(0, match.end);
regExValidator(String? value) {
if (value == null || value.isEmpty) {
return null;
}
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
Response res = await get(Uri.parse(standardUrl));
if (res.statusCode == 200) {
var latestReleaseDiv =
parse(res.body).querySelector('#latest.package-version');
var apkUrl = latestReleaseDiv
?.querySelector('.package-version-download a')
?.attributes['href'];
if (apkUrl == null) {
throw noAPKFound;
}
var version = latestReleaseDiv
?.querySelector('.package-version-header b')
?.innerHtml
.split(' ')
.last;
if (version == null) {
throw couldNotFindLatestVersion;
}
return APKDetails(version, [apkUrl]);
} else {
throw couldNotFindReleases;
}
}
@override
AppNames getAppNames(String standardUrl) {
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last);
}
}
class Mullvad implements AppSource {
@override
late String host = 'mullvad.net';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL;
}
return url.substring(0, match.end);
}
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
Response res = await get(Uri.parse('$standardUrl/en/download/android'));
if (res.statusCode == 200) {
var version = parse(res.body)
.querySelector('p.subtitle.is-6')
?.querySelector('a')
?.attributes['href']
?.split('/')
.last;
if (version == null) {
throw couldNotFindLatestVersion;
}
return APKDetails(
version, ['https://mullvad.net/download/app/apk/latest']);
} else {
throw couldNotFindReleases;
}
}
@override
AppNames getAppNames(String standardUrl) {
return AppNames('Mullvad-VPN', 'Mullvad-VPN');
}
}
class IzzyOnDroid implements AppSource {
@override
late String host = 'android.izzysoft.de';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL;
}
return url.substring(0, match.end);
}
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
Response res = await get(Uri.parse(standardUrl));
if (res.statusCode == 200) {
var parsedHtml = parse(res.body);
var multipleVersionApkUrls = parsedHtml
.querySelectorAll('a')
.where((element) =>
element.attributes['href']?.toLowerCase().endsWith('.apk') ??
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);
try {
RegExp(value);
} catch (e) {
return tr('invalidRegEx');
}
return null;
}
class SourceProvider {
List<AppSource> sources = [
GitHub(),
GitLab(),
FDroid(),
IzzyOnDroid(),
Mullvad(),
Signal()
];
List<MassAppSource> massSources = [GitHubStars()];
// Add more source classes here so they are available via the service
AppSource getSource(String url) {
List<AppSource> get sources => [
GitHub(),
GitLab(),
Codeberg(),
FDroid(),
IzzyOnDroid(),
FDroidRepo(),
Jenkins(),
SourceForge(),
APKMirror(),
Mullvad(),
Signal(),
VLC(),
// WhatsApp(), // As of 2023-03-20 this is unusable as the version on the webpage is months out of date
TelegramApp(),
SteamMobile(),
NeutronCode(),
HTML() // This should ALWAYS be the last option as they are tried in order
];
// Add more mass url source classes here so they are available via the service
List<MassAppUrlSource> massUrlSources = [GitHubStars()];
AppSource getSource(String url, {String? overrideSource}) {
url = preStandardizeUrl(url);
if (overrideSource != null) {
var srcs =
sources.where((e) => e.runtimeType.toString() == overrideSource);
if (srcs.isEmpty) {
throw UnsupportedURLError();
}
var res = srcs.first;
res.host = Uri.parse(url).host;
res.hostChanged = true;
return srcs.first;
}
AppSource? source;
for (var s in sources) {
if (url.toLowerCase().contains('://${s.host}')) {
for (var s in sources.where((element) => element.host != null)) {
if (RegExp('://${s.host}(/|\\z)?').hasMatch(url)) {
source = s;
break;
}
}
if (source == null) {
throw 'URL does not match a known source';
for (var s in sources.where((element) => element.host == null)) {
try {
s.sourceSpecificStandardizeURL(url);
source = s;
break;
} catch (e) {
//
}
}
}
if (source == null) {
throw UnsupportedURLError();
}
return source;
}
Future<App> getApp(String url) async {
if (url.toLowerCase().indexOf('http://') != 0 &&
url.toLowerCase().indexOf('https://') != 0) {
url = 'https://$url';
bool ifRequiredAppSpecificSettingsExist(AppSource source) {
for (var row in source.combinedAppSpecificSettingFormItems) {
for (var element in row) {
if (element is GeneratedFormTextField && element.required) {
return true;
}
}
}
if (url.toLowerCase().indexOf('https://www.') == 0) {
url = 'https://${url.substring(12)}';
}
AppSource source = getSource(url);
String standardUrl = source.standardizeURL(url);
AppNames names = source.getAppNames(standardUrl);
APKDetails apk = await source.getLatestAPKDetails(standardUrl);
return App(
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}',
standardUrl,
names.author[0].toUpperCase() + names.author.substring(1),
names.name[0].toUpperCase() + names.name.substring(1),
null,
apk.version,
apk.apkUrls,
apk.apkUrls.length - 1);
return false;
}
/// Returns a length 2 list, where the first element is a list of Apps and
/// the second is a Map<String, dynamic> of URLs and errors
Future<List<dynamic>> getApps(List<String> urls) async {
String generateTempID(
String standardUrl, Map<String, dynamic> additionalSettings) =>
(standardUrl + additionalSettings.toString()).hashCode.toString();
bool isTempId(App app) {
// return app.id == generateTempID(app.url, app.additionalSettings);
return RegExp('^[0-9]+\$').hasMatch(app.id);
}
Future<App> getApp(
AppSource source, String url, Map<String, dynamic> additionalSettings,
{App? currentApp,
bool trackOnlyOverride = false,
String? overrideSource}) async {
if (trackOnlyOverride || source.enforceTrackOnly) {
additionalSettings['trackOnly'] = true;
}
var trackOnly = additionalSettings['trackOnly'] == true;
String standardUrl = source.standardizeUrl(url);
APKDetails apk =
await source.getLatestAPKDetails(standardUrl, additionalSettings);
if (additionalSettings['versionDetection'] == 'releaseDateAsVersion' &&
apk.releaseDate != null) {
apk.version = apk.releaseDate!.microsecondsSinceEpoch.toString();
}
if (additionalSettings['apkFilterRegEx'] != null) {
var reg = RegExp(additionalSettings['apkFilterRegEx']);
apk.apkUrls =
apk.apkUrls.where((element) => reg.hasMatch(element.key)).toList();
}
if (apk.apkUrls.isEmpty && !trackOnly) {
throw NoAPKError();
}
if (apk.apkUrls.length > 1 &&
additionalSettings['autoApkFilterByArch'] == true) {
var abis = (await DeviceInfoPlugin().androidInfo).supportedAbis;
for (var abi in abis) {
var urls2 = apk.apkUrls
.where((element) => RegExp('.*$abi.*').hasMatch(element.key))
.toList();
if (urls2.isNotEmpty && urls2.length < apk.apkUrls.length) {
apk.apkUrls = urls2;
break;
}
}
}
String apkVersion = apk.version.replaceAll('/', '-');
var name = currentApp != null ? currentApp.name.trim() : '';
name = name.isNotEmpty
? name
: apk.names.name[0].toUpperCase() + apk.names.name.substring(1);
return App(
currentApp?.id ??
source.tryInferringAppId(standardUrl,
additionalSettings: additionalSettings) ??
generateTempID(standardUrl, additionalSettings),
standardUrl,
apk.names.author[0].toUpperCase() + apk.names.author.substring(1),
name,
currentApp?.installedVersion,
apkVersion,
apk.apkUrls,
apk.apkUrls.length - 1 >= 0 ? apk.apkUrls.length - 1 : 0,
additionalSettings,
DateTime.now(),
currentApp?.pinned ?? false,
categories: currentApp?.categories ?? const [],
releaseDate: apk.releaseDate,
changeLog: apk.changeLog,
overrideSource: overrideSource ?? currentApp?.overrideSource);
}
// Returns errors in [results, errors] instead of throwing them
Future<List<dynamic>> getAppsByURLNaive(List<String> urls,
{List<String> alreadyAddedUrls = const []}) async {
List<App> apps = [];
Map<String, dynamic> errors = {};
for (var url in urls) {
try {
apps.add(await getApp(url));
if (alreadyAddedUrls.contains(url)) {
throw ObtainiumError(tr('appAlreadyAdded'));
}
var source = getSource(url);
apps.add(await getApp(
source,
url,
getDefaultValuesFromFormItems(
source.combinedAppSpecificSettingFormItems)));
} catch (e) {
errors.addAll(<String, dynamic>{url: e});
}
}
return [apps, errors];
}
List<String> getSourceHosts() => sources.map((e) => e.host).toList();
}
abstract class MassAppSource {
late String name;
late List<String> requiredArgs;
Future<List<String>> getUrls(List<String> args);
}
class GitHubStars implements MassAppSource {
@override
late String name = 'GitHub Starred Repos';
@override
late List<String> requiredArgs = ['Username'];
@override
Future<List<String>> getUrls(List<String> args) async {
if (args.length != requiredArgs.length) {
throw 'Wrong number of arguments provided';
}
Response res =
await get(Uri.parse('https://api.github.com/users/${args[0]}/starred'));
if (res.statusCode == 200) {
return (jsonDecode(res.body) as List<dynamic>)
.map((e) => e['html_url'] as String)
.toList();
} else {
if (res.headers['x-ratelimit-remaining'] == '0') {
throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes';
}
throw 'Unable to find user\'s starred repos';
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -17,10 +17,10 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 0.2.4+15 # When changing this, update the tag in main() accordingly
version: 0.12.2+162 # When changing this, update the tag in main() accordingly
environment:
sdk: '>=2.19.0-79.0.dev <3.0.0'
sdk: '>=2.18.2 <3.0.0'
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
@ -38,27 +38,36 @@ dependencies:
cupertino_icons: ^1.0.5
path_provider: ^2.0.11
flutter_fgbg: ^0.2.0 # Try removing reliance on this
flutter_local_notifications: ^10.0.0
flutter_local_notifications: ^14.0.0+1
provider: ^6.0.3
http: ^0.13.5
webview_flutter: ^3.0.4
workmanager: ^0.5.0
webview_flutter: ^4.0.0
dynamic_color: ^1.5.4
install_plugin_v2: ^1.0.0 # Try replacing this
html: ^0.15.0
shared_preferences: ^2.0.15
url_launcher: ^6.1.5
permission_handler: ^10.0.0
fluttertoast: ^8.0.9
device_info_plus: ^4.1.2
file_picker: ^5.1.0
device_info_plus: ^8.0.0
file_picker: ^5.2.10
animations: ^2.0.4
android_package_installer:
git:
url: https://github.com/ImranR98/android_package_installer
ref: main
share_plus: ^6.0.1
installed_apps: ^1.3.1
package_archive_info: ^0.1.0
android_alarm_manager_plus: ^2.1.0
sqflite: ^2.2.0+3
easy_localization: ^3.0.1
android_intent_plus: ^3.1.5
flutter_markdown: ^0.6.14
dev_dependencies:
flutter_test:
sdk: flutter
flutter_launcher_icons: ^0.10.0
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
@ -67,12 +76,6 @@ dev_dependencies:
# rules and activating additional ones.
flutter_lints: ^2.0.1
flutter_icons:
android: true
image_path: "assets/graphics/icon.png"
adaptive_icon_background: "#FFFFFF"
adaptive_icon_foreground: "assets/graphics/icon.png"
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
@ -85,9 +88,14 @@ flutter:
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
assets:
- assets/translations/
- assets/graphics/
- assets/ca/
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware

View File

@ -13,7 +13,7 @@ import 'package:obtainium/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
await tester.pumpWidget(const Obtainium());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);