Compare commits

...

438 Commits

Author SHA1 Message Date
Shinokuni
15eb463752 Merge branch 'pager' into develop 2025-02-02 17:27:45 +01:00
Shinokuni
3f7c575e5d Open without pager an item without pagination index 2025-02-02 14:27:25 +01:00
Shinokuni
04d3c3c45e Fix scroll jump in ItemScreen when touching the screen for the first time
fixes #184
2025-02-02 13:51:04 +01:00
Francisco Pina-Martins
2910e2b7ca
Corrects a gramatical form (#248) 2025-02-02 13:22:49 +01:00
Christophe Henry
5cf223582a
UX: increase DrawerFolderItem's expand button to avoid missclicks (#257) 2025-02-02 13:15:30 +01:00
Shinokuni
34e8478708 Add pager in ItemScreen 2025-02-01 14:14:26 +01:00
Shinokuni
84e9b8760c Increase gradle memory 2025-01-29 12:20:11 +01:00
Weblate (bot)
2a961aee16
Translations update from Hosted Weblate (#241)
* Translated using Weblate (Japanese)

Currently translated at 79.2% (153 of 193 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/ja/

* Translated using Weblate (German)

Currently translated at 100.0% (195 of 195 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/de/

* Added translation using Weblate (Chinese (Traditional Han script))

* Added translation using Weblate (Tamil)

---------

Co-authored-by: Kazushi Hayama <hosted.weblate.3yoe@1984.pmail.li>
Co-authored-by: hoems222 <hoems222@mailbox.org>
Co-authored-by: x86_64-pc-linux-gnu <x86_64-pc-linux-gnu@proton.me>
Co-authored-by: தமிழ்நேரம் <anishprabu.t@gmail.com>
2025-01-29 11:58:33 +01:00
Christophe Henry
f9c4e7de75
Integrate login screen with autofill managers (#253) 2025-01-28 14:45:31 +01:00
Shinokuni
73990dbe53 Initial tablet mode
* Navigation bar or rail depending of the device type

* Permanent or modal drawer depending of the device type
2025-01-25 16:58:25 +01:00
Shinokuni
c1e3df1482 Fix main filter selection in TimelineTab 2025-01-25 16:29:46 +01:00
Shinokuni
f5eb5b3114 Add a refresh message during sync in TimelineTab 2025-01-24 18:06:45 +01:00
Shinokuni
5df8376d37 Get feed imageUrl from OpenGraph if it doesn't exist
(only for local accounts)
2025-01-24 17:41:29 +01:00
Shinokuni
4f8a98d2df Delete test file at end 2025-01-24 15:30:18 +01:00
Shinokuni
061b39de2b Rename some old freshrss files to greader 2025-01-24 15:24:05 +01:00
Shinokuni
5453499056 Display warning when creating a Fever or GRreader account 2025-01-24 15:21:24 +01:00
Shinokuni
220b34f465 Fix tests 2025-01-24 13:18:29 +01:00
Shinokuni
f3f02e7801 Make Google Reader API a standalone one 2025-01-23 22:36:26 +01:00
Shinokuni
a25542b78a Loading favicons and theirs colors is now parallelized during initial sync 2025-01-18 16:46:28 +01:00
Shinokuni
213d01d667 Improve support of media namespace for RSS and ATOM feeds
* improve Youtube support

* improve ATOM date handling
2025-01-18 15:46:11 +01:00
Shinokuni
42cc2ec6ed Make RSS2 adapter less strict with media urls 2025-01-18 14:17:03 +01:00
Shinokuni
cbbb52b9ee Improve available actions in FeedColorScreen 2025-01-17 18:59:27 +01:00
Shinokuni
8d134c271d Add new screen to modify a feed color 2025-01-17 15:47:06 +01:00
Shinokuni
ee6b33321b Add parameter to synchronize at launch 2025-01-15 13:30:04 +01:00
Shinokuni
a43415af6a Add parameter to set default main filter in TimelineTab 2025-01-09 17:07:59 +01:00
Shinokuni
c520a3396e Bump android emulator runner version to fix avd path in workflow 2025-01-09 15:13:30 +01:00
Shinokuni
a8cdb926b8 Extract some composables from TimelineTab to reduce its file size 2025-01-09 14:45:28 +01:00
Shinokuni
09094767d5 Add dialog in TimelineTab to select open in parameter by feed 2025-01-09 14:11:18 +01:00
Shinokuni
c16bb6f49f fix weblate conflicts
Merge remote-tracking branch 'weblate/develop' into develop
2024-12-19 17:15:02 +01:00
Boris Brock
f4c5a55bf7
Two minor improvements for the German localization (#237)
* Changing button text from "Validieren" to "OK"

* Adding missing German translations
2024-12-16 17:50:07 +01:00
Shinokuni
a112e2940e Follow feed openIn parameter when opening an item from a notification 2024-12-13 21:28:44 +01:00
Alexey Rochev
144cd9fe95
Fix formatting of plain text items (#236)
* Fix formatting of plain text items

* Preserve whitespace in Konsumer.nullableTextRecursively() extension

This is needed to preserve whitespace in RSS/ATOM item descriptions.

* Test that whitespace is actually preserved

Also fix flipped expected/actual arguments.
2024-12-13 14:03:49 +01:00
Shinokuni
fb49ffe8b6 Put extensions in their own package 2024-12-10 15:22:42 +01:00
Shinokuni
5f99afc8ad Follow Feed openIn parameter in TimelineTab 2024-12-10 15:15:07 +01:00
Shinokuni
ece00ded30 Fix db migration test 2024-12-08 14:24:06 +01:00
Shinokuni
ef9111c604 Add Open In feed parameter 2024-12-07 17:57:17 +01:00
Shinokuni
cf75fc914e Display feed notifications parameter in FeedBottomSheet 2024-12-04 18:47:17 +01:00
Shinokuni
1a727f1f1b Merge branch 'refs/heads/master' into develop
# Conflicts:
#	api/src/test/java/com/readrops/api/services/nextcloudnews/NextcloudNewsDataSourceTest.kt
#	db/src/main/java/com/readrops/db/dao/ItemDao.kt
2024-12-02 16:48:07 +01:00
Alexandre Alapetite
6fdb36085f
Fix endpoint slash (#231)
* Fix endpoint slash
The URLs sent to the API contained a slash too much:

Example for FreshRSS:

```
"POST //api/greader.php/reader/api/0/edit-tag HTTP/1.1" 200 2 "-" "okhttp/4.12.0"

"GET //api/greader.php/reader/api/0/tag/list?output=json HTTP/1.1" 200 654 "-" "okhttp/4.12.0"
```

* Fix import

* Remove dependency to com.readrops.app.util.Utils which was not working

* Skip null

* Simplify
https://github.com/readrops/Readrops/pull/231#issuecomment-2509005818

* Remove slash from end-points
https://github.com/readrops/Readrops/pull/231#issuecomment-2510059340
2024-12-02 16:45:47 +01:00
Shinokuni
1b54518c95 v2.0.3 2024-12-01 22:07:40 +01:00
Shinokuni
7701cebdba Some Nextcloud News item fields can be null 2024-12-01 17:42:10 +01:00
Shinokuni
6e1dbc789c Fix Nextcloud News item duplicates when synchronizing 2024-12-01 17:33:09 +01:00
Shinokuni
35d3b31c5a Improve Fever adapters reliability (fixes #229 and should also fix #228) 2024-12-01 16:13:07 +01:00
Alexandre Alapetite
90875af468
FreshRSS casing (#230)
Minor update of letter casing for FreshRSS
2024-11-30 15:58:53 +01:00
Shinokuni
b803058210 Improve image share/download in ItemScreen
* Downloaded image now appears in media gallery (fixes #226)

* Fix some shared images which could be corrupted

* Shared image preview now appears in share dialog

* Fix crash when no bitmap is fetched due to various errors
2024-11-23 21:15:40 +01:00
Shinokuni
640744bf6d Fix navigation bar transparency in some screens 2024-11-22 18:44:50 +01:00
Shinokuni
3e76597b8f Remove splash screen after TimelineTab lazy column is populated 2024-11-21 19:19:46 +01:00
Shinokuni
cfcc5c48c7 Improve local account synchronization and OPML import global speed 2024-11-21 18:19:51 +01:00
Shinokuni
a7416db1b7 Some Nextcloud News item fields can be null 2024-11-19 18:14:57 +01:00
Shinokuni
49ab7d5a04 Improve FreshRSS adapters reliability 2024-11-19 15:08:19 +01:00
Shinokuni
7173d5a865 Fix tests 2024-11-19 14:03:18 +01:00
Shinokuni
24cdef5460 Merge branch 'refs/heads/master' into develop 2024-11-18 22:39:33 +01:00
Shinokuni
383aea0775 v2.0.2 2024-11-18 22:19:41 +01:00
Shinokuni
d2dff633e1 Fix FeverFeedsAdapter parsing (fixes #228) 2024-11-18 21:35:59 +01:00
Shinokuni
6459957168 Improve icons quality for local account feeds 2024-11-18 17:57:07 +01:00
Shinokuni
db04cdddb7 Display feed image in FeedBottomSheet when available 2024-11-17 19:50:36 +01:00
Shinokuni
cbd5c1bc3d Migrate to Coil3 2024-11-17 17:38:04 +01:00
Shinokuni
b74ad09a07 Display CrashActivity only in release mode 2024-11-17 14:46:07 +01:00
Shinokuni
e9cd681b11 Fix regression in TimelineTab: items wouldn't be updated without disappearing from the screen 2024-11-17 14:33:38 +01:00
Shinokuni
c6eed0eabd Use AccountError in AccountTab 2024-11-13 22:26:13 +01:00
Shinokuni
e7e15b284f Use AccountError in TimelineTab 2024-11-13 22:02:31 +01:00
Shinokuni
a96bf18a18 Use AccountError in FeedTab 2024-11-12 18:40:50 +01:00
Shinokuni
903f6c5427 Improve error handling in NewFeedScreen 2024-11-12 17:15:59 +01:00
Shinokuni
99f068183e Fix not setting the right folder for parsing results in NewFeedScreen 2024-11-12 13:42:13 +01:00
Shinokuni
c7b26a65b1 Add support for adding a new feed in a specific folder for FreshRSS and Nextcloud News 2024-11-11 21:33:28 +01:00
Shinokuni
7dcb2cbb29 Display errors in NewFeedScreen more accurately 2024-11-11 17:32:50 +01:00
Shinokuni
7134772a5f Replace AddFeedDialog by a new screen 2024-11-10 22:25:31 +01:00
Shinokuni
a5f16e089c Reorder some files 2024-11-09 14:28:25 +01:00
Shinokuni
783276a27b Update kotlin-xml-builder dependency 2024-11-08 17:19:06 +01:00
Shinokuni
47b40ac0eb Merge branch 'refs/heads/master' into develop
# Conflicts:
#	db/src/main/java/com/readrops/db/dao/ItemDao.kt
2024-11-08 16:31:02 +01:00
Shinokuni
d306bff63e Do not run database queries on UI Thread in MainActivity (fixes #223) 2024-11-08 16:28:40 +01:00
Shinokuni
d5b5762c2e Disable codecov patch/project 2024-10-10 18:02:41 +02:00
Shinokuni
7dad293771 Display feeds count next to folder name in FeedTab 2024-10-10 17:32:25 +02:00
Shinokuni
70a81235b4 Fix warnings 2024-10-10 15:35:43 +02:00
Shinokuni
71898bf6e9 Update dependencies 2024-10-10 15:03:13 +02:00
Shinokuni
8be236fdfb Enable Room kotlin code gen 2024-10-10 12:55:12 +02:00
Shinokuni
b94933d61b Extract synchronization logic from SyncWorker into a separate class 2024-10-09 18:57:00 +02:00
Shinokuni
5824797aed Fix tests once for all... 2024-10-09 15:48:14 +02:00
Shinokuni
d51607c12f Fix tests 2024-10-09 13:54:02 +02:00
Shinokuni
d21ac178c3 Add some tests to NextcloudNewsDataSource 2024-10-09 13:23:08 +02:00
Shinokuni
968516e891 Add some tests to FreshRSSDataSource 2024-10-09 12:09:04 +02:00
Shinokuni
4753454a9d Migrate database to new version
* Rename some Account fields
* Migrate Account.type enum field from INTEGER to TEXT
2024-10-08 17:59:32 +02:00
Shinokuni
fb68f1f492 Fix merge issues 2024-09-23 14:18:22 +02:00
Shinokuni
379c560236 Merge branch 'refs/heads/master' into develop
# Conflicts:
#	app/src/main/java/com/readrops/app/timelime/TimelineTab.kt
2024-09-23 14:13:39 +02:00
Shinokuni
c88c175596 v2.0.1 2024-09-23 13:27:15 +02:00
Shinokuni
84a54224b5 Make Timeline tab filters persistent (fixes #138) 2024-09-22 19:08:45 +02:00
Shinokuni
60f15a58ab Update Compose, AGP and other dependencies 2024-09-22 14:53:15 +02:00
Shinokuni
74719480df
Update Github action badge 2024-09-21 15:03:20 +02:00
Shinokuni
9acfb1e601 Update Codecov action 2024-09-21 12:15:47 +02:00
Shinokuni
8654e788ef Run jacocoFullReport task in CI 2024-09-20 16:59:13 +02:00
Shinokuni
159a6969c2 Migrate Jacoco task to Kotlin DSL 2024-09-20 16:40:20 +02:00
Shinokuni
9b1f7339eb Fix tests 2024-09-20 16:26:25 +02:00
Shinokuni
77a801c868 Change default order field (#202) 2024-09-20 13:22:26 +02:00
Shinokuni
81dc80d19a Be less strict with feed and folder names (#206) 2024-09-20 13:07:12 +02:00
Shinokuni
bf3c654a25 Fix crash when adding a Fever account (#200) 2024-09-20 12:36:24 +02:00
Shinokuni
6a28aa2578 Print throwable.cause in CrashActivity to get a more detailed stacktrace 2024-09-20 12:12:27 +02:00
Shinokuni
19c5b8b17e Migrate some DAOs to interface 2024-09-17 21:30:25 +02:00
Shinokuni
f1d49e43dc
Update issue templates 2024-09-17 13:32:26 +02:00
Shinokuni
71ffe7b4d5
Update issue templates 2024-09-16 16:38:46 +02:00
Shinokuni
83ad2f66b1
Update issue templates 2024-09-16 16:36:20 +02:00
Shinokuni
377ab70a27 v2.0 2024-09-15 16:23:13 +02:00
Weblate (bot)
4dc8d9d839
Translations update from Hosted Weblate (#181)
* Translated using Weblate (French)

Currently translated at 96.5% (167 of 173 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/fr/

* Translated using Weblate (Indonesian)

Currently translated at 53.7% (93 of 173 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/id/

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 50.8% (88 of 173 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/nb_NO/

* Translated using Weblate (Italian)

Currently translated at 53.7% (93 of 173 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/it/

* Translated using Weblate (German)

Currently translated at 53.1% (92 of 173 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/de/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 53.1% (92 of 173 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/pt_BR/

* Translated using Weblate (Spanish)

Currently translated at 97.1% (168 of 173 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/es/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (173 of 173 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/es/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (173 of 173 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/nl/

* Added translation using Weblate (Japanese)

* Translated using Weblate (Japanese)

Currently translated at 20.2% (35 of 173 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/ja/

* Translated using Weblate (Japanese)

Currently translated at 30.0% (52 of 173 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/ja/

* Translated using Weblate (Japanese)

Currently translated at 56.6% (98 of 173 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/ja/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (180 of 180 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/es/

* Translated using Weblate (Japanese)

Currently translated at 72.2% (130 of 180 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/ja/

---------

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: Heimen Stoffels <vistausss@fastmail.com>
Co-authored-by: Kazushi Hayama <hosted.weblate.3yoe@1984.pmail.li>
2024-09-15 14:06:26 +02:00
Kazushi Hayama
281fc28681
Translated using Weblate (Japanese)
Currently translated at 72.2% (130 of 180 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/ja/
2024-09-15 11:52:26 +00:00
gallegonovato
410e768442
Translated using Weblate (Spanish)
Currently translated at 100.0% (180 of 180 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/es/
2024-09-15 11:52:25 +00:00
Kazushi Hayama
86da6c35ae
Translated using Weblate (Japanese)
Currently translated at 56.6% (98 of 173 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/ja/
2024-09-15 11:52:25 +00:00
Kazushi Hayama
7753476395
Translated using Weblate (Japanese)
Currently translated at 30.0% (52 of 173 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/ja/
2024-09-15 11:52:24 +00:00
Kazushi Hayama
5bd797881d
Translated using Weblate (Japanese)
Currently translated at 20.2% (35 of 173 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/ja/
2024-09-15 11:52:23 +00:00
Kazushi Hayama
0543b872fe
Added translation using Weblate (Japanese) 2024-09-15 11:52:23 +00:00
Heimen Stoffels
827a4bb01b
Translated using Weblate (Dutch)
Currently translated at 100.0% (173 of 173 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/nl/
2024-09-15 11:52:22 +00:00
gallegonovato
8669c3e0d0
Translated using Weblate (Spanish)
Currently translated at 100.0% (173 of 173 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/es/
2024-09-15 11:52:22 +00:00
Anonymous
1860cc41ab
Translated using Weblate (Spanish)
Currently translated at 97.1% (168 of 173 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/es/
2024-09-15 11:52:21 +00:00
Anonymous
d0cc167459
Translated using Weblate (Portuguese (Brazil))
Currently translated at 53.1% (92 of 173 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/pt_BR/
2024-09-15 11:52:21 +00:00
Anonymous
9021e8fdb7
Translated using Weblate (German)
Currently translated at 53.1% (92 of 173 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/de/
2024-09-15 11:52:20 +00:00
Anonymous
062eb29e33
Translated using Weblate (Italian)
Currently translated at 53.7% (93 of 173 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/it/
2024-09-15 11:52:19 +00:00
Anonymous
83ba99aa6d
Translated using Weblate (Norwegian Bokmål)
Currently translated at 50.8% (88 of 173 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/nb_NO/
2024-09-15 11:52:19 +00:00
Anonymous
a549fd173c
Translated using Weblate (Indonesian)
Currently translated at 53.7% (93 of 173 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/id/
2024-09-15 11:52:18 +00:00
Anonymous
b8b88de7de
Translated using Weblate (French)
Currently translated at 96.5% (167 of 173 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/fr/
2024-09-15 11:52:17 +00:00
Shinokuni
4ffea8d2bc Fix wrong favorites icon in TimelineItem 2024-09-15 13:52:06 +02:00
Shinokuni
ebd4a455d9 Fix item opened from notification not being mark as read 2024-09-15 13:43:24 +02:00
Shinokuni
fc1a1c9491 Add one more mime-type to OPML file picker (should fix #195) 2024-09-14 14:13:41 +02:00
Shinokuni
cf9d307f00 Restore Order By Item.id in TimelineTab main query 2024-09-03 16:49:43 +02:00
Shinokuni
e6c880a79f Rework main query filters 2024-09-02 16:36:21 +02:00
Shinokuni
c31a0cde20 Restore swipe to mark as read/unread 2024-09-01 22:35:59 +02:00
Shinokuni
683ca54acc
Update README.md 2024-09-01 16:21:48 +02:00
Shinokuni
cddca8f853 v2.0-beta02 2024-09-01 15:02:12 +02:00
Shinokuni
017fe40b2e Make PreferencesScreen scrollable (fixes #190) 2024-08-30 18:42:03 +02:00
Shinokuni
be865aa7f9 Fix wrong password key in Migrations 2024-08-30 18:25:29 +02:00
Shinokuni
e0fcf265f5 Fix wrong translation in RadioButtonPreferenceDialog #185 2024-08-29 17:57:17 +02:00
Shinokuni
ee931abc17 Fix some migration issues
* Fix getting wrong AccountType from db

* Add credentials migration from old shared preferences to encrypted preferences
2024-08-29 17:45:58 +02:00
Shinokuni
420cd1d9d1 Remove all unused translations 2024-08-25 16:19:58 +02:00
Shinokuni
7327c16086
Merge pull request #179 from weblate/weblate-readrops-strings
Translations update from Hosted Weblate
2024-08-25 15:47:26 +02:00
gallegonovato
ff1e458b40
Translated using Weblate (Spanish)
Currently translated at 100.0% (226 of 226 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/es/
2024-08-23 21:09:52 +00:00
Shinokuni
247fac158f
Merge pull request #177 from weblate/weblate-readrops-strings
Translations update from Hosted Weblate
2024-08-23 16:57:26 +02:00
Heimen Stoffels
f406ad4454
Translated using Weblate (Dutch)
Currently translated at 21.2% (48 of 226 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/nl/
2024-08-21 11:07:28 +00:00
gallegonovato
cf9bb2c3b0
Translated using Weblate (Spanish)
Currently translated at 99.5% (225 of 226 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/es/
2024-08-21 11:07:27 +00:00
Heimen Stoffels
52c2abcec1
Added translation using Weblate (Dutch) 2024-08-21 11:07:27 +00:00
gallegonovato
e0beb171f8
Translated using Weblate (Spanish)
Currently translated at 73.8% (167 of 226 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/es/
2024-08-21 11:07:26 +00:00
gallegonovato
c0f633ea38
Translated using Weblate (Spanish)
Currently translated at 64.6% (146 of 226 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/es/
2024-08-21 11:07:26 +00:00
Hosted Weblate
3dd1ee0de3
Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/
2024-08-21 11:07:25 +00:00
Shinokuni
88825d968a
Update README.md 2024-08-21 13:07:21 +02:00
Shinokuni
6e2051b425 v2.0-beta01 2024-08-21 12:40:48 +02:00
Shinokuni
adb2b0c192 Send the right exception to TimelineTab 2024-08-19 17:38:04 +02:00
Shinokuni
6d55c3200a Add FUNDING.yml 2024-08-19 16:41:02 +02:00
Shinokuni
1e7f9a26a5 Exclude html from repo language stats 2024-08-19 16:27:58 +02:00
Lucas
f7c9b9d4fc Fix conflicts 2024-08-19 14:29:42 +02:00
Allan Nordhøy
136ee2dce7 Translated using Weblate (Norwegian Bokmål)
Currently translated at 94.2% (132 of 140 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/nb_NO/
2024-08-19 14:11:19 +02:00
Hin Weisner
27a26afe54 Translated using Weblate (Spanish)
Currently translated at 100.0% (140 of 140 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/es/
2024-08-19 14:11:19 +02:00
Hin Weisner
2fc298fb7e Added translation using Weblate (Spanish) 2024-08-19 14:11:19 +02:00
inkhorn
6457f50f46 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (140 of 140 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/pt_BR/
2024-08-19 14:11:19 +02:00
inkhorn
e032070492 Added translation using Weblate (Portuguese (Brazil)) 2024-08-19 14:11:19 +02:00
Hosted Weblate
15ea3f3881 Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/

# Conflicts:
#	app/src/main/res/values-fr/strings.xml
2024-08-19 14:11:19 +02:00
Shinokuni
96e3bca4a2 Fix OPML import crashing when entry doesnt't have a name 2024-08-18 18:00:24 +02:00
Shinokuni
c54a14e616 Hide Fab when synchronizing in TimelineTab 2024-08-18 13:31:36 +02:00
Shinokuni
14af99c8df Add beta build type 2024-08-18 13:11:27 +02:00
Shinokuni
7d97588c86 Fix db test package path 2024-08-18 12:49:04 +02:00
Shinokuni
8fe19fbc50 Fix ItemScreen title sometimes not centered 2024-08-17 15:12:01 +02:00
Shinokuni
8e44b8a097 Get most of the exception stacktrace in CrashActivity before a NPE occurs 2024-08-16 18:06:54 +02:00
Shinokuni
1cb6c92edf Fix crash when logging with an url without scheme and improve error and info messages in AccountCredentialsScreen 2024-08-16 17:28:41 +02:00
Shinokuni
cbc9d8b3c2 Fever: do not trust provider ordering and always check for the max instead of the first element 2024-08-16 14:43:51 +02:00
Shinokuni
345c4a2bd7 Add some info messages in AccountCredentialsScreen 2024-08-16 14:39:17 +02:00
Shinokuni
b5484861ac Make Fever API implementation work with Miniflux one
* Rely on favicon_id when mapping favicon to feed
* Item ids are integers in Miniflux and strings in FreshRSS
* Make FeverItemsAdapter more robust to some non useful properties
2024-08-15 17:06:56 +02:00
Shinokuni
326c251878 Improve FreshRSSItemsAdapter test case 2024-08-14 23:17:16 +02:00
Shinokuni
79575a9ccf
Merge pull request #163 from Alkarex/fix-freshrss-get-link
Fix FreshRSS get link
2024-08-14 23:14:34 +02:00
Shinokuni
4f9fbc3f7c Improve key for fever favicons 2024-08-14 22:25:04 +02:00
Shinokuni
dd98c7b0a6 Fix getting sinceId after last items call in FeverDataSource 2024-08-14 18:58:30 +02:00
Shinokuni
7b3920374e Disable Update feed color option in FeedBottomSheet 2024-08-14 14:59:25 +02:00
Shinokuni
eca565ca07 Add rtl support in ItemScreen webView 2024-08-14 14:42:32 +02:00
Shinokuni
cd18ea3594 Fix new articles sql request in TimelineTab 2024-08-14 14:22:03 +02:00
Shinokuni
4803624b33 Improve a bit Drawer folder item 2024-08-14 14:09:13 +02:00
Shinokuni
8fedcf5538 Add usesCleartextTraffic in network security config 2024-08-14 13:39:05 +02:00
Shinokuni
e9d80b3980 Add network security config 2024-08-14 12:40:49 +02:00
Shinokuni
52e8c64d45 Add screen transitions 2024-08-13 21:51:20 +02:00
Shinokuni
fffc3be383 Add splash background for MainActivity 2024-08-13 21:12:54 +02:00
Shinokuni
3417466e30 Polish ItemScreen 2024-08-13 16:16:36 +02:00
Shinokuni
01ce06467d Merge remote-tracking branch 'origin/feature/fever_api' into develop 2024-08-13 14:03:20 +02:00
Shinokuni
edb81f6809 Add fever icon 2024-08-13 13:59:41 +02:00
Shinokuni
326fdde7eb Fix duplicate items when synchronizing a fever account 2024-08-13 13:24:15 +02:00
Shinokuni
57d66b565c Improve refresh loading behaviour in TimelineTab 2024-08-12 20:55:23 +02:00
Shinokuni
e9b3a26074 Fix API tests 2024-08-12 14:29:51 +02:00
Shinokuni
f67f817b82 Add support for Fever favicons 2024-08-12 14:18:31 +02:00
Shinokuni
3c99bafe43 Fix tests 2024-08-11 18:30:19 +02:00
Shinokuni
7a8f255b72 Add some tests to FeverDataSource 2024-08-10 21:45:37 +02:00
Shinokuni
0265b88ff3 Disable feed/folder actions in FeedTab for Fever accounts 2024-08-10 13:52:24 +02:00
Shinokuni
65a49e7fc5 Fix separateState use in ItemScreen 2024-08-08 21:54:22 +02:00
Shinokuni
0c3c3d8ddb Rework Fever read/star state management 2024-08-08 19:42:13 +02:00
Shinokuni
f7cd8ac998 Forgot check for notifications permission 2024-08-08 18:07:10 +02:00
Shinokuni
ba964ccce2 Fix FeverDataSource tests 2024-08-07 15:01:38 +02:00
Shinokuni
89c4dfad1a Rework Fever synchronization to be more efficient 2024-08-07 14:45:44 +02:00
Shinokuni
732ae4efa4 Make FeverRepository compile again and Fever auth work 2024-08-06 22:09:23 +02:00
Shinokuni
04820cd700 Merge remote-tracking branch 'origin/develop' into feature/fever_api 2024-08-06 18:40:02 +02:00
Shinokuni
6af2f62b32 Add New feed intent filter 2024-08-06 18:05:06 +02:00
Shinokuni
22913aa129 Add DonationDialog 2024-08-06 17:40:28 +02:00
Shinokuni
a2e2c04142 Fix CI 2024-08-06 16:50:22 +02:00
Shinokuni
4b80cfd1c1 Migrate to kotlin 2.0 2024-08-05 23:28:04 +02:00
Shinokuni
152117d3bf Update some dependencies 2024-08-05 21:55:22 +02:00
Shinokuni
7abec11b55 Replace joda.time by java.time 2024-08-05 17:29:12 +02:00
Shinokuni
3af1220666 Rework Feed table columns 2024-08-04 18:04:55 +02:00
Shinokuni
5247d878c4 Remove Item.read_it_later and rename remoteId to remote_id 2024-08-04 16:21:45 +02:00
Shinokuni
a00ef31cf7 Remove Item.guid field, use Item.remoteId instead for all account types 2024-08-04 15:34:27 +02:00
Shinokuni
44b2858cb0 Display an error screen when the app crashes 2024-08-03 19:37:21 +02:00
Shinokuni
4d9f71559f Remove unused notification channels 2024-07-31 16:03:24 +02:00
Shinokuni
70d66f3200 Add some cache to Coil 2024-07-31 16:01:11 +02:00
Shinokuni
c932022fe6 Implement FeedIcon once 2024-07-31 15:49:40 +02:00
Shinokuni
4d8de57ade Extract last strings to translations 2024-07-31 15:37:00 +02:00
Shinokuni
4771831d15 Improve NotificationsScreen UI 2024-07-31 14:51:42 +02:00
Shinokuni
9210b8b7fe Ask notifications permission for api 33+ 2024-07-30 15:41:39 +02:00
Shinokuni
ca9a93d731 Improve repositories syncResult logic to be used by notifications 2024-07-29 13:25:48 +02:00
Shinokuni
f143e282c8 Fix SyncAnalyzer tests 2024-07-28 22:36:41 +02:00
Shinokuni
cafb46c727 Display notifications after background synchronization 2024-07-28 15:22:34 +02:00
Shinokuni
76d7f98227 Improve a bit AccountTab UI 2024-07-25 13:52:08 +02:00
Shinokuni
ad7929f444 Rename main koin module 2024-07-25 13:25:56 +02:00
Shinokuni
e721463f9c Display warning message if account notifications are enabled and background sync is disabled 2024-07-25 13:23:22 +02:00
Shinokuni
dc55d2115f Fix assembleRelease 2024-07-24 18:40:56 +02:00
Shinokuni
67b29d7590 Add preference to disable battery optimization 2024-07-24 18:39:12 +02:00
Shinokuni
63867d3acd Add background synchronization launch in PreferencesScreen 2024-07-24 16:21:53 +02:00
Shinokuni
5a2ef0fa2f Add local account refresh logic in SyncWorker 2024-07-23 15:33:06 +02:00
Shinokuni
f95c808aa0 Improve reliability of markItemsReadOnScroll pref implementation in TimelineTab 2024-07-22 17:56:02 +02:00
Shinokuni
0adf55eb02 Implement hideReadFeeds preference in TimelineTab 2024-07-22 15:23:48 +02:00
Shinokuni
1e5a23b722 Add support for markItemsReadOnScroll preference in TimelineTab 2024-07-18 15:24:15 +02:00
Shinokuni
8c551e748f React to theme changes in MainActivity 2024-07-15 21:44:52 +02:00
Shinokuni
c7ccdcd3e3 Add TimelineItemSize preference in TimelineTab 2024-07-15 15:14:18 +02:00
Shinokuni
273868d270 Use openLinksWIth preference in ItemScreen 2024-07-15 14:07:10 +02:00
Shinokuni
965fb2ea67 Improve PreferencesScreen state management 2024-07-13 19:56:23 +02:00
Shinokuni
da5b030ab6 Add PreferencesScreen
Three layers :
- DataStore implementation
- Preference description
- UI Preference composables
2024-07-13 17:03:04 +02:00
Shinokuni
76dfbeff32 Improve TimelineTab isLoading paging case 2024-07-13 15:03:43 +02:00
Shinokuni
eadfb51350 Specify unread next to the number of unread items in TimelineTab drawer filter 2024-07-12 19:46:34 +02:00
Shinokuni
fdf6c4f5c1 Improve paging behaviour in TimelineTab 2024-07-12 16:35:17 +02:00
Shinokuni
7be074f759 Hide FAB in TimelineTab when displaying local account refresh screen 2024-07-12 15:27:23 +02:00
Shinokuni
46800586e1 Rework TimelineItem UI and add three sizes : compact, regular and large 2024-07-12 15:17:53 +02:00
Shinokuni
f57d39ec3b Extract state from AddFeedDialog 2024-07-11 17:53:01 +02:00
Shinokuni
6cb8c2853d Add progress indicator in AddFeedDialog and fix account selection bug 2024-07-11 17:23:54 +02:00
Shinokuni
aa3a835756 Rename some Nextcloud News and FreshRSS files 2024-07-11 14:33:09 +02:00
Shinokuni
49fc62edc2 Improve Nextcloud News updateFeed performance 2024-07-11 14:30:02 +02:00
Shinokuni
85b830b82b Add progress indicator in some FeedTab dialogs 2024-07-11 14:26:23 +02:00
Shinokuni
d14ea9109b Improve FeedBottomSheet UI 2024-07-11 13:03:54 +02:00
Shinokuni
334a33f847 Fix FeedItem UI when not associated to a FolderExpandableItem 2024-07-11 12:41:21 +02:00
Shinokuni
7ad5e0bc54 Add OPML import in AccountSelectionScreen 2024-07-11 12:26:10 +02:00
Shinokuni
36d9aef2bb Improve AccountSelectionScreen UI 2024-07-10 19:02:21 +02:00
Shinokuni
ba7b2b7108 Fix crash when deleting an account 2024-07-10 17:41:02 +02:00
Shinokuni
9de8361a33 Improve FeedTab look and feel 2024-07-10 17:35:21 +02:00
Shinokuni
6f80179fb3 Improve global dialog UI 2024-07-10 16:44:10 +02:00
Shinokuni
8e8d1ce9c8 Replace FolderDialog by TextFieldDialog in FeedTab 2024-07-10 16:43:26 +02:00
Shinokuni
feeed2771f Add rename account option in AccountTab only for local accounts 2024-07-10 12:47:16 +02:00
Shinokuni
3109fed97e Put three dots dropdown menu in a separate file 2024-07-09 21:39:46 +02:00
Shinokuni
9c95e0d63c Fix creating a local account in AccountTab 2024-07-09 19:02:50 +02:00
Shinokuni
45c2de4459 Improve AccountTab UI and add account change 2024-07-09 15:44:53 +02:00
Shinokuni
016d309d05 Add github repo, issues and changelog links in MoreTab 2024-07-07 15:54:26 +02:00
Shinokuni
5bf566184d Add About libraries screen in MoreTab 2024-07-07 12:48:20 +02:00
Shinokuni
0b9a055d89 Remove buildSrc, no utility here 2024-07-07 12:26:07 +02:00
Shinokuni
7f8d7aa87f Add top bar in AccountCredentialsScreen 2024-07-06 23:04:44 +02:00
Shinokuni
f491a60ef6 Remove Parcelize plugin 2024-07-06 22:50:40 +02:00
Shinokuni
c011485819 Migrate plugin definitions in version catalog 2024-07-06 17:04:09 +02:00
Shinokuni
5bf974eb0c Replace kapt by ksp 2024-07-06 14:01:58 +02:00
Shinokuni
e6be6c36fe Migrate last dependencies to version catalog 2024-07-05 13:52:59 +02:00
Shinokuni
10835b4979 Fix CI 2024-07-05 11:50:10 +02:00
Shinokuni
b2f89b4471 Simplify gradle build configuration 2024-07-04 17:37:03 +02:00
Shinokuni
e909eaf656 Remove temporaly jacoco reports from CI 2024-07-04 12:38:26 +02:00
Shinokuni
a15e8d617d Migrate top level gradle build file to kotlin dsl 2024-07-04 12:34:02 +02:00
Shinokuni
4eef3ba3c3 Fix R8 minify 2024-07-04 12:17:59 +02:00
Shinokuni
5ada97d60e Migrate APP gradle file to kotlin dsl 2024-07-04 12:00:24 +02:00
Shinokuni
4fa377bf26 Migrate DB gradle file to kotlin dsl 2024-07-04 11:00:21 +02:00
Shinokuni
a1df9dbe89 Migrate API gradle file to kotlin dsl 2024-07-04 10:47:11 +02:00
Shinokuni
08de3f3a80 Rename appcompose module to app 2024-07-03 16:21:29 +02:00
Shinokuni
84a8377e9e Fix some build performance warnings 2024-07-03 12:11:59 +02:00
Shinokuni
bbda92a9df Remove unused dependencies 2024-07-03 11:55:54 +02:00
Shinokuni
83baac3ba6 Rename DAO declarations in Database 2024-07-03 11:47:47 +02:00
Shinokuni
a0bed539a6 Fix tests and CI 2024-07-03 11:24:34 +02:00
Shinokuni
476ce8a7dc Rename DAO files 2024-07-02 19:42:24 +02:00
Shinokuni
26eaebf442 Remove unused files 2024-07-02 19:25:15 +02:00
Shinokuni
e20bc62418 Fix CI 2024-07-02 18:43:14 +02:00
Shinokuni
4388615a59 Remove old app module
Time to say goodbye, see you old friend
2024-07-02 18:18:39 +02:00
Shinokuni
8aac6e4bf4 Add synchronization notifications content analyzer 2024-07-02 17:58:45 +02:00
Shinokuni
3880fb1fc5 Add notifications screen 2024-07-02 13:18:50 +02:00
Shinokuni
3f43d2219c Migrate Nextcloud News API to v1.3 2024-06-30 19:50:31 +02:00
Shinokuni
a98ef5c449 Use TopAppBar scrollBehaviour in TimelineTab and FeedTab 2024-06-30 17:23:10 +02:00
Shinokuni
b1e8f25c6c Enable edge to edge 2024-06-30 15:29:00 +02:00
Shinokuni
02fb320123 When no ItemState entry is available for an item, by default it is unstarred 2024-06-28 21:30:45 +02:00
Shinokuni
d51cf041b8 Use separate state when needed to get new items unread count 2024-06-28 21:12:03 +02:00
Shinokuni
4382bbb061 Use setAllItemsRead insert methods for remote accounts 2024-06-28 17:07:53 +02:00
Shinokuni
b9aa09e103 Implement database migrations manually and remove RoomMigrant 2024-06-25 14:30:05 +02:00
Shinokuni
85242e6ebf Use separate state in FeedUnreadCountQueryBuilder 2024-06-24 18:13:04 +02:00
Shinokuni
1e95408500 Add support for all cases of setting all items read for all account types 2024-06-24 16:07:32 +02:00
Shinokuni
ed7adb5b76 Add setAllItemsReadByFeed for all account types 2024-06-23 19:33:52 +02:00
Shinokuni
f097edddbc Insert starred items when synchronizing with a Nextcloud News account 2024-06-20 15:45:15 +02:00
Shinokuni
23fd22afff Implement Nextcloud News add/update/delete folder actions 2024-06-20 13:40:24 +02:00
Shinokuni
116a1b9722 Fix image loading indefinitely when getting feed colors 2024-06-20 13:06:21 +02:00
Shinokuni
07b6059921 Implement Nextcloud News add/update/delete feed actions 2024-06-16 21:43:37 +02:00
Shinokuni
4acaa9a85b Make real parallel calls in FreshRSS synchronization 2024-06-16 16:38:08 +02:00
Shinokuni
e723dee12e Add Nextcloud News classic synchronization 2024-06-16 16:06:48 +02:00
Shinokuni
5bff505fc3 Add Nextcloud initial synchronization 2024-06-16 13:22:19 +02:00
Shinokuni
9a63c1a777 Add Nextcloud News login 2024-06-16 11:10:21 +02:00
Shinokuni
b879d0ae9d Rewrite Nextcloud News API implementation
* New data source
* New service
* Tests
2024-06-15 18:00:12 +02:00
Shinokuni
7a4d4d7225 Open AccountCredentialsScreen from AccountTab to edit account credentials 2024-06-02 18:27:54 +02:00
Shinokuni
338e64eca8 Fix some Account screens related issues 2024-06-02 17:14:57 +02:00
Shinokuni
303d7b6786 Add background worker for synchronization 2024-06-02 00:11:23 +02:00
Shinokuni
bc3015ab95 Fetch feed colors on synchronization 2024-05-23 21:45:46 +02:00
Shinokuni
133d8e6992 Implement FreshRSS add/update/delete feed actions 2024-05-20 22:07:30 +02:00
Shinokuni
940b6de97a Add short message to indicate why a feed url can't be updated with a FreshRSS account 2024-05-20 15:29:52 +02:00
Shinokuni
219d816483 Implement FreshRSS update/delete folder 2024-05-19 23:31:36 +02:00
Shinokuni
064d588b28 Use adaptiveIconPainterResource to load mipmap icons in some places 2024-05-19 22:31:38 +02:00
Shinokuni
351409bb26 Use account configuration in FeedTab 2024-05-19 22:17:34 +02:00
Shinokuni
ffe2d5cffb Scroll timeline to the top when synchronization is over 2024-05-12 22:09:27 +02:00
Shinokuni
19409f20a5 Add some permissions and debug app name 2024-05-10 17:15:43 +02:00
Shinokuni
d79c5b4637 Display remote account synchronization error in TimelineTab 2024-05-10 16:52:38 +02:00
Shinokuni
d19b8d90d4 Display RefreshScreen in TimelineTab only with a local account 2024-05-10 16:12:45 +02:00
Shinokuni
f9a2eb5e2c Add FreshRSS item read/state synchronization 2024-05-10 14:36:24 +02:00
Shinokuni
0fbaec1263 Put login and password in encrypted shared preferences 2024-05-10 14:02:45 +02:00
Shinokuni
b3c252a434 Add FreshRSS classic synchronization 2024-05-08 17:40:35 +02:00
Shinokuni
fbe14121b0 Fetch FreshRSS read and star item states 2024-05-07 22:50:44 +02:00
Shinokuni
df2ad13872 Fetch FreshRSS regular and starred items 2024-05-05 22:06:39 +02:00
Shinokuni
36cdf84b34 Fetch FreshRSS folders and feeds 2024-05-04 21:44:05 +02:00
Shinokuni
0d69cfd66d Fix CI build 2024-05-04 19:41:42 +02:00
Shinokuni
8071f0b477 Add new FeedDao upsert method 2024-05-04 19:29:20 +02:00
Shinokuni
052c83cb35 Add new FolderDao upsert method 2024-05-04 18:13:19 +02:00
Shinokuni
e9536e99ed Improve http errors wording 2024-04-30 23:44:13 +02:00
Shinokuni
f14ed7f331 Add initial FreshRSS login with new kotlin repository 2024-04-30 22:40:38 +02:00
Shinokuni
a7c0749641 Improve AccountSelectionScreen UI 2024-04-29 13:08:15 +02:00
Shinokuni
cc7b874ef5 Add initial UI of AccountCredentialsScreen 2024-04-29 00:03:28 +02:00
Shinokuni
2c105f596a Fix GetFoldersWithFeeds tests... 2024-04-27 21:20:35 +02:00
Shinokuni
9f87077945 Switch CI to ubuntu runner 2024-04-26 18:25:29 +02:00
Shinokuni
a3ffde0d73 Fix item read time calculation 2024-04-26 18:02:36 +02:00
Shinokuni
6893e9a199 Fix once and for all folders and feeds query with simple read state 2024-04-26 17:08:58 +02:00
Shinokuni
c55a9dc5e4 Fix BottomBarState not being updated in ItemScreen 2024-04-26 15:55:09 +02:00
Shinokuni
45dc199ea2 Prevent AndroidView being composed more than once in ItemScreen 2024-04-21 22:24:44 +02:00
Shinokuni
841b56e7e5 Adjust ItemBottomBar icon tint color depending of background color in ItemScreen 2024-04-21 22:16:45 +02:00
Shinokuni
8566b55e3f Display unread new items count in TimelineDrawer 2024-04-17 15:10:08 +02:00
Shinokuni
ea51df49bc Display feed description in FeedBottomSheet instead of folder name 2024-04-15 16:34:39 +02:00
Shinokuni
99ff159434 Fix some feed parsing failures 2024-04-15 14:21:29 +02:00
Shinokuni
c115a1edcc Fix content display for some items in ItemScreen 2024-04-15 12:28:37 +02:00
Shinokuni
afbf8129ca Add options to download/share image on long press in ItemScreen 2024-04-14 14:40:32 +02:00
Shinokuni
eeb054f068 Add little template to html blockquote tag 2024-04-13 16:34:34 +02:00
Shinokuni
d486bd92f9 Use Inter custom font in ItemWebView 2024-04-13 15:58:16 +02:00
Shinokuni
7b644cbc97 Open webView urls in external navigator 2024-04-13 14:24:06 +02:00
Shinokuni
02a3f82b72 Extract views from ItemScreen 2024-04-13 14:12:55 +02:00
Shinokuni
91378f0a54 Add collapsable bottom bar in ItemScreen
Gather all actions at the bottom of the screen:
* set read state
* set start state
* share url
* open url
2024-04-13 13:03:37 +02:00
Shinokuni
bf7ac41d6e Inject Koin context in Compose hierarchy 2024-04-10 17:14:16 +02:00
Shinokuni
c071426bbd Display item content in ItemScreen 2024-04-10 16:56:30 +02:00
Shinokuni
da51f504e4 Fix crash for some feeds in TimelineTab 2024-04-07 17:34:56 +02:00
Shinokuni
16e70519e4 Add initial ItemScreen UI 2024-04-07 17:32:53 +02:00
Shinokuni
0ccb4aa9c8 Add OPML export in AccountTab 2024-04-06 22:51:56 +02:00
Shinokuni
8a5c22d144 Migrate more dependencies to version catalog 2024-04-05 22:41:23 +02:00
Shinokuni
cbeea6ca0d Improve refreshIndicator feed name display 2024-04-01 19:41:26 +02:00
Shinokuni
0a1574df0d Show opml import errors in AccountTab 2024-04-01 13:02:59 +02:00
Shinokuni
e0874f2297 Add OPML import in AccountTab 2024-03-31 19:48:29 +02:00
Alexandre Alapetite
9c73738cab
Merge branch 'develop' into fix-freshrss-get-link 2024-03-31 19:23:59 +02:00
Shinokuni
1a92684c18 Migrade room and koin dependencies into version catalog 2024-03-30 19:35:43 +01:00
Shinokuni
b943822194 Add default placeholders for feed icons 2024-03-28 11:54:20 +01:00
Shinokuni
51b7ba73fd Fix progress loading indicator in RefreshScreen 2024-03-28 11:22:54 +01:00
Shinokuni
8b10864578 Add loading screen for local account in TimelineTab 2024-03-26 19:41:35 +01:00
Shinokuni
2b1992e03a Fix pull to refresh not available when displaying the no item placeholder in TimelineTab 2024-03-26 18:39:39 +01:00
Shinokuni
da78d1b835 Improve state reset for some dialogs in FeedTab 2024-03-26 14:30:29 +01:00
Shinokuni
b4ac021159 Display synchronization errors for individual feeds in TimelineTab 2024-03-25 22:58:17 +01:00
Shinokuni
c3026f0fdb Fix synchronization being triggered two times when using the refresh button in TimelineTab 2024-03-21 12:20:06 +01:00
Shinokuni
2be1dd0b72 Fix AddFeedDialog state not being reset after adding a feed 2024-03-21 11:25:43 +01:00
Shinokuni
0aced64faf Make synchronization aware of subFilters (Folder/Feed) in TimelineTab
* Only for local accounts
2024-03-21 10:54:54 +01:00
Shinokuni
32e0c17e08 Improve state hoisting in FilterBottomSheet 2024-03-19 22:35:12 +01:00
Shinokuni
8011759076 Update some dependencies 2024-03-19 22:14:49 +01:00
Shinokuni
1038fb6827 Add some placeholders in TimelineTab 2024-03-19 19:39:34 +01:00
Shinokuni
093936b036 Prevent screenModels being recreated when opening a screen from a tab
Do it the right way, solution from https://github.com/adrielcafe/voyager/issues/266#issuecomment-2002108371
2024-03-19 14:15:04 +01:00
Shinokuni
c52f324a13 Add mark all read items for new articles filter 2024-03-04 23:10:20 +01:00
Shinokuni
5c16ea09de Show folder/feed filter name in TopAppBar 2024-03-04 22:42:54 +01:00
Shinokuni
6664903532 Combine a feed/folder filter with a main filter (all, new, stars) 2024-03-04 22:16:46 +01:00
Shinokuni
e31ebe487f Fix CI test 2024-03-03 23:44:36 +01:00
Shinokuni
3e522538e0 Replace viewModels by screenModels
Fix tabs screenModels being recreated after going back a new screen
2024-03-03 22:18:14 +01:00
Shinokuni
c9259c5a71 Make foldersAndFeeds queries aware of main filters 2024-03-02 22:15:13 +01:00
Shinokuni
59d22fd311 Replace accompanist pull to refresh with the new material3 one 2024-03-01 23:12:43 +01:00
Shinokuni
291c75a685 Start using Gradle Version Catalog in appcompose
Version Catalog will have to be applied to all other modules when old app package is deleted
2024-03-01 22:15:59 +01:00
Shinokuni
97dc2446ce Update Voyager 2024-03-01 19:40:44 +01:00
Shinokuni
2ee2144fd3 Update some dependencies 2024-02-29 10:48:41 +01:00
Shinokuni
6bf7cb00a8 Prevent HttpException being thrown when response code is 304
Todo: cover all non HTTP 200 cases where there is no need to throw an exception
2024-02-28 22:54:07 +01:00
Shinokuni
4467774070 Add support for the new articles filter in main query builder 2024-02-28 22:46:08 +01:00
Shinokuni
56b2943fa1 Fix CI not building 2024-02-28 22:38:24 +01:00
Shinokuni
3a5db09385 Merge branch 'develop' of https://github.com/Readrops/readrops into develop 2024-02-27 22:45:30 +01:00
Shinokuni
ec0cf16b96 Set TimelineTab appBar title maxLines to 1 2024-02-27 19:13:03 +01:00
Shinokuni
ba149db709 Remove filter category
* Remaining: delete database field read_it_later
2024-02-25 22:57:34 +01:00
Shinokuni
c4339acf31 Fix folders and feeds list query
Fix folders and feeds list disappearing from drawer when all items are read
2024-02-25 22:41:28 +01:00
Alexandre Alapetite
d5d8b16148
Merge branch 'develop' into pr/Alkarex/163 2024-02-22 21:53:19 +01:00
Shinokuni
1cdb98eedb Add some filters in TimelineTab 2024-02-22 21:44:03 +01:00
Shinokuni
adb56cdacf Add mark all read items action for some filters in TimelineTab 2024-02-21 23:59:01 +01:00
Shinokuni
09bcba7c5a Change top bar title depending on current filter in TimelineTab 2024-02-21 21:53:11 +01:00
Shinokuni
edc2094a6e Fix GetFoldersWithFeeds not sending feeds without folder 2024-02-19 23:16:06 +01:00
Shinokuni
40accbc8b4 Fix no feed placeholder not appearing in FeedTab 2024-02-19 22:59:19 +01:00
Shinokuni
5a0affdf3b Improve the use of BaseDialog 2024-02-19 22:43:53 +01:00
Shinokuni
7b537123c8 Add new account dialog in AccountTab 2024-02-19 21:53:33 +01:00
Shinokuni
13557c00e6 Add dialog to delete account in AccountTab 2024-02-19 13:27:43 +01:00
Shinokuni
5b74480398 Add initial layout of MoreTab 2024-02-18 23:38:47 +01:00
Shinokuni
939af36b2d Add initial layout of AccountTab 2024-02-18 22:50:02 +01:00
Shinokuni
fc29759641 Add logic to update feed in FeedTab 2024-02-18 21:40:45 +01:00
Shinokuni
ad13ba99e0 Fix GetFoldersWithFeeds test 2024-02-18 19:04:10 +01:00
Shinokuni
425d20741b Merge branch 'develop' of https://github.com/Readrops/readrops into develop 2024-02-15 21:41:37 +01:00
Shinokuni
a29b27801e Fix gradle full build step 2024-02-15 21:41:32 +01:00
Shinokuni
6c875d9d93 Update build workflow to use JDK 1.17 2024-02-15 12:15:57 +01:00
Shinokuni
90b8c54e18 Use string resources in TimelineTab when possible 2024-02-15 12:03:39 +01:00
Shinokuni
9aa2004627 Improve global FeedTab layout 2024-02-14 23:49:45 +01:00
Shinokuni
8718781af4 Use string resources in FeedTab 2024-02-14 22:10:42 +01:00
Shinokuni
423a226510 Transfer strings from old app package to the new one 2024-02-14 21:24:14 +01:00
Shinokuni
768522eee6 Improve small FAB appearance in FeedTab 2024-02-13 23:23:19 +01:00
Shinokuni
5036e021f5 Add initial and error states handling in FeedTab 2024-02-13 23:06:23 +01:00
Shinokuni
bfd54e1b19 Add unfold more/unfold less action in FeedTab 2024-02-13 22:21:18 +01:00
Shinokuni
a3c9e0a89e Improve error handling when adding a new feed in FeedTab 2024-02-11 18:48:45 +01:00
Shinokuni
e176cdbdb1 Improve some naming in feed package 2024-02-11 18:24:49 +01:00
Shinokuni
f0ac2de8e4 Improve dialog for folder and feed deletion in FeedTab 2024-02-11 17:56:45 +01:00
Shinokuni
6f01333065 Add update and delete folder dialogs 2024-02-11 17:51:58 +01:00
Shinokuni
a3f78094f1 Merge all TextField errors in TextFieldError class 2024-01-19 22:38:41 +01:00
Shinokuni
2631712361 Add AddFolderDialog in FeedTab 2024-01-19 21:35:55 +01:00
Shinokuni
caf55451d3 Add UpdateFeedDialog in FeedTab 2024-01-18 18:45:34 +01:00
Shinokuni
a6d753ef8a Add DeleteFeedDialog in FeedTab 2024-01-17 16:42:03 +01:00
Shinokuni
215399d3ac Improve state management in FeedTab 2024-01-17 15:48:07 +01:00
Shinokuni
69788de077 Show feeds without folders in FeedTab 2024-01-14 01:01:45 +01:00
Shinokuni
672de764de Improve AddFeedDialog looking and behaviour
- Add error management for URL TextField
- Add account dropdown
2024-01-14 00:57:22 +01:00
Shinokuni
76cff80e68 Improve FeedTab global behaviour and looking 2024-01-12 21:23:20 +01:00
Shinokuni
7d17740713 Improve FeedBottomSheet looking and add Update color option 2024-01-12 19:49:35 +01:00
Shinokuni
5cd7ead78f Show feeds as sub items of folders in FeedTab 2024-01-12 19:28:25 +01:00
Shinokuni
6b50d40800 Improve feed item lookingin FeedTab and add initial feed bottom sheet 2024-01-11 23:37:48 +01:00
Shinokuni
d76be31b75 Merge branch 'compose-migration' into develop 2024-01-11 22:16:42 +01:00
Alexandre Alapetite
7e5c0268ea
Fix FreshRSS get link
#fix https://github.com/readrops/Readrops/issues/162
#fix https://github.com/FreshRSS/FreshRSS/issues/4567
2022-08-31 12:47:30 +02:00
Shinokuni
d655653361 Fix item state request not working 2022-02-25 19:49:10 +01:00
Shinokuni
856f0ef333 Add item state update request 2022-02-25 19:00:56 +01:00
Shinokuni
3d698b3051 Add tests for FeverDataSource login method 2021-12-30 21:53:00 +01:00
Shinokuni
53c5d2f56b Improve a little bit Fever login error handling 2021-12-29 13:12:41 +01:00
Shinokuni
dcf58a537e Add Fever initial and classic synchronisation logic 2021-12-28 20:09:15 +01:00
Shinokuni
fe57d5b2d7 Add items and items ids insertion for Fever synchronisation 2021-12-28 18:29:34 +01:00
Shinokuni
701c188cfa Map Fever folders to feeds with feeds_groups content parsing 2021-12-27 15:59:31 +01:00
Shinokuni
64b6fb0b4d Add basic Fever synchronisation with feeds and folders insertion 2021-12-27 14:03:52 +01:00
Shinokuni
0885a9a3e4 Fix Fever adapters not consuming data to the end 2021-12-27 13:41:01 +01:00
Shinokuni
562f11e2cc Add adapter for Fever favicons call 2021-12-25 19:08:49 +01:00
Shinokuni
5975458011 Add adapter for Fever items ids call 2021-12-25 19:08:25 +01:00
Shinokuni
76cd9355f8 Add adapter for Fever items call 2021-12-25 18:16:44 +01:00
Shinokuni
2396ba1f7e Add adapter for Fever feeds api call 2021-12-25 17:34:40 +01:00
Shinokuni
9189be9d93 Add adapter for Fever folders api call 2021-12-25 17:34:15 +01:00
Shinokuni
eb6d487f8f Add proper login call with adapter for Fever api 2021-12-25 16:22:16 +01:00
Shinokuni
162fcfa75d Add initial files for Fever API implementation 2021-12-24 19:07:05 +01:00
Allan Nordhøy
14190bebe6
Translated using Weblate (Norwegian Bokmål)
Currently translated at 94.2% (132 of 140 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/nb_NO/
2021-10-28 09:34:07 +02:00
Hin Weisner
49e7f3d186
Translated using Weblate (Spanish)
Currently translated at 100.0% (140 of 140 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/es/
2021-10-17 03:35:23 +02:00
Hin Weisner
658b52806c
Added translation using Weblate (Spanish) 2021-10-16 01:56:21 +02:00
inkhorn
33eb256fc4
Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (140 of 140 strings)

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/pt_BR/
2021-10-13 17:49:07 +02:00
inkhorn
35401a8466
Added translation using Weblate (Portuguese (Brazil)) 2021-10-12 15:35:03 +02:00
Hosted Weblate
45be886229
Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Translation: Readrops/Strings
Translate-URL: https://hosted.weblate.org/projects/readrops/strings/
2021-10-11 23:05:44 +02:00
566 changed files with 22534 additions and 17782 deletions

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
*.html linguist-vendored

1
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1 @@
custom: ["https://paypal.me/readropsapp"]

32
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,32 @@
---
name: Bug report
about: Create a report to help us fix a bug
title: "[Bug] "
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Environment information (please complete the following information):**
- Account type: [e.g. FreshRSS, Nextcloud News]
- App version: [e.g. 2.0]
- Android version: [e.g. Android 13, 14]
- Device type: [e.g. One Plus 12, Samsung Galaxy S23]
- Store: [e.g F-Droid, Play Store, standalone apk]
- [ ] Stacktrace collected from crash screen
**Additional context**
Add any other context about the problem here.

View File

@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[Feature]"
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@ -11,22 +11,28 @@ on:
jobs:
build:
runs-on: macos-latest
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: set up JDK 1.11
- name: set up JDK 1.17
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '11'
java-version: '17'
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Android Emulator Runner
uses: ReactiveCircus/android-emulator-runner@v2.28.0
uses: ReactiveCircus/android-emulator-runner@v2.33.0
with:
api-level: 29
script: ./gradlew clean build connectedCheck jacocoFullReport
- uses: codecov/codecov-action@v2.1.0
- uses: codecov/codecov-action@v4
with:
files: ./build/reports/jacoco/jacocoFullReport.xml
token: ${{ secrets.CODECOV_TOKEN }}
files: ./build/reports/jacoco/jacocoFullReport/jacocoFullReport.xml
fail_ci_if_error: true
verbose: true

2
.gitignore vendored
View File

@ -135,3 +135,5 @@ Gemfile
mapping/
**/*.exec
.kotlin/

View File

@ -1,8 +1,94 @@
# v2.0.3
- Fix Fever API compatibility with TinyTiny RSS and yarr, should also fix other providers (#228 + #229)
- Fix Nextcloud News item duplicates when syncing which would made the app unusable
- Fix Nextcloud News item parsing: items with no title will be ignored
# v2.0.2
- Fix crash when opening app from a notification (#223)
- Fix Fever API synchronization error (#228)
# v2.0.1
- Make Timeline tab filters persistent (#138)
- Change Timeline tab order field default value (#202)
- Fix crash when adding a Fever API account (#200)
- Be less strict with feed and folder names (#206)
# v2.0
- Restore swipe to mark as read (#188)
- Restore Ordering by article id in Timeline tab
- Improve OPML file picker filtering (#195)
- Translation updates
- See previous beta versions to get full changelog since v1.3
# v2.0-beta02
- Fix migration issues from v1.3 and older (especially for F-Droid builds)
- Make Preferences screen scrollable (#190)
- Fix wrong translation in RadioButtonPreferenceDialog (#185)
- Translation updates
# v2.0-beta01
## General
- 🆕 design:
- 🆕 Material3: Readrops implements last material design system version
- 🆕 Bottom bar navigation: you can now navigate to feeds and account management way more easily, with 4 tabs in total:
- Timeline
- Feeds
- Account
- More
- Timeline tab:
- 🆕 Article size: you can now choose among three article sizes: compact, regular and large
- 🆕 You can now show only articles less than 24h old
- 🆕 Mark all articles as read FAB: the floating action button now lets you mark all articles read, taking into account the current filter, replacing opening new feed activity action
- 🆕 Mark articles read on scroll: an option is now available to mark items read on scroll
- 🆕 Drawer: hide folders and feeds without unread articles
- 🆕 Local account: sync now respects the current filter and will only synchronize affected feeds
- Feeds Tab:
- 🆕 Feeds and folder management have been merged into a single screen
- Account Tab:
- 🆕 Add, manage and remove any account from Account Tab
- More Tab:
- 🆕 This new screen gathers some app infos, parameters, open source libraries and a donation dialog
- Articles screen:
- The global UI has been improved with a new title layout
- 🆕 Action bottom bar: you now have access to a collapsable bottom bar containing the following actions:
- Mark as read/non read
- Add to favorites/remove from favorites
- Share
- Open in external navigator/navigator view
- 🆕 A new font, Inter is used for the article content
- 🆕 Some html tags look have been improved:
- blockquote
- figure/figcaption
- iframe
- table
- "Open in" option has been reduced to two options: navigator view and external navigator
- 🆕 FEVER API implementation, should work with any provider which supports it
- Migrate to Nextcloud News API 1.3
- 🆕 Follow system theme option (default)
- 🆕 Option to disable battery optimization for background synchronization
- Add support for new Android versions until Android 14 (API 34)
## Technical
- The UI has been entirely rewritten in Kotlin using Jetpack Compose, moving from old traditional view system
- All other Java parts have also been rewritten in Kotlin, including API implementations, repositories, etc
- RXJava was replaced by Kotlin coroutines and flows
- Migrate to Gradle Kotlin DSL
- Migrate dependencies to Version Catalog
- 🆕 Support user certificates
# v1.3.1
- FreshRSS : Fix items being fav unintentionally
- FreshRSS : Fix 401 error when synchronising for the second time
# v1.3.0
- New local RSS parser, much reliable
- New external navigator view for items (Custom tabs)
- FreshRSS and Nextcloud News favorites
@ -69,8 +155,6 @@ Fix a crash related to Proguard Rules.
- Change feed/folders way to interact
- Minor bug fixes and improvements
# v1.0.1
# v1.0 Initial release

View File

@ -5,36 +5,41 @@
<h1 align="center"><b>Readrops</b></h1>
<p align="center">
<a href="https://github.com/readrops/Readrops/actions"><img src="https://github.com/readrops/Readrops/workflows/Android%20CI/badge.svg?branch=develop"></a>
<a href="https://github.com/readrops/Readrops/actions"><img src="https://github.com/readrops/Readrops/actions/workflows/android.yml/badge.svg?branch=develop"></a>
<a href="https://codecov.io/gh/readrops/Readrops"><img src="https://codecov.io/gh/readrops/Readrops/branch/develop/graph/badge.svg?token=229PNPQPMM"></a>
<a href="https://hosted.weblate.org/engage/readrops/"><img src="https://hosted.weblate.org/widgets/readrops/-/strings/svg-badge.svg"/></a>
<h4 align="center">Readrops is a multi-services RSS client for Android. Its name is composed of "Read" and "drops", where drops are information drops in an ocean of news.</h4>
<h4 align="center">Readrops is a multi-services RSS client for Android. Its name is composed of "Read" and "drops", where drops are articles in an ocean of news.</h4>
<p align="center">
<a href="https://play.google.com/store/apps/details?id=com.readrops.app"><img src="images/google-play-badge.png" width=250></a>
<a href="https://f-droid.org/en/packages/com.readrops.app/"><img src="images/fdroid-badge.png" width=250></a>
</p>
</p>
# Features
- Local RSS parsing : support for RSS 2, RSS 1, ATOM and JSONFeed
- Nextcloud news support
- FreshRSS support
- Multiple accounts
- Feeds and folders management (create, update and delete feeds/folders if your service API supports it)
- Local RSS parsing:
- RSS2
- RSS1
- ATOM
- JSONFeed
- External services:
- FreshRSS
- Nextcloud News
- Fever API
- Multi-account
- Feeds and folders management (create, update and delete feeds/folders if supported by the service API)
- OPML import/export
- Background synchronisation
- Notifications
# Screenshots
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_1.png" width=250> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_2.png" width=250> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_3.png" width=250>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_1.jpg" width=250> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_2.jpg" width=250> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_3.jpg" width=250>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_4.png" width=250> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_5.png" width=250> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_6.png" width=250>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_4.jpg" width=250> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_5.jpg" width=250> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_6.jpg" width=250>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_7.png" width=250> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_8.png" width=250>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_7.jpg" width=250> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_8.jpg" width=250>
# Licence

View File

@ -1,80 +0,0 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
android {
compileSdkVersion rootProject.ext.compileSdkVersion
defaultConfig {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/androidTest/assets".toString())
}
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
debug {
minifyEnabled false
testCoverageEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
lint {
abortOnError false
}
namespace 'com.readrops.api'
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':db')
testImplementation 'junit:junit:4.13'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test:runner:1.4.0'
androidTestImplementation 'androidx.test:rules:1.4.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
testImplementation 'com.squareup.okhttp3:mockwebserver:4.9.0'
testImplementation "io.insert-koin:koin-test-junit4:$rootProject.ext.koin_version"
testImplementation "io.insert-koin:koin-test:$rootProject.ext.koin_version"
implementation 'com.gitlab.mvysny.konsume-xml:konsume-xml:1.0'
implementation 'org.redundent:kotlin-xml-builder:1.7.3'
api 'com.squareup.okhttp3:okhttp:4.9.1'
implementation('com.squareup.retrofit2:retrofit:2.9.0') {
exclude group: 'com.squareup.okhttp3', module: 'okhttp3'
}
implementation('com.squareup.retrofit2:converter-moshi:2.9.0') {
exclude group: 'com.squareup.moshi', module: 'moshi'
}
implementation 'com.squareup.retrofit2:adapter-rxjava2:2.9.0'
implementation 'com.squareup.moshi:moshi:1.12.0'
api 'io.reactivex.rxjava2:rxandroid:2.1.1'
api 'org.jsoup:jsoup:1.13.1'
debugApi 'com.chimerapps.niddler:niddler:1.5.5'
releaseApi 'com.chimerapps.niddler:niddler-noop:1.5.5'
}

63
api/build.gradle.kts Normal file
View File

@ -0,0 +1,63 @@
plugins {
id("com.android.library")
kotlin("android")
}
android {
namespace = "com.readrops.api"
buildTypes {
debug {
enableUnitTestCoverage = true
}
create("beta") {
initWith(getByName("release"))
signingConfig = signingConfigs.getByName("debug")
}
}
sourceSets {
getByName("androidTest") {
assets.srcDirs("$projectDir/androidTest/assets")
}
}
kotlinOptions {
freeCompilerArgs = listOf("-Xstring-concat=inline")
}
lint {
abortOnError = false
}
}
dependencies {
implementation(project(":db"))
coreLibraryDesugaring(libs.jdk.desugar)
testImplementation(libs.junit4)
implementation(libs.coroutines.core)
testImplementation(libs.coroutines.test)
implementation(platform(libs.koin.bom))
implementation(libs.bundles.koin)
testImplementation(libs.bundles.kointest)
implementation(libs.konsumexml)
implementation(libs.kotlinxmlbuilder)
implementation(libs.okhttp)
testImplementation(libs.okhttp.mockserver)
implementation(libs.bundles.retrofit) {
exclude("com.squareup.okhttp3", "okhttp3")
exclude("com.squareup.moshi", "moshi")
}
implementation(libs.moshi)
implementation(libs.jsoup)
}

View File

@ -2,14 +2,27 @@ package com.readrops.api
import com.readrops.api.localfeed.LocalRSSDataSource
import com.readrops.api.services.Credentials
import com.readrops.api.services.freshrss.FreshRSSDataSource
import com.readrops.api.services.freshrss.FreshRSSService
import com.readrops.api.services.freshrss.adapters.*
import com.readrops.api.services.nextcloudnews.NextNewsDataSource
import com.readrops.api.services.nextcloudnews.NextNewsService
import com.readrops.api.services.nextcloudnews.adapters.NextNewsFeedsAdapter
import com.readrops.api.services.nextcloudnews.adapters.NextNewsFoldersAdapter
import com.readrops.api.services.nextcloudnews.adapters.NextNewsItemsAdapter
import com.readrops.api.services.fever.FeverDataSource
import com.readrops.api.services.fever.FeverService
import com.readrops.api.services.fever.adapters.FeverAPIAdapter
import com.readrops.api.services.fever.adapters.FeverFaviconsAdapter
import com.readrops.api.services.fever.adapters.FeverFeeds
import com.readrops.api.services.fever.adapters.FeverFeedsAdapter
import com.readrops.api.services.fever.adapters.FeverFoldersAdapter
import com.readrops.api.services.fever.adapters.FeverItemsAdapter
import com.readrops.api.services.fever.adapters.FeverItemsIdsAdapter
import com.readrops.api.services.greader.GReaderDataSource
import com.readrops.api.services.greader.GReaderService
import com.readrops.api.services.greader.adapters.FreshRSSUserInfoAdapter
import com.readrops.api.services.greader.adapters.GReaderFeedsAdapter
import com.readrops.api.services.greader.adapters.GReaderFoldersAdapter
import com.readrops.api.services.greader.adapters.GReaderItemsAdapter
import com.readrops.api.services.greader.adapters.GReaderItemsIdsAdapter
import com.readrops.api.services.nextcloudnews.NextcloudNewsDataSource
import com.readrops.api.services.nextcloudnews.NextcloudNewsService
import com.readrops.api.services.nextcloudnews.adapters.NextcloudNewsFeedsAdapter
import com.readrops.api.services.nextcloudnews.adapters.NextcloudNewsFoldersAdapter
import com.readrops.api.services.nextcloudnews.adapters.NextcloudNewsItemsAdapter
import com.readrops.api.utils.AuthInterceptor
import com.readrops.api.utils.ErrorInterceptor
import com.readrops.db.entities.Item
@ -19,7 +32,6 @@ import okhttp3.OkHttpClient
import org.koin.core.qualifier.named
import org.koin.dsl.module
import retrofit2.Retrofit
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory
import retrofit2.converter.moshi.MoshiConverterFactory
import java.util.concurrent.TimeUnit
@ -28,7 +40,7 @@ val apiModule = module {
single {
OkHttpClient.Builder()
.callTimeout(1, TimeUnit.MINUTES)
.readTimeout(1, TimeUnit.HOURS)
.readTimeout(1, TimeUnit.MINUTES)
.addInterceptor(get<AuthInterceptor>())
.addInterceptor(get<ErrorInterceptor>())
//.addInterceptor(NiddlerOkHttpInterceptor(get(), "niddler"))
@ -41,53 +53,77 @@ val apiModule = module {
single { LocalRSSDataSource(get()) }
//region freshrss
//region greader/freshrss
factory { params -> FreshRSSDataSource(get(parameters = { params })) }
factory { params -> GReaderDataSource(get(parameters = { params })) }
factory { (credentials: Credentials) ->
Retrofit.Builder()
.baseUrl(credentials.url)
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.client(get())
.addConverterFactory(MoshiConverterFactory.create(get(named("freshrssMoshi"))))
.addConverterFactory(MoshiConverterFactory.create(get(named("greaderMoshi"))))
.build()
.create(FreshRSSService::class.java)
.create(GReaderService::class.java)
}
single(named("freshrssMoshi")) {
single(named("greaderMoshi")) {
Moshi.Builder()
.add(Types.newParameterizedType(List::class.java, Item::class.java), FreshRSSItemsAdapter())
.add(Types.newParameterizedType(List::class.java, String::class.java), FreshRSSItemsIdsAdapter())
.add(FreshRSSFeedsAdapter())
.add(FreshRSSFoldersAdapter())
.add(Types.newParameterizedType(List::class.java, Item::class.java), GReaderItemsAdapter())
.add(Types.newParameterizedType(List::class.java, String::class.java), GReaderItemsIdsAdapter())
.add(GReaderFeedsAdapter())
.add(GReaderFoldersAdapter())
.add(FreshRSSUserInfoAdapter())
.build()
}
//endregion freshrss
//endregion greader/freshrss
//region nextcloud news
factory { params -> NextNewsDataSource(get(parameters = { params })) }
factory { params -> NextcloudNewsDataSource(get(parameters = { params })) }
factory { (credentials: Credentials) ->
Retrofit.Builder()
.baseUrl(credentials.url)
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.client(get())
.addConverterFactory(MoshiConverterFactory.create(get(named("nextcloudNewsMoshi"))))
.build()
.create(NextNewsService::class.java)
.create(NextcloudNewsService::class.java)
}
single(named("nextcloudNewsMoshi")) {
Moshi.Builder()
.add(NextNewsFeedsAdapter())
.add(NextNewsFoldersAdapter())
.add(Types.newParameterizedType(List::class.java, Item::class.java), NextNewsItemsAdapter())
.add(NextcloudNewsFeedsAdapter())
.add(NextcloudNewsFoldersAdapter())
.add(Types.newParameterizedType(List::class.java, Item::class.java), NextcloudNewsItemsAdapter())
.build()
}
//endregion nextcloud news
//region Fever
factory { params -> FeverDataSource(get(parameters = { params })) }
factory { (credentials: Credentials) ->
Retrofit.Builder()
.baseUrl(credentials.url)
.client(get())
.addConverterFactory(MoshiConverterFactory.create(get(named("feverMoshi"))))
.build()
.create(FeverService::class.java)
}
single(named("feverMoshi")) {
Moshi.Builder()
.add(FeverFoldersAdapter())
.add(FeverFeeds::class.java, FeverFeedsAdapter())
.add(FeverItemsAdapter())
.add(FeverFaviconsAdapter())
.add(Boolean::class.java, FeverAPIAdapter())
.add(FeverItemsIdsAdapter())
.build()
}
//endregion Fever
}

View File

@ -1,6 +1,5 @@
package com.readrops.api.localfeed
import android.accounts.NetworkErrorException
import androidx.annotation.WorkerThread
import com.gitlab.mvysny.konsumexml.Konsumer
import com.gitlab.mvysny.konsumexml.konsumeXml
@ -22,7 +21,6 @@ import okio.Buffer
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import java.io.IOException
import java.lang.Exception
import java.net.HttpURLConnection
class LocalRSSDataSource(private val httpClient: OkHttpClient) : KoinComponent {
@ -75,7 +73,8 @@ class LocalRSSDataSource(private val httpClient: OkHttpClient) : KoinComponent {
val rootKonsumer = nextElement(LocalRSSHelper.RSS_ROOT_NAMES)
rootKonsumer?.let { type = LocalRSSHelper.guessRSSType(rootKonsumer) }
} catch (e: Exception) {
throw UnknownFormatException(e.message)
close()
return false
}
}

View File

@ -3,7 +3,6 @@ package com.readrops.api.localfeed
import com.gitlab.mvysny.konsumexml.Konsumer
import com.gitlab.mvysny.konsumexml.Names
import com.readrops.api.utils.extensions.checkRoot
import java.io.InputStream
object LocalRSSHelper {
@ -26,12 +25,11 @@ object LocalRSSHelper {
RSS_1_CONTENT_TYPE -> RSSType.RSS_1
RSS_2_CONTENT_TYPE -> RSSType.RSS_2
ATOM_CONTENT_TYPE -> RSSType.ATOM
JSON_CONTENT_TYPE, JSONFEED_CONTENT_TYPE -> RSSType.JSONFEED
JSONFEED_CONTENT_TYPE -> RSSType.JSONFEED
else -> RSSType.UNKNOWN
}
}
@JvmStatic
fun isRSSType(type: String?): Boolean =
if (type != null) getRSSType(type) != RSSType.UNKNOWN else false

View File

@ -0,0 +1,41 @@
package com.readrops.api.localfeed
import com.gitlab.mvysny.konsumexml.Konsumer
import com.gitlab.mvysny.konsumexml.Names
import com.gitlab.mvysny.konsumexml.allChildrenAutoIgnore
import com.readrops.api.utils.extensions.nullableTextRecursively
import com.readrops.db.entities.Item
object RSSMedia {
fun parseMediaContent(konsumer: Konsumer, item: Item) = with(konsumer) {
val url = attributes.getValueOrNull("url")
if (url != null && isUrlImage(url) && item.imageLink == null) {
item.imageLink = url
}
konsumer.skipContents() // ignore media content sub elements
}
fun parseMediaGroup(konsumer: Konsumer, item: Item) = with(konsumer) {
allChildrenAutoIgnore(Names.of("content", "thumbnail", "description")) {
when (tagName) {
"media:content" -> parseMediaContent(this, item)
"media:thumbnail"-> parseMediaContent(this, item)
"media:description" -> {
// Youtube case, might be useful for others
val description = nullableTextRecursively()
if (item.text == null) {
item.content = description
}
}
else -> skipContents()
}
}
}
private fun isUrlImage(url: String): Boolean = with(url) {
return endsWith(".jpg") || endsWith(".jpeg") || endsWith(".png")
}
}

View File

@ -28,7 +28,9 @@ class ATOMFeedAdapter : XmlAdapter<Pair<Feed, List<Item>>> {
"title" -> name = nonNullText()
"link" -> parseLink(this@allChildrenAutoIgnore, feed)
"subtitle" -> description = nullableText()
"logo" -> imageUrl = nullableText()
"entry" -> items += itemAdapter.fromXml(this@allChildrenAutoIgnore)
else -> skipContents()
}
}
}
@ -51,6 +53,6 @@ class ATOMFeedAdapter : XmlAdapter<Pair<Feed, List<Item>>> {
}
companion object {
val names = Names.of("title", "link", "subtitle", "entry")
val names = Names.of("title", "link", "subtitle", "logo", "entry")
}
}

View File

@ -3,14 +3,15 @@ package com.readrops.api.localfeed.atom
import com.gitlab.mvysny.konsumexml.Konsumer
import com.gitlab.mvysny.konsumexml.Names
import com.gitlab.mvysny.konsumexml.allChildrenAutoIgnore
import com.readrops.api.localfeed.RSSMedia
import com.readrops.api.localfeed.XmlAdapter
import com.readrops.api.utils.*
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.extensions.nonNullText
import com.readrops.api.utils.extensions.nullableText
import com.readrops.api.utils.extensions.nullableTextRecursively
import com.readrops.db.entities.Item
import org.joda.time.LocalDateTime
import com.readrops.db.util.DateUtils
import java.time.LocalDateTime
class ATOMItemAdapter : XmlAdapter<Item> {
@ -22,12 +23,19 @@ class ATOMItemAdapter : XmlAdapter<Item> {
konsumer.allChildrenAutoIgnore(names) {
when (tagName) {
"title" -> title = nonNullText()
"id" -> guid = nullableText()
"updated" -> pubDate = DateUtils.parse(nullableText())
"id" -> remoteId = nullableText()
"published" -> pubDate = DateUtils.parse(nullableText())
"updated" -> {
val updated = nullableText()
if (pubDate == null) {
pubDate = DateUtils.parse(updated)
}
}
"link" -> parseLink(this, this@apply)
"author" -> allChildrenAutoIgnore("name") { author = nullableText() }
"summary" -> description = nullableTextRecursively()
"content" -> content = nullableTextRecursively()
"media:group" -> RSSMedia.parseMediaGroup(this, item)
else -> skipContents()
}
}
@ -35,7 +43,7 @@ class ATOMItemAdapter : XmlAdapter<Item> {
validateItem(item)
if (item.pubDate == null) item.pubDate = LocalDateTime.now()
if (item.guid == null) item.guid = item.link
if (item.remoteId == null) item.remoteId = item.link
item
} catch (e: Exception) {
@ -57,6 +65,7 @@ class ATOMItemAdapter : XmlAdapter<Item> {
}
companion object {
val names = Names.of("title", "id", "updated", "link", "author", "summary", "content")
val names = Names.of("title", "id", "updated", "link", "author", "summary",
"content", "group", "published")
}
}

View File

@ -5,7 +5,9 @@ import com.readrops.api.utils.extensions.nextNonEmptyString
import com.readrops.api.utils.extensions.nextNullableString
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Item
import com.squareup.moshi.*
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
class JSONFeedAdapter : JsonAdapter<Pair<Feed, List<Item>>>() {
@ -27,8 +29,9 @@ class JSONFeedAdapter : JsonAdapter<Pair<Feed, List<Item>>>() {
0 -> name = reader.nextNonEmptyString()
1 -> siteUrl = reader.nextNullableString()
2 -> url = reader.nextNullableString()
3 -> description = reader.nextNullableString()
4 -> items += itemAdapter.fromJson(reader)
3 -> imageUrl = reader.nextNullableString()
4 -> description = reader.nextNullableString()
5 -> items += itemAdapter.fromJson(reader)
else -> reader.skipValue()
}
}
@ -42,6 +45,6 @@ class JSONFeedAdapter : JsonAdapter<Pair<Feed, List<Item>>>() {
companion object {
val names: JsonReader.Options = JsonReader.Options.of("title", "home_page_url",
"feed_url", "description", "items")
"feed_url", "icon", "description", "items")
}
}

View File

@ -1,15 +1,15 @@
package com.readrops.api.localfeed.json
import com.readrops.api.localfeed.XmlAdapter.Companion.AUTHORS_MAX
import com.readrops.api.utils.DateUtils
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.extensions.nextNonEmptyString
import com.readrops.api.utils.extensions.nextNullableString
import com.readrops.db.entities.Item
import com.readrops.db.util.DateUtils
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
import org.joda.time.LocalDateTime
import java.time.LocalDateTime
class JSONItemsAdapter : JsonAdapter<List<Item>>() {
@ -33,7 +33,7 @@ class JSONItemsAdapter : JsonAdapter<List<Item>>() {
while (hasNext()) {
with(item) {
when (selectName(names)) {
0 -> guid = nextNonEmptyString()
0 -> remoteId = nextNonEmptyString()
1 -> link = nextNonEmptyString()
2 -> title = nextNonEmptyString()
3 -> contentHtml = nextNullableString()

View File

@ -26,6 +26,7 @@ class RSS1FeedAdapter : XmlAdapter<Pair<Feed, List<Item>>> {
when (tagName) {
"channel" -> parseChannel(this, feed)
"item" -> items += itemAdapter.fromXml(this)
else -> skipContents()
}
}
}
@ -39,8 +40,10 @@ class RSS1FeedAdapter : XmlAdapter<Pair<Feed, List<Item>>> {
}
private fun parseChannel(konsumer: Konsumer, feed: Feed) = with(konsumer) {
feed.url = attributes.getValueOrNull("about",
namespace = "http://www.w3.org/1999/02/22-rdf-syntax-ns#")
feed.url = attributes.getValueOrNull(
localName = "about",
namespace = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
)
allChildrenAutoIgnore(names) {
with(feed) {
@ -48,12 +51,16 @@ class RSS1FeedAdapter : XmlAdapter<Pair<Feed, List<Item>>> {
"title" -> name = nonNullText()
"link" -> siteUrl = nonNullText()
"description" -> description = nullableText()
"image" -> imageUrl = attributes.getValueOrNull(
localName = "resource",
namespace = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
)
}
}
}
}
companion object {
val names = Names.of("title", "link", "description")
val names = Names.of("title", "link", "description", "image")
}
}

View File

@ -5,13 +5,13 @@ import com.gitlab.mvysny.konsumexml.Names
import com.gitlab.mvysny.konsumexml.allChildrenAutoIgnore
import com.readrops.api.localfeed.XmlAdapter
import com.readrops.api.localfeed.XmlAdapter.Companion.AUTHORS_MAX
import com.readrops.api.utils.*
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.extensions.nonNullText
import com.readrops.api.utils.extensions.nullableText
import com.readrops.api.utils.extensions.nullableTextRecursively
import com.readrops.db.entities.Item
import org.joda.time.LocalDateTime
import com.readrops.db.util.DateUtils
import java.time.LocalDateTime
class RSS1ItemAdapter : XmlAdapter<Item> {
@ -40,7 +40,7 @@ class RSS1ItemAdapter : XmlAdapter<Item> {
if (item.pubDate == null) item.pubDate = LocalDateTime.now()
if (item.link == null) item.link = about
?: throw ParseException("RSS1 link or about element is required")
item.guid = item.link
item.remoteId = item.link
if (authors.filterNotNull().isNotEmpty()) item.author = authors.filterNotNull()
.joinToString(limit = AUTHORS_MAX)

View File

@ -35,6 +35,7 @@ class RSS2FeedAdapter : XmlAdapter<Pair<Feed, List<Item>>> {
url = attributes.getValueOrNull("href")
}
"item" -> items += itemAdapter.fromXml(this@allChildrenAutoIgnore)
"image" -> imageUrl = parseImage(this@allChildrenAutoIgnore)
else -> skipContents()
}
}
@ -49,7 +50,20 @@ class RSS2FeedAdapter : XmlAdapter<Pair<Feed, List<Item>>> {
}
}
private fun parseImage(konsumer: Konsumer): String? = with(konsumer) {
var url: String? = null
allChildrenAutoIgnore(Names.of("url")) {
when (tagName) {
"url" -> url = nullableText()
else -> skipContents()
}
}
url
}
companion object {
val names = Names.of("title", "description", "link", "item")
val names = Names.of("title", "description", "link", "item", "image")
}
}

View File

@ -1,15 +1,20 @@
package com.readrops.api.localfeed.rss2
import com.gitlab.mvysny.konsumexml.*
import com.gitlab.mvysny.konsumexml.Konsumer
import com.gitlab.mvysny.konsumexml.KonsumerException
import com.gitlab.mvysny.konsumexml.Names
import com.gitlab.mvysny.konsumexml.allChildrenAutoIgnore
import com.readrops.api.localfeed.RSSMedia
import com.readrops.api.localfeed.XmlAdapter
import com.readrops.api.localfeed.XmlAdapter.Companion.AUTHORS_MAX
import com.readrops.api.utils.*
import com.readrops.api.utils.ApiUtils
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.extensions.nonNullText
import com.readrops.api.utils.extensions.nullableText
import com.readrops.api.utils.extensions.nullableTextRecursively
import com.readrops.db.entities.Item
import org.joda.time.LocalDateTime
import com.readrops.db.util.DateUtils
import java.time.LocalDateTime
class RSS2ItemAdapter : XmlAdapter<Item> {
@ -17,7 +22,6 @@ class RSS2ItemAdapter : XmlAdapter<Item> {
val item = Item()
return try {
//konsumer.checkCurrent("item")
val creators = arrayListOf<String?>()
item.apply {
@ -29,12 +33,12 @@ class RSS2ItemAdapter : XmlAdapter<Item> {
"dc:creator" -> creators += nullableText()
"pubDate" -> pubDate = DateUtils.parse(nullableText())
"dc:date" -> pubDate = DateUtils.parse(nullableText())
"guid" -> guid = nullableText()
"guid" -> remoteId = nullableText()
"description" -> description = nullableTextRecursively()
"content:encoded" -> content = nullableTextRecursively()
"enclosure" -> parseEnclosure(this, item = this@apply)
"media:content" -> parseMediaContent(this, item = this@apply)
"media:group" -> parseMediaGroup(this, item = this@apply)
"enclosure" -> RSSMedia.parseMediaContent(this, item = this@apply)
"media:content" -> RSSMedia.parseMediaContent(this, item = this@apply)
"media:group" -> RSSMedia.parseMediaGroup(this, item = this@apply)
else -> skipContents() // for example media:description
}
}
@ -47,41 +51,11 @@ class RSS2ItemAdapter : XmlAdapter<Item> {
}
}
private fun parseEnclosure(konsumer: Konsumer, item: Item) = with(konsumer) {
if (attributes.getValueOrNull("type") != null
&& ApiUtils.isMimeImage(attributes["type"]) && item.imageLink == null)
item.imageLink = attributes.getValueOrNull("url")
}
private fun isMediumImage(konsumer: Konsumer) = with(konsumer) {
attributes.getValueOrNull("medium") != null && ApiUtils.isMimeImage(attributes["medium"])
}
private fun isTypeImage(konsumer: Konsumer) = with(konsumer) {
attributes.getValueOrNull("type") != null && ApiUtils.isMimeImage(attributes["type"])
}
private fun parseMediaContent(konsumer: Konsumer, item: Item) = with(konsumer) {
if ((isMediumImage(konsumer) || isTypeImage(konsumer)) && item.imageLink == null)
item.imageLink = konsumer.attributes.getValueOrNull("url")
konsumer.skipContents() // ignore media content sub elements
}
private fun parseMediaGroup(konsumer: Konsumer, item: Item) = with(konsumer) {
allChildrenAutoIgnore("content") {
when (tagName) {
"media:content" -> parseMediaContent(this, item)
else -> skipContents()
}
}
}
private fun finalizeItem(item: Item, creators: List<String?>) = with(item) {
validateItem(this)
if (pubDate == null) pubDate = LocalDateTime.now()
if (guid == null) guid = link
if (remoteId == null) remoteId = link
if (author == null && creators.filterNotNull().isNotEmpty())
author = creators.filterNotNull().joinToString(limit = AUTHORS_MAX)
}

View File

@ -1,33 +1,31 @@
package com.readrops.api.opml
import com.gitlab.mvysny.konsumexml.konsumeXml
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import io.reactivex.Completable
import io.reactivex.Single
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.redundent.kotlin.xml.xml
import java.io.InputStream
import java.io.OutputStream
object OPMLParser {
@JvmStatic
fun read(stream: InputStream): Single<Map<Folder?, List<Feed>>> {
return Single.create { emitter ->
suspend fun read(stream: InputStream): Map<Folder?, List<Feed>> = withContext(Dispatchers.IO) {
try {
val adapter = OPMLAdapter()
val opml = adapter.fromXml(stream.konsumeXml())
emitter.onSuccess(opml)
stream.close()
opml
} catch (e: Exception) {
emitter.onError(e)
}
throw ParseException(e.message)
}
}
@JvmStatic
fun write(foldersAndFeeds: Map<Folder?, List<Feed>>, outputStream: OutputStream): Completable {
return Completable.create { emitter ->
suspend fun write(foldersAndFeeds: Map<Folder?, List<Feed>>, outputStream: OutputStream) =
withContext(Dispatchers.IO) {
val opml = xml("opml") {
attribute("version", "2.0")
@ -67,8 +65,6 @@ object OPMLParser {
outputStream.write(opml.toString().toByteArray())
outputStream.flush()
emitter.onComplete()
}
outputStream.close()
}
}

View File

@ -1,30 +1,31 @@
package com.readrops.api.services
import com.readrops.api.services.freshrss.FreshRSSCredentials
import com.readrops.api.services.freshrss.FreshRSSService
import com.readrops.api.services.nextcloudnews.NextNewsCredentials
import com.readrops.api.services.nextcloudnews.NextNewsService
import com.readrops.api.services.fever.FeverCredentials
import com.readrops.api.services.greader.GReaderCredentials
import com.readrops.api.services.nextcloudnews.NextcloudNewsCredentials
import com.readrops.api.services.nextcloudnews.NextcloudNewsService
import com.readrops.db.entities.account.Account
import com.readrops.db.entities.account.AccountType
abstract class Credentials(val authorization: String?, val url: String) {
companion object {
@JvmStatic
fun toCredentials(account: Account): Credentials {
val endPoint = getEndPoint(account.accountType!!)
val endPoint = getEndPoint(account.type!!)
return when (account.accountType) {
AccountType.NEXTCLOUD_NEWS -> NextNewsCredentials(account.login, account.password, account.url + endPoint)
AccountType.FRESHRSS -> FreshRSSCredentials(account.token, account.url + endPoint)
return when (account.type) {
AccountType.NEXTCLOUD_NEWS -> NextcloudNewsCredentials(account.login, account.password, account.url + endPoint)
AccountType.FRESHRSS, AccountType.GREADER -> GReaderCredentials(account.token, account.url + endPoint)
AccountType.FEVER -> FeverCredentials(account.login, account.password, account.url + endPoint)
else -> throw IllegalArgumentException("Unknown account type")
}
}
private fun getEndPoint(accountType: AccountType): String {
return when (accountType) {
AccountType.FRESHRSS -> FreshRSSService.END_POINT
AccountType.NEXTCLOUD_NEWS -> NextNewsService.END_POINT
AccountType.NEXTCLOUD_NEWS -> NextcloudNewsService.END_POINT
AccountType.FRESHRSS -> "api/greader.php/"
AccountType.FEVER, AccountType.GREADER -> ""
else -> throw IllegalArgumentException("Unknown account type")
}
}

View File

@ -0,0 +1,15 @@
package com.readrops.api.services
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import com.readrops.db.entities.Item
data class DataSourceResult(
var items: List<Item> = mutableListOf(),
var starredItems: List<Item> = mutableListOf(),
var feeds: List<Feed> = listOf(),
var folders: List<Folder> = listOf(),
var unreadIds: List<String> = listOf(),
var readIds: List<String> = listOf(),
var starredIds: List<String> = listOf(),
)

View File

@ -1,15 +0,0 @@
package com.readrops.api.services
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import com.readrops.db.entities.Item
class SyncResult(var items: List<Item> = mutableListOf(),
var starredItems: List<Item> = mutableListOf(),
var feeds: List<Feed> = listOf(),
var folders: List<Folder> = listOf(),
var unreadIds: List<String>? = null,
var readIds: List<String>? = null,
var starredIds: List<String>? = null,
var isError: Boolean = false
)

View File

@ -0,0 +1,7 @@
package com.readrops.api.services.fever
import com.readrops.api.services.Credentials
class FeverCredentials(login: String?, password: String?, url: String) :
Credentials(/*(login != null && password != null)
.let { "api_key=" + ApiUtils.md5hash("$login:p$password") }*/null, url)

View File

@ -0,0 +1,127 @@
package com.readrops.api.services.fever
import com.readrops.api.services.SyncType
import com.readrops.api.services.fever.adapters.FeverAPIAdapter
import com.readrops.api.utils.ApiUtils
import com.squareup.moshi.Moshi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import okhttp3.MultipartBody
class FeverDataSource(private val service: FeverService) {
suspend fun login(login: String, password: String): Boolean {
val response = service.login(getFeverRequestBody(login, password))
val adapter = Moshi.Builder()
.add(Boolean::class.java, FeverAPIAdapter())
.build()
.adapter(Boolean::class.java)
return adapter.fromJson(response.source())!!
}
suspend fun synchronize(
login: String,
password: String,
syncType: SyncType,
lastSinceId: String,
): FeverSyncResult = with(CoroutineScope(Dispatchers.IO)) {
val body = getFeverRequestBody(login, password)
if (syncType == SyncType.INITIAL_SYNC) {
return FeverSyncResult().apply {
awaitAll(
async { feverFeeds = service.getFeeds(body) },
async { folders = service.getFolders(body) },
async {
unreadIds = service.getUnreadItemsIds(body)
.reversed()
.take(MAX_ITEMS_IDS)
var maxId = unreadIds.maxOfOrNull { it }
items = buildList {
for (index in 0 until INITIAL_SYNC_ITEMS_REQUESTS_COUNT) {
val newItems = service.getItems(body, maxId, null)
if (newItems.isEmpty()) break
// always take the lowest id
maxId = newItems.minOfOrNull { it.remoteId!!.toLong() }.toString()
addAll(newItems)
}
}
sinceId = unreadIds.maxOfOrNull { it.toLong() } ?: 0
},
async { starredIds = service.getStarredItemsIds(body) },
async { favicons = service.getFavicons(body) }
)
}
} else {
return FeverSyncResult().apply {
awaitAll(
async { folders = service.getFolders(body) },
async { feverFeeds = service.getFeeds(body) },
async { unreadIds = service.getUnreadItemsIds(body) },
async { starredIds = service.getStarredItemsIds(body) },
async { favicons = service.getFavicons(body) },
async {
items = buildList {
var localSinceId = lastSinceId
while (true) {
val newItems = service.getItems(body, null, localSinceId)
if (newItems.isEmpty()) break
// always take the highest id
localSinceId =
newItems.maxOfOrNull { it.remoteId!!.toLong() }.toString()
addAll(newItems)
}
sinceId = if (items.isNotEmpty()) {
items.maxOfOrNull { it.remoteId!!.toLong() }!!
} else {
localSinceId.toLong()
}
}
}
)
}
}
}
suspend fun setItemState(login: String, password: String, action: String, id: String) {
val body = getFeverRequestBody(login, password)
service.updateItemState(body, action, id)
}
private fun getFeverRequestBody(login: String, password: String): MultipartBody {
val credentials = ApiUtils.md5hash("$login:$password")
return MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("api_key", credentials)
.build()
}
companion object {
private const val MAX_ITEMS_IDS = 1000
private const val INITIAL_SYNC_ITEMS_REQUESTS_COUNT = 20 // (1000 items max)
}
}
sealed class ItemAction(val value: String) {
sealed class ReadStateAction(value: String) : ItemAction(value) {
data object ReadAction : ReadStateAction("read")
data object UnreadAction : ReadStateAction("unread")
}
sealed class StarStateAction(value: String) : ItemAction(value) {
data object StarAction : StarStateAction("saved")
data object UnstarAction : StarStateAction("unsaved")
}
}

View File

@ -0,0 +1,45 @@
package com.readrops.api.services.fever
import com.readrops.api.services.fever.adapters.Favicon
import com.readrops.api.services.fever.adapters.FeverFeeds
import com.readrops.db.entities.Folder
import com.readrops.db.entities.Item
import okhttp3.MultipartBody
import okhttp3.ResponseBody
import retrofit2.http.Body
import retrofit2.http.POST
import retrofit2.http.Query
interface FeverService {
@POST("?api")
suspend fun login(@Body body: MultipartBody): ResponseBody
@POST("?feeds")
suspend fun getFeeds(@Body body: MultipartBody): FeverFeeds
@POST("?groups")
suspend fun getFolders(@Body body: MultipartBody): List<Folder>
@POST("?favicons")
suspend fun getFavicons(@Body body: MultipartBody): List<Favicon>
@POST("?items")
suspend fun getItems(@Body body: MultipartBody, @Query("max_id") maxId: String?,
@Query("since_id") sinceId: String?): List<Item>
@POST("?unread_item_ids")
suspend fun getUnreadItemsIds(@Body body: MultipartBody): List<String>
@POST("?saved_item_ids")
suspend fun getStarredItemsIds(@Body body: MultipartBody): List<String>
@POST("?mark=item")
suspend fun updateItemState(@Body body: MultipartBody, @Query("as") action: String,
@Query("id") id: String)
companion object {
const val END_POINT = "api/fever.php/"
}
}

View File

@ -0,0 +1,16 @@
package com.readrops.api.services.fever
import com.readrops.api.services.fever.adapters.Favicon
import com.readrops.api.services.fever.adapters.FeverFeeds
import com.readrops.db.entities.Folder
import com.readrops.db.entities.Item
data class FeverSyncResult(
var feverFeeds: FeverFeeds = FeverFeeds(),
var folders: List<Folder> = listOf(),
var items: List<Item> = listOf(),
var unreadIds: List<String> = listOf(),
var starredIds: List<String> = listOf(),
var favicons: List<Favicon> = listOf(),
var sinceId: Long = 0,
)

View File

@ -0,0 +1,37 @@
package com.readrops.api.services.fever.adapters
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.extensions.toBoolean
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
import com.squareup.moshi.ToJson
class FeverAPIAdapter : JsonAdapter<Boolean>() {
@ToJson
override fun toJson(writer: JsonWriter, value: Boolean?) {
// useless here
}
@FromJson
override fun fromJson(reader: JsonReader): Boolean = with(reader) {
return try {
beginObject()
var authenticated = false
while (hasNext()) {
when (nextName()) {
"auth" -> authenticated = nextInt().toBoolean()
else -> skipValue()
}
}
endObject()
authenticated
} catch (e: Exception) {
throw ParseException(e.message)
}
}
}

View File

@ -0,0 +1,79 @@
package com.readrops.api.services.fever.adapters
import android.annotation.SuppressLint
import com.readrops.api.utils.exceptions.ParseException
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonReader
import com.squareup.moshi.ToJson
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
data class Favicon(
val id: Int,
val data: ByteArray
)
class FeverFaviconsAdapter {
@ToJson
fun toJson(favicons: List<Favicon>) = ""
@SuppressLint("CheckResult")
@FromJson
fun fromJson(reader: JsonReader): List<Favicon> = with(reader) {
return try {
val favicons = arrayListOf<Favicon>()
beginObject()
while (hasNext()) {
when (nextName()) {
"favicons" -> {
beginArray()
while (hasNext()) {
beginObject()
parseFavicon(reader)?.let { favicons += it }
endObject()
}
endArray()
}
else -> skipValue()
}
}
endObject()
favicons
} catch (e: Exception) {
throw ParseException(e.message)
}
}
@OptIn(ExperimentalEncodingApi::class)
private fun parseFavicon(reader: JsonReader): Favicon? = with(reader) {
var id = 0
var data: ByteArray? = null
while (hasNext()) {
when (selectName(NAMES)) {
0 -> id = nextInt()
1 -> data = Base64.decode(nextString().substringAfter("base64,"))
else -> skipValue()
}
}
if (id > 0 && data != null) {
return Favicon(
id = id,
data = data,
)
} else {
null
}
}
companion object {
val NAMES: JsonReader.Options = JsonReader.Options.of("id", "data")
}
}

View File

@ -0,0 +1,111 @@
package com.readrops.api.services.fever.adapters
import android.annotation.SuppressLint
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.extensions.nextNonEmptyString
import com.readrops.api.utils.extensions.nextNullableString
import com.readrops.db.entities.Feed
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
data class FeverFeeds(
val feeds: List<Feed> = listOf(),
val favicons: Map<Int, String> = mapOf(), // <faviconId, feedRemoteId>
val feedsGroups: Map<Int, List<Int>> = emptyMap()
)
class FeverFeedsAdapter : JsonAdapter<FeverFeeds>() {
override fun toJson(writer: JsonWriter, value: FeverFeeds?) {
// not useful here
}
@SuppressLint("CheckResult")
override fun fromJson(reader: JsonReader): FeverFeeds = with(reader) {
return try {
val feeds = arrayListOf<Feed>()
val favicons = mutableMapOf<Int, String>()
val feedsGroups = mutableMapOf<Int, List<Int>>()
beginObject()
while (hasNext()) {
when (nextName()) {
"feeds" -> {
beginArray()
while (hasNext()) {
beginObject()
feeds += parseFeed(reader, favicons)
endObject()
}
endArray()
}
"feeds_groups" -> {
beginArray()
while (hasNext()) {
beginObject()
val (folderId, feedsIds) = parseFeedsGroups(reader)
folderId?.let { feedsGroups[it] = feedsIds }
endObject()
}
endArray()
}
else -> skipValue()
}
}
endObject()
FeverFeeds(
feeds = feeds,
favicons = favicons,
feedsGroups = feedsGroups
)
} catch (e: Exception) {
throw ParseException(e.message)
}
}
private fun parseFeed(reader: JsonReader, favicons: MutableMap<Int, String>): Feed = with(reader) {
val feed = Feed()
while (hasNext()) {
with(feed) {
when (selectName(NAMES)) {
0 -> remoteId = nextInt().toString()
1 -> favicons[nextInt()] = remoteId!!
2 -> name = nextNonEmptyString()
3 -> url = nextNonEmptyString()
4 -> siteUrl = nextNullableString()
else -> skipValue()
}
}
}
return feed
}
private fun parseFeedsGroups(reader: JsonReader): Pair<Int?, List<Int>> = with(reader) {
var folderId: Int? = null
val feedsIds = mutableListOf<Int>()
while (hasNext()) {
when (selectName(JsonReader.Options.of("group_id", "feed_ids"))) {
0 -> folderId = nextInt()
1 -> feedsIds += nextNonEmptyString().split(",").map { it.toInt() }
else -> skipValue()
}
}
folderId to feedsIds
}
companion object {
val NAMES: JsonReader.Options =
JsonReader.Options.of("id", "favicon_id", "title", "url", "site_url")
}
}

View File

@ -0,0 +1,61 @@
package com.readrops.api.services.fever.adapters
import android.annotation.SuppressLint
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.extensions.nextNonEmptyString
import com.readrops.db.entities.Folder
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonReader
import com.squareup.moshi.ToJson
class FeverFoldersAdapter {
@ToJson
fun toJson(folders: List<Folder>) = ""
@SuppressLint("CheckResult")
@FromJson
fun fromJson(reader: JsonReader): List<Folder> = with(reader) {
return try {
val folders = arrayListOf<Folder>()
beginObject()
while (hasNext()) {
when (nextName()) {
"groups" -> {
beginArray()
while (hasNext()) {
beginObject()
val folder = Folder()
while (hasNext()) {
with(folder) {
when (selectName(NAMES)) {
0 -> remoteId = nextInt().toString()
1 -> name = nextNonEmptyString()
}
}
}
folders += folder
endObject()
}
endArray()
}
else -> skipValue()
}
}
endObject()
folders
} catch (e: Exception) {
throw ParseException(e.message)
}
}
companion object {
val NAMES: JsonReader.Options = JsonReader.Options.of("id", "title")
}
}

View File

@ -0,0 +1,85 @@
package com.readrops.api.services.fever.adapters
import android.annotation.SuppressLint
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.extensions.nextNonEmptyString
import com.readrops.api.utils.extensions.nextNullableString
import com.readrops.api.utils.extensions.toBoolean
import com.readrops.db.entities.Item
import com.readrops.db.util.DateUtils
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonReader
import com.squareup.moshi.ToJson
class FeverItemsAdapter {
@ToJson
fun toJson(items: List<Item>) = ""
@SuppressLint("CheckResult")
@FromJson
fun fromJson(reader: JsonReader): List<Item> = with(reader) {
return try {
val items = arrayListOf<Item>()
beginObject()
while (hasNext()) {
when (nextName()) {
"items" -> {
beginArray()
while (hasNext()) {
beginObject()
items += parseItem(reader)
endObject()
}
endArray()
}
else -> skipValue()
}
}
endObject()
items
} catch (e: Exception) {
throw ParseException(e.message)
}
}
private fun parseItem(reader: JsonReader): Item = with(reader) {
val item = Item()
while (hasNext()) {
with(item) {
when (selectName(NAMES)) {
0 -> {
remoteId = if (reader.peek() == JsonReader.Token.STRING) {
nextNonEmptyString()
} else {
nextInt().toString()
}
}
1 -> feedRemoteId = nextNonEmptyString()
2 -> title = nextNonEmptyString()
3 -> author = nextNullableString()
4 -> content = nextNullableString()
5 -> link = nextNullableString()
6 -> isRead = nextInt().toBoolean()
7 -> isStarred = nextInt().toBoolean()
8 -> pubDate = DateUtils.fromEpochSeconds(nextLong())
else -> skipValue()
}
}
}
return item
}
companion object {
val NAMES: JsonReader.Options = JsonReader.Options.of(
"id", "feed_id", "title", "author", "html", "url",
"is_read", "is_saved", "created_on_time"
)
}
}

View File

@ -0,0 +1,34 @@
package com.readrops.api.services.fever.adapters
import android.annotation.SuppressLint
import com.readrops.api.utils.exceptions.ParseException
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonReader
import com.squareup.moshi.ToJson
class FeverItemsIdsAdapter {
@ToJson
fun toJson(ids: List<String>) = ""
@SuppressLint("CheckResult")
@FromJson
fun fromJson(reader: JsonReader): List<String> = with(reader) {
return try {
beginObject()
val ids = arrayListOf<String>()
while (hasNext()) {
when (nextName()) {
"unread_item_ids" -> ids.addAll(nextString().split(","))
else -> skipValue()
}
}
endObject()
ids
} catch (e: Exception) {
throw ParseException(e.message)
}
}
}

View File

@ -1,316 +0,0 @@
package com.readrops.api.services.freshrss;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.readrops.api.services.SyncResult;
import com.readrops.api.services.SyncType;
import com.readrops.api.services.freshrss.adapters.FreshRSSUserInfo;
import com.readrops.db.entities.Feed;
import com.readrops.db.entities.Folder;
import com.readrops.db.entities.Item;
import java.io.StringReader;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Properties;
import io.reactivex.Completable;
import io.reactivex.Single;
import okhttp3.MultipartBody;
import okhttp3.RequestBody;
public class FreshRSSDataSource {
private static final int MAX_ITEMS = 2500;
private static final int MAX_STARRED_ITEMS = 1000;
public static final String GOOGLE_READ = "user/-/state/com.google/read";
public static final String GOOGLE_UNREAD = "user/-/state/com.google/unread";
public static final String GOOGLE_STARRED = "user/-/state/com.google/starred";
public static final String GOOGLE_READING_LIST = "user/-/state/com.google/reading-list";
private static final String FEED_PREFIX = "feed/";
private final FreshRSSService api;
public FreshRSSDataSource(FreshRSSService api) {
this.api = api;
}
/**
* Call token API to generate a new token from account credentials
*
* @param login login
* @param password password
* @return the generated token
*/
public Single<String> login(@NonNull String login, @NonNull String password) {
RequestBody requestBody = new MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("Email", login)
.addFormDataPart("Passwd", password)
.build();
return api.login(requestBody)
.flatMap(response -> {
Properties properties = new Properties();
properties.load(new StringReader(response.string()));
return Single.just(properties.getProperty("Auth"));
});
}
/**
* Get a write token to modify feeds, folders and items on the server
*
* @return the write token generated by the server
*/
public Single<String> getWriteToken() {
return api.getWriteToken()
.flatMap(responseBody -> Single.just(responseBody.string()));
}
/**
* Retrieve user information : name, email, id, profileId
*
* @return user information
*/
public Single<FreshRSSUserInfo> getUserInfo() {
return api.getUserInfo();
}
/**
* Synchronize feeds, folders, items and push read/unread items
*
* @param syncType INITIAL or CLASSIC
* @param syncData data to sync (read/unread items ids, lastModified timestamp)
* @param writeToken token for making modifications on the server
* @return the result of the synchronization
*/
public Single<SyncResult> sync(@NonNull SyncType syncType, @NonNull FreshRSSSyncData syncData, @NonNull String writeToken) {
if (syncType == SyncType.INITIAL_SYNC) {
return Single.zip(setItemsReadState(syncData, writeToken).toSingleDefault(""),
setItemsStarState(syncData, writeToken).toSingleDefault(""),
getFolders(),
getFeeds(),
getItems(Arrays.asList(GOOGLE_READ, GOOGLE_STARRED), MAX_ITEMS, null),
getItemsIds(GOOGLE_READ, GOOGLE_READING_LIST, MAX_ITEMS), // unread items ids
getItemsIds(null, GOOGLE_STARRED, MAX_STARRED_ITEMS), // starred items ids
getStarredItems(MAX_STARRED_ITEMS),
(readState, starState, folders, feeds, items, unreadItemsIds, starredItemsIds, starredItems) ->
new SyncResult(items, starredItems, feeds, folders, unreadItemsIds, Collections.emptyList(), starredItemsIds, false)
);
} else {
return Single.zip(setItemsReadState(syncData, writeToken).toSingleDefault(""),
setItemsStarState(syncData, writeToken).toSingleDefault(""),
getFolders(),
getFeeds(),
getItems(null, MAX_ITEMS, syncData.getLastModified()),
getItemsIds(GOOGLE_READ, GOOGLE_READING_LIST, MAX_ITEMS), // unread items ids
getItemsIds(GOOGLE_UNREAD, GOOGLE_READING_LIST, MAX_ITEMS), // read items ids
getItemsIds(null, GOOGLE_STARRED, MAX_STARRED_ITEMS), // starred items ids
(readState, starState, folders, feeds, items, unreadItemsIds, readItemsIds, starredItemsIds) ->
new SyncResult(items, Collections.emptyList(), feeds, folders, unreadItemsIds, readItemsIds, starredItemsIds, false)
);
}
}
/**
* Fetch the feeds folders
*
* @return the feeds folders
*/
public Single<List<Folder>> getFolders() {
return api.getFolders();
}
/**
* Fetch the feeds
*
* @return the feeds
*/
public Single<List<Feed>> getFeeds() {
return api.getFeeds();
}
/**
* Fetch the items
*
* @param excludeTargets type of items to exclude (read items and starred items)
* @param max max number of items to fetch
* @param lastModified fetch only items created after this timestamp
* @return the items
*/
public Single<List<Item>> getItems(@Nullable List<String> excludeTargets, int max, @Nullable Long lastModified) {
return api.getItems(excludeTargets, max, lastModified);
}
/**
* Fetch starred items
*
* @param max max number of items to fetch
* @return items
*/
public Single<List<Item>> getStarredItems(int max) {
return api.getStarredItems(max);
}
public Single<List<String>> getItemsIds(String excludeTarget, String includeTarget, int max) {
return api.getItemsIds(excludeTarget, includeTarget, max);
}
/**
* Mark items read or unread
*
* @param read true for read, false for unread
* @param itemIds items ids to mark
* @param token token for modifications
* @return Completable
*/
public Completable setItemsReadState(boolean read, @NonNull List<String> itemIds, @NonNull String token) {
if (read) {
return api.setItemsState(token, GOOGLE_READ, null, itemIds);
} else {
return api.setItemsState(token, null, GOOGLE_READ, itemIds);
}
}
/**
* Mark items as starred or unstarred
*
* @param starred true for starred, false for unstarred
* @param itemIds items ids to mark
* @param token token for modifications
* @return Completable
*/
public Completable setItemsStarState(boolean starred, @NonNull List<String> itemIds, @NonNull String token) {
if (starred) {
return api.setItemsState(token, GOOGLE_STARRED, null, itemIds);
} else {
return api.setItemsState(token, null, GOOGLE_STARRED, itemIds);
}
}
/**
* Create a new feed
*
* @param token token for modifications
* @param feedUrl url of the feed to parse
* @return Completable
*/
public Completable createFeed(@NonNull String token, @NonNull String feedUrl) {
return api.createOrDeleteFeed(token, FEED_PREFIX + feedUrl, "subscribe");
}
/**
* Delete a feed
*
* @param token token for modifications
* @param feedUrl url of the feed to delete
* @return Completable
*/
public Completable deleteFeed(@NonNull String token, @NonNull String feedUrl) {
return api.createOrDeleteFeed(token, FEED_PREFIX + feedUrl, "unsubscribe");
}
/**
* Update feed title and folder
*
* @param token token for modifications
* @param feedUrl url of the feed to update
* @param title new title
* @param folderId id of the new folder
* @return Completable
*/
public Completable updateFeed(@NonNull String token, @NonNull String feedUrl, @NonNull String title, @NonNull String folderId) {
return api.updateFeed(token, FEED_PREFIX + feedUrl, title, folderId, "edit");
}
/**
* Create a new folder
*
* @param token token for modifications
* @param tagName name of the new folder
* @return Completable
*/
public Completable createFolder(@NonNull String token, @NonNull String tagName) {
return api.createFolder(token, "user/-/label/" + tagName);
}
/**
* Update folder name
*
* @param token token for modifications
* @param folderId id of the folder
* @param name new folder name
* @return Completable
*/
public Completable updateFolder(@NonNull String token, @NonNull String folderId, @NonNull String name) {
return api.updateFolder(token, folderId, "user/-/label/" + name);
}
/**
* Delete a folder
*
* @param token token for modifications
* @param folderId id of the folder to delete
* @return Completable
*/
public Completable deleteFolder(@NonNull String token, @NonNull String folderId) {
return api.deleteFolder(token, folderId);
}
/**
* Set items star state
*
* @param syncData data containing items to mark
* @param token token for modifications
* @return A concatenation of two completable (read and unread completable)
*/
private Completable setItemsReadState(@NonNull FreshRSSSyncData syncData, @NonNull String token) {
Completable readItemsCompletable;
if (syncData.getReadItemsIds().isEmpty()) {
readItemsCompletable = Completable.complete();
} else {
readItemsCompletable = setItemsReadState(true, syncData.getReadItemsIds(), token);
}
Completable unreadItemsCompletable;
if (syncData.getUnreadItemsIds().isEmpty()) {
unreadItemsCompletable = Completable.complete();
} else {
unreadItemsCompletable = setItemsReadState(false, syncData.getUnreadItemsIds(), token);
}
return readItemsCompletable.concatWith(unreadItemsCompletable);
}
/**
* Set items star state
*
* @param syncData data containing items to mark
* @param token token for modifications
* @return A concatenation of two completable (starred and unstarred completable)
*/
private Completable setItemsStarState(@NonNull FreshRSSSyncData syncData, @NonNull String token) {
Completable starredItemsCompletable;
if (syncData.getStarredItemsIds().isEmpty()) {
starredItemsCompletable = Completable.complete();
} else {
starredItemsCompletable = setItemsStarState(true, syncData.getStarredItemsIds(), token);
}
Completable unstarredItemsCompletable;
if (syncData.getUnstarredItemsIds().isEmpty()) {
unstarredItemsCompletable = Completable.complete();
} else {
unstarredItemsCompletable = setItemsStarState(false, syncData.getUnstarredItemsIds(), token);
}
return starredItemsCompletable.concatWith(unstarredItemsCompletable);
}
}

View File

@ -1,70 +0,0 @@
package com.readrops.api.services.freshrss
import com.readrops.api.services.freshrss.adapters.FreshRSSUserInfo
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import com.readrops.db.entities.Item
import io.reactivex.Completable
import io.reactivex.Single
import okhttp3.RequestBody
import okhttp3.ResponseBody
import retrofit2.http.*
interface FreshRSSService {
@POST("accounts/ClientLogin")
fun login(@Body body: RequestBody?): Single<ResponseBody?>?
@get:GET("reader/api/0/token")
val writeToken: Single<ResponseBody>
@get:GET("reader/api/0/user-info")
val userInfo: Single<FreshRSSUserInfo>
@get:GET("reader/api/0/subscription/list?output=json")
val feeds: Single<List<Feed>>
@get:GET("reader/api/0/tag/list?output=json")
val folders: Single<List<Folder>>
@GET("reader/api/0/stream/contents/user/-/state/com.google/reading-list")
fun getItems(@Query("xt") excludeTarget: List<String>?, @Query("n") max: Int,
@Query("ot") lastModified: Long?): Single<List<Item>>
@GET("reader/api/0/stream/contents/user/-/state/com.google/starred")
fun getStarredItems(@Query("n") max: Int): Single<List<Item>>
@GET("reader/api/0/stream/items/ids")
fun getItemsIds(@Query("xt") excludeTarget: String?, @Query("s") includeTarget: String?,
@Query("n") max: Int): Single<List<String>>
@FormUrlEncoded
@POST("reader/api/0/edit-tag")
fun setItemsState(@Field("T") token: String, @Field("a") addAction: String?,
@Field("r") removeAction: String?, @Field("i") itemIds: List<String>): Completable
@FormUrlEncoded
@POST("reader/api/0/subscription/edit")
fun createOrDeleteFeed(@Field("T") token: String, @Field("s") feedUrl: String, @Field("ac") action: String): Completable
@FormUrlEncoded
@POST("reader/api/0/subscription/edit")
fun updateFeed(@Field("T") token: String, @Field("s") feedUrl: String, @Field("t") title: String,
@Field("a") folderId: String, @Field("ac") action: String): Completable
@FormUrlEncoded
@POST("reader/api/0/edit-tag")
fun createFolder(@Field("T") token: String, @Field("a") tagName: String): Completable
@FormUrlEncoded
@POST("reader/api/0/rename-tag")
fun updateFolder(@Field("T") token: String, @Field("s") folderId: String, @Field("dest") newFolderId: String): Completable
@FormUrlEncoded
@POST("reader/api/0/disable-tag")
fun deleteFolder(@Field("T") token: String, @Field("s") folderId: String): Completable
companion object {
const val END_POINT = "/api/greader.php/"
}
}

View File

@ -1,9 +0,0 @@
package com.readrops.api.services.freshrss
data class FreshRSSSyncData(
var lastModified: Long = 0,
var readItemsIds: List<String> = listOf(),
var unreadItemsIds: List<String> = listOf(),
var starredItemsIds: List<String> = listOf(),
var unstarredItemsIds: List<String> = listOf(),
)

View File

@ -1,121 +0,0 @@
package com.readrops.api.services.freshrss
import com.readrops.api.services.freshrss.adapters.FreshRSSUserInfo
import com.readrops.db.entities.Item
import okhttp3.MultipartBody
import java.io.StringReader
import java.util.Properties
class NewFreshRSSDataSource(private val service: NewFreshRSSService) {
suspend fun login(login: String, password: String): String {
val requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("Email", login)
.addFormDataPart("Passwd", password)
.build()
val response = service.login(requestBody)
val properties = Properties()
properties.load(StringReader(response.string()))
response.close()
return properties.getProperty("Auth")
}
suspend fun getWriteToken(): String = service.getWriteToken().string()
suspend fun getUserInfo(): FreshRSSUserInfo = service.userInfo()
suspend fun sync() {
}
suspend fun getFolders() = service.getFolders()
suspend fun getFeeds() = service.getFeeds()
suspend fun getItems(excludeTargets: List<String>, max: Int, lastModified: Long): List<Item> {
return service.getItems(excludeTargets, max, lastModified)
}
suspend fun getStarredItems(max: Int) = service.getStarredItems(max)
suspend fun getItemsIds(excludeTarget: String, includeTarget: String, max: Int): List<String> {
return service.getItemsIds(excludeTarget, includeTarget, max)
}
private suspend fun setItemsReadState(read: Boolean, itemIds: List<String>, token: String) {
return if (read) {
service.setItemsState(token, GOOGLE_READ, null, itemIds)
} else {
service.setItemsState(token, null, GOOGLE_READ, itemIds)
}
}
private suspend fun setItemStarState(starred: Boolean, itemIds: List<String>, token: String) {
return if (starred) {
service.setItemsState(token, GOOGLE_STARRED, null, itemIds)
} else {
service.setItemsState(token, null, GOOGLE_STARRED, itemIds)
}
}
suspend fun createFeed(token: String, feedUrl: String) {
service.createOrDeleteFeed(token, FEED_PREFIX + feedUrl, "subscribe");
}
suspend fun deleteFeed(token: String, feedUrl: String) {
service.createOrDeleteFeed(token, FEED_PREFIX + feedUrl, "unsubscribe")
}
suspend fun updateFeed(token: String, feedUrl: String, title: String, folderId: String) {
service.updateFeed(token, FEED_PREFIX + feedUrl, title, folderId, "edit")
}
suspend fun createFolder(token: String, tagName: String) {
service.createFolder(token, "$FOLDER_PREFIX$tagName")
}
suspend fun updateFolder(token: String, folderId: String, name: String) {
service.updateFolder(token, folderId, "$FOLDER_PREFIX$name")
}
suspend fun deleteFolder(token: String, folderId: String) {
service.deleteFolder(token, folderId)
}
suspend fun setItemsReadState(syncData: FreshRSSSyncData, token: String) {
if (syncData.readItemsIds.isNotEmpty()) {
setItemsReadState(true, syncData.readItemsIds, token)
}
if (syncData.unreadItemsIds.isNotEmpty()) {
setItemsReadState(false, syncData.unreadItemsIds, token)
}
}
suspend fun setItemsStarState(syncData: FreshRSSSyncData, token: String) {
if (syncData.starredItemsIds.isNotEmpty()) {
setItemStarState(true, syncData.starredItemsIds, token)
}
if (syncData.unstarredItemsIds.isNotEmpty()) {
setItemStarState(false, syncData.unstarredItemsIds, token)
}
}
companion object {
private const val MAX_ITEMS = 2500
private const val MAX_STARRED_ITEMS = 1000
const val GOOGLE_READ = "user/-/state/com.google/read"
const val GOOGLE_UNREAD = "user/-/state/com.google/unread"
const val GOOGLE_STARRED = "user/-/state/com.google/starred"
const val GOOGLE_READING_LIST = "user/-/state/com.google/reading-list"
const val FEED_PREFIX = "feed/"
const val FOLDER_PREFIX = "user/-/label/"
}
}

View File

@ -1,85 +0,0 @@
package com.readrops.api.services.freshrss.adapters
import android.annotation.SuppressLint
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.extensions.nextNonEmptyString
import com.readrops.api.utils.extensions.nextNullableString
import com.readrops.db.entities.Feed
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonReader
import com.squareup.moshi.ToJson
class FreshRSSFeedsAdapter {
@ToJson
fun toJson(feeds: List<Feed>): String = ""
@SuppressLint("CheckResult")
@FromJson
fun fromJson(reader: JsonReader): List<Feed> {
val feeds = mutableListOf<Feed>()
return try {
reader.beginObject()
reader.nextName() // "subscriptions", beginning of the feed array
reader.beginArray()
while (reader.hasNext()) {
reader.beginObject()
val feed = Feed()
while (reader.hasNext()) {
with(feed) {
when (reader.selectName(NAMES)) {
0 -> name = reader.nextNonEmptyString()
1 -> url = reader.nextNonEmptyString()
2 -> siteUrl = reader.nextNullableString()
3 -> iconUrl = reader.nextNullableString()
4 -> remoteId = reader.nextNonEmptyString()
5 -> remoteFolderId = getCategoryId(reader)
else -> reader.skipValue()
}
}
}
feeds += feed
reader.endObject()
}
reader.endArray()
reader.endObject()
feeds
} catch (e: Exception) {
throw ParseException(e.message)
}
}
private fun getCategoryId(reader: JsonReader): String? {
var id: String? = null
reader.beginArray()
while (reader.hasNext()) {
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
"id" -> id = reader.nextNullableString()
else -> reader.skipValue()
}
}
reader.endObject()
if (!id.isNullOrEmpty())
break
}
reader.endArray()
return id
}
companion object {
val NAMES: JsonReader.Options = JsonReader.Options.of("title", "url", "htmlUrl",
"iconUrl", "id", "categories")
}
}

View File

@ -1,67 +0,0 @@
package com.readrops.api.services.freshrss.adapters
import android.annotation.SuppressLint
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.extensions.nextNonEmptyString
import com.readrops.db.entities.Folder
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonReader
import com.squareup.moshi.ToJson
import java.util.*
class FreshRSSFoldersAdapter {
@ToJson
fun toJson(folders: List<Folder>): String = ""
@SuppressLint("CheckResult")
@FromJson
fun fromJson(reader: JsonReader): List<Folder> {
val folders = mutableListOf<Folder>()
return try {
reader.beginObject()
reader.nextName() // "tags", beginning of folder array
reader.beginArray()
while (reader.hasNext()) {
reader.beginObject()
val folder = Folder()
var type: String? = null
while (reader.hasNext()) {
with(folder) {
when (reader.selectName(NAMES)) {
0 -> {
val id = reader.nextNonEmptyString()
name = StringTokenizer(id, "/")
.toList()
.last() as String
remoteId = id
}
1 -> type = reader.nextString()
else -> reader.skipValue()
}
}
}
if (type == "folder") // add only folders and avoid tags
folders += folder
reader.endObject()
}
reader.endArray()
reader.endObject()
folders
} catch (e: Exception) {
throw ParseException(e.message)
}
}
companion object {
val NAMES: JsonReader.Options = JsonReader.Options.of("id", "type")
}
}

View File

@ -1,139 +0,0 @@
package com.readrops.api.services.freshrss.adapters
import android.util.TimingLogger
import com.readrops.api.services.freshrss.FreshRSSDataSource.GOOGLE_READ
import com.readrops.api.services.freshrss.FreshRSSDataSource.GOOGLE_STARRED
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.extensions.nextNonEmptyString
import com.readrops.api.utils.extensions.nextNullableString
import com.readrops.db.entities.Item
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
import org.joda.time.DateTimeZone
import org.joda.time.LocalDateTime
class FreshRSSItemsAdapter : JsonAdapter<List<Item>>() {
override fun toJson(writer: JsonWriter, value: List<Item>?) {
// no need of this
}
override fun fromJson(reader: JsonReader): List<Item>? {
val items = mutableListOf<Item>()
return try {
reader.beginObject()
while (reader.hasNext()) {
if (reader.nextName() == "items") parseItems(reader, items) else reader.skipValue()
}
reader.endObject()
items
} catch (e: Exception) {
throw ParseException(e.message)
}
}
private fun parseItems(reader: JsonReader, items: MutableList<Item>) {
reader.beginArray()
while (reader.hasNext()) {
val item = Item()
reader.beginObject()
while (reader.hasNext()) {
with(item) {
when (reader.selectName(NAMES)) {
0 -> remoteId = reader.nextNonEmptyString()
1 -> pubDate = LocalDateTime(reader.nextLong() * 1000L,
DateTimeZone.getDefault())
2 -> title = reader.nextNonEmptyString()
3 -> content = getContent(reader)
4 -> link = getLink(reader)
5 -> getStates(reader, this)
6 -> feedRemoteId = getRemoteFeedId(reader)
7 -> author = reader.nextNullableString()
else -> reader.skipValue()
}
}
}
items += item
reader.endObject()
}
reader.endArray()
}
private fun getContent(reader: JsonReader): String? {
var content: String? = null
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
"content" -> content = reader.nextNullableString()
else -> reader.skipValue()
}
}
reader.endObject()
return content
}
private fun getLink(reader: JsonReader): String? {
var href: String? = null
reader.beginArray()
while (reader.hasNext()) {
reader.beginObject()
when (reader.nextName()) {
"href" -> href = reader.nextNullableString()
else -> reader.skipValue()
}
reader.endObject()
}
reader.endArray()
return href
}
private fun getStates(reader: JsonReader, item: Item) {
reader.beginArray()
while (reader.hasNext()) {
when (reader.nextString()) {
GOOGLE_READ -> item.isRead = true
GOOGLE_STARRED -> item.isStarred = true
else -> reader.skipValue()
}
}
reader.endArray()
}
private fun getRemoteFeedId(reader: JsonReader): String? {
var remoteFeedId: String? = null
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
"streamId" -> remoteFeedId = reader.nextString()
else -> reader.skipValue()
}
}
reader.endObject()
return remoteFeedId
}
companion object {
val NAMES: JsonReader.Options = JsonReader.Options.of("id", "published", "title",
"summary", "alternate", "categories", "origin", "author")
val TAG: String = FreshRSSItemsAdapter::class.java.simpleName
}
}

View File

@ -1,58 +0,0 @@
package com.readrops.api.services.freshrss.adapters
import android.annotation.SuppressLint
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.extensions.nextNonEmptyString
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
class FreshRSSItemsIdsAdapter : JsonAdapter<List<String>>() {
override fun toJson(writer: JsonWriter, value: List<String>?) {
// not useful here
}
@SuppressLint("CheckResult")
override fun fromJson(reader: JsonReader): List<String>? = with(reader) {
val ids = arrayListOf<String>()
return try {
beginObject()
nextName()
beginArray()
while (hasNext()) {
beginObject()
when (nextName()) {
"id" -> {
val value = nextNonEmptyString()
ids += "tag:google.com,2005:reader/item/${
value.toLong()
.toString(16).padStart(value.length, '0')
}"
}
else -> skipValue()
}
endObject()
}
endArray()
// skip continuation
if (hasNext()) {
skipName()
skipValue()
}
endObject()
ids
} catch (e: Exception) {
throw ParseException(e.message)
}
}
}

View File

@ -1,8 +1,8 @@
package com.readrops.api.services.freshrss
package com.readrops.api.services.greader
import com.readrops.api.services.Credentials
class FreshRSSCredentials(token: String?, url: String) :
class GReaderCredentials(token: String?, url: String) :
Credentials(token?.let { AUTH_PREFIX + it }, url) {
companion object {

View File

@ -0,0 +1,165 @@
package com.readrops.api.services.greader
import com.readrops.api.services.DataSourceResult
import com.readrops.api.services.SyncType
import com.readrops.api.services.greader.adapters.FreshRSSUserInfo
import com.readrops.db.entities.Item
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import okhttp3.MultipartBody
import java.io.StringReader
import java.util.Properties
class GReaderDataSource(private val service: GReaderService) {
suspend fun login(login: String, password: String): String {
val requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("Email", login)
.addFormDataPart("Passwd", password)
.build()
val response = service.login(requestBody)
val properties = Properties()
properties.load(StringReader(response.string()))
response.close()
return properties.getProperty("Auth")
}
suspend fun getWriteToken(): String = service.getWriteToken().string()
suspend fun getUserInfo(): FreshRSSUserInfo = service.userInfo()
suspend fun synchronize(
syncType: SyncType,
syncData: GReaderSyncData,
writeToken: String
): DataSourceResult = with(CoroutineScope(Dispatchers.IO)) {
return if (syncType == SyncType.INITIAL_SYNC) {
DataSourceResult().apply {
awaitAll(
async { folders = getFolders() },
async { feeds = getFeeds() },
async {
items = getItems(listOf(GOOGLE_READ, GOOGLE_STARRED), MAX_ITEMS, null)
},
async { starredItems = getStarredItems(MAX_STARRED_ITEMS) },
async { unreadIds = getItemsIds(GOOGLE_READ, GOOGLE_READING_LIST, MAX_ITEMS) },
async { starredIds = getItemsIds(null, GOOGLE_STARRED, MAX_STARRED_ITEMS) }
)
}
} else {
DataSourceResult().apply {
awaitAll(
async { setItemsReadState(syncData, writeToken) },
async { setItemsStarState(syncData, writeToken) },
)
awaitAll(
async { folders = getFolders() },
async { feeds = getFeeds() },
async { items = getItems(null, MAX_ITEMS, syncData.lastModified) },
async { unreadIds = getItemsIds(GOOGLE_READ, GOOGLE_READING_LIST, MAX_ITEMS) },
async {
readIds = getItemsIds(GOOGLE_UNREAD, GOOGLE_READING_LIST, MAX_ITEMS)
},
async { starredIds = getItemsIds(null, GOOGLE_STARRED, MAX_STARRED_ITEMS) }
)
}
}
}
suspend fun getFolders() = service.getFolders()
suspend fun getFeeds() = service.getFeeds()
suspend fun getItems(excludeTargets: List<String>?, max: Int, lastModified: Long?): List<Item> {
return service.getItems(excludeTargets, max, lastModified)
}
suspend fun getStarredItems(max: Int) = service.getStarredItems(max)
suspend fun getItemsIds(excludeTarget: String?, includeTarget: String, max: Int): List<String> {
return service.getItemsIds(excludeTarget, includeTarget, max)
}
private suspend fun setItemsReadState(read: Boolean, itemIds: List<String>, token: String) {
return if (read) {
service.setItemsState(token, GOOGLE_READ, null, itemIds)
} else {
service.setItemsState(token, null, GOOGLE_READ, itemIds)
}
}
private suspend fun setItemStarState(starred: Boolean, itemIds: List<String>, token: String) {
return if (starred) {
service.setItemsState(token, GOOGLE_STARRED, null, itemIds)
} else {
service.setItemsState(token, null, GOOGLE_STARRED, itemIds)
}
}
suspend fun createFeed(token: String, feedUrl: String, folderId: String?) {
// no feed here of the folder prefix for the folder id
service.createOrDeleteFeed(token, FEED_PREFIX + feedUrl, "subscribe", folderId)
}
suspend fun deleteFeed(token: String, feedUrl: String) {
service.createOrDeleteFeed(token, FEED_PREFIX + feedUrl, "unsubscribe", null)
}
suspend fun updateFeed(token: String, feedUrl: String, title: String, folderId: String) {
service.updateFeed(token, FEED_PREFIX + feedUrl, title, folderId, "edit")
}
suspend fun createFolder(token: String, tagName: String) {
service.createFolder(token, "$FOLDER_PREFIX$tagName")
}
suspend fun updateFolder(token: String, folderId: String, name: String) {
service.updateFolder(token, folderId, "$FOLDER_PREFIX$name")
}
suspend fun deleteFolder(token: String, folderId: String) {
service.deleteFolder(token, folderId)
}
private suspend fun setItemsReadState(syncData: GReaderSyncData, token: String) {
if (syncData.readIds.isNotEmpty()) {
setItemsReadState(true, syncData.readIds, token)
}
if (syncData.unreadIds.isNotEmpty()) {
setItemsReadState(false, syncData.unreadIds, token)
}
}
private suspend fun setItemsStarState(syncData: GReaderSyncData, token: String) {
if (syncData.starredIds.isNotEmpty()) {
setItemStarState(true, syncData.starredIds, token)
}
if (syncData.unstarredIds.isNotEmpty()) {
setItemStarState(false, syncData.unstarredIds, token)
}
}
companion object {
private const val MAX_ITEMS = 2500
private const val MAX_STARRED_ITEMS = 1000
const val GOOGLE_READ = "user/-/state/com.google/read"
const val GOOGLE_UNREAD = "user/-/state/com.google/unread"
const val GOOGLE_STARRED = "user/-/state/com.google/starred"
const val GOOGLE_READING_LIST = "user/-/state/com.google/reading-list"
const val FEED_PREFIX = "feed/"
const val FOLDER_PREFIX = "user/-/label/"
}
}

View File

@ -1,6 +1,6 @@
package com.readrops.api.services.freshrss
package com.readrops.api.services.greader
import com.readrops.api.services.freshrss.adapters.FreshRSSUserInfo
import com.readrops.api.services.greader.adapters.FreshRSSUserInfo
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import com.readrops.db.entities.Item
@ -13,7 +13,7 @@ import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
interface NewFreshRSSService {
interface GReaderService {
@POST("accounts/ClientLogin")
suspend fun login(@Body body: RequestBody?): ResponseBody
@ -31,29 +31,49 @@ interface NewFreshRSSService {
suspend fun getFolders(): List<Folder>
@GET("reader/api/0/stream/contents/user/-/state/com.google/reading-list")
suspend fun getItems(@Query("xt") excludeTarget: List<String>?, @Query("n") max: Int,
@Query("ot") lastModified: Long?): List<Item>
suspend fun getItems(
@Query("xt") excludeTarget: List<String>?,
@Query("n") max: Int,
@Query("ot") lastModified: Long?
): List<Item>
@GET("reader/api/0/stream/contents/user/-/state/com.google/starred")
suspend fun getStarredItems(@Query("n") max: Int): List<Item>
@GET("reader/api/0/stream/items/ids")
suspend fun getItemsIds(@Query("xt") excludeTarget: String?, @Query("s") includeTarget: String?,
@Query("n") max: Int): List<String>
suspend fun getItemsIds(
@Query("xt") excludeTarget: String?,
@Query("s") includeTarget: String?,
@Query("n") max: Int
): List<String>
@FormUrlEncoded
@POST("reader/api/0/edit-tag")
suspend fun setItemsState(@Field("T") token: String, @Field("a") addAction: String?,
@Field("r") removeAction: String?, @Field("i") itemIds: List<String>)
suspend fun setItemsState(
@Field("T") token: String,
@Field("a") addAction: String?,
@Field("r") removeAction: String?,
@Field("i") itemIds: List<String>
)
@FormUrlEncoded
@POST("reader/api/0/subscription/edit")
suspend fun createOrDeleteFeed(@Field("T") token: String, @Field("s") feedUrl: String, @Field("ac") action: String)
suspend fun createOrDeleteFeed(
@Field("T") token: String,
@Field("s") feedUrl: String,
@Field("ac") action: String,
@Field("a") folderId: String?
)
@FormUrlEncoded
@POST("reader/api/0/subscription/edit")
suspend fun updateFeed(@Field("T") token: String, @Field("s") feedUrl: String, @Field("t") title: String,
@Field("a") folderId: String, @Field("ac") action: String)
suspend fun updateFeed(
@Field("T") token: String,
@Field("s") feedUrl: String,
@Field("t") title: String,
@Field("a") folderId: String,
@Field("ac") action: String
)
@FormUrlEncoded
@POST("reader/api/0/edit-tag")
@ -61,13 +81,13 @@ interface NewFreshRSSService {
@FormUrlEncoded
@POST("reader/api/0/rename-tag")
suspend fun updateFolder(@Field("T") token: String, @Field("s") folderId: String, @Field("dest") newFolderId: String)
suspend fun updateFolder(
@Field("T") token: String,
@Field("s") folderId: String,
@Field("dest") newFolderId: String
)
@FormUrlEncoded
@POST("reader/api/0/disable-tag")
suspend fun deleteFolder(@Field("T") token: String, @Field("s") folderId: String)
companion object {
const val END_POINT = "/api/greader.php/"
}
}

View File

@ -0,0 +1,9 @@
package com.readrops.api.services.greader
data class GReaderSyncData(
var lastModified: Long = 0,
var readIds: List<String> = listOf(),
var unreadIds: List<String> = listOf(),
var starredIds: List<String> = listOf(),
var unstarredIds: List<String> = listOf(),
)

View File

@ -0,0 +1,99 @@
package com.readrops.api.services.greader.adapters
import android.annotation.SuppressLint
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.extensions.nextNonEmptyString
import com.readrops.api.utils.extensions.nextNullableString
import com.readrops.db.entities.Feed
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonReader
import com.squareup.moshi.ToJson
class GReaderFeedsAdapter {
@ToJson
fun toJson(feeds: List<Feed>): String = ""
@SuppressLint("CheckResult")
@FromJson
fun fromJson(reader: JsonReader): List<Feed> = with(reader) {
val feeds = mutableListOf<Feed>()
return try {
beginObject()
while (hasNext()) {
when (nextName()) {
"subscriptions" -> {
beginArray()
while (hasNext()) {
beginObject()
feeds += parseFeed(reader)
endObject()
}
endArray()
}
else -> skipValue()
}
}
endObject()
feeds
} catch (e: Exception) {
throw ParseException(e.message)
}
}
private fun parseFeed(reader: JsonReader): Feed = with(reader) {
val feed = Feed()
while (hasNext()) {
with(feed) {
when (selectName(NAMES)) {
0 -> name = nextNonEmptyString()
1 -> url = nextNonEmptyString()
2 -> siteUrl = nextNullableString()
3 -> iconUrl = nextNullableString()
4 -> remoteId = nextNonEmptyString()
5 -> remoteFolderId = getCategoryId(reader)
else -> skipValue()
}
}
}
return feed
}
private fun getCategoryId(reader: JsonReader): String? = with(reader) {
var id: String? = null
beginArray()
while (hasNext()) {
beginObject()
while (hasNext()) {
when (nextName()) {
"id" -> id = nextNullableString()
else -> skipValue()
}
}
endObject()
if (!id.isNullOrEmpty())
break
}
endArray()
return id
}
companion object {
val NAMES: JsonReader.Options = JsonReader.Options.of(
"title", "url", "htmlUrl",
"iconUrl", "id", "categories"
)
}
}

View File

@ -0,0 +1,81 @@
package com.readrops.api.services.greader.adapters
import android.annotation.SuppressLint
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.extensions.nextNonEmptyString
import com.readrops.db.entities.Folder
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonReader
import com.squareup.moshi.ToJson
import java.util.StringTokenizer
class GReaderFoldersAdapter {
@ToJson
fun toJson(folders: List<Folder>): String = ""
@SuppressLint("CheckResult")
@FromJson
fun fromJson(reader: JsonReader): List<Folder> = with(reader) {
val folders = mutableListOf<Folder>()
return try {
beginObject()
while (hasNext()) {
when (nextName()) {
"tags" -> {
beginArray()
while (hasNext()) {
beginObject()
parseFolder(reader)?.let { folders += it }
endObject()
}
endArray()
}
else -> skipValue()
}
}
endObject()
folders
} catch (e: Exception) {
throw ParseException(e.message)
}
}
private fun parseFolder(reader: JsonReader): Folder? = with(reader) {
val folder = Folder()
var type: String? = null
while (hasNext()) {
with(folder) {
when (selectName(NAMES)) {
0 -> {
val id = nextNonEmptyString()
name = StringTokenizer(id, "/")
.toList()
.last() as String
remoteId = id
}
1 -> type = nextString()
else -> skipValue()
}
}
}
// add only folders and avoid tags
if (type == "folder") {
folder
} else {
null
}
}
companion object {
val NAMES: JsonReader.Options = JsonReader.Options.of("id", "type")
}
}

View File

@ -0,0 +1,139 @@
package com.readrops.api.services.greader.adapters
import com.readrops.api.services.greader.GReaderDataSource.Companion.GOOGLE_READ
import com.readrops.api.services.greader.GReaderDataSource.Companion.GOOGLE_STARRED
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.extensions.nextNonEmptyString
import com.readrops.api.utils.extensions.nextNullableString
import com.readrops.db.entities.Item
import com.readrops.db.util.DateUtils
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
class GReaderItemsAdapter : JsonAdapter<List<Item>>() {
override fun toJson(writer: JsonWriter, value: List<Item>?) {
// no need of this
}
override fun fromJson(reader: JsonReader): List<Item> = with(reader) {
val items = mutableListOf<Item>()
return try {
beginObject()
while (hasNext()) {
when (nextName()) {
"items" -> parseItems(reader, items)
else -> skipValue()
}
}
endObject()
items
} catch (e: Exception) {
throw ParseException(e.message)
}
}
private fun parseItems(reader: JsonReader, items: MutableList<Item>) = with(reader) {
beginArray()
while (hasNext()) {
val item = Item()
beginObject()
while (hasNext()) {
with(item) {
when (selectName(NAMES)) {
0 -> remoteId = nextNonEmptyString()
1 -> pubDate = DateUtils.fromEpochSeconds(nextLong())
2 -> title = nextNonEmptyString()
3 -> content = getContent(reader)
4 -> link = getLink(reader)
5 -> getStates(reader, this)
6 -> feedRemoteId = getRemoteFeedId(reader)
7 -> author = nextNullableString()
else -> skipValue()
}
}
}
items += item
endObject()
}
endArray()
}
private fun getContent(reader: JsonReader): String? = with(reader) {
var content: String? = null
beginObject()
while (hasNext()) {
when (nextName()) {
"content" -> content = nextNullableString()
else -> skipValue()
}
}
endObject()
return content
}
private fun getLink(reader: JsonReader): String? = with(reader) {
var href: String? = null
beginArray()
while (hasNext()) {
beginObject()
while (hasNext()) {
when (nextName()) {
"href" -> href = nextString()
else -> skipValue()
}
}
endObject()
}
endArray()
return href
}
private fun getStates(reader: JsonReader, item: Item) = with(reader) {
beginArray()
while (hasNext()) {
when (nextString()) {
GOOGLE_READ -> item.isRead = true
GOOGLE_STARRED -> item.isStarred = true
}
}
endArray()
}
private fun getRemoteFeedId(reader: JsonReader): String? = with(reader) {
var remoteFeedId: String? = null
beginObject()
while (hasNext()) {
when (nextName()) {
"streamId" -> remoteFeedId = nextString()
else -> skipValue()
}
}
endObject()
return remoteFeedId
}
companion object {
val NAMES: JsonReader.Options = JsonReader.Options.of(
"id", "published", "title", "summary", "alternate", "categories", "origin", "author"
)
}
}

View File

@ -0,0 +1,56 @@
package com.readrops.api.services.greader.adapters
import android.annotation.SuppressLint
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.extensions.nextNonEmptyString
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
class GReaderItemsIdsAdapter : JsonAdapter<List<String>>() {
override fun toJson(writer: JsonWriter, value: List<String>?) {
// not useful here
}
@SuppressLint("CheckResult")
override fun fromJson(reader: JsonReader): List<String>? = with(reader) {
val ids = arrayListOf<String>()
return try {
beginObject()
while (hasNext()) {
when (nextName()) {
"itemRefs" -> {
beginArray()
while (hasNext()) {
beginObject()
when (nextName()) {
"id" -> {
val value = nextNonEmptyString()
ids += "tag:google.com,2005:reader/item/" +
value.toLong()
.toString(16).padStart(value.length, '0')
}
else -> skipValue()
}
endObject()
}
endArray()
}
else -> skipValue()
}
}
endObject()
ids
} catch (e: Exception) {
throw ParseException(e.message)
}
}
}

View File

@ -1,4 +1,4 @@
package com.readrops.api.services.freshrss.adapters
package com.readrops.api.services.greader.adapters
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.extensions.nextNullableString

View File

@ -1,8 +0,0 @@
package com.readrops.api.services.nextcloudnews
import com.readrops.api.services.Credentials
class NextNewsCredentials(login: String?, password: String?, url: String):
Credentials((login != null && password != null).let {
okhttp3.Credentials.basic(login!!, password!!)
}, url)

View File

@ -1,300 +0,0 @@
package com.readrops.api.services.nextcloudnews;
import android.content.res.Resources;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.readrops.api.services.SyncResult;
import com.readrops.api.services.SyncType;
import com.readrops.api.services.nextcloudnews.adapters.NextNewsUserAdapter;
import com.readrops.api.utils.ApiUtils;
import com.readrops.api.utils.exceptions.ConflictException;
import com.readrops.api.utils.exceptions.UnknownFormatException;
import com.readrops.api.utils.extensions.KonsumerExtensionsKt;
import com.readrops.db.entities.Feed;
import com.readrops.db.entities.Folder;
import com.readrops.db.entities.Item;
import com.readrops.db.entities.account.Account;
import com.readrops.db.pojo.StarItem;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import retrofit2.Response;
public class NextNewsDataSource {
private static final String TAG = NextNewsDataSource.class.getSimpleName();
private static final int MAX_ITEMS = 5000;
private static final int MAX_STARRED_ITEMS = 1000;
private NextNewsService api;
public NextNewsDataSource(NextNewsService api) {
this.api = api;
}
@Nullable
public String login(OkHttpClient client, Account account) throws IOException {
Request request = new Request.Builder()
.url(account.getUrl() + "/ocs/v1.php/cloud/users/" + account.getLogin())
.addHeader("OCS-APIRequest", "true")
.build();
okhttp3.Response response = client.newCall(request).execute();
String displayName = new NextNewsUserAdapter().fromXml(KonsumerExtensionsKt
.instantiateKonsumer(response.body().byteStream()));
response.body().close();
return displayName;
}
@Nullable
public List<Feed> createFeed(String url, int folderId) throws IOException, UnknownFormatException {
Response<List<Feed>> response = api.createFeed(url, folderId).execute();
if (!response.isSuccessful()) {
if (response.code() == ApiUtils.HTTP_UNPROCESSABLE)
throw new UnknownFormatException();
else
return null;
}
return response.body();
}
public SyncResult sync(@NonNull SyncType syncType, @Nullable NextNewsSyncData data) throws IOException {
SyncResult syncResult = new SyncResult();
switch (syncType) {
case INITIAL_SYNC:
initialSync(syncResult);
break;
case CLASSIC_SYNC:
if (data == null)
throw new NullPointerException("NextNewsSyncData can't be null");
classicSync(syncResult, data);
break;
}
return syncResult;
}
private void initialSync(SyncResult syncResult) throws IOException {
getFeedsAndFolders(syncResult);
// unread items
Response<List<Item>> itemsResponse = api.getItems(ItemQueryType.ALL.value, false, MAX_ITEMS).execute();
List<Item> itemList = itemsResponse.body();
if (!itemsResponse.isSuccessful())
syncResult.setError(true);
if (itemList != null)
syncResult.setItems(itemList);
// starred items
Response<List<Item>> starredItemsResponse = api.getItems(ItemQueryType.STARRED.value, true, MAX_STARRED_ITEMS).execute();
List<Item> starredItems = starredItemsResponse.body();
if (!itemsResponse.isSuccessful())
syncResult.setError(true);
if (itemList != null)
syncResult.setStarredItems(starredItems);
}
private void classicSync(SyncResult syncResult, NextNewsSyncData data) throws IOException {
putModifiedItems(data, syncResult);
getFeedsAndFolders(syncResult);
Response<List<Item>> itemsResponse = api.getNewItems(data.getLastModified(), ItemQueryType.ALL.value).execute();
List<Item> itemList = itemsResponse.body();
if (!itemsResponse.isSuccessful())
syncResult.setError(true);
if (itemList != null)
syncResult.setItems(itemList);
}
private void getFeedsAndFolders(SyncResult syncResult) throws IOException {
Response<List<Feed>> feedResponse = api.getFeeds().execute();
List<Feed> feedList = feedResponse.body();
if (!feedResponse.isSuccessful())
syncResult.setError(true);
Response<List<Folder>> folderResponse = api.getFolders().execute();
List<Folder> folderList = folderResponse.body();
if (!folderResponse.isSuccessful())
syncResult.setError(true);
if (folderList != null)
syncResult.setFolders(folderList);
if (feedList != null)
syncResult.setFeeds(feedList);
}
private void putModifiedItems(NextNewsSyncData data, SyncResult syncResult) throws IOException {
setReadState(data.getReadItems(), syncResult, StateType.READ);
setReadState(data.getUnreadItems(), syncResult, StateType.UNREAD);
setStarState(data.getStarredItems(), syncResult, StateType.STAR);
setStarState(data.getUnstarredItems(), syncResult, StateType.UNSTAR);
}
public List<Folder> createFolder(Folder folder) throws IOException, UnknownFormatException, ConflictException {
Map<String, String> folderNameMap = new HashMap<>();
folderNameMap.put("name", folder.getName());
Response<List<Folder>> foldersResponse = api.createFolder(folderNameMap).execute();
if (foldersResponse.isSuccessful())
return foldersResponse.body();
else if (foldersResponse.code() == ApiUtils.HTTP_UNPROCESSABLE)
throw new UnknownFormatException();
else if (foldersResponse.code() == ApiUtils.HTTP_CONFLICT)
throw new ConflictException();
else
return new ArrayList<>();
}
public boolean deleteFolder(Folder folder) throws IOException {
Response response = api.deleteFolder(Integer.parseInt(folder.getRemoteId())).execute();
if (response.isSuccessful())
return true;
else if (response.code() == ApiUtils.HTTP_NOT_FOUND)
throw new Resources.NotFoundException();
else
return false;
}
public boolean renameFolder(Folder folder) throws IOException, UnknownFormatException, ConflictException {
Map<String, String> folderNameMap = new HashMap<>();
folderNameMap.put("name", folder.getName());
Response response = api.renameFolder(Integer.parseInt(folder.getRemoteId()), folderNameMap).execute();
if (response.isSuccessful())
return true;
else {
switch (response.code()) {
case ApiUtils.HTTP_NOT_FOUND:
throw new Resources.NotFoundException();
case ApiUtils.HTTP_UNPROCESSABLE:
throw new UnknownFormatException();
case ApiUtils.HTTP_CONFLICT:
throw new ConflictException();
default:
return false;
}
}
}
public boolean deleteFeed(int feedId) throws IOException {
Response response = api.deleteFeed(feedId).execute();
if (response.isSuccessful())
return true;
else if (response.code() == ApiUtils.HTTP_NOT_FOUND)
throw new Resources.NotFoundException();
else
return false;
}
public boolean changeFeedFolder(Feed feed) throws IOException {
Map<String, Integer> folderIdMap = new HashMap<>();
folderIdMap.put("folderId", Integer.parseInt(feed.getRemoteFolderId()));
Response response = api.changeFeedFolder(Integer.parseInt(feed.getRemoteId()), folderIdMap).execute();
if (response.isSuccessful())
return true;
else if (response.code() == ApiUtils.HTTP_NOT_FOUND)
throw new Resources.NotFoundException();
else
return false;
}
public boolean renameFeed(Feed feed) throws IOException {
Map<String, String> feedTitleMap = new HashMap<>();
feedTitleMap.put("feedTitle", feed.getName());
Response response = api.renameFeed(Integer.parseInt(feed.getRemoteId()), feedTitleMap).execute();
if (response.isSuccessful())
return true;
else if (response.code() == ApiUtils.HTTP_NOT_FOUND)
throw new Resources.NotFoundException();
else
return false;
}
private void setReadState(List<String> items, SyncResult syncResult, StateType stateType) throws IOException {
if (!items.isEmpty()) {
Map<String, List<String>> itemIdsMap = new HashMap<>();
itemIdsMap.put("items", items);
Response readItemsResponse = api.setReadState(stateType.name().toLowerCase(),
itemIdsMap).execute();
if (!readItemsResponse.isSuccessful())
syncResult.setError(true);
}
}
private void setStarState(List<StarItem> items, SyncResult syncResult, StateType stateType) throws IOException {
if (!items.isEmpty()) {
List<Map<String, String>> body = new ArrayList<>();
for (StarItem item : items) {
Map<String, String> itemBody = new HashMap<>();
itemBody.put("feedId", item.getFeedRemoteId());
itemBody.put("guidHash", item.getGuidHash());
body.add(itemBody);
}
Response response = api.setStarState(stateType.name().toLowerCase(),
Collections.singletonMap("items", body)).execute();
if (!response.isSuccessful()) {
syncResult.setError(true);
}
}
}
public enum StateType {
READ,
UNREAD,
STAR,
UNSTAR
}
public enum ItemQueryType {
ALL(3),
STARRED(2);
private int value;
ItemQueryType(int value) {
this.value = value;
}
public int getValue() {
return value;
}
}
}

View File

@ -1,58 +0,0 @@
package com.readrops.api.services.nextcloudnews
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import com.readrops.db.entities.Item
import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.http.*
interface NextNewsService {
@GET("/ocs/v1.php/cloud/users/{userId}")
@Headers("OCS-APIRequest: true")
fun getUser(@Path("userId") userId: String): Call<ResponseBody>
@get:GET("folders")
val folders: Call<List<Folder>>
@get:GET("feeds")
val feeds: Call<List<Feed>>
@GET("items")
fun getItems(@Query("type") type: Int, @Query("getRead") read: Boolean, @Query("batchSize") batchSize: Int): Call<List<Item>>
@GET("items/updated")
fun getNewItems(@Query("lastModified") lastModified: Long, @Query("type") type: Int): Call<List<Item>>
@PUT("items/{stateType}/multiple")
fun setReadState(@Path("stateType") stateType: String, @Body itemIdsMap: Map<String, List<String>>): Call<ResponseBody>
@PUT("items/{starType}/multiple")
fun setStarState(@Path("starType") starType: String?, @Body body: Map<String?, List<Map<String, String>>>): Call<ResponseBody>
@POST("feeds")
fun createFeed(@Query("url") url: String, @Query("folderId") folderId: Int): Call<List<Feed>>
@DELETE("feeds/{feedId}")
fun deleteFeed(@Path("feedId") feedId: Int): Call<Void?>?
@PUT("feeds/{feedId}/move")
fun changeFeedFolder(@Path("feedId") feedId: Int, @Body folderIdMap: Map<String, Int>): Call<ResponseBody>
@PUT("feeds/{feedId}/rename")
fun renameFeed(@Path("feedId") feedId: Int, @Body feedTitleMap: Map<String, String>): Call<ResponseBody>
@POST("folders")
fun createFolder(@Body folderName: Map<String, String>): Call<List<Folder>>
@DELETE("folders/{folderId}")
fun deleteFolder(@Path("folderId") folderId: Int): Call<ResponseBody>
@PUT("folders/{folderId}")
fun renameFolder(@Path("folderId") folderId: Int, @Body folderName: Map<String, String>): Call<ResponseBody>
companion object {
const val END_POINT = "/index.php/apps/news/api/v1-2/"
}
}

View File

@ -1,11 +0,0 @@
package com.readrops.api.services.nextcloudnews
import com.readrops.db.pojo.StarItem
data class NextNewsSyncData(
var lastModified: Long = 0,
var unreadItems: List<String> = listOf(),
var readItems: List<String> = listOf(),
var starredItems: List<StarItem> = listOf(),
var unstarredItems: List<StarItem> = listOf(),
)

View File

@ -0,0 +1,8 @@
package com.readrops.api.services.nextcloudnews
import com.readrops.api.services.Credentials
class NextcloudNewsCredentials(login: String?, password: String?, url: String):
Credentials(if (login != null && password != null) {
okhttp3.Credentials.basic(login, password)
} else null, url)

View File

@ -0,0 +1,153 @@
package com.readrops.api.services.nextcloudnews
import com.gitlab.mvysny.konsumexml.konsumeXml
import com.readrops.api.services.DataSourceResult
import com.readrops.api.services.SyncType
import com.readrops.api.services.nextcloudnews.adapters.NextcloudNewsUserAdapter
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import com.readrops.db.entities.Item
import com.readrops.db.entities.account.Account
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import okhttp3.OkHttpClient
import okhttp3.Request
class NextcloudNewsDataSource(private val service: NextcloudNewsService) {
suspend fun login(client: OkHttpClient, account: Account): String {
val request = Request.Builder()
.url(account.url + "/ocs/v1.php/cloud/users/" + account.login)
.addHeader("OCS-APIRequest", "true")
.build()
val response = client.newCall(request)
.execute()
val displayName = NextcloudNewsUserAdapter().fromXml(response.body!!.byteStream().konsumeXml())
response.close()
return displayName
}
suspend fun synchronize(syncType: SyncType, syncData: NextcloudNewsSyncData): DataSourceResult =
with(CoroutineScope(Dispatchers.IO)) {
return if (syncType == SyncType.INITIAL_SYNC) {
DataSourceResult().apply {
awaitAll(
async { folders = getFolders() },
async { feeds = getFeeds() },
async { items = getItems(ItemQueryType.ALL.value, false, MAX_ITEMS) },
async {
starredItems =
getItems(ItemQueryType.STARRED.value, true, MAX_STARRED_ITEMS)
}
)
}
} else {
awaitAll(
async { setItemsReadState(syncData) },
async { setItemsStarState(syncData) },
)
DataSourceResult().apply {
awaitAll(
async { folders = getFolders() },
async { feeds = getFeeds() },
async { items = getNewItems(syncData.lastModified, ItemQueryType.ALL) }
)
}
}
}
suspend fun getFolders() = service.getFolders()
suspend fun getFeeds() = service.getFeeds()
suspend fun getItems(type: Int, read: Boolean, batchSize: Int): List<Item> {
return service.getItems(type, read, batchSize)
}
suspend fun getNewItems(lastModified: Long, itemQueryType: ItemQueryType): List<Item> {
return service.getNewItems(lastModified, itemQueryType.value)
}
suspend fun createFeed(url: String, folderId: Int?): List<Feed> {
return service.createFeed(url, folderId)
}
suspend fun changeFeedFolder(newFolderId: Int?, feedId: Int) {
service.changeFeedFolder(feedId, mapOf("folderId" to newFolderId))
}
suspend fun renameFeed(name: String, feedId: Int) {
service.renameFeed(feedId, mapOf("feedTitle" to name))
}
suspend fun deleteFeed(feedId: Int) {
service.deleteFeed(feedId)
}
suspend fun createFolder(name: String): List<Folder> {
return service.createFolder(mapOf("name" to name))
}
suspend fun renameFolder(name: String, folderId: Int) {
service.renameFolder(folderId, mapOf("name" to name))
}
suspend fun deleteFolder(folderId: Int) {
service.deleteFolder(folderId)
}
suspend fun setItemsReadState(syncData: NextcloudNewsSyncData) = with(syncData) {
if (unreadIds.isNotEmpty()) {
service.setReadState(
StateType.UNREAD.name.lowercase(),
mapOf("itemIds" to unreadIds)
)
}
if (readIds.isNotEmpty()) {
service.setReadState(
StateType.READ.name.lowercase(),
mapOf("itemIds" to readIds)
)
}
}
suspend fun setItemsStarState(syncData: NextcloudNewsSyncData) = with(syncData) {
if (starredIds.isNotEmpty()) {
service.setStarState(
StateType.STAR.name.lowercase(),
mapOf("itemIds" to starredIds)
)
}
if (unstarredIds.isNotEmpty()) {
service.setStarState(
StateType.UNSTAR.name.lowercase(),
mapOf("itemIds" to unstarredIds)
)
}
}
enum class ItemQueryType(val value: Int) {
ALL(3),
STARRED(2)
}
enum class StateType {
READ,
UNREAD,
STAR,
UNSTAR
}
companion object {
private const val MAX_ITEMS = 5000
private const val MAX_STARRED_ITEMS = 1000
}
}

View File

@ -0,0 +1,73 @@
package com.readrops.api.services.nextcloudnews
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import com.readrops.db.entities.Item
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
interface NextcloudNewsService {
@GET("folders")
suspend fun getFolders(): List<Folder>
@GET("feeds")
suspend fun getFeeds(): List<Feed>
@GET("items")
suspend fun getItems(
@Query("type") type: Int,
@Query("getRead") read: Boolean,
@Query("batchSize") batchSize: Int
): List<Item>
@GET("items/updated")
suspend fun getNewItems(
@Query("lastModified") lastModified: Long,
@Query("type") type: Int
): List<Item>
@POST("items/{stateType}/multiple")
@JvmSuppressWildcards
suspend fun setReadState(
@Path("stateType") stateType: String,
@Body itemIdsMap: Map<String, List<Int>>
)
@POST("items/{starType}/multiple")
@JvmSuppressWildcards
suspend fun setStarState(
@Path("starType") starType: String?,
@Body body: Map<String, List<Int>>
)
@POST("feeds")
suspend fun createFeed(@Query("url") url: String, @Query("folderId") folderId: Int?): List<Feed>
@DELETE("feeds/{feedId}")
suspend fun deleteFeed(@Path("feedId") feedId: Int)
@POST("feeds/{feedId}/move")
suspend fun changeFeedFolder(@Path("feedId") feedId: Int, @Body folderIdMap: Map<String, Int?>)
@POST("feeds/{feedId}/rename")
suspend fun renameFeed(@Path("feedId") feedId: Int, @Body feedTitleMap: Map<String, String>)
@POST("folders")
suspend fun createFolder(@Body folderName: Map<String, String>): List<Folder>
@DELETE("folders/{folderId}")
suspend fun deleteFolder(@Path("folderId") folderId: Int)
@PUT("folders/{folderId}")
suspend fun renameFolder(@Path("folderId") folderId: Int, @Body folderName: Map<String, String>)
companion object {
const val END_POINT = "index.php/apps/news/api/v1-3/"
}
}

View File

@ -0,0 +1,9 @@
package com.readrops.api.services.nextcloudnews
data class NextcloudNewsSyncData(
val lastModified: Long = 0,
val readIds: List<Int> = listOf(),
val unreadIds: List<Int> = listOf(),
val starredIds: List<Int> = listOf(),
val unstarredIds: List<Int> = listOf(),
)

View File

@ -11,7 +11,7 @@ import com.squareup.moshi.JsonReader
import com.squareup.moshi.ToJson
import java.net.URI
class NextNewsFeedsAdapter {
class NextcloudNewsFeedsAdapter {
@ToJson
fun toJson(feeds: List<Feed>): String = ""

View File

@ -8,7 +8,7 @@ import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonReader
import com.squareup.moshi.ToJson
class NextNewsFoldersAdapter {
class NextcloudNewsFoldersAdapter {
@ToJson
fun toJson(folders: List<Folder>): String = ""

View File

@ -1,18 +1,18 @@
package com.readrops.api.services.nextcloudnews.adapters
import android.annotation.SuppressLint
import com.readrops.db.entities.Item
import com.readrops.api.utils.ApiUtils
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.extensions.nextNonEmptyString
import com.readrops.api.utils.extensions.nextNullableLong
import com.readrops.api.utils.extensions.nextNullableString
import com.readrops.db.entities.Item
import com.readrops.db.util.DateUtils
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
import org.joda.time.DateTimeZone
import org.joda.time.LocalDateTime
import java.time.LocalDateTime
class NextNewsItemsAdapter : JsonAdapter<List<Item>>() {
class NextcloudNewsItemsAdapter : JsonAdapter<List<Item>>() {
override fun toJson(writer: JsonWriter, value: List<Item>?) {
// no need of this
@ -40,25 +40,37 @@ class NextNewsItemsAdapter : JsonAdapter<List<Item>>() {
when (reader.selectName(NAMES)) {
0 -> remoteId = reader.nextInt().toString()
1 -> link = reader.nextNullableString()
2 -> title = reader.nextNonEmptyString()
2 -> title = reader.nextNullableString()
3 -> author = reader.nextNullableString()
4 -> pubDate = LocalDateTime(reader.nextLong() * 1000L, DateTimeZone.getDefault())
4 -> {
val value = reader.nextNullableLong()
pubDate = if (value != null) {
DateUtils.fromEpochSeconds(value)
} else {
LocalDateTime.now()
}
}
5 -> content = reader.nextNullableString()
6 -> enclosureMime = reader.nextNullableString()
7 -> enclosureLink = reader.nextNullableString()
8 -> feedRemoteId = reader.nextInt().toString()
9 -> isRead = !reader.nextBoolean() // the negation is important here
10 -> isStarred = reader.nextBoolean()
11 -> guid = reader.nextNullableString()
else -> reader.skipValue()
}
}
}
if (enclosureMime != null && ApiUtils.isMimeImage(enclosureMime!!))
if (enclosureMime != null && ApiUtils.isMimeImage(enclosureMime!!)) {
item.imageLink = enclosureLink
}
if (item.title != null) {
items += item
}
reader.endObject()
}
@ -72,7 +84,9 @@ class NextNewsItemsAdapter : JsonAdapter<List<Item>>() {
}
companion object {
val NAMES: JsonReader.Options = JsonReader.Options.of("id", "url", "title", "author",
"pubDate", "body", "enclosureMime", "enclosureLink", "feedId", "unread", "starred", "guidHash")
val NAMES: JsonReader.Options = JsonReader.Options.of(
"id", "url", "title", "author",
"pubDate", "body", "enclosureMime", "enclosureLink", "feedId", "unread", "starred"
)
}
}

View File

@ -6,7 +6,7 @@ import com.readrops.api.localfeed.XmlAdapter
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.extensions.nonNullText
class NextNewsUserAdapter : XmlAdapter<String> {
class NextcloudNewsUserAdapter : XmlAdapter<String> {
override fun fromXml(konsumer: Konsumer): String {
var displayName: String? = null

View File

@ -1,6 +1,8 @@
package com.readrops.api.utils
import org.jsoup.Jsoup
import java.math.BigInteger
import java.security.MessageDigest
import java.util.regex.Pattern
object ApiUtils {
@ -12,16 +14,14 @@ object ApiUtils {
const val LAST_MODIFIED_HEADER = "Last-Modified"
const val IF_MODIFIED_HEADER = "If-Modified-Since"
const val HTTP_UNPROCESSABLE = 422
const val HTTP_NOT_FOUND = 404
const val HTTP_CONFLICT = 409
val OPML_MIMETYPES = listOf("application/xml", "text/xml", "text/x-opml", "application/octet-stream")
private const val RSS_CONTENT_TYPE_REGEX = "([^;]+)"
fun isMimeImage(type: String): Boolean =
type == "image" || type == "image/jpeg" || type == "image/jpg" || type == "image/png"
fun parseContentType(header: String?): String? {
fun parseContentType(header: String): String? {
val matcher = Pattern.compile(RSS_CONTENT_TYPE_REGEX)
.matcher(header)
return if (matcher.find()) {
@ -37,7 +37,14 @@ object ApiUtils {
* @param text string to clean
* @return cleaned text
*/
fun cleanText(text: String?): String {
fun cleanText(text: String): String {
return Jsoup.parse(text).text().trim()
}
fun md5hash(value: String): String {
val bytes = MessageDigest.getInstance("MD5")
.digest(value.toByteArray())
return BigInteger(1, bytes).toString(16)
}
}

View File

@ -1,67 +0,0 @@
package com.readrops.api.utils
import org.joda.time.LocalDateTime
import org.joda.time.format.DateTimeFormat
import org.joda.time.format.DateTimeFormatterBuilder
import java.util.*
object DateUtils {
private val TAG = DateUtils::class.java.simpleName
/**
* Base of common RSS 2 date formats.
* Examples :
* Fri, 04 Jan 2019 22:21:46 GMT
* Fri, 04 Jan 2019 22:21:46 +0000
*/
private const val RSS_2_BASE_PATTERN = "EEE, dd MMM yyyy HH:mm:ss"
private const val GMT_PATTERN = "ZZZ"
private const val OFFSET_PATTERN = "Z"
private const val ISO_PATTERN = ".SSSZZ"
private const val EDT_PATTERN = "zzz"
/**
* Date pattern for format : 2019-01-04T22:21:46+00:00
*/
private const val ATOM_JSON_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss"
@JvmStatic
fun parse(value: String?): LocalDateTime? = if (value == null) {
null
} else try {
val formatter = DateTimeFormatterBuilder()
.appendOptional(DateTimeFormat.forPattern("$RSS_2_BASE_PATTERN ").parser) // with timezone
.appendOptional(DateTimeFormat.forPattern(RSS_2_BASE_PATTERN).parser) // no timezone, important order here
.appendOptional(DateTimeFormat.forPattern(ATOM_JSON_DATE_FORMAT).parser)
.appendOptional(DateTimeFormat.forPattern(GMT_PATTERN).parser)
.appendOptional(DateTimeFormat.forPattern(OFFSET_PATTERN).parser)
.appendOptional(DateTimeFormat.forPattern(ISO_PATTERN).parser)
.appendOptional(DateTimeFormat.forPattern(EDT_PATTERN).parser)
.toFormatter()
.withLocale(Locale.ENGLISH)
.withOffsetParsed()
formatter.parseLocalDateTime(value)
} catch (e: Exception) {
null
}
@JvmStatic
fun formattedDateByLocal(dateTime: LocalDateTime): String {
return DateTimeFormat.mediumDate()
.withLocale(Locale.getDefault())
.print(dateTime)
}
@JvmStatic
fun formattedDateTimeByLocal(dateTime: LocalDateTime): String {
return DateTimeFormat.forPattern("dd MMM yyyy · HH:mm")
.withLocale(Locale.getDefault())
.print(dateTime)
}
}

View File

@ -4,13 +4,14 @@ import com.readrops.api.utils.exceptions.HttpException
import okhttp3.Interceptor
import okhttp3.Response
class ErrorInterceptor() : Interceptor {
class ErrorInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val response = chain.proceed(request)
if (!response.isSuccessful) {
// TODO cover all cases
if (!response.isSuccessful && response.code != 304) {
throw HttpException(response)
}

View File

@ -2,10 +2,13 @@ package com.readrops.api.utils
import android.nfc.FormatException
import com.readrops.api.localfeed.LocalRSSHelper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
data class ParsingResult(
val url: String,
@ -14,42 +17,74 @@ data class ParsingResult(
object HtmlParser {
suspend fun getFaviconLink(url: String, client: OkHttpClient): String? {
val document = getHTMLHeadFromUrl(url, client)
val elements = document.select("link")
for (element in elements) {
if (element.attributes()["rel"].lowercase().contains("icon")) {
return element.absUrl("href")
}
}
return null
}
@Throws(FormatException::class)
suspend fun getFeedLink(url: String, client: OkHttpClient): List<ParsingResult> {
val results = mutableListOf<ParsingResult>()
val document = getHTMLHeadFromUrl(url, client)
val elements = document.select("link")
for (element in elements) {
return document.select("link")
.filter { element ->
val type = element.attributes()["type"]
if (LocalRSSHelper.isRSSType(type)) {
results += ParsingResult(
url = element.absUrl("href"),
label = element.attributes()["title"]
LocalRSSHelper.isRSSType(type)
}.map {
ParsingResult(
url = it.absUrl("href"),
label = it.attributes()["title"]
)
}
}
return results
fun getFaviconLink(document: Document): String? {
val links = document.select("link")
.filter { element -> element.attributes()["rel"].contains("icon") }
.sortedWith(compareByDescending<Element> {
it.attributes()["rel"] == "apple-touch-icon"
}.thenByDescending { element ->
val sizes = element.attr("sizes")
if (sizes.isNotEmpty()) {
try {
sizes.filter { it.isDigit() }
.toInt()
} catch (e: Exception) {
0
}
} else {
0
}
})
return links.firstOrNull()
?.absUrl("href")
}
private fun getHTMLHeadFromUrl(url: String, client: OkHttpClient): Document {
client.newCall(Request.Builder().url(url).build()).execute().use { response ->
if (response.header(ApiUtils.CONTENT_TYPE_HEADER)!!.contains(ApiUtils.HTML_CONTENT_TYPE)
fun getFeedImage(document: Document): String? {
return document.select("meta")
.firstOrNull { element ->
val property = element.attr("property")
listOf("og:image", "twitter:image").any { it == property }
}
?.absUrl("content")
}
fun getFeedDescription(document: Document): String? {
return document.select("meta")
.firstOrNull { element ->
val property = element.attr("property")
listOf("og:title", "twitter:title").any { it == property }
}
?.attr("content")
}
suspend fun getHTMLHeadFromUrl(url: String, client: OkHttpClient): Document =
withContext(Dispatchers.IO) {
client.newCall(
Request.Builder()
.url(url)
.build()
).execute()
.use { response ->
if (response.header(ApiUtils.CONTENT_TYPE_HEADER)!!
.contains(ApiUtils.HTML_CONTENT_TYPE)
) {
val body = response.body!!.source()
@ -64,25 +99,29 @@ object HtmlParser {
stringBuilder.append(currentLine)
collectionStarted = true
}
currentLine.contains("</head>") -> {
stringBuilder.append(currentLine)
break
}
collectionStarted -> {
stringBuilder.append(currentLine)
}
}
}
if (!stringBuilder.contains("<head>") || !stringBuilder.contains("</head>"))
throw FormatException("Failed to get HTML head")
if (!stringBuilder.contains("<head>") || !stringBuilder.contains("</head>")) {
body.close()
throw FormatException("Failed to get HTML head from $url")
}
body.close()
return Jsoup.parse(stringBuilder.toString(), url)
Jsoup.parse(stringBuilder.toString(), url)
} else {
throw FormatException("The response is not a html file")
response.close()
throw FormatException("Response from $url is not a html file")
}
}
}
}

View File

@ -1,9 +1,10 @@
package com.readrops.api.utils.exceptions
import okhttp3.Response
import java.io.IOException
class HttpException(val response: Response) : Exception() {
class HttpException(val response: Response) : IOException() {
val code: Int
get() = response.code

View File

@ -0,0 +1,3 @@
package com.readrops.api.utils.exceptions
class LoginFailedException(override val message: String? = null) : Exception()

View File

@ -13,3 +13,19 @@ fun JsonReader.nextNonEmptyString(): String {
fun JsonReader.nextNullableInt(): Int? =
if (peek() != JsonReader.Token.NULL) nextInt() else nextNull()
fun JsonReader.nextNullableLong(): Long? =
if (peek() != JsonReader.Token.NULL) nextLong() else nextNull()
fun JsonReader.skipField() {
skipName()
skipValue()
}
fun JsonReader.skipToEnd() {
while (hasNext()) {
skipField()
}
}
fun Int.toBoolean(): Boolean = this == 1

View File

@ -19,7 +19,7 @@ fun Konsumer.nullableText(): String? {
}
fun Konsumer.nullableTextRecursively(): String? {
val text = textRecursively()
val text = textRecursively(whitespace = Whitespace.preserve)
return if (text.isNotEmpty()) text.trim() else null
}

View File

@ -0,0 +1,25 @@
package com.readrops.api
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okio.Buffer
import java.io.InputStream
import java.net.HttpURLConnection
fun MockWebServer.enqueueOK() {
enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
)
}
fun MockWebServer.enqueueOKStream(stream: InputStream) {
enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setBody(Buffer().readFrom(stream)))
}
fun MockResponse.Companion.okResponseWithBody(stream: InputStream): MockResponse {
return MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setBody(Buffer().readFrom(stream))
}

View File

@ -2,9 +2,10 @@ package com.readrops.api.localfeed
import com.gitlab.mvysny.konsumexml.Names
import com.gitlab.mvysny.konsumexml.konsumeXml
import junit.framework.TestCase.*
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertTrue
import org.junit.Test
import java.io.ByteArrayInputStream
class LocalRSSHelperTest {
@ -16,8 +17,6 @@ class LocalRSSHelperTest {
LocalRSSHelper.RSSType.RSS_2)
assertEquals(LocalRSSHelper.getRSSType("application/atom+xml"),
LocalRSSHelper.RSSType.ATOM)
assertEquals(LocalRSSHelper.getRSSType("application/json"),
LocalRSSHelper.RSSType.JSONFEED)
assertEquals(LocalRSSHelper.getRSSType("application/feed+json"),
LocalRSSHelper.RSSType.JSONFEED)
}

View File

@ -3,17 +3,12 @@ package com.readrops.api.localfeed
import com.readrops.api.localfeed.atom.ATOMFeedAdapter
import com.readrops.api.localfeed.rss1.RSS1FeedAdapter
import com.readrops.api.localfeed.rss2.RSS2FeedAdapter
import junit.framework.Assert.assertTrue
import org.junit.Assert.assertThrows
import org.junit.Rule
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.rules.ExpectedException
class XmlAdapterTest {
@get:Rule
val expectedException: ExpectedException = ExpectedException.none()
@Test
fun xmlFeedAdapterFactoryTest() {
assertTrue(XmlAdapter.xmlFeedAdapterFactory(LocalRSSHelper.RSSType.RSS_1) is RSS1FeedAdapter)

View File

@ -2,14 +2,12 @@ package com.readrops.api.localfeed.atom
import com.gitlab.mvysny.konsumexml.konsumeXml
import com.readrops.api.TestUtils
import com.readrops.api.utils.DateUtils
import com.readrops.api.utils.exceptions.ParseException
import junit.framework.TestCase
import junit.framework.TestCase.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import java.lang.Exception
class ATOMAdapterTest {
@ -28,16 +26,17 @@ class ATOMAdapterTest {
assertEquals(url, "https://github.com/readrops/Readrops/commits/develop.atom")
assertEquals(siteUrl, "https://github.com/readrops/Readrops/commits/develop")
assertEquals(description, "Here is a subtitle")
assertEquals(imageUrl, "https://github.com/readrops/Readrops/blob/develop/images/readrops_logo.png")
}
with(items[0]) {
with(items.first()) {
assertEquals(items.size, 4)
assertEquals(title, "Add an option to open item url in custom tab")
assertEquals(link, "https://github.com/readrops/Readrops/commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac")
assertEquals(pubDate, DateUtils.parse("2020-09-06T21:09:59Z"))
assertEquals(pubDate!!.year, 2019)
assertEquals(author, "Shinokuni")
assertEquals(description, "Summary")
assertEquals(guid, "tag:github.com,2008:Grit::Commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac")
assertEquals(remoteId, "tag:github.com,2008:Grit::Commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac")
TestCase.assertNotNull(content)
}
}
@ -71,4 +70,15 @@ class ATOMAdapterTest {
assertTrue(exception.message!!.contains("Item link is required"))
}
@Test
fun mediaGroupTest() {
val stream = TestUtils.loadResource("localfeed/atom/atom_item_media_group.xml")
val pair = adapter.fromXml(stream.konsumeXml())
with(pair.second.first()) {
assertEquals("description", text)
assertEquals("https://i3.ytimg.com/vi/.../hqdefault.jpg", imageLink)
}
}
}

View File

@ -1,15 +1,14 @@
package com.readrops.api.localfeed.json
import com.readrops.api.TestUtils
import com.readrops.api.utils.DateUtils
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Item
import com.readrops.db.util.DateUtils
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import junit.framework.TestCase
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertTrue
import okio.Buffer
import org.junit.Assert.assertThrows
import org.junit.Test
@ -36,11 +35,12 @@ class JSONFeedAdapterTest {
assertEquals(url, "http://flyingmeat.com/blog/feed.json")
assertEquals(siteUrl, "http://flyingmeat.com/blog/")
assertEquals(description, "News from your friends at Flying Meat.")
assertEquals(imageUrl, "https://secure.flyingmeat.com/favicon.ico")
}
with(items[0]) {
assertEquals(items.size, 10)
assertEquals(guid, "http://flyingmeat.com/blog/archives/2017/9/acorn_and_10.13.html")
assertEquals(remoteId, "http://flyingmeat.com/blog/archives/2017/9/acorn_and_10.13.html")
assertEquals(title, "Acorn and 10.13")
assertEquals(link, "http://flyingmeat.com/blog/archives/2017/9/acorn_and_10.13.html")
assertEquals(pubDate, DateUtils.parse("2017-09-25T14:27:27-07:00"))

View File

@ -2,11 +2,11 @@ package com.readrops.api.localfeed.rss1
import com.gitlab.mvysny.konsumexml.konsumeXml
import com.readrops.api.TestUtils
import com.readrops.api.utils.DateUtils
import com.readrops.api.utils.exceptions.ParseException
import junit.framework.Assert.assertEquals
import junit.framework.Assert.assertNotNull
import com.readrops.db.util.DateUtils
import junit.framework.TestCase
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
@ -28,6 +28,7 @@ class RSS1AdapterTest {
assertEquals(url, "https://slashdot.org/")
assertEquals(siteUrl, "https://slashdot.org/")
assertEquals(description, "News for nerds, stuff that matters")
assertEquals(imageUrl, "https://a.fsdn.com/sd/topics/topicslashdot.gif")
}
with(items[0]) {
@ -35,7 +36,7 @@ class RSS1AdapterTest {
assertEquals(title, "Google Expands its Flutter Development Kit To Windows Apps")
assertEquals(link!!.trim(), "https://developers.slashdot.org/story/20/09/23/1616231/google-expands-" +
"its-flutter-development-kit-to-windows-apps?utm_source=rss1.0mainlinkanon&utm_medium=feed")
assertEquals(guid!!.trim(), "https://developers.slashdot.org/story/20/09/23/1616231/google-expands-" +
assertEquals(remoteId!!.trim(), "https://developers.slashdot.org/story/20/09/23/1616231/google-expands-" +
"its-flutter-development-kit-to-windows-apps?utm_source=rss1.0mainlinkanon&utm_medium=feed")
assertEquals(pubDate, DateUtils.parse("2020-09-23T16:15:00+00:00"))
assertEquals(author, "msmash")

View File

@ -2,8 +2,8 @@ package com.readrops.api.localfeed.rss2
import com.gitlab.mvysny.konsumexml.konsumeXml
import com.readrops.api.TestUtils
import com.readrops.api.utils.DateUtils
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.db.util.DateUtils
import junit.framework.TestCase
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertTrue
@ -27,6 +27,7 @@ class RSS2AdapterTest {
assertEquals(url, "https://news.ycombinator.com/feed/")
assertEquals(siteUrl, "https://news.ycombinator.com/")
assertEquals(description, "Links for the intellectually curious, ranked by readers.")
assertEquals(imageUrl, "https://news.ycombinator.com/y18.svg")
}
with(items[0]) {
@ -36,7 +37,7 @@ class RSS2AdapterTest {
assertEquals(pubDate, DateUtils.parse("Tue, 25 Aug 2020 17:15:49 +0000"))
assertEquals(author, "Author 1")
assertEquals(description, "<a href=\"https://news.ycombinator.com/item?id=24273602\">Comments</a>")
assertEquals(guid, "https://www.bbc.com/news/world-africa-53887947")
assertEquals(remoteId, "https://www.bbc.com/news/world-africa-53887947")
}
}
@ -55,7 +56,7 @@ class RSS2AdapterTest {
val stream = TestUtils.loadResource("localfeed/rss2/rss_items_other_namespaces.xml")
val item = adapter.fromXml(stream.konsumeXml()).second[0]
assertEquals(item.guid, "guid")
assertEquals(item.remoteId, "guid")
assertEquals(item.author, "creator 1, creator 2, creator 3, creator 4")
assertEquals(item.pubDate, DateUtils.parse("2020-08-05T14:03:48Z"))
assertEquals(item.content, "content:encoded")
@ -94,9 +95,9 @@ class RSS2AdapterTest {
@Test
fun enclosureTest() {
val stream = TestUtils.loadResource("localfeed/rss2/rss_items_enclosure.xml")
val item = adapter.fromXml(stream.konsumeXml()).second[0]
val item = adapter.fromXml(stream.konsumeXml()).second.first()
assertEquals(item.imageLink, "https://image1.jpg")
assertEquals("https://image1.jpg", item.imageLink)
}
@Test
@ -111,8 +112,8 @@ class RSS2AdapterTest {
@Test
fun mediaGroupTest() {
val stream = TestUtils.loadResource("localfeed/rss2/rss_items_media_group.xml")
val item = adapter.fromXml(stream.konsumeXml()).second[0]
val item = adapter.fromXml(stream.konsumeXml()).second.first()
assertEquals(item.imageLink, "https://image1.jpg")
assertEquals("https://image1.jpg", item.imageLink)
}
}

View File

@ -4,8 +4,8 @@ import com.readrops.api.TestUtils
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import io.reactivex.schedulers.Schedulers
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.io.File
import java.io.FileOutputStream
@ -13,84 +13,87 @@ import java.io.FileOutputStream
class OPMLParserTest {
@Test
fun readOpmlTest() {
fun readOpmlTest() = runTest {
val stream = TestUtils.loadResource("opml/subscriptions.opml")
val foldersAndFeeds = OPMLParser.read(stream)
var foldersAndFeeds: Map<Folder?, List<Feed>>? = null
assertEquals(foldersAndFeeds.size, 6)
OPMLParser.read(stream)
.observeOn(Schedulers.trampoline())
.subscribeOn(Schedulers.trampoline())
.subscribe { result -> foldersAndFeeds = result }
assertEquals(foldersAndFeeds?.size, 6)
assertEquals(foldersAndFeeds?.get(Folder(name = "Folder 1"))?.size, 2)
assertEquals(foldersAndFeeds?.get(Folder(name = "Subfolder 1"))?.size, 4)
assertEquals(foldersAndFeeds?.get(Folder(name = "Subfolder 2"))?.size, 1)
assertEquals(foldersAndFeeds?.get(Folder(name = "Sub subfolder 1"))?.size, 2)
assertEquals(foldersAndFeeds?.get(Folder(name = "Sub subfolder 2"))?.size, 0)
assertEquals(foldersAndFeeds?.get(null)?.size, 2)
assertEquals(foldersAndFeeds[Folder(name = "Folder 1")]?.size, 2)
assertEquals(foldersAndFeeds[Folder(name = "Subfolder 1")]?.size, 4)
assertEquals(foldersAndFeeds[Folder(name = "Subfolder 2")]?.size, 1)
assertEquals(foldersAndFeeds[Folder(name = "Sub subfolder 1")]?.size, 2)
assertEquals(foldersAndFeeds[Folder(name = "Sub subfolder 2")]?.size, 0)
assertEquals(foldersAndFeeds[null]?.size, 2)
stream.close()
}
@Test
fun readLiteSubscriptionsTest() {
fun readLiteSubscriptionsTest() = runTest {
val stream = TestUtils.loadResource("opml/lite_subscriptions.opml")
var foldersAndFeeds: Map<Folder?, List<Feed>>? = null
val foldersAndFeeds = OPMLParser.read(stream)
OPMLParser.read(stream)
.subscribe { result -> foldersAndFeeds = result }
assertEquals(foldersAndFeeds?.values?.first()?.size, 2)
assertEquals(foldersAndFeeds?.values?.first()?.first()?.url, "http://www.theverge.com/rss/index.xml")
assertEquals(foldersAndFeeds?.values?.first()?.get(1)?.url, "https://techcrunch.com/feed/")
assertEquals(foldersAndFeeds.values.first().size, 2)
assertEquals(
foldersAndFeeds.values.first().first().url,
"http://www.theverge.com/rss/index.xml"
)
assertEquals(foldersAndFeeds.values.first()[1].url, "https://techcrunch.com/feed/")
stream.close()
}
@Test
fun opmlVersionTest() {
@Test(expected = ParseException::class)
fun opmlVersionTest() = runTest {
val stream = TestUtils.loadResource("opml/wrong_version.opml")
OPMLParser.read(stream)
.test()
.assertError(ParseException::class.java)
stream.close()
}
@Test
fun writeOpmlTest() {
fun writeOpmlTest() = runTest {
val file = File("subscriptions.opml")
val outputStream = FileOutputStream(file)
val foldersAndFeeds: Map<Folder?, List<Feed>> = HashMap<Folder?, List<Feed>>().apply {
put(null, listOf(Feed(name = "Feed1", url = "https://feed1.com"),
Feed(name = "Feed2", url = "https://feed2.com")))
put(
null, listOf(
Feed(name = "Feed1", url = "https://feed1.com"),
Feed(name = "Feed2", url = "https://feed2.com")
)
)
put(Folder(name = "Folder1"), listOf())
put(Folder(name = "Folder2"), listOf(Feed(name = "Feed3", url = "https://feed3.com"),
Feed(name = "Feed4", url ="https://feed4.com")))
put(
Folder(name = "Folder2"), listOf(
Feed(name = "Feed3", url = "https://feed3.com"),
Feed(name = "Feed4", url = "https://feed4.com")
)
)
}
OPMLParser.write(foldersAndFeeds, outputStream)
.subscribeOn(Schedulers.trampoline())
.subscribe()
outputStream.flush()
outputStream.close()
val inputStream = file.inputStream()
var foldersAndFeeds2: Map<Folder?, List<Feed>>? = null
OPMLParser.read(inputStream).subscribe { result -> foldersAndFeeds2 = result }
val foldersAndFeeds2 = OPMLParser.read(inputStream)
assertEquals(foldersAndFeeds.size, foldersAndFeeds2?.size)
assertEquals(foldersAndFeeds[Folder(name = "Folder1")]?.size, foldersAndFeeds2?.get(Folder(name = "Folder1"))?.size)
assertEquals(foldersAndFeeds[Folder(name = "Folder2")]?.size, foldersAndFeeds2?.get(Folder(name = "Folder2"))?.size)
assertEquals(foldersAndFeeds[null]?.size, foldersAndFeeds2?.get(null)?.size)
assertEquals(foldersAndFeeds.size, foldersAndFeeds2.size)
assertEquals(
foldersAndFeeds[Folder(name = "Folder1")]?.size,
foldersAndFeeds2[Folder(name = "Folder1")]?.size
)
assertEquals(
foldersAndFeeds[Folder(name = "Folder2")]?.size,
foldersAndFeeds2[Folder(name = "Folder2")]?.size
)
assertEquals(foldersAndFeeds[null]?.size, foldersAndFeeds2[null]?.size)
inputStream.close()
file.delete()
}
}

View File

@ -1,7 +1,7 @@
package com.readrops.api.services
import com.readrops.api.services.freshrss.FreshRSSCredentials
import com.readrops.api.services.nextcloudnews.NextNewsCredentials
import com.readrops.api.services.greader.GReaderCredentials
import com.readrops.api.services.nextcloudnews.NextcloudNewsCredentials
import org.junit.Test
import kotlin.test.assertEquals
@ -9,7 +9,7 @@ class CredentialsTest {
@Test
fun credentialsTest() {
val credentials = FreshRSSCredentials("token", "https://freshrss.org")
val credentials = GReaderCredentials("token", "https://freshrss.org")
assertEquals(credentials.authorization!!, "GoogleLogin auth=token")
assertEquals(credentials.url, "https://freshrss.org")
@ -17,7 +17,7 @@ class CredentialsTest {
@Test
fun nextcloudNewsCredentialsTest() {
val credentials = NextNewsCredentials("login", "password", "https://freshrss.org")
val credentials = NextcloudNewsCredentials("login", "password", "https://freshrss.org")
assertEquals(credentials.authorization!!, "Basic bG9naW46cGFzc3dvcmQ=")
assertEquals(credentials.url, "https://freshrss.org")

View File

@ -0,0 +1,241 @@
package com.readrops.api.services.fever
import com.readrops.api.TestUtils
import com.readrops.api.apiModule
import com.readrops.api.enqueueOK
import com.readrops.api.enqueueOKStream
import com.readrops.api.okResponseWithBody
import com.readrops.api.services.SyncType
import com.readrops.api.utils.AuthInterceptor
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.koin.core.parameter.parametersOf
import org.koin.dsl.module
import org.koin.test.KoinTest
import org.koin.test.KoinTestRule
import org.koin.test.get
import java.util.concurrent.TimeUnit
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class FeverDataSourceTest : KoinTest {
private lateinit var dataSource: FeverDataSource
private val mockServer = MockWebServer()
@Before
fun before() {
mockServer.start(8080)
val url = mockServer.url("")
dataSource = get(parameters = {
parametersOf(FeverCredentials(null, null, url.toString()))
})
}
@After
fun tearDown() {
mockServer.close()
}
@get:Rule
val koinTestRule = KoinTestRule.create {
modules(apiModule, module {
single {
OkHttpClient.Builder()
.callTimeout(1, TimeUnit.MINUTES)
.readTimeout(1, TimeUnit.HOURS)
.addInterceptor(get<AuthInterceptor>())
.build()
}
})
}
@Test
fun loginSuccessfulTest() = runTest {
val stream = TestUtils.loadResource("services/fever/successful_auth.json")
mockServer.enqueueOKStream(stream)
assertTrue { dataSource.login("", "") }
}
@Test
fun loginFailedTest() = runTest {
val stream = TestUtils.loadResource("services/fever/failed_auth.json")
mockServer.enqueueOKStream(stream)
assertFalse { dataSource.login("", "") }
}
@Test
fun setItemStateTest() = runTest {
mockServer.enqueueOK()
dataSource.setItemState("login", "password", "saved", "itemId")
val request = mockServer.takeRequest()
val requestBody = request.body.readUtf8()
assertEquals("saved", request.requestUrl?.queryParameter("as"))
assertEquals("itemId", request.requestUrl?.queryParameter("id"))
assertTrue { requestBody.contains("api_key") }
assertTrue { requestBody.contains("fb2f5a9b0eccc1ee95c1d559a2dd797a") }
}
@Test
fun initialSyncTest() = runTest {
var pageNumber = 0
var firstMaxId = ""
var secondMaxId = ""
var thirdMaxId = ""
mockServer.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
with(request.path!!) {
return when {
this == "/?feeds" -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/feeds.json"))
}
this == "/?groups" -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/folders.json"))
}
this == "/?favicons" -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/favicons.json"))
}
this == "/?unread_item_ids" -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/itemsIds.json"))
}
this == "/?saved_item_ids" -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/itemsIds.json"))
}
contains("/?items") -> {
when (pageNumber++) {
0 -> {
firstMaxId = request.requestUrl?.queryParameter("max_id").orEmpty()
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/items_page2.json"))
}
1 -> {
secondMaxId = request.requestUrl?.queryParameter("max_id").orEmpty()
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/items_page1.json"))
}
2 -> {
thirdMaxId = request.requestUrl?.queryParameter("max_id").orEmpty()
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/empty_items.json"))
}
else -> MockResponse().setResponseCode(404)
}
}
else -> MockResponse().setResponseCode(404)
}
}
}
}
val result = dataSource.synchronize("login", "password", SyncType.INITIAL_SYNC, "")
assertEquals(1, result.folders.size)
assertEquals(1, result.feverFeeds.feeds.size)
assertEquals(3, result.favicons.size)
assertEquals(6, result.unreadIds.size)
assertEquals(6, result.starredIds.size)
assertEquals(10, result.items.size)
assertEquals(10, result.items.size)
assertEquals(1564058340320135, result.sinceId)
assertEquals("1564058340320135", firstMaxId)
assertEquals("6", secondMaxId)
assertEquals("1", thirdMaxId)
}
@Test
fun classicSyncTest() = runTest {
var pageNumber = 0
var firstLastSinceId = ""
var secondLastSinceId = ""
var thirdLastSinceId = ""
mockServer.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
with(request.path!!) {
return when {
this == "/?feeds" -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/feeds.json"))
}
this == "/?groups" -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/folders.json"))
}
this == "/?favicons" -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/favicons.json"))
}
this == "/?unread_item_ids" -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/itemsIds.json"))
}
this == "/?saved_item_ids" -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/itemsIds.json"))
}
contains("/?items") -> {
when (pageNumber++) {
0 -> {
firstLastSinceId = request.requestUrl?.queryParameter("since_id").orEmpty()
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/items_page1.json"))
}
1 -> {
secondLastSinceId = request.requestUrl?.queryParameter("since_id").orEmpty()
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/items_page2.json"))
}
2 -> {
thirdLastSinceId = request.requestUrl?.queryParameter("since_id").orEmpty()
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/empty_items.json"))
}
else -> MockResponse().setResponseCode(404)
}
}
else -> MockResponse().setResponseCode(404)
}
}
}
}
val result = dataSource.synchronize("login", "password", SyncType.CLASSIC_SYNC, "1")
assertEquals(1, result.folders.size)
assertEquals(1, result.feverFeeds.feeds.size)
assertEquals(3, result.favicons.size)
assertEquals(6, result.unreadIds.size)
assertEquals(6, result.starredIds.size)
assertEquals(10, result.items.size)
assertEquals(10, result.sinceId)
assertEquals("1", firstLastSinceId)
assertEquals("5", secondLastSinceId)
assertEquals("10", thirdLastSinceId)
mockServer.dispatcher.shutdown()
}
}

View File

@ -0,0 +1,30 @@
package com.readrops.api.services.fever.adapters
import com.readrops.api.TestUtils
import com.squareup.moshi.Moshi
import okio.Buffer
import org.junit.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class FeverAPIAdapterTest {
private val adapter = Moshi.Builder()
.add(Boolean::class.java, FeverAPIAdapter())
.build()
.adapter(Boolean::class.java)
@Test
fun authenticatedTest() {
val stream = TestUtils.loadResource("services/fever/successful_auth.json")
assertTrue { adapter.fromJson(Buffer().readFrom(stream))!! }
}
@Test
fun unauthenticatedTest() {
val stream = TestUtils.loadResource("services/fever/unsuccessful_auth.json")
assertFalse { adapter.fromJson(Buffer().readFrom(stream))!! }
}
}

View File

@ -0,0 +1,32 @@
package com.readrops.api.services.fever.adapters
import com.readrops.api.TestUtils
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import okio.Buffer
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
class FeverFaviconsAdapterTest {
private val adapter = Moshi.Builder()
.add(FeverFaviconsAdapter())
.build()
.adapter<List<Favicon>>(Types.newParameterizedType(List::class.java, Favicon::class.java))
@Test
fun validFaviconsTest() {
val stream = TestUtils.loadResource("services/fever/favicons.json")
val favicons = adapter.fromJson(Buffer().readFrom(stream))!!
assertEquals(favicons.size, 3)
with(favicons.first()) {
assertEquals(id, 85)
assertNotNull(data)
}
}
}

View File

@ -0,0 +1,41 @@
package com.readrops.api.services.fever.adapters
import com.readrops.api.TestUtils
import com.squareup.moshi.Moshi
import okio.Buffer
import org.junit.Test
import kotlin.test.assertEquals
class FeverFeedsAdapterTest {
val adapter = Moshi.Builder()
.add(FeverFeeds::class.java, FeverFeedsAdapter())
.build()
.adapter(FeverFeeds::class.java)!!
@Test
fun validFeedsTest() {
val stream = TestUtils.loadResource("services/fever/feeds.json")
val feverFeeds = adapter.fromJson(Buffer().readFrom(stream))!!
assertEquals(feverFeeds.feeds.size, 1)
with(feverFeeds.feeds.first()) {
assertEquals(name, "xda-developers")
assertEquals(url, "https://www.xda-developers.com/feed/")
assertEquals(siteUrl, "https://www.xda-developers.com/")
assertEquals(remoteId, "32")
}
with(feverFeeds.feedsGroups.entries.first()) {
assertEquals(key, 3)
assertEquals(value, listOf(5, 4))
}
with(feverFeeds.favicons.entries.first()) {
assertEquals(30, key)
assertEquals("32", value)
}
}
}

View File

@ -0,0 +1,31 @@
package com.readrops.api.services.fever.adapters
import com.readrops.api.TestUtils
import com.readrops.db.entities.Folder
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import okio.Buffer
import org.junit.Test
import kotlin.test.assertEquals
class FeverFoldersAdapterTest {
private val adapter = Moshi.Builder()
.add(FeverFoldersAdapter())
.build()
.adapter<List<Folder>>(Types.newParameterizedType(List::class.java, Folder::class.java))
@Test
fun validFoldersTest() {
val stream = TestUtils.loadResource("services/fever/folders.json")
val folders = adapter.fromJson(Buffer().readFrom(stream))!!
with(folders.first()) {
assertEquals(name, "Libre")
assertEquals(remoteId, "4")
}
}
}

View File

@ -0,0 +1,38 @@
package com.readrops.api.services.fever.adapters
import com.readrops.api.TestUtils
import com.readrops.db.entities.Item
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import okio.Buffer
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class FeverItemsAdapterTest {
private val adapter = Moshi.Builder()
.add(FeverItemsAdapter())
.build()
.adapter<List<Item>>(Types.newParameterizedType(List::class.java, Item::class.java))
@Test
fun validItemsTest() {
val stream = TestUtils.loadResource("services/fever/items_page2.json")
val items = adapter.fromJson(Buffer().readFrom(stream))!!
with(items.first()) {
assertEquals(title, "FreshRSS 1.9.0")
assertEquals(author, "Alkarex")
assertEquals(link, "https://github.com/FreshRSS/FreshRSS/releases/tag/1.9.0")
assertNotNull(content)
assertTrue(isStarred)
assertTrue(isRead)
assertNotNull(pubDate)
assertEquals(remoteId, "6")
assertEquals(feedRemoteId, "2")
}
}
}

View File

@ -0,0 +1,25 @@
package com.readrops.api.services.fever.adapters
import com.readrops.api.TestUtils
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import okio.Buffer
import org.junit.Test
import kotlin.test.assertEquals
class FeverItemsIdsAdapterTest {
private val adapter = Moshi.Builder()
.add(FeverItemsIdsAdapter())
.build()
.adapter<List<String>>(Types.newParameterizedType(List::class.java, String::class.java))
@Test
fun validIdsTest() {
val stream = TestUtils.loadResource("services/fever/itemsIds.json")
val ids = adapter.fromJson(Buffer().readFrom(stream))!!
assertEquals(ids.size, 6)
}
}

View File

@ -1,260 +0,0 @@
package com.readrops.api.services.freshrss
import com.readrops.api.TestUtils
import com.readrops.api.apiModule
import kotlinx.coroutines.runBlocking
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okio.Buffer
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.koin.core.qualifier.named
import org.koin.dsl.module
import org.koin.test.KoinTest
import org.koin.test.KoinTestRule
import org.koin.test.get
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import java.net.HttpURLConnection
import java.net.URLEncoder
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class FreshRSSDataSourceTest : KoinTest {
private lateinit var freshRSSDataSource: NewFreshRSSDataSource
private val mockServer = MockWebServer()
@get:Rule
val koinTestRule = KoinTestRule.create {
modules(apiModule, module {
single {
Retrofit.Builder()
.baseUrl("http://localhost:8080/")
.client(get())
.addConverterFactory(MoshiConverterFactory.create(get(named("freshrssMoshi"))))
.build()
.create(NewFreshRSSService::class.java)
}
})
}
@Before
fun before() {
mockServer.start(8080)
freshRSSDataSource = NewFreshRSSDataSource(get())
}
@After
fun tearDown() {
mockServer.shutdown()
}
@Test
fun loginTest() {
runBlocking {
val responseBody = TestUtils.loadResource("services/freshrss/login_response_body")
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setBody(Buffer().readFrom(responseBody)))
val authString = freshRSSDataSource.login("Login", "Password")
assertEquals("login/p1f8vmzid4hzzxf31mgx50gt8pnremgp4z8xe44a", authString)
val request = mockServer.takeRequest()
val requestBody = request.body.readUtf8()
assertTrue {
requestBody.contains("name=\"Email\"") && requestBody.contains("Login")
}
assertTrue {
requestBody.contains("name=\"Passwd\"") && requestBody.contains("Password")
}
}
}
@Test
fun writeTokenTest() = runBlocking {
val responseBody = TestUtils.loadResource("services/freshrss/writetoken_response_body")
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setBody(Buffer().readFrom(responseBody)))
val writeToken = freshRSSDataSource.getWriteToken()
assertEquals("PMvYZHrnC57cyPLzxFvQmJEGN6KvNmkHCmHQPKG5eznWMXriq13H1nQZg", writeToken)
}
@Test
fun userInfoTest() = runBlocking {
}
@Test
fun foldersTest() = runBlocking {
val stream = TestUtils.loadResource("services/freshrss/adapters/folders.json")
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setBody(Buffer().readFrom(stream)))
val folders = freshRSSDataSource.getFolders()
assertTrue { folders.size == 1 }
}
@Test
fun feedsTest() = runBlocking {
val stream = TestUtils.loadResource("services/freshrss/adapters/feeds.json")
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setBody(Buffer().readFrom(stream)))
val feeds = freshRSSDataSource.getFeeds()
assertTrue { feeds.size == 1 }
}
@Test
fun itemsTest() = runBlocking {
val stream = TestUtils.loadResource("services/freshrss/adapters/items.json")
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setBody(Buffer().readFrom(stream)))
val items = freshRSSDataSource.getItems(listOf(NewFreshRSSDataSource.GOOGLE_READ, NewFreshRSSDataSource.GOOGLE_STARRED), 100, 21343321321321)
assertTrue { items.size == 2 }
val request = mockServer.takeRequest()
with(request.requestUrl!!) {
assertEquals(listOf(NewFreshRSSDataSource.GOOGLE_READ, NewFreshRSSDataSource.GOOGLE_STARRED), queryParameterValues("xt"))
assertEquals("100", queryParameter("n"))
assertEquals("21343321321321", queryParameter("ot"))
}
}
@Test
fun starredItemsTest() = runBlocking {
val stream = TestUtils.loadResource("services/freshrss/adapters/items.json")
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setBody(Buffer().readFrom(stream)))
val items = freshRSSDataSource.getStarredItems(100)
assertTrue { items.size == 2 }
val request = mockServer.takeRequest()
assertEquals("100", request.requestUrl!!.queryParameter("n"))
}
@Test
fun getItemsIdsTest() = runBlocking {
val stream = TestUtils.loadResource("services/freshrss/adapters/items_starred_ids.json")
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setBody(Buffer().readFrom(stream)))
val ids = freshRSSDataSource.getItemsIds(NewFreshRSSDataSource.GOOGLE_READ, NewFreshRSSDataSource.GOOGLE_READING_LIST, 100)
assertTrue { ids.size == 5 }
val request = mockServer.takeRequest()
with(request.requestUrl!!) {
assertEquals(NewFreshRSSDataSource.GOOGLE_READ, queryParameter("xt"))
assertEquals(NewFreshRSSDataSource.GOOGLE_READING_LIST, queryParameter("s"))
assertEquals("100", queryParameter("n"))
}
}
@Test
fun createFeedTest() = runBlocking {
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK))
freshRSSDataSource.createFeed("token", "https://feed.url")
val request = mockServer.takeRequest()
with(request.body.readUtf8()) {
assertTrue { contains("T=token") }
assertTrue { contains("s=${URLEncoder.encode("${NewFreshRSSDataSource.FEED_PREFIX}https://feed.url", "UTF-8")}") }
assertTrue { contains("ac=subscribe") }
}
}
@Test
fun deleteFeedTest() = runBlocking {
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK))
freshRSSDataSource.deleteFeed("token", "https://feed.url")
val request = mockServer.takeRequest()
with(request.body.readUtf8()) {
assertTrue { contains("T=token") }
assertTrue { contains("s=${URLEncoder.encode("${NewFreshRSSDataSource.FEED_PREFIX}https://feed.url", "UTF-8")}") }
assertTrue { contains("ac=unsubscribe") }
}
}
@Test
fun updateFeedTest() = runBlocking {
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK))
freshRSSDataSource.updateFeed("token", "https://feed.url", "title", "folderId")
val request = mockServer.takeRequest()
with(request.body.readUtf8()) {
assertTrue { contains("T=token") }
assertTrue { contains("s=${URLEncoder.encode("${NewFreshRSSDataSource.FEED_PREFIX}https://feed.url", "UTF-8")}") }
assertTrue { contains("t=title") }
assertTrue { contains("a=folderId") }
assertTrue { contains("ac=edit") }
}
}
@Test
fun createFolderTest() = runBlocking {
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK))
freshRSSDataSource.createFolder("token", "folder")
val request = mockServer.takeRequest()
with(request.body.readUtf8()) {
assertTrue { contains("T=token") }
assertTrue { contains("a=${URLEncoder.encode("${NewFreshRSSDataSource.FOLDER_PREFIX}folder", "UTF-8")}") }
}
}
@Test
fun updateFolderTest() = runBlocking {
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK))
freshRSSDataSource.updateFolder("token", "folderId", "folder")
val request = mockServer.takeRequest()
with(request.body.readUtf8()) {
assertTrue { contains("T=token") }
assertTrue { contains("s=folderId") }
assertTrue { contains("dest=${URLEncoder.encode("${NewFreshRSSDataSource.FOLDER_PREFIX}folder", "UTF-8")}") }
}
}
@Test
fun deleteFolderTest() = runBlocking {
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK))
freshRSSDataSource.deleteFolder("token", "folderId")
val request = mockServer.takeRequest()
with(request.body.readUtf8()) {
assertTrue { contains("T=token") }
assertTrue { contains("s=folderId") }
}
}
}

View File

@ -0,0 +1,421 @@
package com.readrops.api.services.greader
import com.readrops.api.TestUtils
import com.readrops.api.apiModule
import com.readrops.api.enqueueOK
import com.readrops.api.enqueueOKStream
import com.readrops.api.okResponseWithBody
import com.readrops.api.services.SyncType
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.koin.core.qualifier.named
import org.koin.dsl.module
import org.koin.test.KoinTest
import org.koin.test.KoinTestRule
import org.koin.test.get
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import java.net.URLEncoder
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class GReaderDataSourceTest : KoinTest {
private lateinit var freshRSSDataSource: GReaderDataSource
private val mockServer = MockWebServer()
@get:Rule
val koinTestRule = KoinTestRule.create {
modules(apiModule, module {
single {
Retrofit.Builder()
.baseUrl("http://localhost:8080/")
.client(get())
.addConverterFactory(MoshiConverterFactory.create(get(named("greaderMoshi"))))
.build()
.create(GReaderService::class.java)
}
})
}
@Before
fun before() {
mockServer.start(8080)
freshRSSDataSource = GReaderDataSource(get())
}
@After
fun tearDown() {
mockServer.shutdown()
}
@Test
fun loginTest() = runTest {
val responseBody = TestUtils.loadResource("services/greader/login_response_body")
mockServer.enqueueOKStream(responseBody)
val authString = freshRSSDataSource.login("Login", "Password")
assertEquals("login/p1f8vmzid4hzzxf31mgx50gt8pnremgp4z8xe44a", authString)
val request = mockServer.takeRequest()
val requestBody = request.body.readUtf8()
assertTrue {
requestBody.contains("name=\"Email\"") && requestBody.contains("Login")
}
assertTrue {
requestBody.contains("name=\"Passwd\"") && requestBody.contains("Password")
}
}
@Test
fun writeTokenTest() = runTest {
val responseBody = TestUtils.loadResource("services/greader/writetoken_response_body")
mockServer.enqueueOKStream(responseBody)
val writeToken = freshRSSDataSource.getWriteToken()
assertEquals("PMvYZHrnC57cyPLzxFvQmJEGN6KvNmkHCmHQPKG5eznWMXriq13H1nQZg", writeToken)
}
@Test
fun userInfoTest() = runTest {
val responseBody = TestUtils.loadResource("services/greader/adapters/user_info.json")
mockServer.enqueueOKStream(responseBody)
val userInfo = freshRSSDataSource.getUserInfo()
assertEquals("test", userInfo.userName)
}
@Test
fun foldersTest() = runTest {
val stream = TestUtils.loadResource("services/greader/adapters/folders.json")
mockServer.enqueueOKStream(stream)
val folders = freshRSSDataSource.getFolders()
assertTrue { folders.size == 1 }
}
@Test
fun feedsTest() = runTest {
val stream = TestUtils.loadResource("services/greader/adapters/feeds.json")
mockServer.enqueueOKStream(stream)
val feeds = freshRSSDataSource.getFeeds()
assertTrue { feeds.size == 1 }
}
@Test
fun itemsTest() = runTest {
val stream = TestUtils.loadResource("services/greader/adapters/items.json")
mockServer.enqueueOKStream(stream)
val items = freshRSSDataSource.getItems(
excludeTargets = listOf(
GReaderDataSource.GOOGLE_READ,
GReaderDataSource.GOOGLE_STARRED
),
max = 100,
lastModified = 21343321321321
)
assertTrue { items.size == 2 }
val request = mockServer.takeRequest()
with(request.requestUrl!!) {
assertEquals(
listOf(GReaderDataSource.GOOGLE_READ, GReaderDataSource.GOOGLE_STARRED),
queryParameterValues("xt")
)
assertEquals("100", queryParameter("n"))
assertEquals("21343321321321", queryParameter("ot"))
}
}
@Test
fun starredItemsTest() = runTest {
val stream = TestUtils.loadResource("services/greader/adapters/items.json")
mockServer.enqueueOKStream(stream)
val items = freshRSSDataSource.getStarredItems(100)
assertTrue { items.size == 2 }
val request = mockServer.takeRequest()
assertEquals("100", request.requestUrl!!.queryParameter("n"))
}
@Test
fun getItemsIdsTest() = runTest {
val stream = TestUtils.loadResource("services/greader/adapters/items_starred_ids.json")
mockServer.enqueueOKStream(stream)
val ids = freshRSSDataSource.getItemsIds(
excludeTarget = GReaderDataSource.GOOGLE_READ,
includeTarget = GReaderDataSource.GOOGLE_READING_LIST,
max = 100
)
assertTrue { ids.size == 5 }
val request = mockServer.takeRequest()
with(request.requestUrl!!) {
assertEquals(GReaderDataSource.GOOGLE_READ, queryParameter("xt"))
assertEquals(GReaderDataSource.GOOGLE_READING_LIST, queryParameter("s"))
assertEquals("100", queryParameter("n"))
}
}
@Test
fun createFeedTest() = runTest {
mockServer.enqueueOK()
freshRSSDataSource.createFeed("token", "https://feed.url", "feed/1")
val request = mockServer.takeRequest()
with(request.body.readUtf8()) {
assertTrue { contains("T=token") }
assertTrue { contains("a=feed%2F1") }
assertTrue {
contains(
"s=${
URLEncoder.encode(
"${GReaderDataSource.FEED_PREFIX}https://feed.url", "UTF-8"
)
}"
)
}
assertTrue { contains("ac=subscribe") }
}
}
@Test
fun deleteFeedTest() = runTest {
mockServer.enqueueOK()
freshRSSDataSource.deleteFeed("token", "https://feed.url")
val request = mockServer.takeRequest()
with(request.body.readUtf8()) {
assertTrue { contains("T=token") }
assertTrue {
contains(
"s=${
URLEncoder.encode(
"${GReaderDataSource.FEED_PREFIX}https://feed.url",
"UTF-8"
)
}"
)
}
assertTrue { contains("ac=unsubscribe") }
}
}
@Test
fun updateFeedTest() = runTest {
mockServer.enqueueOK()
freshRSSDataSource.updateFeed("token", "https://feed.url", "title", "folderId")
val request = mockServer.takeRequest()
with(request.body.readUtf8()) {
assertTrue { contains("T=token") }
assertTrue {
contains(
"s=${
URLEncoder.encode(
"${GReaderDataSource.FEED_PREFIX}https://feed.url",
"UTF-8"
)
}"
)
}
assertTrue { contains("t=title") }
assertTrue { contains("a=folderId") }
assertTrue { contains("ac=edit") }
}
}
@Test
fun createFolderTest() = runTest {
mockServer.enqueueOK()
freshRSSDataSource.createFolder("token", "folder")
val request = mockServer.takeRequest()
with(request.body.readUtf8()) {
assertTrue { contains("T=token") }
assertTrue {
contains(
"a=${
URLEncoder.encode(
"${GReaderDataSource.FOLDER_PREFIX}folder",
"UTF-8"
)
}"
)
}
}
}
@Test
fun updateFolderTest() = runTest {
mockServer.enqueueOK()
freshRSSDataSource.updateFolder("token", "folderId", "folder")
val request = mockServer.takeRequest()
with(request.body.readUtf8()) {
assertTrue { contains("T=token") }
assertTrue { contains("s=folderId") }
assertTrue {
contains(
"dest=${
URLEncoder.encode(
"${GReaderDataSource.FOLDER_PREFIX}folder",
"UTF-8"
)
}"
)
}
}
}
@Test
fun deleteFolderTest() = runTest {
mockServer.enqueueOK()
freshRSSDataSource.deleteFolder("token", "folderId")
val request = mockServer.takeRequest()
with(request.body.readUtf8()) {
assertTrue { contains("T=token") }
assertTrue { contains("s=folderId") }
}
}
@Test
fun initialSyncTest() = runTest {
mockServer.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
with(request.path!!) {
return when {
contains("tag/list") -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/greader/adapters/folders.json"))
}
contains("subscription/list") -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/greader/adapters/feeds.json"))
}
// items
contains("contents/user/-/state/com.google/reading-list") -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/greader/adapters/items.json"))
}
// starred items
contains("contents/user/-/state/com.google/starred") -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/greader/adapters/items.json"))
}
// unread ids & starred ids
contains("stream/items/ids") -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/greader/adapters/items_starred_ids.json"))
}
else -> MockResponse().setResponseCode(404)
}
}
}
}
val result =
freshRSSDataSource.synchronize(SyncType.INITIAL_SYNC, GReaderSyncData(), "writeToken")
with(result) {
assertEquals(1, folders.size)
assertEquals(1, feeds.size)
assertEquals(2, items.size)
assertEquals(2, starredItems.size)
assertEquals(5, unreadIds.size)
assertEquals(5, starredIds.size)
}
}
@Test
fun classicSync() = runTest {
var setItemState = 0
val ids = listOf("1", "2", "3", "4")
val lastModified = 10L
mockServer.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
with(request.path!!) {
// printing request path before anything prevents a request being ignored and the test fail, I don't really know why
println("request: ${request.path}")
return when {
contains("0/edit-tag") -> {
setItemState++
MockResponse().setResponseCode(200)
}
contains("tag/list") -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/greader/adapters/folders.json"))
}
contains("subscription/list") -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/greader/adapters/feeds.json"))
}
// items
contains("contents/user/-/state/com.google/reading-list") -> {
assertTrue { request.path!!.contains("ot=$lastModified") }
MockResponse.okResponseWithBody(TestUtils.loadResource("services/greader/adapters/items.json"))
}
// unread & read ids
contains("stream/items/ids") -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/greader/adapters/items_starred_ids.json"))
}
else -> MockResponse().setResponseCode(404)
}
}
}
}
val result = freshRSSDataSource.synchronize(
syncType = SyncType.CLASSIC_SYNC,
syncData = GReaderSyncData(
lastModified = 10L,
readIds = ids,
unreadIds = ids,
starredIds = ids,
unstarredIds = ids
),
writeToken = "writeToken"
)
with(result) {
assertEquals(4, setItemState)
assertEquals(1, folders.size)
assertEquals(1, feeds.size)
assertEquals(2, items.size)
assertEquals(5, unreadIds.size)
assertEquals(5, readIds.size)
assertEquals(5, starredIds.size)
}
}
}

View File

@ -1,4 +1,4 @@
package com.readrops.api.services.freshrss.adapters
package com.readrops.api.services.greader.adapters
import com.readrops.api.TestUtils
import com.readrops.db.entities.Feed
@ -8,18 +8,18 @@ import junit.framework.TestCase.assertEquals
import okio.Buffer
import org.junit.Test
class FreshRSSFeedsAdapterTest {
class GReaderFeedsAdapterTest {
private val adapter = Moshi.Builder()
.add(FreshRSSFeedsAdapter())
.add(GReaderFeedsAdapter())
.build()
.adapter<List<Feed>>(Types.newParameterizedType(List::class.java, Feed::class.java))
@Test
fun validFeedsTest() {
val stream = TestUtils.loadResource("services/freshrss/adapters/feeds.json")
val stream = TestUtils.loadResource("services/greader/adapters/feeds.json")
val feed = adapter.fromJson(Buffer().readFrom(stream))!![0]
val feed = adapter.fromJson(Buffer().readFrom(stream))!!.first()
with(feed) {
assertEquals(remoteId, "feed/2")

View File

@ -1,4 +1,4 @@
package com.readrops.api.services.freshrss.adapters
package com.readrops.api.services.greader.adapters
import com.readrops.api.TestUtils
import com.readrops.db.entities.Folder
@ -8,22 +8,22 @@ import junit.framework.TestCase.assertEquals
import okio.Buffer
import org.junit.Test
class FreshRSSFoldersAdapterTest {
class GReaderFoldersAdapterTest {
private val adapter = Moshi.Builder()
.add(FreshRSSFoldersAdapter())
.add(GReaderFoldersAdapter())
.build()
.adapter<List<Folder>>(Types.newParameterizedType(List::class.java, Folder::class.java))
@Test
fun validFoldersTest() {
val stream = TestUtils.loadResource("services/freshrss/adapters/folders.json")
val stream = TestUtils.loadResource("services/greader/adapters/folders.json")
val folders = adapter.fromJson(Buffer().readFrom(stream))!!
assertEquals(folders.size, 1)
with(folders[0]) {
with(folders.first()) {
assertEquals(name, "Blogs")
assertEquals(remoteId, "user/-/label/Blogs")
}

View File

@ -1,35 +1,35 @@
package com.readrops.api.services.freshrss.adapters
package com.readrops.api.services.greader.adapters
import com.readrops.api.TestUtils
import com.readrops.db.entities.Item
import com.readrops.db.util.DateUtils
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertNotNull
import okio.Buffer
import org.joda.time.LocalDateTime
import org.junit.Test
class FreshRSSItemsAdapterTest {
class GReaderItemsAdapterTest {
private val adapter = Moshi.Builder()
.add(Types.newParameterizedType(List::class.java, Item::class.java), FreshRSSItemsAdapter())
.add(Types.newParameterizedType(List::class.java, Item::class.java), GReaderItemsAdapter())
.build()
.adapter<List<Item>>(Types.newParameterizedType(List::class.java, Item::class.java))
@Test
fun validItemsTest() {
val stream = TestUtils.loadResource("services/freshrss/adapters/items.json")
val stream = TestUtils.loadResource("services/greader/adapters/items.json")
val items = adapter.fromJson(Buffer().readFrom(stream))!!
with(items[0]) {
with(items.first()) {
assertEquals(remoteId, "tag:google.com,2005:reader/item/0005c62466ee28fe")
assertEquals(title, "GNOMEs Default Theme is Getting a Revamp")
assertNotNull(content)
assertEquals(link, "http://feedproxy.google.com/~r/d0od/~3/4Zk-fncSuek/adwaita-borderless-theme-in-development-gnome-41")
assertEquals(author, "Joey Sneddon")
assertEquals(pubDate, LocalDateTime(1625234040 * 1000L))
assertEquals(pubDate, DateUtils.fromEpochSeconds(1625234040))
assertEquals(isRead, false)
assertEquals(isStarred, false)
}

View File

@ -1,4 +1,4 @@
package com.readrops.api.services.freshrss.adapters
package com.readrops.api.services.greader.adapters
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
@ -6,16 +6,16 @@ import junit.framework.TestCase.assertEquals
import okio.Buffer
import org.junit.Test
class FreshRSSItemsIdsAdapterTest {
class GReaderItemsIdsAdapterTest {
private val adapter = Moshi.Builder()
.add(Types.newParameterizedType(List::class.java, String::class.java), FreshRSSItemsIdsAdapter())
.add(Types.newParameterizedType(List::class.java, String::class.java), GReaderItemsIdsAdapter())
.build()
.adapter<List<String>>(Types.newParameterizedType(List::class.java, String::class.java))
@Test
fun validIdsTest() {
val stream = javaClass.classLoader!!.getResourceAsStream("services/freshrss/adapters/items_starred_ids.json")
val stream = javaClass.classLoader!!.getResourceAsStream("services/greader/adapters/items_starred_ids.json")
val ids = adapter.fromJson(Buffer().readFrom(stream))!!

View File

@ -1,4 +1,4 @@
package com.readrops.api.services.freshrss.adapters
package com.readrops.api.services.greader.adapters
import com.readrops.api.TestUtils
import com.squareup.moshi.Moshi
@ -6,7 +6,7 @@ import junit.framework.TestCase.assertEquals
import okio.Buffer
import org.junit.Test
class FreshRSSUserInfoAdapterTest {
class GReaderUserInfoAdapterTest {
private val adapter = Moshi.Builder()
.add(FreshRSSUserInfoAdapter())
@ -15,7 +15,7 @@ class FreshRSSUserInfoAdapterTest {
@Test
fun userInfoTest() {
val stream = TestUtils.loadResource("services/freshrss/adapters/user_info.json")
val stream = TestUtils.loadResource("services/greader/adapters/user_info.json")
val userInfo = adapter.fromJson(Buffer().readFrom(stream))!!

View File

@ -0,0 +1,389 @@
package com.readrops.api.services.nextcloudnews
import com.readrops.api.TestUtils
import com.readrops.api.apiModule
import com.readrops.api.enqueueOK
import com.readrops.api.enqueueOKStream
import com.readrops.api.okResponseWithBody
import com.readrops.api.services.SyncType
import com.readrops.db.entities.account.Account
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.koin.core.qualifier.named
import org.koin.dsl.module
import org.koin.test.KoinTest
import org.koin.test.KoinTestRule
import org.koin.test.get
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class NextcloudNewsDataSourceTest : KoinTest {
private lateinit var nextcloudNewsDataSource: NextcloudNewsDataSource
private val mockServer = MockWebServer()
private val moshi = Moshi.Builder()
.build()
@get:Rule
val koinTestRule = KoinTestRule.create {
modules(apiModule, module {
single {
Retrofit.Builder()
.baseUrl("http://localhost:8080/")
.client(get())
.addConverterFactory(MoshiConverterFactory.create(get(named("nextcloudNewsMoshi"))))
.build()
.create(NextcloudNewsService::class.java)
}
})
}
@Before
fun before() {
mockServer.start(8080)
nextcloudNewsDataSource = NextcloudNewsDataSource(get())
}
@After
fun tearDown() {
mockServer.shutdown()
}
@Test
fun loginTest() = runTest {
val stream = TestUtils.loadResource("services/nextcloudnews/user.xml")
val account = Account(login = "login", url = mockServer.url("").toString())
mockServer.enqueueOKStream(stream)
val displayName = nextcloudNewsDataSource.login(get(), account)
val request = mockServer.takeRequest()
assertTrue { displayName == "Shinokuni" }
assertTrue { request.headers.contains("OCS-APIRequest" to "true") }
assertTrue { request.path == "//ocs/v1.php/cloud/users/login" }
}
@Test
fun foldersTest() = runTest {
val stream = TestUtils.loadResource("services/nextcloudnews/adapters/valid_folder.json")
mockServer.enqueueOKStream(stream)
val folders = nextcloudNewsDataSource.getFolders()
assertTrue { folders.size == 1 }
}
@Test
fun feedsTest() = runTest {
val stream = TestUtils.loadResource("services/nextcloudnews/adapters/feeds.json")
mockServer.enqueueOKStream(stream)
val feeds = nextcloudNewsDataSource.getFeeds()
assertTrue { feeds.size == 3 }
}
@Test
fun itemsTest() = runTest {
val stream = TestUtils.loadResource("services/nextcloudnews/adapters/items.json")
mockServer.enqueueOKStream(stream)
val type = NextcloudNewsDataSource.ItemQueryType.ALL.value
val items = nextcloudNewsDataSource.getItems(
type = type,
read = false,
batchSize = 10
)
val request = mockServer.takeRequest()
assertTrue { items.size == 2 }
with(request.requestUrl!!) {
assertEquals("$type", queryParameter("type"))
assertEquals("false", queryParameter("getRead"))
assertEquals("10", queryParameter("batchSize"))
}
}
@Test
fun newItemsTest() = runTest {
val stream = TestUtils.loadResource("services/nextcloudnews/adapters/items.json")
mockServer.enqueueOKStream(stream)
val items =
nextcloudNewsDataSource.getNewItems(1512, NextcloudNewsDataSource.ItemQueryType.ALL)
val request = mockServer.takeRequest()
assertTrue { items.size == 2 }
with(request.requestUrl!!) {
assertEquals("1512", queryParameter("lastModified"))
assertEquals("3", queryParameter("type"))
}
}
@Test
fun createFeedTest() = runTest {
val stream = TestUtils.loadResource("services/nextcloudnews/adapters/feeds.json")
mockServer.enqueueOKStream(stream)
val feeds = nextcloudNewsDataSource.createFeed("https://news.ycombinator.com/rss", 100)
val request = mockServer.takeRequest()
assertTrue { feeds.isNotEmpty() }
with(request.requestUrl!!) {
assertEquals("https://news.ycombinator.com/rss", queryParameter("url"))
assertEquals("100", queryParameter("folderId"))
}
}
@Test
fun deleteFeedTest() = runTest {
mockServer.enqueueOK()
nextcloudNewsDataSource.deleteFeed(15)
val request = mockServer.takeRequest()
assertTrue { request.path!!.endsWith("/15") }
}
@Test
fun changeFeedFolderTest() = runTest {
mockServer.enqueueOK()
nextcloudNewsDataSource.changeFeedFolder(15, 18)
val request = mockServer.takeRequest()
val type = Types.newParameterizedType(
Map::class.java,
String::class.java,
Int::class.javaObjectType
)
val adapter = moshi.adapter<Map<String, Int>>(type)
val body = adapter.fromJson(request.body)!!
assertTrue { request.path!!.endsWith("/18/move") }
assertEquals(15, body["folderId"])
}
@Test
fun renameFeedTest() = runTest {
mockServer.enqueueOK()
nextcloudNewsDataSource.renameFeed("name", 15)
val request = mockServer.takeRequest()
val type =
Types.newParameterizedType(Map::class.java, String::class.java, String::class.java)
val adapter = moshi.adapter<Map<String, String>>(type)
val body = adapter.fromJson(request.body)!!
assertTrue { request.path!!.endsWith("/15/rename") }
assertEquals("name", body["feedTitle"])
}
@Test
fun createFolderTest() = runTest {
val stream = TestUtils.loadResource("services/nextcloudnews/adapters/valid_folder.json")
mockServer.enqueueOKStream(stream)
val folders = nextcloudNewsDataSource.createFolder("folder name")
val request = mockServer.takeRequest()
val type =
Types.newParameterizedType(Map::class.java, String::class.java, String::class.java)
val adapter = moshi.adapter<Map<String, String>>(type)
val body = adapter.fromJson(request.body)!!
assertTrue { folders.size == 1 }
assertEquals("folder name", body["name"])
}
@Test
fun renameFolderTest() = runTest {
mockServer.enqueueOK()
nextcloudNewsDataSource.renameFolder("new name", 15)
val request = mockServer.takeRequest()
val type =
Types.newParameterizedType(Map::class.java, String::class.java, String::class.java)
val adapter = moshi.adapter<Map<String, String>>(type)
val body = adapter.fromJson(request.body)!!
assertTrue { request.path!!.endsWith("/15") }
assertEquals("new name", body["name"])
}
@Test
fun deleteFolderTest() = runTest {
mockServer.enqueueOK()
nextcloudNewsDataSource.deleteFolder(15)
val request = mockServer.takeRequest()
assertEquals(request.method, "DELETE")
assertTrue { request.path!!.endsWith("/15") }
}
@Test
fun setItemsReadStateTest() = runTest {
mockServer.enqueueOK()
mockServer.enqueueOK()
val data = NextcloudNewsSyncData(
readIds = listOf(15, 16, 17),
unreadIds = listOf(18, 19, 20)
)
nextcloudNewsDataSource.setItemsReadState(data)
val unreadRequest = mockServer.takeRequest()
val readRequest = mockServer.takeRequest()
val type =
Types.newParameterizedType(
Map::class.java,
String::class.java,
Types.newParameterizedType(List::class.java, Int::class.javaObjectType)
)
val adapter = moshi.adapter<Map<String, List<Int>>>(type)
val unreadBody = adapter.fromJson(unreadRequest.body)!!
val readBody = adapter.fromJson(readRequest.body)!!
assertEquals(data.readIds, readBody["itemIds"])
assertEquals(data.unreadIds, unreadBody["itemIds"])
}
@Test
fun setItemsStarStateTest() = runTest {
mockServer.enqueueOK()
mockServer.enqueueOK()
val data = NextcloudNewsSyncData(
starredIds = listOf(15, 16, 17),
unstarredIds = listOf(18, 19, 20)
)
nextcloudNewsDataSource.setItemsStarState(data)
val starRequest = mockServer.takeRequest()
val unstarRequest = mockServer.takeRequest()
val type = Types.newParameterizedType(
Map::class.java,
String::class.java,
Types.newParameterizedType(List::class.java, Int::class.javaObjectType)
)
val adapter = moshi.adapter<Map<String, List<Int>>>(type)
val starBody = adapter.fromJson(starRequest.body)!!
val unstarBody = adapter.fromJson(unstarRequest.body)!!
assertEquals(data.starredIds, starBody["itemIds"])
assertEquals(data.unstarredIds, unstarBody["itemIds"])
}
@Test
fun initialSyncTest() = runTest {
mockServer.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
with(request.path!!) {
return when {
this == "/folders" -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/nextcloudnews/adapters/valid_folder.json"))
}
this == "/feeds" -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/nextcloudnews/adapters/feeds.json"))
}
contains("/items") -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/nextcloudnews/adapters/items.json"))
}
else -> MockResponse().setResponseCode(404)
}
}
}
}
val result =
nextcloudNewsDataSource.synchronize(SyncType.INITIAL_SYNC, NextcloudNewsSyncData())
with(result) {
assertEquals(1, folders.size)
assertEquals(3, feeds.size)
assertEquals(2, items.size)
assertEquals(2, starredItems.size)
}
}
@Test
fun classicSyncTest() = runTest {
var setItemState = 0
val lastModified = 10L
val ids = listOf(1, 2, 3, 4)
mockServer.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
with(request.path!!) {
// important, otherwise test fails and I don't know why
println("request: ${request.path}")
return when {
this == "/folders" -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/nextcloudnews/adapters/valid_folder.json"))
}
this == "/feeds" -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/nextcloudnews/adapters/feeds.json"))
}
contains("/items/updated") -> {
assertEquals(
"$lastModified",
request.requestUrl!!.queryParameter("lastModified")
)
MockResponse.okResponseWithBody(TestUtils.loadResource("services/nextcloudnews/adapters/items.json"))
}
this.matches(Regex("/items/(read|unread|star|unstar)/multiple")) -> {
setItemState++
MockResponse().setResponseCode(200)
}
else -> MockResponse().setResponseCode(404)
}
}
}
}
val result = nextcloudNewsDataSource.synchronize(
SyncType.CLASSIC_SYNC,
NextcloudNewsSyncData(
lastModified = lastModified,
readIds = ids,
unreadIds = ids,
starredIds = ids,
unstarredIds = ids
)
)
with(result) {
assertEquals(4, setItemState)
assertEquals(1, folders.size)
assertEquals(3, feeds.size)
assertEquals(2, items.size)
}
}
}

View File

@ -9,10 +9,10 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class NextNewsFeedsAdapterTest {
class NextcloudNewsFeedsAdapterTest {
private val adapter = Moshi.Builder()
.add(NextNewsFeedsAdapter())
.add(NextcloudNewsFeedsAdapter())
.build()
.adapter<List<Feed>>(Types.newParameterizedType(List::class.java, Feed::class.java))

View File

@ -10,10 +10,10 @@ import okio.Buffer
import org.junit.Assert.assertThrows
import org.junit.Test
class NextNewsFoldersAdapterTest {
class NextcloudNewsFoldersAdapterTest {
private val adapter = Moshi.Builder()
.add(NextNewsFoldersAdapter())
.add(NextcloudNewsFoldersAdapter())
.build()
.adapter<List<Folder>>(Types.newParameterizedType(List::class.java, Folder::class.java))

View File

@ -2,17 +2,17 @@ package com.readrops.api.services.nextcloudnews.adapters
import com.readrops.api.TestUtils
import com.readrops.db.entities.Item
import com.readrops.db.util.DateUtils
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import junit.framework.TestCase.assertEquals
import okio.Buffer
import org.joda.time.LocalDateTime
import org.junit.Test
class NextNewsItemsAdapterTest {
class NextcloudNewsItemsAdapterTest {
private val adapter = Moshi.Builder()
.add(Types.newParameterizedType(List::class.java, Item::class.java), NextNewsItemsAdapter())
.add(Types.newParameterizedType(List::class.java, Item::class.java), NextcloudNewsItemsAdapter())
.build()
.adapter<List<Item>>(Types.newParameterizedType(List::class.java, Item::class.java))
@ -21,11 +21,12 @@ class NextNewsItemsAdapterTest {
val stream = TestUtils.loadResource("services/nextcloudnews/adapters/items.json")
val items = adapter.fromJson(Buffer().readFrom(stream))!!
val item = items[0]
val item = items.first()
assertEquals(2, items.size)
with(item) {
assertEquals(remoteId, "3443")
assertEquals(guid, "3059047a572cd9cd5d0bf645faffd077")
assertEquals(link, "http://grulja.wordpress.com/2013/04/29/plasma-nm-after-the-solid-sprint/")
assertEquals(title, "Plasma-nm after the solid sprint")
assertEquals(author, "Jan Grulich (grulja)")
@ -33,12 +34,12 @@ class NextNewsItemsAdapterTest {
assertEquals(feedRemoteId, "67")
assertEquals(isRead, false)
assertEquals(isStarred, false)
assertEquals(pubDate, LocalDateTime(1367270544000))
assertEquals(imageLink, null)
assertEquals(pubDate, DateUtils.fromEpochSeconds(1367270544))
assertEquals(imageLink, "https://test.org/image.jpg")
}
with(items[1]) {
assertEquals(imageLink, "https://test.org/image.jpg")
assertEquals(imageLink, null)
}
}

Some files were not shown because too many files have changed in this diff Show More