diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..7cd3814 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,73 @@ +# Introduction + +### Hey you ! + +Thank you for wanting to help. Even the smallest things can help this project become better. + +Please read the guidelines before contributing, and follow them (or try to) when contributing. + +### What you can do to help. + +There are many ways to contribute to this project, you could [translate the app](https://crowdin.com/project/readerforselfoss), report bugs, request missing features, suggest enhancements and changes to existing ones. You also can improve the README with useful tips that could help the other users. + +You can fork the repository, and [help me solve some issues](https://github.com/aminecmi/ReaderforSelfoss/issues?q=is%3Aissue+is%3Aopen+label%3A%22Up+For+Grabs%22) or [develop new things](https://github.com/aminecmi/ReaderforSelfoss/issues) + +### What I can't help you with. + +Please, don't use the issue tracker for anything related to [Selfoss itself](https://github.com/SSilence/selfoss). The app calls the api provided by Selfoss, and can't help with solving issues with your Selfoss instance. + +Always check if the web version of your instance is working. + +# Some rules +### Bug reports/Feature request + +* Always search before reporting an issue or asking for a feature to avoid duplicates. +* Include your unique user id. It's displayed on the debug settings page. (You can tap it, it'll be copied to your clipboard) +* Include every other useful details (app version, phone model, Android version and screenshots when possible). +* Avoid bumping non-fatal issues, or feature requests. I'll try to fix them as soon as possible, and try to prioritize the requests. (You may wan to use the [reactions](https://github.com/blog/2119-add-reactions-to-pull-requests-issues-and-comments) for that) + +### Pull requests + +* Don't create a PR for translations. See [here](https://github.com/aminecmi/ReaderforSelfoss/pull/170#issuecomment-355715654) for an explanation why. +* Please ask before starting to work on an issue. I may be working on it, or someone else could be doing so. +* Each pull request should implement **ONE** feature or bugfix. Keep in mind that you can submit as many PR as you want. +* Your code must be simple and clear enough to avoid using comments to explain what it does. +* Follow the used coding style [the android koding style](https://android.github.io/kotlin-guides/style.html) ([some idoms for reference](http://kotlinlang.org/docs/reference/idioms.html)) with more to come. +* Try as much as possible to write a test for your feature, and if you do so, run it, and make it work. +* Always check your changes and discard the ones that are irrelevant to your feature or bugfix. +* Have meaningful commit messages. +* Always reference the issue you are working on in your PR description. +* Be willing to accept criticism on your PRs (as I am on mine). +* Remember that PR review can take time. + + +# Install Selfoss (if you don't have an instance) + +I won't provide any selfoss instance url. If you want to help, but to not have one, you'll have to install one, and use it. + +All the details to need are [here](https://selfoss.aditu.de/). + +# Build the project + +You can directly import this project into IntellIJ/Android Studio. + +You'll have to: + +- Define some parameters either in `~/.gradle/gradle.properties` or as gradle parameters (see the examples) + + - appLoginUrl, appLoginUsername and appLoginPassword: url, username and password of a selfoss instance. **These are only used for tests. They can be empty if you don't test API calls.** + +### Examples: +#### Inside ~/.gradle/gradle.properties + +``` +appLoginUrl="URL" # It can be empty. +appLoginUsername="LOGIN" # It can be empty. +appLoginPassword="PASS" # It can be empty. +``` + +#### As gradle parameters + +``` +./gradlew .... -P appLoginUrl="URL" -P appLoginUsername="LOGIN" -P appLoginPassword="PASS" +``` diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..a457db0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,32 @@ +### Prerequisites + +* [ ] Are you running the latest version? +* [ ] Did you check for an existing issue ? +* [ ] Are you reporting to the correct repository? +* [ ] Did you perform a cursory search? +* [ ] Did you read the `CONTRIBUTING` guide ? + +### Description + +[Description of the bug or feature] + +### Steps to Reproduce + +1. [First Step] +2. [Second Step] +3. [and so on...] + +**Expected behavior:** [What you expected to happen] + +**Actual behavior:** [What actually happened] + + +### Screenshots (optional) + +`...` + +### Device + +- Device (manufacturer, model ...) +- OS (Android Version, ROM/Stock, Rooted/not, mods...) +- App version _(See Prerequisites)_ \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..76b8ec1 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,14 @@ +## Types of changes + +- [ ] I have read the **CONTRIBUTING** document. +- [ ] My code follows the code style of this project. +- [ ] I have updated the documentation accordingly. +- [ ] I have added tests to cover my changes. +- [ ] All new and existing tests passed. +- [ ] This is **NOT** translation related. (See [here](https://github.com/aminecmi/ReaderforSelfoss/pull/170#issuecomment-355715654)) + +This closes issue #XXX + +This is implements feature #YYY + +This finishes chore #ZZZ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a87b03b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,562 @@ +**1.7.x** + +- Hiding tags with 0 articles + +- Fixed issue with basic auth and images loading + +- Added the ability to justify or left align the reader text + +- Fixed #251 + +- Added experimental issue to set a default timeout. Should work for #238. + +- Closing #220. + +- Start of #238. "Add a quick shortcut to open the app on offline mode ?" + +- Closes #216. Issue with selfoss version 2.19. + +- Closes #179. Sync of read/unread/star/unstar items on background task or on app reload with network available. + +- Closes #33. Background sync with settings. + +- Closing #1. Initial article caching. + +- Closing #228 by removing the list action bar. Action buttons are exclusively on the card view from now on. + +- Closing #38. Only doing api calls on network available. + +- Closing #298 and #287. Issues with Listview rendering + +- Closing #290. Fixing back button issue in Settings + +- Closing #300. Fixing issues when displaying some special characters. + +- Closing #310. Some feeds don't have icons nor thumbnails. + +- Closing #178. Expending images on tap. + +- Closing #323. Old issue with textview not having the right color. + +- Closing #324. Svg images loading crashes the app. + +- Closing #322. App crashed because of svg images. + +- Closing #236. New sources can be added in Selfoss 2.19. + +- Closing #397 and #355. Tag and Sources filters are now exclusive. + +- Dropped support for android 4, the last version supporting it is v1721030811 + +- Added ability to scroll articles up and down using the volume keys #400 + +**1.6.x** + +- Handling hidden tags. + +- Fixed pre-lolipop issue with automatic theme changes. + +- Removed all Build config things. + +- Removed firebase and fabric. + +- Added Acra for optional crash reporting and error logging. + +- Dynamic themes ! + +- Strings cleaning. + +- Versions updates. + +- Fixes #215, #208. + +- Fixes #328. + +**1.5.7.x** + +- Added confirmation to the mark as read and update menues. + +- Add to favorites from article viewer. + +- Added an option to use a webview in the article viewer (see #149) + +- Fixes (#151 #152 #155 #157 #160 #174) and more. + +- New year fixes !!! + +- Changed page indicator position as it was overlaping content. + +- Now using slack instead of gitter. + +- Moved completely to a webview to fix #161. + +- Fixed typos in French ( Thanks @aancel ) + +- Updated the Contribution guide about translations. + +- Better handling for articles update. (See #169) + +- Ability to change the article viewer content font size (see #153) + +- Versions updates * 2. + +- Added padding to the recyclerview. + +**1.5.5.x (didn't last long) AND 1.5.6.x** + +- Toolbar in reader activity. + +- Marking items as read on scroll (with settings to enable/disable). + +- Swapped the title and subtitle in the article viewer. + +- Added an animation to the viewpager. + +- Completed Dutch, Indonesian and Portuguese translations ! + +- Fixed #142, #144, #147. + +- Changed versions handling. + +- Removed indonesian english as it was causing issues with the english version of the app. + +**1.5.4.22** + +- You can now scroll through the loaded articles ! + +**1.5.4.21** + +- Spanish translation and some Indonesian ! + +**1.5.4.20** + +- Turkish translation ! + +**1.5.4.19** + +- Fixed an issue with crowdin configuration (and its translations) + +**1.5.4.18** + +- Typo fix. + +- The real last infinite scroll bug fix. + +- Simplified Chinese translation ! + +**1.5.4.17** + +- Fixed the last bug with infinite scroll. + +**1.5.4.16** + +- Fixing list view displaying issues. + +- Endless scroll is not in beta anymore. + +**1.5.4.15** + +- Fixed an issue with the sources list. + +**1.5.4.14** + +- Fixing infinite scroll trying to load more items when there are no more. + +**1.5.4.13** + +- Displaying the right number of items. + +- Fixing infinite scroll remaining issues. Should be stable enough. + +**1.5.4.12** + +- Fixed fab and toolbar issue (#113) + +- Fixed links clickable (#114) + +- Changed the link colors in the article viewer + +**1.5.4.11** + +- Hiding FABs on scroll. + +- Closing #109 (code cleaning) + +- Hiding fabs on scroll (#101) + +**1.5.4.10** + +- Displaying a loader when "reading more" in the article viewer. + +- Displaying the thumbnail instead of icon on the article viewer. + +- Scrolling to top when loading content with the "read more" button. + +**1.5.4.09** + +- Using the kotlin wrapper for the material drawer (see #98 for more details). + +- Updated support libraries + +- Changed the Floating Action Button to the support library version. + +- New reader activity action bar #103. + +**1.5.4.08** + +- Thanks @jrafaelsantana for translating the whole app in Brazilian Portuguese. + +**1.5.4.07** + +- Loading more items on swipe too. + +- Fixed popup menu style. User may need to reselect the theme. + +- Disabled reporting marking items as read if there isn't an issue. + +**1.5.4.05/06** + +- Translation fix. + +**1.5.4.04** + +- Fixing an issue with marking items as read (something related to an old version of selfoss). + +**1.5.4.03** + +- Trying to fix some issue with pre-launch reports. Reverted because it seems to be related to the dev console side. + +**1.5.4.02** + +- Fixing full height cards issue. + +**1.5.4.01** + +- Removed the "apk downloaded from outside of playstore" message. + +- Versions update. + +- HTML viewer version update. It should fix an issue with images. + +- Some code cleaning. + +**1.5.4.00** + +- Added issue reporting from within the app. + +**1.5.3.06** + +- Fixed infinite scroll not working. + +- Fixed logs not working. + +- Temporary workaround handling opening invalid urls. Waiting to solve #83. + +**1.5.3.05** + +- Fixed an issue on older versions of Android. + +- Libs update. + +**1.5.3.04** + +- Crowdin translations + +**1.5.3.03** + +- Libs updates. + +- Translation fix. + +**1.5.3.01/02** + +- Added translation link to the settings page. + +- Added the translation link to the README. + +**1.5.3.00** + +- (BETA) Added pull from bottom to load more pages of results. May be buggy. + +**1.5.2.18/19** + +- APK minification finally working. That means less space taken ! +- Added an option to log every API call. + +**1.5.2.17** + +- Source code and tracker links weren't being set, and updated the contributing doc. + +**1.5.2.15/16** + +- Adding an account header on the lateral drawer. + +- The account header is only displayed when the setting is enabled. + +**1.5.2.13/14** + +- Updated glide. + +- Loading images from self signed certificate now working. + +**1.5.2.12** + +- Self signed certificates are now working for loading data. Image are not loading yet. + +**1.5.2.11** + +- Added a random unique identifier to be used in the logs. + +**1.5.2.08/09/10** + +- Added settable logs for reading articles problems. + +**1.5.2.07** + +- Added the ability to choose the number of items loaded (the maximum value is 200 and is imposed by the selfoss api) + +**1.5.2.06** + +- Fix problem introduced in 1.5.2.04. SVG file not working on older versions of android. + +**1.5.2.05** + +- Versions updates + +**1.5.2.04** + +- Reverted to the old icon. + +- Better icon for the intro activity. + +- Updated gradle version. + +**1.5.2.03** + +- Added the ability to accept self signed certificates. (Needs more testing) + +**1.5.2.02** + +- Added optional login option. + +**1.5.2.01** + +- New (Better) Icon ! + +**1.5.2.0** + +- New Icon ! + +**1.5.1.9/10/11** + +- Hiding the unread badge when marking all items as read. + +**1.5.1.8** + +- Fixes and libs updates. + +**1.5.1.7** + +- Bug fixes. + +- Code cleaning + +**1.5.1.6** + +- Added back the badges after it was fixed on the library side. + +**1.5.1.5** + +- THEMES !!!! For now, the app has predefined themes. You can ask for new ones until I make them dynamic. + +**1.5.1.3/4** + +- Fixes introduces by the previous alpha (1.5.1.2) + +**1.5.1.2** + +- Added testing to the CI. + +- Code cleaning + +- Display the pull to refresh loader on api call + +- Fixes : + + - Can't pull down to refresh on first launch + + - Recurring crash because of the url + + - Couldn't open some urls because of missing "http" + + - Adding a source with invalid url would crash + + +**1.5.1.1** + +- Fixed an issue when trying to add a source without being logged in. + +- Reloading drawer tags badges on slide to refresh. + +**1.5.1** + +- Added a drawer for filtering sources and tags. + +- You can now search for items from the toolbar. + +**1.5.0.2** + +- If the content in the article viewer is empty, the article will open in a custom tab. + +- Added a share button, and an "open in browser" button to the bottom of the article viewer. + +- Updated custom tab code. + +**1.5.0.1** + +- The release APK wasn't working at all. + +**1.5.0.0** + +_New_ + +- The app is now open source ! And rewritten in Kotlin ! + +**1.4.0.9** + +_Fixes_ + +- Fixes and missing translations. + +**1.4.0.8** + +_New_ + +- Added setting for full height and fixed height cards size. + +_Fixed_ + +- Action Bar color now matches the primary color on the recent apps screen. + +- Added a bottom margin to de article viewer content + +- Multiple fixes for the new article viewer. + +**1.4.0.7** + +_Fixed_ + +- Disable swipe to hide from other "tabs" and avoid badges problems + +- Fixed a bug with the new Article viewer with some displaying fixes + + +**1.4.0.6** + +_New_ + +- Added the ability to use http authentication (Basic and Digest) + +_Fixed_ + +- Fixed gitter link + +- Change the article viewer because the other was causing crashes + +**1.4.0.5** + +_New_ + +- Added an intro to the app. + +- Added the ability to test the app without a Selfoss instance. + +**1.4.0.4** + +_New_ + +- Added the ability to have a github build. If the apk is a Github build, check for update and ask the user to download it (directly from the github page). + +_Changes_ + +- The apk stating that the app wasn't installed from the store is only displayed on start. + +**1.4.0.3** + +_Fixed_ + +- Fixed boolean problem. + +**1.4.0.2** + +_New_ + +- The app is available in Dutch ! + +_Fixed_ + +- Fixed a bug with the articles states. + +**1.4.0.1** + +_New_ + +- You can now help me translate the app ! There will be a dialog displayed the first time you open the app, and the link will still be available from the settings page. + +_Changes_ + +- Changed the custom tabs color to dark orange to fix the wrong title color. + +_Fixes_ + +- The badges now are shown even if the tab is selected. + +- Fixed feeds not reloading on app resume (caused by 1.4.0.0 changes). + +**1.4.0.0** + +_New_ + +- Added a setting to enable/disable the article viewer when the internal browser is enabled. + +- Added peek to the card view. + +- Text drawable if no icon. + + +_Changes_ + +- Changed the external browser setting to internal browser and handled the change on first open. + +- Some text changes. + +- Better animations handling on slow networks. + +... + +**1.3.3.5** + +_New_ + +- Added tab bar badges with settings to display them. + +- Added invites. + +_Fixes_ + +- Fixed a typo. + +_Updates_ + +- Updated support library to 10.2.0. + +- Updated firebase to 10.2.0. + +- Updated article_viewer to 0.20.1. + +- Updated bottom-bar to 2.1.1. + + +**1.3.3.4** + +... diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..30ace6a --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + {one line to give the program's name and a brief idea of what it does.} + Copyright (C) {year} {name of author} + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + {project} Copyright (C) {year} {fullname} + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. \ No newline at end of file diff --git a/README.md b/README.md index 9ab8678..ab45c30 100644 --- a/README.md +++ b/README.md @@ -1 +1,47 @@ -# ReaderForSelfoss-multiplatform \ No newline at end of file +# ReaderForSelfoss-multiplatform + +[![Crowdin](https://d322cqt584bo4o.cloudfront.net/readerforselfoss/localized.svg)](https://crowdin.com/project/readerforselfoss) + +It's an RSS Reader for Android, that **only** works with [Selfoss](https://selfoss.aditu.de/) + +**The project is not dead at all.** + +I still want to work on it, but for the last few months, I didn't have that much time to do so. + +If you are a developer, don't hesitate to help with PRs. + +If you are a user, you can still create new issues. I'll fix them when I can. + +Get it on F-Droid + +## Screen captures + +card view list view + +## Like my app ? + +Buy Me A Coffee + +## Want to help ? + +1. **You'll have to have a Selfoss instance running.** You'll find everything you need to install it [here](https://selfoss.aditu.de/). + +2. Check the [Contribution guide](https://github.com/aminecmi/ReaderforSelfoss/blob/master/.github/CONTRIBUTING.md). + +3. Build the project by following [these steps](https://github.com/aminecmi/ReaderforSelfoss/blob/master/.github/CONTRIBUTING.md#build-the-project) (you should have read them after the contribution guide) + +## Useful links + +- [Check what changed](https://github.com/aminecmi/ReaderforSelfoss/blob/master/CHANGELOG.md) +- [See what I'm doing](https://github.com/aminecmi/ReaderforSelfoss/projects/1) +- [Create an issue, or request a new feature](https://github.com/aminecmi/ReaderforSelfoss/issues) +- [Help translation the app](https://crowdin.com/project/readerforselfoss) + +## Contributors (Alphabetical order) ❤️ + +- [@aancel](https://github.com/aancel) +- [@Binnette](https://github.com/Binnette) +- [@davidoskky](https://github.com/davidoskky) +- [@hectorgabucio](https://github.com/hectorgabucio) +- [@licaon-kter](https://github.com/licaon-kter) +- [@sergey-babkin](https://github.com/sergey-babkin) diff --git a/androidApp/.gitignore b/androidApp/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/androidApp/.gitignore @@ -0,0 +1 @@ +/build diff --git a/androidApp/build.sh b/androidApp/build.sh new file mode 100755 index 0000000..abdea4d --- /dev/null +++ b/androidApp/build.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +git fetch --tags -p + +BASE_VERSION="1.7" +LAST_TAG=$(git tag -l | sort -V | tail -1) + +INITIAL_VERSION="${BASE_VERSION//./}$(date '+%y%m%j')" + +LAST_DAY_VERSION=$(echo $LAST_TAG | sed "s/v${INITIAL_VERSION}//") +LAST_DAY_VERSION_LENGTH=$(echo "${#LAST_DAY_VERSION}") + +if [[ "$LAST_DAY_VERSION_LENGTH" == "1" ]] +then + TODAYS_VERSION=$(( $LAST_DAY_VERSION + 1 )) +else + TODAYS_VERSION="1" +fi + +VERSION="${INITIAL_VERSION}${TODAYS_VERSION}" + +PARAMS_EXCEPT_PUBLISH=$(echo $1 | sed 's/\-\-publish//') + +./version.sh ${VERSION} ${PARAMS_EXCEPT_PUBLISH} + +if [[ "$@" == *'--publish'* ]] +then + ./publish-version.sh ${VERSION} +else + echo "Did not publish. If you wanted to do so, call the script with \"--publish\" or \"--publish-local\"." +fi diff --git a/androidApp/proguard-rules.pro b/androidApp/proguard-rules.pro new file mode 100644 index 0000000..0b3ee7b --- /dev/null +++ b/androidApp/proguard-rules.pro @@ -0,0 +1,65 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /home/amine/apps/android-sdk-linux/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +#About libraries +-keep class .R +-keep class **.R$* { + ; +} + +-dontwarn okio.** +-dontwarn retrofit2.Platform$Java8 +-keep class retrofit.** { *; } +-keepclasseswithmembers class * { + @retrofit.http.* ; +} +-keepattributes *Annotation*,Signature +-keepattributes Exceptions +-dontwarn okio.** +-dontwarn javax.annotation.Nullable +-dontwarn javax.annotation.ParametersAreNonnullByDefault + + +#Bottom bar lib +-dontwarn com.roughike.bottombar.** + + +# self signed glidemodule +-keep public class * implements com.bumptech.glide.module.GlideModule +-keep public class * extends com.bumptech.glide.AppGlideModule +-keep public enum com.bumptech.glide.load.resource.bitmap.ImageHeaderParser$** { + **[] $VALUES; + public *; +} + +-dontwarn com.anupcowkur.reservoir.** + +-dontwarn javax.annotation.** + +-keep class android.support.v7.widget.SearchView { *; } + +# maybe remove later ? +-keep class * extends androidx.fragment.app.Fragment diff --git a/androidApp/publish-version.sh b/androidApp/publish-version.sh new file mode 100755 index 0000000..4d4c5b9 --- /dev/null +++ b/androidApp/publish-version.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# NOTE: This is copy/pasted in jenkins + +rm -f version.txt +printf "versionName=$1-github\nversionCode=$1" >> version.txt + +# You'll need to change server as your server and define a VERSION_PATH. +scp version.txt server:$VERSION_PATH + +rm version.txt diff --git a/androidApp/schemas/apps.amine.bou.readerforselfoss.persistence.database.AppDatabase/1.json b/androidApp/schemas/apps.amine.bou.readerforselfoss.persistence.database.AppDatabase/1.json new file mode 100644 index 0000000..b478c9e --- /dev/null +++ b/androidApp/schemas/apps.amine.bou.readerforselfoss.persistence.database.AppDatabase/1.json @@ -0,0 +1,96 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "08ca537d7ac9d4dd216e8e395d70801a", + "entities": [ + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tag` TEXT NOT NULL, `color` TEXT NOT NULL, `unread` INTEGER NOT NULL, PRIMARY KEY(`tag`))", + "fields": [ + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "tag" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `tags` TEXT NOT NULL, `spout` TEXT NOT NULL, `error` TEXT NOT NULL, `icon` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spout", + "columnName": "spout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"08ca537d7ac9d4dd216e8e395d70801a\")" + ] + } +} \ No newline at end of file diff --git a/androidApp/schemas/apps.amine.bou.readerforselfoss.persistence.database.AppDatabase/2.json b/androidApp/schemas/apps.amine.bou.readerforselfoss.persistence.database.AppDatabase/2.json new file mode 100644 index 0000000..c9d43bc --- /dev/null +++ b/androidApp/schemas/apps.amine.bou.readerforselfoss.persistence.database.AppDatabase/2.json @@ -0,0 +1,176 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "6fa6944b04100d68eab61039876a8804", + "entities": [ + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tag` TEXT NOT NULL, `color` TEXT NOT NULL, `unread` INTEGER NOT NULL, PRIMARY KEY(`tag`))", + "fields": [ + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "tag" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `tags` TEXT NOT NULL, `spout` TEXT NOT NULL, `error` TEXT NOT NULL, `icon` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spout", + "columnName": "spout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `datetime` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `unread` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `thumbnail` TEXT NOT NULL, `icon` TEXT NOT NULL, `link` TEXT NOT NULL, `sourcetitle` TEXT NOT NULL, `tags` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "datetime", + "columnName": "datetime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnail", + "columnName": "thumbnail", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourcetitle", + "columnName": "sourcetitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"6fa6944b04100d68eab61039876a8804\")" + ] + } +} \ No newline at end of file diff --git a/androidApp/schemas/apps.amine.bou.readerforselfoss.persistence.database.AppDatabase/3.json b/androidApp/schemas/apps.amine.bou.readerforselfoss.persistence.database.AppDatabase/3.json new file mode 100644 index 0000000..70d1621 --- /dev/null +++ b/androidApp/schemas/apps.amine.bou.readerforselfoss.persistence.database.AppDatabase/3.json @@ -0,0 +1,226 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "7ad9c4961992c13b670128485ebb3efc", + "entities": [ + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tag` TEXT NOT NULL, `color` TEXT NOT NULL, `unread` INTEGER NOT NULL, PRIMARY KEY(`tag`))", + "fields": [ + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "tag" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `tags` TEXT NOT NULL, `spout` TEXT NOT NULL, `error` TEXT NOT NULL, `icon` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spout", + "columnName": "spout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `datetime` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `unread` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `thumbnail` TEXT NOT NULL, `icon` TEXT NOT NULL, `link` TEXT NOT NULL, `sourcetitle` TEXT NOT NULL, `tags` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "datetime", + "columnName": "datetime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnail", + "columnName": "thumbnail", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourcetitle", + "columnName": "sourcetitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "actions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `articleid` TEXT NOT NULL, `read` INTEGER NOT NULL, `unread` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `unstarred` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "articleId", + "columnName": "articleid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unstarred", + "columnName": "unstarred", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"7ad9c4961992c13b670128485ebb3efc\")" + ] + } +} \ No newline at end of file diff --git a/androidApp/schemas/apps.amine.bou.readerforselfoss.persistence.database.AppDatabase/4.json b/androidApp/schemas/apps.amine.bou.readerforselfoss.persistence.database.AppDatabase/4.json new file mode 100644 index 0000000..051cd37 --- /dev/null +++ b/androidApp/schemas/apps.amine.bou.readerforselfoss.persistence.database.AppDatabase/4.json @@ -0,0 +1,226 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "9cf8b03d32f80dfd58160599a1df197d", + "entities": [ + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tag` TEXT NOT NULL, `color` TEXT NOT NULL, `unread` INTEGER NOT NULL, PRIMARY KEY(`tag`))", + "fields": [ + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "tag" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `tags` TEXT NOT NULL, `spout` TEXT NOT NULL, `error` TEXT NOT NULL, `icon` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spout", + "columnName": "spout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `datetime` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `unread` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `thumbnail` TEXT, `icon` TEXT, `link` TEXT NOT NULL, `sourcetitle` TEXT NOT NULL, `tags` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "datetime", + "columnName": "datetime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnail", + "columnName": "thumbnail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourcetitle", + "columnName": "sourcetitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "actions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `articleid` TEXT NOT NULL, `read` INTEGER NOT NULL, `unread` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `unstarred` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "articleId", + "columnName": "articleid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unstarred", + "columnName": "unstarred", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"9cf8b03d32f80dfd58160599a1df197d\")" + ] + } +} \ No newline at end of file diff --git a/androidApp/schemas/bou.amine.apps.readerforselfossv2.android.persistence.database.AppDatabase/4.json b/androidApp/schemas/bou.amine.apps.readerforselfossv2.android.persistence.database.AppDatabase/4.json new file mode 100644 index 0000000..7b7d406 --- /dev/null +++ b/androidApp/schemas/bou.amine.apps.readerforselfossv2.android.persistence.database.AppDatabase/4.json @@ -0,0 +1,226 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "9cf8b03d32f80dfd58160599a1df197d", + "entities": [ + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tag` TEXT NOT NULL, `color` TEXT NOT NULL, `unread` INTEGER NOT NULL, PRIMARY KEY(`tag`))", + "fields": [ + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "tag" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `tags` TEXT NOT NULL, `spout` TEXT NOT NULL, `error` TEXT NOT NULL, `icon` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spout", + "columnName": "spout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `datetime` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `unread` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `thumbnail` TEXT, `icon` TEXT, `link` TEXT NOT NULL, `sourcetitle` TEXT NOT NULL, `tags` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "datetime", + "columnName": "datetime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnail", + "columnName": "thumbnail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourcetitle", + "columnName": "sourcetitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "actions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`articleid` TEXT NOT NULL, `read` INTEGER NOT NULL, `unread` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `unstarred` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "articleId", + "columnName": "articleid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unstarred", + "columnName": "unstarred", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9cf8b03d32f80dfd58160599a1df197d')" + ] + } +} \ No newline at end of file diff --git a/androidApp/src/androidTest/java/apps/amine/bou/readerforselfoss/AddSourceEspressoTest.kt b/androidApp/src/androidTest/java/apps/amine/bou/readerforselfoss/AddSourceEspressoTest.kt new file mode 100644 index 0000000..11a8688 --- /dev/null +++ b/androidApp/src/androidTest/java/apps/amine/bou/readerforselfoss/AddSourceEspressoTest.kt @@ -0,0 +1,3 @@ +package bou.amine.apps.readerforselfossv2.android + +// TODO: test source adding \ No newline at end of file diff --git a/androidApp/src/androidTest/java/apps/amine/bou/readerforselfoss/HomeActivityEspressoTest.kt b/androidApp/src/androidTest/java/apps/amine/bou/readerforselfoss/HomeActivityEspressoTest.kt new file mode 100644 index 0000000..79cd207 --- /dev/null +++ b/androidApp/src/androidTest/java/apps/amine/bou/readerforselfoss/HomeActivityEspressoTest.kt @@ -0,0 +1,102 @@ +package bou.amine.apps.readerforselfossv2.android + +import android.content.Context +import android.content.Intent +import androidx.test.InstrumentationRegistry +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.closeSoftKeyboard +import androidx.test.espresso.action.ViewActions.pressKey +import androidx.test.espresso.action.ViewActions.typeText +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withContentDescription +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.rule.ActivityTestRule +import androidx.test.runner.AndroidJUnit4 +import android.view.KeyEvent +import androidx.test.espresso.matcher.RootMatchers.isDialog +import bou.amine.apps.readerforselfossv2.android.HomeActivity +import bou.amine.apps.readerforselfossv2.android.LoginActivity +import bou.amine.apps.readerforselfossv2.android.utils.Config +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class HomeActivityEspressoTest { + lateinit var context: Context + + @Rule @JvmField + val rule = ActivityTestRule(HomeActivity::class.java, true, false) + + @Before + fun clearData() { + context = InstrumentationRegistry.getInstrumentation().targetContext + + val editor = + context + .getSharedPreferences(Config.settingsName, Context.MODE_PRIVATE) + .edit() + editor.clear() + + editor.putString("url", BuildConfig.LOGIN_URL) + editor.putString("login", BuildConfig.LOGIN_USERNAME) + editor.putString("password", BuildConfig.LOGIN_PASSWORD) + + editor.commit() + + Intents.init() + } + + @Test + fun menuItems() { + + rule.launchActivity(Intent()) + + onView( + withMenu( + id = R.id.action_search, + titleId = R.string.menu_home_search + ) + ).perform(click()) + + onView(withId(R.id.search_bar)).check(matches(isDisplayed())) + + onView(withId(R.id.search_src_text)).perform( + typeText("android"), + pressKey(KeyEvent.KEYCODE_SEARCH), + closeSoftKeyboard() + ) + + onView(withContentDescription(R.string.abc_toolbar_collapse_description)).perform(click()) + + openActionBarOverflowOrOptionsMenu(context) + + onView(withMenu(id = R.id.refresh, titleId = R.string.menu_home_refresh)) + .perform(click()) + + onView(withText(android.R.string.ok)) + .inRoot(isDialog()).check(matches(isDisplayed())).perform(click()) + + openActionBarOverflowOrOptionsMenu(context) + + onView(withText(R.string.action_disconnect)).perform(click()) + + intended(hasComponent(LoginActivity::class.java.name)) + } + + // TODO: test articles opening and actions for cards and lists + + @After + fun releaseIntents() { + Intents.release() + } +} \ No newline at end of file diff --git a/androidApp/src/androidTest/java/apps/amine/bou/readerforselfoss/LoginActivityEspressoTest.kt b/androidApp/src/androidTest/java/apps/amine/bou/readerforselfoss/LoginActivityEspressoTest.kt new file mode 100644 index 0000000..e64adb3 --- /dev/null +++ b/androidApp/src/androidTest/java/apps/amine/bou/readerforselfoss/LoginActivityEspressoTest.kt @@ -0,0 +1,180 @@ +package bou.amine.apps.readerforselfossv2.android + +import android.content.Context +import android.content.Intent +import androidx.test.InstrumentationRegistry +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.closeSoftKeyboard +import androidx.test.espresso.action.ViewActions.pressBack +import androidx.test.espresso.action.ViewActions.typeText +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.Intents.times +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.rule.ActivityTestRule +import androidx.test.runner.AndroidJUnit4 +import bou.amine.apps.readerforselfossv2.android.HomeActivity +import bou.amine.apps.readerforselfossv2.android.LoginActivity +import bou.amine.apps.readerforselfossv2.android.utils.Config +import com.mikepenz.aboutlibraries.ui.LibsActivity +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LoginActivityEspressoTest { + + @Rule @JvmField + val rule = ActivityTestRule(LoginActivity::class.java, true, false) + + private lateinit var context: Context + private lateinit var url: String + private lateinit var username: String + private lateinit var password: String + + @Before + fun setUp() { + context = InstrumentationRegistry.getInstrumentation().targetContext + val editor = + context + .getSharedPreferences(Config.settingsName, Context.MODE_PRIVATE) + .edit() + editor.clear() + editor.commit() + + + url = BuildConfig.LOGIN_URL + username = BuildConfig.LOGIN_USERNAME + password = BuildConfig.LOGIN_PASSWORD + + Intents.init() + } + + @Test + fun menuItems() { + + rule.launchActivity(Intent()) + + openActionBarOverflowOrOptionsMenu(context) + + onView(withText(R.string.action_about)).perform(click()) + + intended(hasComponent(LibsActivity::class.java.name), times(1)) + + onView(isRoot()).perform(pressBack()) + + intended(hasComponent(LoginActivity::class.java.name)) + } + + @Test + fun wrongLoginUrl() { + rule.launchActivity(Intent()) + + onView(withId(R.id.loginProgress)) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))) + + onView(withId(R.id.urlView)).perform(click()).perform(typeText("WRONGURL")) + + onView(withId(R.id.signInButton)).perform(click()) + + onView(withId(R.id.urlView)).check(matches(isHintOrErrorEnabled())) + } + + // TODO: Add tests for multiple false urls with dialog + + @Test + fun emptyAuthData() { + + rule.launchActivity(Intent()) + + onView(withId(R.id.urlView)).perform(click()).perform(typeText(url), closeSoftKeyboard()) + + onView(withId(R.id.withLogin)).perform(click()) + + onView(withId(R.id.signInButton)).perform(click()) + + onView(withId(R.id.loginView)).check(matches(isHintOrErrorEnabled())) + onView(withId(R.id.passwordView)).check(matches(isHintOrErrorEnabled())) + + onView(withId(R.id.loginView)).perform(click()).perform( + typeText(username), + closeSoftKeyboard() + ) + + onView(withId(R.id.passwordView)).check(matches(isHintOrErrorEnabled())) + + onView(withId(R.id.signInButton)).perform(click()) + + onView(withId(R.id.passwordView)).check( + matches( + isHintOrErrorEnabled() + ) + ) + } + + @Test + fun wrongAuthData() { + + rule.launchActivity(Intent()) + + onView(withId(R.id.urlView)).perform(click()).perform(typeText(url), closeSoftKeyboard()) + + onView(withId(R.id.withLogin)).perform(click()) + + onView(withId(R.id.loginView)).perform(click()).perform( + typeText(username), + closeSoftKeyboard() + ) + + onView(withId(R.id.passwordView)).perform(click()).perform( + typeText("WRONGPASS"), + closeSoftKeyboard() + ) + + onView(withId(R.id.signInButton)).perform(click()) + + onView(withId(R.id.urlView)).check(matches(isHintOrErrorEnabled())) + onView(withId(R.id.loginView)).check(matches(isHintOrErrorEnabled())) + onView(withId(R.id.passwordView)).check(matches(isHintOrErrorEnabled())) + } + + @Test + fun workingAuth() { + + rule.launchActivity(Intent()) + + onView(withId(R.id.urlView)).perform(click()).perform(typeText(url), closeSoftKeyboard()) + + onView(withId(R.id.withLogin)).perform(click()) + + onView(withId(R.id.loginView)).perform(click()).perform( + typeText(username), + closeSoftKeyboard() + ) + + onView(withId(R.id.passwordView)).perform(click()).perform( + typeText(password), + closeSoftKeyboard() + ) + + onView(withId(R.id.signInButton)).perform(click()) + + Thread.sleep(2000) + intended(hasComponent(HomeActivity::class.java.name)) + } + + @After + fun releaseIntents() { + Intents.release() + } +} \ No newline at end of file diff --git a/androidApp/src/androidTest/java/apps/amine/bou/readerforselfoss/MainActivityEspressoTest.kt b/androidApp/src/androidTest/java/apps/amine/bou/readerforselfoss/MainActivityEspressoTest.kt new file mode 100644 index 0000000..1400a0e --- /dev/null +++ b/androidApp/src/androidTest/java/apps/amine/bou/readerforselfoss/MainActivityEspressoTest.kt @@ -0,0 +1,81 @@ +package bou.amine.apps.readerforselfossv2.android + +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import androidx.test.InstrumentationRegistry.getInstrumentation +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.Intents.times +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.rule.ActivityTestRule +import androidx.test.runner.AndroidJUnit4 +import bou.amine.apps.readerforselfossv2.android.HomeActivity +import bou.amine.apps.readerforselfossv2.android.LoginActivity +import bou.amine.apps.readerforselfossv2.android.MainActivity +import bou.amine.apps.readerforselfossv2.android.utils.Config +import org.junit.After + +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MainActivityEspressoTest { + + lateinit var intent: Intent + lateinit var preferencesEditor: SharedPreferences.Editor + private lateinit var url: String + private lateinit var username: String + private lateinit var password: String + + @Rule @JvmField + val rule = ActivityTestRule(MainActivity::class.java, true, false) + + @Before + fun setUp() { + intent = Intent() + val context = getInstrumentation().targetContext + + // create a SharedPreferences editor + preferencesEditor = context.getSharedPreferences(Config.settingsName, Context.MODE_PRIVATE).edit() + + url = BuildConfig.LOGIN_URL + username = BuildConfig.LOGIN_USERNAME + password = BuildConfig.LOGIN_PASSWORD + + Intents.init() + } + + @Test + fun checkFirstOpenLaunchesIntro() { + preferencesEditor.putString("url", "") + preferencesEditor.putString("password", "") + preferencesEditor.putString("login", "") + preferencesEditor.commit() + + rule.launchActivity(intent) + + intended(hasComponent(LoginActivity::class.java.name)) + intended(hasComponent(HomeActivity::class.java.name), times(0)) + } + + @Test + fun checkNotFirstOpenLaunchesLogin() { + preferencesEditor.putString("url", url) + preferencesEditor.putString("password", password) + preferencesEditor.putString("login", username) + preferencesEditor.commit() + + rule.launchActivity(intent) + + intended(hasComponent(MainActivity::class.java.name)) + intended(hasComponent(HomeActivity::class.java.name)) + } + + @After + fun releaseIntents() { + Intents.release() + } +} \ No newline at end of file diff --git a/androidApp/src/androidTest/java/apps/amine/bou/readerforselfoss/Utils.kt b/androidApp/src/androidTest/java/apps/amine/bou/readerforselfoss/Utils.kt new file mode 100644 index 0000000..b8fd12a --- /dev/null +++ b/androidApp/src/androidTest/java/apps/amine/bou/readerforselfoss/Utils.kt @@ -0,0 +1,29 @@ +package bou.amine.apps.readerforselfossv2.android + +import androidx.test.espresso.matcher.ViewMatchers +import android.view.View +import android.widget.EditText +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.hamcrest.Matchers +import org.hamcrest.TypeSafeMatcher + +fun isHintOrErrorEnabled(): Matcher = + object : TypeSafeMatcher() { + override fun describeTo(description: Description?) { + } + + override fun matchesSafely(item: View?): Boolean { + if (item !is EditText) { + return false + } + + return item.error.isNotEmpty() + } + } + +fun withMenu(id: Int, titleId: Int): Matcher = + Matchers.anyOf( + ViewMatchers.withId(id), + ViewMatchers.withText(titleId) + ) diff --git a/androidApp/src/androidTest/java/apps/amine/bou/readerforselfoss/utils/DateUtilsTest.kt b/androidApp/src/androidTest/java/apps/amine/bou/readerforselfoss/utils/DateUtilsTest.kt new file mode 100644 index 0000000..f599393 --- /dev/null +++ b/androidApp/src/androidTest/java/apps/amine/bou/readerforselfoss/utils/DateUtilsTest.kt @@ -0,0 +1,31 @@ +package bou.amine.apps.readerforselfossv2.android.utils + +import bou.amine.apps.readerforselfossv2.android.utils.Config +import bou.amine.apps.readerforselfossv2.android.utils.parseDate +import org.junit.Test + +class DateUtilsTest { + + @Test + fun parseDateV4() { + + Config.apiVersion = 4 + val dateString = "2013-04-07T13:43:00+01:00" + + val milliseconds = parseDate(dateString).toEpochMilli() + val correctMilliseconds : Long = 1365338580000 + + assert(milliseconds == correctMilliseconds) + } + + @Test + fun parseDateV1() { + Config.apiVersion = 0 + val dateString = "2013-04-07 13:43:00" + + val milliseconds = parseDate(dateString).toEpochMilli() + val correctMilliseconds = 1365342180000 + + assert(milliseconds == correctMilliseconds) + } +} \ No newline at end of file diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 82fe01a..f2e3130 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -6,16 +6,81 @@ + android:networkSecurityConfig="@xml/network_security_config" + android:theme="@style/NoBar"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/androidApp/src/main/ic_launcher-web.png b/androidApp/src/main/ic_launcher-web.png new file mode 100644 index 0000000..a149479 Binary files /dev/null and b/androidApp/src/main/ic_launcher-web.png differ diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/AddSourceActivity.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/AddSourceActivity.kt new file mode 100644 index 0000000..2067b1c --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/AddSourceActivity.kt @@ -0,0 +1,266 @@ +package bou.amine.apps.readerforselfossv2.android + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.preference.PreferenceManager +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.appcompat.app.AppCompatActivity +import android.view.View +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.EditText +import android.widget.ProgressBar +import android.widget.Spinner +import android.widget.TextView +import android.widget.Toast +import bou.amine.apps.readerforselfossv2.android.api.selfoss.SelfossApi +import bou.amine.apps.readerforselfossv2.android.api.selfoss.Spout +import bou.amine.apps.readerforselfossv2.android.api.selfoss.SuccessResponse +import bou.amine.apps.readerforselfossv2.android.themes.AppColors +import bou.amine.apps.readerforselfossv2.android.themes.Toppings +import bou.amine.apps.readerforselfossv2.android.utils.Config +import bou.amine.apps.readerforselfossv2.android.utils.isBaseUrlValid +import com.ftinc.scoop.Scoop +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import bou.amine.apps.readerforselfossv2.android.databinding.ActivityAddSourceBinding + + +class AddSourceActivity : AppCompatActivity() { + + private var mSpoutsValue: String? = null + private lateinit var api: SelfossApi + + private lateinit var appColors: AppColors + private lateinit var binding: ActivityAddSourceBinding + + override fun onCreate(savedInstanceState: Bundle?) { + appColors = AppColors(this@AddSourceActivity) + + super.onCreate(savedInstanceState) + binding = ActivityAddSourceBinding.inflate(layoutInflater) + val view = binding.root + + setContentView(view) + + val scoop = Scoop.getInstance() + scoop.bind(this, Toppings.PRIMARY.value, binding.toolbar) + scoop.bindStatusBar(this, Toppings.PRIMARY_DARK.value) + + val drawable = binding.nameInput.background + drawable.setTint(appColors.colorAccent) + + + // TODO: clean + binding.nameInput.background = drawable + + val drawable1 = binding.sourceUri.background + drawable1.setTint(appColors.colorAccent) + + binding.sourceUri.background = drawable1 + + val drawable2 = binding.tags.background + drawable2.setTint(appColors.colorAccent) + + binding.tags.background = drawable2 + + setSupportActionBar(binding.toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.setDisplayShowHomeEnabled(true) + + try { + val prefs = PreferenceManager.getDefaultSharedPreferences(this) + val settings = + getSharedPreferences(Config.settingsName, Context.MODE_PRIVATE) + api = SelfossApi( + this, + this@AddSourceActivity, + settings.getBoolean("isSelfSignedCert", false), + prefs.getString("api_timeout", "-1")!!.toLong() + ) + } catch (e: IllegalArgumentException) { + mustLoginToAddSource() + } + + maybeGetDetailsFromIntentSharing(intent, binding.sourceUri, binding.nameInput) + + binding.saveBtn.setTextColor(appColors.colorAccent) + + binding.saveBtn.setOnClickListener { + handleSaveSource(binding.tags, binding.nameInput.text.toString(), binding.sourceUri.text.toString(), api) + } + } + + override fun onResume() { + super.onResume() + val config = Config(this) + + if (config.baseUrl.isEmpty() || !config.baseUrl.isBaseUrlValid(this@AddSourceActivity)) { + mustLoginToAddSource() + } else { + handleSpoutsSpinner(binding.spoutsSpinner, api, binding.progress, binding.formContainer) + } + } + + private fun handleSpoutsSpinner( + spoutsSpinner: Spinner, + api: SelfossApi?, + mProgress: ProgressBar, + formContainer: ConstraintLayout + ) { + val spoutsKV = HashMap() + spoutsSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(adapterView: AdapterView<*>, view: View?, i: Int, l: Long) { + if (view != null) { + val spoutName = (view as TextView).text.toString() + mSpoutsValue = spoutsKV[spoutName] + } + } + + override fun onNothingSelected(adapterView: AdapterView<*>) { + mSpoutsValue = null + } + } + + var items: Map + api!!.spouts().enqueue(object : Callback> { + override fun onResponse( + call: Call>, + response: Response> + ) { + if (response.body() != null) { + items = response.body()!! + + val itemsStrings = items.map { it.value.name } + for ((key, value) in items) { + spoutsKV[value.name] = key + } + + mProgress.visibility = View.GONE + formContainer.visibility = View.VISIBLE + + val spinnerArrayAdapter = + ArrayAdapter( + this@AddSourceActivity, + android.R.layout.simple_spinner_item, + itemsStrings + ) + spinnerArrayAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + spoutsSpinner.adapter = spinnerArrayAdapter + } else { + handleProblemWithSpouts() + } + } + + override fun onFailure(call: Call>, t: Throwable) { + handleProblemWithSpouts() + } + + private fun handleProblemWithSpouts() { + Toast.makeText( + this@AddSourceActivity, + R.string.cant_get_spouts, + Toast.LENGTH_SHORT + ).show() + mProgress.visibility = View.GONE + } + }) + } + + private fun maybeGetDetailsFromIntentSharing( + intent: Intent, + sourceUri: EditText, + nameInput: EditText + ) { + if (Intent.ACTION_SEND == intent.action && "text/plain" == intent.type) { + sourceUri.setText(intent.getStringExtra(Intent.EXTRA_TEXT)) + nameInput.setText(intent.getStringExtra(Intent.EXTRA_TITLE)) + } + } + + private fun mustLoginToAddSource() { + Toast.makeText(this, getString(R.string.addStringNoUrl), Toast.LENGTH_SHORT).show() + val i = Intent(this, LoginActivity::class.java) + startActivity(i) + finish() + } + + private fun handleSaveSource(tags: EditText, title: String, url: String, api: SelfossApi) { + + val sourceDetailsUnavailable = + title.isEmpty() || url.isEmpty() || mSpoutsValue == null || mSpoutsValue!!.isEmpty() + + when { + sourceDetailsUnavailable -> { + Toast.makeText(this, R.string.form_not_complete, Toast.LENGTH_SHORT).show() + } + PreferenceManager.getDefaultSharedPreferences(this).getInt("apiVersionMajor", 0) > 1 -> { + val tagList = tags.text.toString().split(",").map { it.trim() } + api.createSourceApi2( + title, + url, + mSpoutsValue!!, + tagList, + "" + ).enqueue(object : Callback { + override fun onResponse( + call: Call, + response: Response + ) { + if (response.body() != null && response.body()!!.isSuccess) { + finish() + } else { + Toast.makeText( + this@AddSourceActivity, + R.string.cant_create_source, + Toast.LENGTH_SHORT + ).show() + } + } + + override fun onFailure(call: Call, t: Throwable) { + Toast.makeText( + this@AddSourceActivity, + R.string.cant_create_source, + Toast.LENGTH_SHORT + ).show() + } + }) + } + else -> { + api.createSource( + title, + url, + mSpoutsValue!!, + tags.text.toString(), + "" + ).enqueue(object : Callback { + override fun onResponse( + call: Call, + response: Response + ) { + if (response.body() != null && response.body()!!.isSuccess) { + finish() + } else { + Toast.makeText( + this@AddSourceActivity, + R.string.cant_create_source, + Toast.LENGTH_SHORT + ).show() + } + } + + override fun onFailure(call: Call, t: Throwable) { + Toast.makeText( + this@AddSourceActivity, + R.string.cant_create_source, + Toast.LENGTH_SHORT + ).show() + } + }) + } + } + } +} diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/HomeActivity.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/HomeActivity.kt new file mode 100644 index 0000000..8178cf7 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/HomeActivity.kt @@ -0,0 +1,1294 @@ +package bou.amine.apps.readerforselfossv2.android + +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.net.Uri +import android.os.Bundle +import androidx.preference.PreferenceManager +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.widget.ImageView +import android.widget.Toast +import androidx.appcompat.app.ActionBarDrawerToggle +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.SearchView +import androidx.core.view.doOnNextLayout +import androidx.drawerlayout.widget.DrawerLayout +import androidx.recyclerview.widget.* +import androidx.room.Room +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import bou.amine.apps.readerforselfossv2.android.adapters.ItemCardAdapter +import bou.amine.apps.readerforselfossv2.android.adapters.ItemListAdapter +import bou.amine.apps.readerforselfossv2.android.adapters.ItemsAdapter +import bou.amine.apps.readerforselfossv2.android.api.selfoss.* +import bou.amine.apps.readerforselfossv2.android.background.LoadingWorker +import bou.amine.apps.readerforselfossv2.android.databinding.ActivityHomeBinding +import bou.amine.apps.readerforselfossv2.android.persistence.database.AppDatabase +import bou.amine.apps.readerforselfossv2.android.persistence.entities.ActionEntity +import bou.amine.apps.readerforselfossv2.android.persistence.migrations.MIGRATION_1_2 +import bou.amine.apps.readerforselfossv2.android.persistence.migrations.MIGRATION_2_3 +import bou.amine.apps.readerforselfossv2.android.persistence.migrations.MIGRATION_3_4 +import bou.amine.apps.readerforselfossv2.android.settings.SettingsActivity +import bou.amine.apps.readerforselfossv2.android.themes.AppColors +import bou.amine.apps.readerforselfossv2.android.themes.Toppings +import bou.amine.apps.readerforselfossv2.android.utils.Config +import bou.amine.apps.readerforselfossv2.android.utils.SharedItems +import bou.amine.apps.readerforselfossv2.android.utils.bottombar.maybeShow +import bou.amine.apps.readerforselfossv2.android.utils.bottombar.removeBadge +import bou.amine.apps.readerforselfossv2.android.utils.customtabs.CustomTabActivityHelper +import bou.amine.apps.readerforselfossv2.android.utils.longHash +import bou.amine.apps.readerforselfossv2.android.utils.network.isNetworkAccessible +import bou.amine.apps.readerforselfossv2.android.utils.persistence.toEntity +import bou.amine.apps.readerforselfossv2.android.utils.persistence.toView +import bou.amine.apps.readerforselfossv2.android.api.selfoss.* +import com.ashokvarma.bottomnavigation.BottomNavigationBar +import com.ashokvarma.bottomnavigation.BottomNavigationItem +import com.ashokvarma.bottomnavigation.TextBadgeItem +import com.bumptech.glide.Glide +import com.bumptech.glide.request.RequestOptions +import com.ftinc.scoop.Scoop +import com.mikepenz.aboutlibraries.LibsBuilder +import com.mikepenz.materialdrawer.holder.BadgeStyle +import com.mikepenz.materialdrawer.holder.ColorHolder +import com.mikepenz.materialdrawer.holder.StringHolder +import com.mikepenz.materialdrawer.model.DividerDrawerItem +import com.mikepenz.materialdrawer.model.PrimaryDrawerItem +import com.mikepenz.materialdrawer.model.ProfileDrawerItem +import com.mikepenz.materialdrawer.model.SecondaryDrawerItem +import com.mikepenz.materialdrawer.model.interfaces.* +import com.mikepenz.materialdrawer.util.AbstractDrawerImageLoader +import com.mikepenz.materialdrawer.util.DrawerImageLoader +import com.mikepenz.materialdrawer.util.addStickyFooterItem +import com.mikepenz.materialdrawer.util.updateBadge +import com.mikepenz.materialdrawer.widget.AccountHeaderView +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread + +class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener { + + private val MENU_PREFERENCES = 12302 + private val DRAWER_ID_TAGS = 100101L + private val DRAWER_ID_HIDDEN_TAGS = 101100L + private val DRAWER_ID_SOURCES = 100110L + private val DRAWER_ID_FILTERS = 100111L + private val UNREAD_SHOWN = 1 + private val READ_SHOWN = 2 + private val FAV_SHOWN = 3 + + private var items: ArrayList = ArrayList() + private var allItems: ArrayList = ArrayList() + + private var internalBrowser = false + private var articleViewer = false + private var shouldBeCardView = false + private var displayUnreadCount = false + private var displayAllCount = false + private var fullHeightCards: Boolean = false + private var itemsNumber: Int = 200 + private var elementsShown: Int = 1 + private var userIdentifier: String = "" + private var displayAccountHeader: Boolean = false + private var infiniteScroll: Boolean = false + private var lastFetchDone: Boolean = false + private var itemsCaching: Boolean = false + private var updateSources: Boolean = true + private var markOnScroll: Boolean = false + private var hiddenTags: List = emptyList() + private var apiVersionMajor: Int = 0 + + private var periodicRefresh = false + private var refreshMinutes: Long = 360L + private var refreshWhenChargingOnly = false + + private lateinit var tabNewBadge: TextBadgeItem + private lateinit var tabArchiveBadge: TextBadgeItem + private lateinit var tabStarredBadge: TextBadgeItem + private lateinit var api: SelfossApi + private lateinit var customTabActivityHelper: CustomTabActivityHelper + private lateinit var editor: SharedPreferences.Editor + private lateinit var sharedPref: SharedPreferences + private lateinit var appColors: AppColors + private var offset: Int = 0 + private var firstVisible: Int = 0 + private lateinit var recyclerViewScrollListener: RecyclerView.OnScrollListener + private lateinit var settings: SharedPreferences + private lateinit var binding: ActivityHomeBinding + + private var recyclerAdapter: RecyclerView.Adapter<*>? = null + + private var fromTabShortcut: Boolean = false + private var offlineShortcut: Boolean = false + + private lateinit var tagsBadge: Map + + private lateinit var db: AppDatabase + + private lateinit var config: Config + + data class DrawerData(val tags: List?, val sources: List?) + + override fun onStart() { + super.onStart() + customTabActivityHelper.bindCustomTabsService(this) + } + + override fun onCreate(savedInstanceState: Bundle?) { + appColors = AppColors(this@HomeActivity) + config = Config(this@HomeActivity) + + super.onCreate(savedInstanceState) + binding = ActivityHomeBinding.inflate(layoutInflater) + val view = binding.root + + fromTabShortcut = intent.getIntExtra("shortcutTab", -1) != -1 + offlineShortcut = intent.getBooleanExtra("startOffline", false) + + if (fromTabShortcut) { + elementsShown = intent.getIntExtra("shortcutTab", UNREAD_SHOWN) + } + + setContentView(view) + + handleThemeBinding() + + setSupportActionBar(binding.toolBar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.setHomeButtonEnabled(true) + val mDrawerToggle = ActionBarDrawerToggle(this, binding.drawerContainer, binding.toolBar, R.string.material_drawer_open, R.string.material_drawer_close) + binding.drawerContainer.addDrawerListener(mDrawerToggle) + mDrawerToggle.syncState() + + db = Room.databaseBuilder( + applicationContext, + AppDatabase::class.java, "selfoss-database" + ).addMigrations(MIGRATION_1_2).addMigrations(MIGRATION_2_3).addMigrations(MIGRATION_3_4).build() + + + customTabActivityHelper = CustomTabActivityHelper() + + sharedPref = PreferenceManager.getDefaultSharedPreferences(this) + settings = getSharedPreferences(Config.settingsName, Context.MODE_PRIVATE) + + api = SelfossApi( + this, + this@HomeActivity, + settings.getBoolean("isSelfSignedCert", false), + sharedPref.getString("api_timeout", "-1")!!.toLong() + ) + items = ArrayList() + allItems = ArrayList() + + handleBottomBar() + handleDrawer() + + handleSwipeRefreshLayout() + + handleSharedPrefs() + + getApiMajorVersion() + + getElementsAccordingToTab() + } + + private fun handleSwipeRefreshLayout() { + binding.swipeRefreshLayout.setColorSchemeResources( + R.color.refresh_progress_1, + R.color.refresh_progress_2, + R.color.refresh_progress_3 + ) + binding.swipeRefreshLayout.setOnRefreshListener { + offlineShortcut = false + allItems = ArrayList() + lastFetchDone = false + handleDrawerItems() + CoroutineScope(Dispatchers.Main).launch { + refreshFocusedItems(applicationContext, api, db, itemsNumber) + getElementsAccordingToTab() + binding.swipeRefreshLayout.isRefreshing = false + } + } + + val simpleItemTouchCallback = + object : ItemTouchHelper.SimpleCallback( + 0, + ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT + ) { + override fun getSwipeDirs( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ): Int = + if (elementsShown == FAV_SHOWN) { + 0 + } else { + super.getSwipeDirs( + recyclerView, + viewHolder + ) + } + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean = false + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, swipeDir: Int) { + val position = viewHolder.bindingAdapterPosition + val i = items.elementAtOrNull(position) + + if (i != null) { + val adapter = binding.recyclerView.adapter as ItemsAdapter<*> + + adapter.handleItemAtIndex(position) + + reloadBadgeContent() + + val tagHashes = i.tags.tags.split(",").map { it.longHash() } + tagsBadge = tagsBadge.map { + if (tagHashes.contains(it.key)) { + (it.key to (it.value - 1)) + } else { + (it.key to it.value) + } + }.toMap() + reloadTagsBadges() + + // Just load everythin + if (items.size <= 0) { + getElementsAccordingToTab() + } + } else { + Toast.makeText( + this@HomeActivity, + "Found null when swiping at positon $position.", + Toast.LENGTH_LONG + ).show() + } + } + } + + ItemTouchHelper(simpleItemTouchCallback).attachToRecyclerView(binding.recyclerView) + } + + private fun handleBottomBar() { + + tabNewBadge = TextBadgeItem() + .setText("") + .setHideOnSelect(false).hide(false) + .setBackgroundColor(appColors.colorPrimary) + tabArchiveBadge = TextBadgeItem() + .setText("") + .setHideOnSelect(false).hide(false) + .setBackgroundColor(appColors.colorPrimary) + tabStarredBadge = TextBadgeItem() + .setText("") + .setHideOnSelect(false).hide(false) + .setBackgroundColor(appColors.colorPrimary) + + val tabNew = + BottomNavigationItem( + R.drawable.ic_tab_fiber_new_black_24dp, + getString(R.string.tab_new) + ).setActiveColor(appColors.colorAccent) + .setBadgeItem(tabNewBadge) + val tabArchive = + BottomNavigationItem( + R.drawable.ic_tab_archive_black_24dp, + getString(R.string.tab_read) + ).setActiveColor(appColors.colorAccentDark) + .setBadgeItem(tabArchiveBadge) + val tabStarred = + BottomNavigationItem( + R.drawable.ic_tab_favorite_black_24dp, + getString(R.string.tab_favs) + ).setActiveColorResource(R.color.pink) + .setBadgeItem(tabStarredBadge) + + binding.bottomBar + .addItem(tabNew) + .addItem(tabArchive) + .addItem(tabStarred) + .setFirstSelectedPosition(0) + .initialise() + binding.bottomBar.setMode(BottomNavigationBar.MODE_SHIFTING) + binding.bottomBar.setBackgroundStyle(BottomNavigationBar.BACKGROUND_STYLE_STATIC) + + if (fromTabShortcut) { + binding.bottomBar.selectTab(elementsShown - 1) + } + } + + private fun getApiMajorVersion() { + api.apiVersion.enqueue(object : Callback { + override fun onFailure(call: Call, t: Throwable) { + Config.apiVersion = apiVersionMajor + } + + override fun onResponse(call: Call, response: Response) { + if(response.body() != null) { + val version = response.body() as ApiVersion + apiVersionMajor = version.getApiMajorVersion() + sharedPref.edit().putInt("apiVersionMajor", apiVersionMajor).apply() + + Config.apiVersion = apiVersionMajor + } + } + }) + } + + override fun onResume() { + super.onResume() + + // TODO: Make this the only appcolors init + appColors = AppColors(this@HomeActivity) + + sharedPref = PreferenceManager.getDefaultSharedPreferences(this) + + editor = settings.edit() + + handleDrawerItems() + + handleThemeUpdate() + + reloadLayoutManager() + + if (!infiniteScroll) { + binding.recyclerView.setHasFixedSize(true) + } else { + handleInfiniteScroll() + } + + handleBottomBarActions() + + handleRecurringTask() + + handleOfflineActions() + + getElementsAccordingToTab() + } + + private fun getAndStoreAllItems() { + CoroutineScope(Dispatchers.Main).launch { + binding.swipeRefreshLayout.isRefreshing = true + getAndStoreAllItems(applicationContext ,api, db) + this@HomeActivity.isNetworkAccessible(this@HomeActivity.findViewById(R.id.coordLayout), offlineShortcut) + handleListResult() + binding.swipeRefreshLayout.isRefreshing = false + SharedItems.updateDatabase(db) + } + } + + override fun onStop() { + super.onStop() + customTabActivityHelper.unbindCustomTabsService(this) + } + + private fun handleSharedPrefs() { + internalBrowser = sharedPref.getBoolean("prefer_internal_browser", true) + articleViewer = sharedPref.getBoolean("prefer_article_viewer", true) + shouldBeCardView = sharedPref.getBoolean("card_view_active", false) + displayUnreadCount = sharedPref.getBoolean("display_unread_count", true) + displayAllCount = sharedPref.getBoolean("display_other_count", false) + fullHeightCards = sharedPref.getBoolean("full_height_cards", false) + itemsNumber = sharedPref.getString("prefer_api_items_number", "200")!!.toInt() + userIdentifier = sharedPref.getString("unique_id", "")!! + displayAccountHeader = sharedPref.getBoolean("account_header_displaying", false) + infiniteScroll = sharedPref.getBoolean("infinite_loading", false) + itemsCaching = sharedPref.getBoolean("items_caching", false) + SharedItems.itemsCaching = itemsCaching + updateSources = sharedPref.getBoolean("update_sources", true) + markOnScroll = sharedPref.getBoolean("mark_on_scroll", false) + hiddenTags = if (sharedPref.getString("hidden_tags", "")!!.isNotEmpty()) { + sharedPref.getString("hidden_tags", "")!!.replace("\\s".toRegex(), "").split(",") + } else { + emptyList() + } + periodicRefresh = sharedPref.getBoolean("periodic_refresh", false) + refreshWhenChargingOnly = sharedPref.getBoolean("refresh_when_charging", false) + refreshMinutes = sharedPref.getString("periodic_refresh_minutes", "360")!!.toLong() + + if (refreshMinutes <= 15) { + refreshMinutes = 15 + } + + apiVersionMajor = sharedPref.getInt("apiVersionMajor", 0) + } + + private fun handleThemeBinding() { + val scoop = Scoop.getInstance() + scoop.bind(this, Toppings.PRIMARY.value, binding.toolBar) + scoop.bindStatusBar(this, Toppings.PRIMARY_DARK.value) + } + + private fun handleThemeUpdate() { + + val scoop = Scoop.getInstance() + scoop.update(Toppings.PRIMARY.value, appColors.colorPrimary) + + scoop.update(Toppings.PRIMARY_DARK.value, appColors.colorPrimaryDark) + } + + private fun handleDrawer() { + DrawerImageLoader.init(object : AbstractDrawerImageLoader() { + override fun set(imageView: ImageView, uri: Uri, placeholder: Drawable, tag: String?) { + Glide.with(this@HomeActivity) + .asBitmap() + .load(uri) + .apply(RequestOptions() + .placeholder(R.mipmap.ic_launcher) + .fallback(R.mipmap.ic_launcher) + .fitCenter()) + .into(imageView) + } + + override fun cancel(imageView: ImageView) { + Glide.with(this@HomeActivity).clear(imageView) + } + }) + + val drawerListener = object : DrawerLayout.DrawerListener { + override fun onDrawerSlide(drawerView: View, slideOffset: Float) { + } + + override fun onDrawerOpened(drawerView: View) { + binding.bottomBar.hide() + } + + override fun onDrawerClosed(drawerView: View) { + binding.bottomBar.show() + } + + override fun onDrawerStateChanged(newState: Int) { + } + + } + + binding.drawerContainer.addDrawerListener(drawerListener) + + displayAccountHeader = + PreferenceManager.getDefaultSharedPreferences(this) + .getBoolean("account_header_displaying", false) + + binding.mainDrawer.addStickyFooterItem( + PrimaryDrawerItem().apply { + nameRes = R.string.drawer_report_bug + iconRes = R.drawable.ic_bug_report_black_24dp + isIconTinted = true + onDrawerItemClickListener = { _, _, _ -> + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(Config.trackerUrl)) + startActivity(browserIntent) + false + } + }) + + binding.mainDrawer.addStickyFooterItem( + PrimaryDrawerItem().apply { + nameRes = R.string.title_activity_settings + iconRes = R.drawable.ic_settings_black_24dp + isIconTinted = true + onDrawerItemClickListener = { _, _, _ -> + startActivity(Intent(this@HomeActivity, SettingsActivity::class.java)) + false + } + }) + + if (displayAccountHeader) { + AccountHeaderView(this).apply { + attachToSliderView(binding.mainDrawer) + addProfiles( + ProfileDrawerItem().apply { + nameText = settings.getString("url", "").toString() + setBackgroundResource(R.drawable.bg) + iconRes = R.mipmap.ic_launcher + selectionListEnabledForSingleProfile = false + } + ) + } + } + } + + private fun handleDrawerItems() { + tagsBadge = emptyMap() + fun handleDrawerData(maybeDrawerData: DrawerData?, loadedFromCache: Boolean = false) { + fun handleTags(maybeTags: List?) { + if (maybeTags == null) { + if (loadedFromCache) { + binding.mainDrawer.itemAdapter.add( + SecondaryDrawerItem() + .apply { nameRes = R.string.drawer_error_loading_tags; isSelectable = false } + ) + } + } else { + val filteredTags = maybeTags + .filterNot { hiddenTags.contains(it.tag) } + .sortedBy { it.unread == 0 } + tagsBadge = filteredTags.map { + val gd = GradientDrawable() + val gdColor = try { + Color.parseColor(it.color) + } catch (e: IllegalArgumentException) { + appColors.colorPrimary + } + + gd.setColor(gdColor) + gd.shape = GradientDrawable.RECTANGLE + gd.setSize(30, 30) + gd.cornerRadius = 30F + val drawerItem = + PrimaryDrawerItem() + .apply { + nameText = it.getTitleDecoded() + identifier = it.tag.longHash() + iconDrawable = gd + badgeStyle = BadgeStyle().apply { + textColor = ColorHolder.fromColor(Color.WHITE) + color = ColorHolder.fromColor(appColors.colorAccent) } + onDrawerItemClickListener = { _,_,_ -> + allItems = ArrayList() + SharedItems.tagFilter = it.tag + SharedItems.sourceFilter = null + SharedItems.sourceIDFilter = null + getElementsAccordingToTab() + fetchOnEmptyList() + false + } } + if (it.unread > 0) { + drawerItem.badgeText = it.unread.toString() + } + + binding.mainDrawer.itemAdapter.add(drawerItem) + + (it.tag.longHash() to it.unread) + }.toMap() + } + } + + fun handleHiddenTags(maybeTags: List?) { + if (maybeTags == null) { + if (loadedFromCache) { + binding.mainDrawer.itemAdapter.add( + SecondaryDrawerItem().apply { + nameRes = R.string.drawer_error_loading_tags + isSelectable = false + } + ) + } + } else { + val filteredHiddenTags: List = + maybeTags.filter { hiddenTags.contains(it.tag) } + tagsBadge = filteredHiddenTags.map { + val gd = GradientDrawable() + val gdColor = try { + Color.parseColor(it.color) + } catch (e: IllegalArgumentException) { + appColors.colorPrimary + } + + gd.setColor(gdColor) + gd.shape = GradientDrawable.RECTANGLE + gd.setSize(30, 30) + gd.cornerRadius = 30F + val drawerItem = + PrimaryDrawerItem().apply { + nameText = it.getTitleDecoded() + identifier = it.tag.longHash() + iconDrawable = gd + badgeStyle = BadgeStyle().apply { + textColor = ColorHolder.fromColor(Color.WHITE) + color = ColorHolder.fromColor(appColors.colorAccent) } + onDrawerItemClickListener = { _,_,_ -> + allItems = ArrayList() + SharedItems.tagFilter = it.tag + SharedItems.sourceFilter = null + SharedItems.sourceIDFilter = null + getElementsAccordingToTab() + fetchOnEmptyList() + false + } } + + if (it.unread > 0) { + drawerItem.badgeText = it.unread.toString() + } + binding.mainDrawer.itemAdapter.add(drawerItem) + + (it.tag.longHash() to it.unread) + }.toMap() + } + } + + fun handleSources(maybeSources: List?) { + if (maybeSources == null) { + if (loadedFromCache) { + binding.mainDrawer.itemAdapter.add( + SecondaryDrawerItem().apply { + nameRes = R.string.drawer_error_loading_sources + isSelectable = false + } + ) + } + } else { + for (source in maybeSources) { + val item = PrimaryDrawerItem().apply { + nameText = source.getTitleDecoded() + identifier = source.id.toLong() + iconUrl = source.getIcon(this@HomeActivity) + onDrawerItemClickListener = { _,_,_ -> + allItems = ArrayList() + SharedItems.sourceIDFilter = source.id.toLong() + SharedItems.sourceFilter = source.title + SharedItems.tagFilter = null + getElementsAccordingToTab() + fetchOnEmptyList() + false + } + } + binding.mainDrawer.itemAdapter.add(item) + } + } + } + + binding.mainDrawer.itemAdapter.clear() + if (maybeDrawerData != null) { + binding.mainDrawer.itemAdapter.add( + SecondaryDrawerItem().apply { + nameRes = R.string.drawer_item_filters + isSelectable = false + identifier = DRAWER_ID_FILTERS + badgeRes = R.string.drawer_action_clear + onDrawerItemClickListener = { _,_,_ -> + allItems = ArrayList() + SharedItems.sourceFilter = null + SharedItems.sourceIDFilter = null + SharedItems.tagFilter = null + binding.mainDrawer.setSelectionAtPosition(-1) + getElementsAccordingToTab() + fetchOnEmptyList() + false + } + } + ) + if (hiddenTags.isNotEmpty()) { + binding.mainDrawer.itemAdapter.add( + DividerDrawerItem(), + SecondaryDrawerItem().apply { + nameRes = R.string.drawer_item_hidden_tags + identifier = DRAWER_ID_HIDDEN_TAGS + isSelectable = false + } + ) + handleHiddenTags(maybeDrawerData.tags) + } + binding.mainDrawer.itemAdapter.add( + DividerDrawerItem(), + SecondaryDrawerItem().apply { + nameRes = R.string.drawer_item_tags + identifier = DRAWER_ID_TAGS + isSelectable = false + } + ) + handleTags(maybeDrawerData.tags) + binding.mainDrawer.itemAdapter.add( + DividerDrawerItem(), + SecondaryDrawerItem().apply { + nameRes = R.string.drawer_item_sources + identifier = DRAWER_ID_SOURCES + isSelectable = false + badgeRes = R.string.drawer_action_edit + onDrawerItemClickListener = { v,_,_ -> + startActivity(Intent(v!!.context, SourcesActivity::class.java)) + false + } + } + ) + handleSources(maybeDrawerData.sources) + binding.mainDrawer.itemAdapter.add( + DividerDrawerItem(), + PrimaryDrawerItem().apply { + nameRes = R.string.action_about + isSelectable = false + iconRes = R.drawable.ic_info_outline_white_24dp + isIconTinted = true + onDrawerItemClickListener = { _,_,_ -> + LibsBuilder() + .withAboutIconShown(true) + .withAboutVersionShown(true) + .start(this@HomeActivity) + false + } + } + ) + + if (!loadedFromCache) { + if (maybeDrawerData.tags != null) { + thread { + val tagEntities = maybeDrawerData.tags.map { it.toEntity() } + db.drawerDataDao().deleteAllTags() + db.drawerDataDao().insertAllTags(*tagEntities.toTypedArray()) + } + } + if (maybeDrawerData.sources != null) { + thread { + val sourceEntities = + maybeDrawerData.sources.map { it.toEntity() } + db.drawerDataDao().deleteAllSources() + db.drawerDataDao().insertAllSources(*sourceEntities.toTypedArray()) + } + } + } + } else { + if (!loadedFromCache) { + binding.mainDrawer.itemAdapter.add( + PrimaryDrawerItem().apply { + nameRes = R.string.no_tags_loaded + identifier = DRAWER_ID_TAGS + isSelectable = false + }, + PrimaryDrawerItem().apply { + nameRes = R.string.no_sources_loaded + identifier = DRAWER_ID_SOURCES + isSelectable = false + } + ) + } + } + } + + fun drawerApiCalls(maybeDrawerData: DrawerData?) { + var tags: List? = null + var sources: List? + + fun sourcesApiCall() { + if (this@HomeActivity.isNetworkAccessible(null, offlineShortcut) && updateSources) { + api.sources.enqueue(object : Callback> { + override fun onResponse( + call: Call>?, + response: Response> + ) { + sources = response.body() + val apiDrawerData = DrawerData(tags, sources) + if ((maybeDrawerData != null && maybeDrawerData != apiDrawerData) || maybeDrawerData == null) { + handleDrawerData(apiDrawerData) + } + } + + override fun onFailure(call: Call>?, t: Throwable?) { + val apiDrawerData = DrawerData(tags, null) + if ((maybeDrawerData != null && maybeDrawerData != apiDrawerData) || maybeDrawerData == null) { + handleDrawerData(apiDrawerData) + } + } + }) + } + } + + if (this@HomeActivity.isNetworkAccessible(null, offlineShortcut) && updateSources) { + api.tags.enqueue(object : Callback> { + override fun onResponse( + call: Call>, + response: Response> + ) { + tags = response.body() + sourcesApiCall() + } + + override fun onFailure(call: Call>?, t: Throwable?) { + sourcesApiCall() + } + }) + } + } + + binding.mainDrawer.itemAdapter.add( + PrimaryDrawerItem().apply { + nameRes = R.string.drawer_loading + isSelectable = false + } + ) + + thread { + val drawerData = DrawerData(db.drawerDataDao().tags().map { it.toView() }, + db.drawerDataDao().sources().map { it.toView() }) + runOnUiThread { + handleDrawerData(drawerData, loadedFromCache = true) + drawerApiCalls(drawerData) + } + } + } + + private fun reloadLayoutManager() { + val currentManager = binding.recyclerView.layoutManager + val layoutManager: RecyclerView.LayoutManager + + // This will only update the layout manager if settings changed + when (currentManager) { + is StaggeredGridLayoutManager -> + if (!shouldBeCardView) { + layoutManager = GridLayoutManager( + this, + calculateNoOfColumns() + ) + binding.recyclerView.layoutManager = layoutManager + } + is GridLayoutManager -> + if (shouldBeCardView) { + layoutManager = StaggeredGridLayoutManager( + calculateNoOfColumns(), + StaggeredGridLayoutManager.VERTICAL + ) + layoutManager.gapStrategy = + StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS + binding.recyclerView.layoutManager = layoutManager + } + else -> + if (currentManager == null) { + if (!shouldBeCardView) { + layoutManager = GridLayoutManager( + this, + calculateNoOfColumns() + ) + binding.recyclerView.layoutManager = layoutManager + } else { + layoutManager = StaggeredGridLayoutManager( + calculateNoOfColumns(), + StaggeredGridLayoutManager.VERTICAL + ) + layoutManager.gapStrategy = + StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS + binding.recyclerView.layoutManager = layoutManager + } + } + } + } + + private fun handleBottomBarActions() { + binding.bottomBar.setTabSelectedListener(object : BottomNavigationBar.OnTabSelectedListener { + override fun onTabUnselected(position: Int) = Unit + + override fun onTabReselected(position: Int) { + val layoutManager = binding.recyclerView.adapter + + when (layoutManager) { + is StaggeredGridLayoutManager -> + if (layoutManager.findFirstCompletelyVisibleItemPositions(null)[0] == 0) { + getElementsAccordingToTab() + } else { + layoutManager.scrollToPositionWithOffset(0, 0) + } + is GridLayoutManager -> + if (layoutManager.findFirstCompletelyVisibleItemPosition() == 0) { + getElementsAccordingToTab() + } else { + layoutManager.scrollToPositionWithOffset(0, 0) + } + else -> Unit + } + } + + override fun onTabSelected(position: Int) { + offset = 0 + lastFetchDone = false + + elementsShown = position + 1 + getElementsAccordingToTab() + binding.recyclerView.scrollToPosition(0) + + fetchOnEmptyList() + } + }) + } + + private fun fetchOnEmptyList() { + binding.recyclerView.doOnNextLayout { + if (SharedItems.focusedItems.size - 1 == getLastVisibleItem()) { + getElementsAccordingToTab(true) + } + } + } + + private fun handleInfiniteScroll() { + recyclerViewScrollListener = object : RecyclerView.OnScrollListener() { + override fun onScrolled(localRecycler: RecyclerView, dx: Int, dy: Int) { + if (dy > 0) { + val lastVisibleItem = getLastVisibleItem() + + if (lastVisibleItem == (items.size - 1) && items.size < maxItemNumber()) { + getElementsAccordingToTab(appendResults = true) + } + } + } + } + + binding.recyclerView.clearOnScrollListeners() + binding.recyclerView.addOnScrollListener(recyclerViewScrollListener) + } + + private fun getLastVisibleItem() : Int { + val manager = binding.recyclerView.layoutManager + return when (manager) { + is StaggeredGridLayoutManager -> manager.findLastCompletelyVisibleItemPositions( + null + ).last() + is GridLayoutManager -> manager.findLastCompletelyVisibleItemPosition() + else -> 0 + } + } + + private fun mayBeEmpty() = + if (items.isEmpty()) { + binding.emptyText.visibility = View.VISIBLE + } else { + binding.emptyText.visibility = View.GONE + } + + private fun getElementsAccordingToTab( + appendResults: Boolean = false, + offsetOverride: Int? = null + ) { + fun doGetAccordingToTab() { + when (elementsShown) { + UNREAD_SHOWN -> getUnRead(appendResults) + READ_SHOWN -> getRead(appendResults) + FAV_SHOWN -> getStarred(appendResults) + else -> getUnRead(appendResults) + } + } + + offset = if (appendResults) { + SharedItems.focusedItems.size - 1 + } else { + 0 + } + firstVisible = if (appendResults) firstVisible else 0 + + doGetAccordingToTab() + } + + private fun getUnRead(appendResults: Boolean = false) { + CoroutineScope(Dispatchers.Main).launch { + if (appendResults || !SharedItems.fetchedUnread) { + binding.swipeRefreshLayout.isRefreshing = true + getUnreadItems(applicationContext, api, db, itemsNumber, offset) + binding.swipeRefreshLayout.isRefreshing = false + } + SharedItems.getUnRead() + items = SharedItems.focusedItems + handleListResult() + } + } + + private fun getRead(appendResults: Boolean = false) { + CoroutineScope(Dispatchers.Main).launch { + if (appendResults || !SharedItems.fetchedAll) { + binding.swipeRefreshLayout.isRefreshing = true + getReadItems(applicationContext, api, db, itemsNumber, offset) + binding.swipeRefreshLayout.isRefreshing = false + } + SharedItems.getAll() + items = SharedItems.focusedItems + handleListResult() + } + } + + private fun getStarred(appendResults: Boolean = false) { + CoroutineScope(Dispatchers.Main).launch { + if (appendResults || !SharedItems.fetchedStarred) { + binding.swipeRefreshLayout.isRefreshing = true + getStarredItems(applicationContext, api, db, itemsNumber, offset) + binding.swipeRefreshLayout.isRefreshing = false + } + SharedItems.getStarred() + items = SharedItems.focusedItems + handleListResult() + } + } + + private fun handleListResult(appendResults: Boolean = false) { + if (appendResults) { + val oldManager = binding.recyclerView.layoutManager + firstVisible = when (oldManager) { + is StaggeredGridLayoutManager -> + oldManager.findFirstCompletelyVisibleItemPositions(null).last() + is GridLayoutManager -> + oldManager.findFirstCompletelyVisibleItemPosition() + else -> 0 + } + } + + if (recyclerAdapter == null) { + if (shouldBeCardView) { + recyclerAdapter = + ItemCardAdapter( + this, + items, + api, + db, + customTabActivityHelper, + internalBrowser, + articleViewer, + fullHeightCards, + appColors, + userIdentifier, + config + ) { + updateItems(it) + } + } else { + recyclerAdapter = + ItemListAdapter( + this, + items, + api, + db, + customTabActivityHelper, + internalBrowser, + articleViewer, + userIdentifier, + appColors, + config + ) { + updateItems(it) + } + + binding.recyclerView.addItemDecoration( + DividerItemDecoration( + this@HomeActivity, + DividerItemDecoration.VERTICAL + ) + ) + } + binding.recyclerView.adapter = recyclerAdapter + } else { + (recyclerAdapter as ItemsAdapter<*>).updateAllItems() + } + + reloadBadges() + mayBeEmpty() + } + + private fun reloadBadges() { + if (displayUnreadCount || displayAllCount) { + CoroutineScope(Dispatchers.Main).launch { + reloadBadges(applicationContext, api) + reloadBadgeContent() + } + } + } + + private fun reloadBadgeContent() { + if (displayUnreadCount) { + tabNewBadge + .setText(SharedItems.badgeUnread.toString()) + .maybeShow() + } + if (displayAllCount) { + tabArchiveBadge + .setText(SharedItems.badgeAll.toString()) + .maybeShow() + tabStarredBadge + .setText(SharedItems.badgeStarred.toString()) + .maybeShow() + } + } + + private fun reloadTagsBadges() { + tagsBadge.forEach { + binding.mainDrawer.updateBadge(it.key, StringHolder(it.value.toString())) + } + binding.mainDrawer.resetDrawerContent() + } + + private fun calculateNoOfColumns(): Int { + val displayMetrics = resources.displayMetrics + val dpWidth = displayMetrics.widthPixels / displayMetrics.density + return (dpWidth / 300).toInt() + } + + override fun onQueryTextChange(p0: String?): Boolean { + if (p0.isNullOrBlank()) { + SharedItems.searchFilter = null + getElementsAccordingToTab() + fetchOnEmptyList() + } + return false + } + + override fun onQueryTextSubmit(p0: String?): Boolean { + SharedItems.searchFilter = p0 + getElementsAccordingToTab() + fetchOnEmptyList() + return false + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + val inflater = menuInflater + inflater.inflate(R.menu.home_menu, menu) + + val searchItem = menu.findItem(R.id.action_search) + val searchView = searchItem.getActionView() as SearchView + searchView.setOnQueryTextListener(this) + + return true + } + + private fun needsConfirmation(titleRes: Int, messageRes: Int, doFn: () -> Unit) { + AlertDialog.Builder(this@HomeActivity) + .setMessage(messageRes) + .setTitle(titleRes) + .setPositiveButton(android.R.string.ok) { _, _ -> doFn() } + .setNegativeButton(android.R.string.cancel) { _, _ -> } + .create() + .show() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.refresh -> { + if (this@HomeActivity.isNetworkAccessible(null, offlineShortcut)) { + needsConfirmation(R.string.menu_home_refresh, R.string.refresh_dialog_message) { + api.update().enqueue(object : Callback { + override fun onResponse( + call: Call, + response: Response + ) { + Toast.makeText( + this@HomeActivity, + R.string.refresh_success_response, Toast.LENGTH_LONG + ) + .show() + } + + override fun onFailure(call: Call, t: Throwable) { + Toast.makeText( + this@HomeActivity, + R.string.refresh_failer_message, + Toast.LENGTH_SHORT + ).show() + } + }) + Toast.makeText(this, R.string.refresh_in_progress, Toast.LENGTH_SHORT).show() + } + return true + } else { + return false + } + } + R.id.readAll -> { + if (elementsShown == UNREAD_SHOWN) { + needsConfirmation(R.string.readAll, R.string.markall_dialog_message) { + binding.swipeRefreshLayout.isRefreshing = true + + if (this@HomeActivity.isNetworkAccessible(null, offlineShortcut)) { + CoroutineScope(Dispatchers.Main).launch { + val success = readAll(applicationContext, api, db) + if (success) { + Toast.makeText( + this@HomeActivity, + R.string.all_posts_read, + Toast.LENGTH_SHORT + ).show() + tabNewBadge.removeBadge() + + handleDrawerItems() + + getElementsAccordingToTab() + } else { + Toast.makeText( + this@HomeActivity, + R.string.all_posts_not_read, + Toast.LENGTH_SHORT + ).show() + } + handleListResult() + binding.swipeRefreshLayout.isRefreshing = false + } + } + } + } + return true + } + R.id.action_disconnect -> { + return Config.logoutAndRedirect(this, this@HomeActivity, editor) + } + else -> return super.onOptionsItemSelected(item) + } + } + + private fun maxItemNumber(): Int = + when (elementsShown) { + UNREAD_SHOWN -> SharedItems.badgeUnread + READ_SHOWN -> SharedItems.badgeAll + FAV_SHOWN -> SharedItems.badgeStarred + else -> SharedItems.badgeUnread // if !elementsShown then unread are fetched. + } + + private fun updateItems(adapterItems: ArrayList) { + items = adapterItems + } + + private fun handleRecurringTask() { + if (periodicRefresh) { + val myConstraints = Constraints.Builder() + .setRequiresBatteryNotLow(true) + .setRequiresCharging(refreshWhenChargingOnly) + .setRequiresStorageNotLow(true) + .build() + + val backgroundWork = + PeriodicWorkRequestBuilder(refreshMinutes, TimeUnit.MINUTES) + .setConstraints(myConstraints) + .addTag("selfoss-loading") + .build() + + WorkManager.getInstance(baseContext).enqueueUniquePeriodicWork("selfoss-loading", ExistingPeriodicWorkPolicy.KEEP, backgroundWork) + } + } + + private fun handleOfflineActions() { + fun doAndReportOnFail(call: Call, action: ActionEntity) { + call.enqueue(object: Callback { + override fun onResponse( + call: Call, + response: Response + ) { + thread { + db.actionsDao().delete(action) + } + } + + override fun onFailure(call: Call, t: Throwable) { + } + }) + } + + if (this@HomeActivity.isNetworkAccessible(null, offlineShortcut)) { + CoroutineScope(Dispatchers.Main).launch { + val actions = db.actionsDao().actions() + + actions.forEach { action -> + when { + action.read -> doAndReportOnFail(api.markItem(action.articleId), action) + action.unread -> doAndReportOnFail(api.unmarkItem(action.articleId), action) + action.starred -> doAndReportOnFail(api.starrItem(action.articleId), action) + action.unstarred -> doAndReportOnFail(api.unstarrItem(action.articleId), action) + } + } + } + } + } +} + diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/ImageActivity.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/ImageActivity.kt new file mode 100644 index 0000000..16db2e8 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/ImageActivity.kt @@ -0,0 +1,53 @@ +package bou.amine.apps.readerforselfossv2.android + +import android.os.Bundle +import android.view.MenuItem +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.viewpager2.adapter.FragmentStateAdapter +import bou.amine.apps.readerforselfossv2.android.databinding.ActivityImageBinding +import bou.amine.apps.readerforselfossv2.android.fragments.ImageFragment + +class ImageActivity : AppCompatActivity() { + private lateinit var allImages : ArrayList + private var position : Int = 0 + + private lateinit var binding: ActivityImageBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityImageBinding.inflate(layoutInflater) + val view = binding.root + + setContentView(view) + + setSupportActionBar(binding.toolBar) + supportActionBar?.setDisplayShowTitleEnabled(false) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + allImages = intent.getStringArrayListExtra("allImages") as ArrayList + position = intent.getIntExtra("position", 0) + + binding.pager.adapter = ScreenSlidePagerAdapter(this) + binding.pager.setCurrentItem(position, false) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + onBackPressed() + return true + } + } + + return super.onOptionsItemSelected(item) + } + + private inner class ScreenSlidePagerAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) { + + override fun getItemCount(): Int = allImages.size + + override fun createFragment(position: Int): Fragment = ImageFragment.newInstance(allImages[position]) + } +} \ No newline at end of file diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/LoginActivity.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/LoginActivity.kt new file mode 100644 index 0000000..76c7542 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/LoginActivity.kt @@ -0,0 +1,293 @@ +package bou.amine.apps.readerforselfossv2.android + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.content.Intent +import android.content.SharedPreferences +import android.os.Bundle +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import android.text.TextUtils +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.inputmethod.EditorInfo +import android.widget.TextView +import androidx.preference.PreferenceManager +import bou.amine.apps.readerforselfossv2.android.api.selfoss.SelfossApi +import bou.amine.apps.readerforselfossv2.android.api.selfoss.SuccessResponse +import bou.amine.apps.readerforselfossv2.android.databinding.ActivityLoginBinding +import bou.amine.apps.readerforselfossv2.android.themes.AppColors +import bou.amine.apps.readerforselfossv2.android.utils.isBaseUrlValid +import bou.amine.apps.readerforselfossv2.android.utils.network.isNetworkAccessible +import com.mikepenz.aboutlibraries.LibsBuilder +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response + +class LoginActivity : AppCompatActivity() { + + private var inValidCount: Int = 0 + private var isWithSelfSignedCert = false + private var isWithLogin = false + private var isWithHTTPLogin = false + + private lateinit var settings: SharedPreferences + private lateinit var editor: SharedPreferences.Editor + private lateinit var userIdentifier: String + private lateinit var appColors: AppColors + private lateinit var binding: ActivityLoginBinding + + override fun onCreate(savedInstanceState: Bundle?) { + appColors = AppColors(this@LoginActivity) + + super.onCreate(savedInstanceState) + binding = ActivityLoginBinding.inflate(layoutInflater) + val view = binding.root + + setContentView(view) + + setSupportActionBar(binding.toolbar) + + handleBaseUrlFail() + + settings = PreferenceManager.getDefaultSharedPreferences(applicationContext) + userIdentifier = settings.getString("unique_id", "")!! + + editor = settings.edit() + + if (settings.getString("url", "")!!.isNotEmpty()) { + goToMain() + } + + handleActions() + } + + private fun handleActions() { + + binding.withSelfhostedCert.setOnCheckedChangeListener { _, b -> + isWithSelfSignedCert = !isWithSelfSignedCert + val visi: Int = if (b) View.VISIBLE else View.GONE + + binding.warningText.visibility = visi + } + + binding.passwordView.setOnEditorActionListener( + TextView.OnEditorActionListener { _, id, _ -> + if (id == R.id.loginView || id == EditorInfo.IME_NULL) { + attemptLogin() + return@OnEditorActionListener true + } + false + } + ) + + binding.signInButton.setOnClickListener { attemptLogin() } + + binding.withLogin.setOnCheckedChangeListener { _, b -> + isWithLogin = !isWithLogin + val visi: Int = if (b) View.VISIBLE else View.GONE + + binding.loginView.visibility = visi + binding.passwordView.visibility = visi + } + + binding.withHttpLogin.setOnCheckedChangeListener { _, b -> + isWithHTTPLogin = !isWithHTTPLogin + val visi: Int = if (b) View.VISIBLE else View.GONE + + binding.httpLoginView.visibility = visi + binding.httpPasswordView.visibility = visi + } + } + + private fun handleBaseUrlFail() { + if (intent.getBooleanExtra("baseUrlFail", false)) { + val alertDialog = AlertDialog.Builder(this).create() + alertDialog.setTitle(getString(R.string.warning_wrong_url)) + alertDialog.setMessage(getString(R.string.base_url_error)) + alertDialog.setButton( + AlertDialog.BUTTON_NEUTRAL, + "OK" + ) { dialog, _ -> dialog.dismiss() } + alertDialog.show() + } + } + + private fun goToMain() { + val intent = Intent(this, HomeActivity::class.java) + startActivity(intent) + finish() + } + + private fun attemptLogin() { + + // Reset errors. + binding.urlView.error = null + binding.loginView.error = null + binding.httpLoginView.error = null + binding.passwordView.error = null + binding.httpPasswordView.error = null + + // Store values at the time of the login attempt. + val url = binding.urlView.text.toString() + val login = binding.loginView.text.toString() + val httpLogin = binding.httpLoginView.text.toString() + val password = binding.passwordView.text.toString() + val httpPassword = binding.httpPasswordView.text.toString() + + var cancel = false + var focusView: View? = null + + if (!url.isBaseUrlValid(this@LoginActivity)) { + binding.urlView.error = getString(R.string.login_url_problem) + focusView = binding.urlView + cancel = true + inValidCount++ + if (inValidCount == 3) { + val alertDialog = AlertDialog.Builder(this).create() + alertDialog.setTitle(getString(R.string.warning_wrong_url)) + alertDialog.setMessage(getString(R.string.text_wrong_url)) + alertDialog.setButton( + AlertDialog.BUTTON_NEUTRAL, + "OK" + ) { dialog, _ -> dialog.dismiss() } + alertDialog.show() + inValidCount = 0 + } + } + + if (isWithLogin) { + if (TextUtils.isEmpty(password)) { + binding.passwordView.error = getString(R.string.error_invalid_password) + focusView = binding.passwordView + cancel = true + } + + if (TextUtils.isEmpty(login)) { + binding.loginView.error = getString(R.string.error_field_required) + focusView = binding.loginView + cancel = true + } + } + + if (isWithHTTPLogin) { + if (TextUtils.isEmpty(httpPassword)) { + binding.httpPasswordView.error = getString(R.string.error_invalid_password) + focusView = binding.httpPasswordView + cancel = true + } + + if (TextUtils.isEmpty(httpLogin)) { + binding.httpLoginView.error = getString(R.string.error_field_required) + focusView = binding.httpLoginView + cancel = true + } + } + + if (cancel) { + focusView?.requestFocus() + } else { + showProgress(true) + + editor.putString("url", url) + editor.putString("login", login) + editor.putString("httpUserName", httpLogin) + editor.putString("password", password) + editor.putString("httpPassword", httpPassword) + editor.putBoolean("isSelfSignedCert", isWithSelfSignedCert) + editor.apply() + + val api = SelfossApi( + this, + this@LoginActivity, + isWithSelfSignedCert, + -1L + ) + + if (this@LoginActivity.isNetworkAccessible(this@LoginActivity.findViewById(R.id.loginForm))) { + api.login().enqueue(object : Callback { + private fun preferenceError(t: Throwable) { + editor.remove("url") + editor.remove("login") + editor.remove("httpUserName") + editor.remove("password") + editor.remove("httpPassword") + editor.apply() + binding.urlView.error = getString(R.string.wrong_infos) + binding.loginView.error = getString(R.string.wrong_infos) + binding.passwordView.error = getString(R.string.wrong_infos) + binding.httpLoginView.error = getString(R.string.wrong_infos) + binding.httpPasswordView.error = getString(R.string.wrong_infos) + showProgress(false) + } + + override fun onResponse( + call: Call, + response: Response + ) { + if (response.body() != null && response.body()!!.isSuccess) { + goToMain() + } else { + preferenceError(Exception("No response body...")) + } + } + + override fun onFailure(call: Call, t: Throwable) { + preferenceError(t) + } + }) + } else { + showProgress(false) + } + } + } + + private fun showProgress(show: Boolean) { + val shortAnimTime = resources.getInteger(android.R.integer.config_shortAnimTime) + + binding.loginForm.visibility = if (show) View.GONE else View.VISIBLE + binding.loginForm + .animate() + .setDuration(shortAnimTime.toLong()) + .alpha( + if (show) 0F else 1F + ).setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + binding.loginForm.visibility = if (show) View.GONE else View.VISIBLE + } + } + ) + + binding.loginProgress.visibility = if (show) View.VISIBLE else View.GONE + binding.loginProgress + .animate() + .setDuration(shortAnimTime.toLong()) + .alpha( + if (show) 1F else 0F + ).setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + binding.loginProgress.visibility = if (show) View.VISIBLE else View.GONE + } + } + ) + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.login_menu, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.about -> { + LibsBuilder() + .withAboutIconShown(true) + .withAboutVersionShown(true) + .start(this) + true + } + else -> super.onOptionsItemSelected(item) + } + } +} diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/MainActivity.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/MainActivity.kt index 916dfa4..7e08951 100644 --- a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/MainActivity.kt +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/MainActivity.kt @@ -1,30 +1,23 @@ package bou.amine.apps.readerforselfossv2.android -import androidx.appcompat.app.AppCompatActivity +import android.content.Intent import android.os.Bundle -import bou.amine.apps.readerforselfossv2.Greeting -import android.widget.TextView -import bou.amine.apps.readerforselfossv2.rest.SelfossApi -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import java.util.logging.Logger - -fun greet(): String { - return Greeting().greeting() -} +import androidx.appcompat.app.AppCompatActivity +import bou.amine.apps.readerforselfossv2.android.databinding.ActivityMainBinding class MainActivity : AppCompatActivity() { + private lateinit var binding: ActivityMainBinding + + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - CoroutineScope(Dispatchers.IO).launch { - val s = SelfossApi().getItems("unread", 300, 0).forEach { i -> println(i.getImages()) } - Logger.getLogger(")").info(s.toString()) - } + binding = ActivityMainBinding.inflate(layoutInflater) + val view = binding.root + setContentView(view) - val tv: TextView = findViewById(R.id.text_view) - tv.text = greet() + val intent = Intent(this, LoginActivity::class.java) + + startActivity(intent) + finish() } } diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/MyApp.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/MyApp.kt new file mode 100644 index 0000000..7d19deb --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/MyApp.kt @@ -0,0 +1,101 @@ +package bou.amine.apps.readerforselfossv2.android + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Build +import androidx.preference.PreferenceManager +import android.widget.ImageView +import androidx.multidex.MultiDexApplication +import bou.amine.apps.readerforselfossv2.android.utils.Config +import bou.amine.apps.readerforselfossv2.android.utils.glide.loadMaybeBasicAuth +import com.bumptech.glide.Glide +import com.bumptech.glide.request.RequestOptions +import com.ftinc.scoop.Scoop +import com.mikepenz.materialdrawer.util.AbstractDrawerImageLoader +import com.mikepenz.materialdrawer.util.DrawerImageLoader +import java.util.UUID.randomUUID + +class MyApp : MultiDexApplication() { + private lateinit var config: Config + + override fun onCreate() { + super.onCreate() + config = Config(baseContext) + + val prefs = getSharedPreferences(Config.settingsName, Context.MODE_PRIVATE) + if (prefs.getString("unique_id", "")!!.isEmpty()) { + val editor = prefs.edit() + editor.putString("unique_id", randomUUID().toString()) + editor.apply() + } + + initDrawerImageLoader() + + initTheme() + + tryToHandleBug() + + handleNotificationChannels() + } + + private fun handleNotificationChannels() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + + val name = getString(R.string.notification_channel_sync) + val importance = NotificationManager.IMPORTANCE_LOW + val mChannel = NotificationChannel(Config.syncChannelId, name, importance) + + val newItemsChannelname = getString(R.string.new_items_channel_sync) + val newItemsChannelimportance = NotificationManager.IMPORTANCE_DEFAULT + val newItemsChannelmChannel = NotificationChannel(Config.newItemsChannelId, newItemsChannelname, newItemsChannelimportance) + + notificationManager.createNotificationChannel(mChannel) + notificationManager.createNotificationChannel(newItemsChannelmChannel) + } + } + + private fun initDrawerImageLoader() { + DrawerImageLoader.init(object : AbstractDrawerImageLoader() { + override fun set(imageView: ImageView, uri: Uri, placeholder: Drawable, tag: String?) { + Glide.with(imageView.context) + .loadMaybeBasicAuth(config, uri.toString()) + .apply(RequestOptions.fitCenterTransform().placeholder(placeholder)) + .into(imageView) + } + + override fun cancel(imageView: ImageView) { + Glide.with(imageView.context).clear(imageView) + } + + override fun placeholder(ctx: Context, tag: String?): Drawable { + return baseContext.resources.getDrawable(R.mipmap.ic_launcher) + } + }) + } + + private fun initTheme() { + Scoop.waffleCone() + .addFlavor(getString(R.string.default_theme), R.style.NoBar, true) + .addFlavor(getString(R.string.default_dark_theme), R.style.NoBarDark, false) + .setSharedPreferences(PreferenceManager.getDefaultSharedPreferences(this)) + .initialize() + } + + private fun tryToHandleBug() { + val oldHandler = Thread.getDefaultUncaughtExceptionHandler() + + Thread.setDefaultUncaughtExceptionHandler { thread, e -> + if (e is java.lang.NoClassDefFoundError && e.stackTrace.asList().any { + it.toString().contains("android.view.ViewDebug") + }) { + Unit + } else { + oldHandler.uncaughtException(thread, e) + } + } + } +} \ No newline at end of file diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/ReaderActivity.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/ReaderActivity.kt new file mode 100644 index 0000000..8bd1edd --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/ReaderActivity.kt @@ -0,0 +1,265 @@ +package bou.amine.apps.readerforselfossv2.android + +import android.content.Context +import android.content.SharedPreferences +import android.graphics.Color +import android.os.Bundle +import android.view.KeyEvent +import androidx.preference.PreferenceManager +import androidx.appcompat.app.AppCompatActivity +import android.view.Menu +import android.view.MenuItem +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.room.Room +import androidx.viewpager2.adapter.FragmentStateAdapter +import androidx.viewpager2.widget.ViewPager2 +import bou.amine.apps.readerforselfossv2.android.api.selfoss.Item +import bou.amine.apps.readerforselfossv2.android.api.selfoss.SelfossApi +import bou.amine.apps.readerforselfossv2.android.databinding.ActivityReaderBinding +import bou.amine.apps.readerforselfossv2.android.fragments.ArticleFragment +import bou.amine.apps.readerforselfossv2.android.persistence.database.AppDatabase +import bou.amine.apps.readerforselfossv2.android.persistence.migrations.MIGRATION_1_2 +import bou.amine.apps.readerforselfossv2.android.persistence.migrations.MIGRATION_2_3 +import bou.amine.apps.readerforselfossv2.android.persistence.migrations.MIGRATION_3_4 +import bou.amine.apps.readerforselfossv2.android.themes.AppColors +import bou.amine.apps.readerforselfossv2.android.themes.Toppings +import bou.amine.apps.readerforselfossv2.android.utils.Config +import bou.amine.apps.readerforselfossv2.android.utils.SharedItems +import bou.amine.apps.readerforselfossv2.android.utils.toggleStar +import com.ftinc.scoop.Scoop + +class ReaderActivity : AppCompatActivity() { + + private var markOnScroll: Boolean = false + private var currentItem: Int = 0 + private lateinit var userIdentifier: String + private lateinit var appColors: AppColors + + private lateinit var api: SelfossApi + + private lateinit var toolbarMenu: Menu + + private lateinit var db: AppDatabase + private lateinit var prefs: SharedPreferences + private lateinit var binding: ActivityReaderBinding + + private var activeAlignment: Int = 1 + private val JUSTIFY = 1 + private val ALIGN_LEFT = 2 + + private fun showMenuItem(willAddToFavorite: Boolean) { + if (willAddToFavorite) { + toolbarMenu.findItem(R.id.star).icon.setTint(Color.WHITE) + } else { + toolbarMenu.findItem(R.id.star).icon.setTint(Color.RED) + } + } + + private fun canFavorite() { + showMenuItem(true) + } + + private fun canRemoveFromFavorite() { + showMenuItem(false) + } + + private lateinit var editor: SharedPreferences.Editor + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + appColors = AppColors(this) + binding = ActivityReaderBinding.inflate(layoutInflater) + val view = binding.root + + setContentView(view) + + db = Room.databaseBuilder( + applicationContext, + AppDatabase::class.java, "selfoss-database" + ).addMigrations(MIGRATION_1_2).addMigrations(MIGRATION_2_3).addMigrations(MIGRATION_3_4).build() + + val scoop = Scoop.getInstance() + scoop.bind(this, Toppings.PRIMARY.value, binding.toolBar) + scoop.bindStatusBar(this, Toppings.PRIMARY_DARK.value) + + setSupportActionBar(binding.toolBar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.setDisplayShowHomeEnabled(true) + + val settings = + getSharedPreferences(Config.settingsName, Context.MODE_PRIVATE) + + prefs = PreferenceManager.getDefaultSharedPreferences(this) + editor = prefs.edit() + + userIdentifier = prefs.getString("unique_id", "")!! + markOnScroll = prefs.getBoolean("mark_on_scroll", false) + activeAlignment = prefs.getInt("text_align", JUSTIFY) + + api = SelfossApi( + this, + this@ReaderActivity, + settings.getBoolean("isSelfSignedCert", false), + prefs.getString("api_timeout", "-1")!!.toLong() + ) + + if (allItems.isEmpty()) { + finish() + } + + currentItem = intent.getIntExtra("currentItem", 0) + + readItem(allItems[currentItem]) + + binding.pager.adapter = ScreenSlidePagerAdapter(this) + binding.pager.setCurrentItem(currentItem, false) + } + + override fun onResume() { + super.onResume() + + binding.indicator.setViewPager(binding.pager) + } + + private fun readItem(item: Item) { + if (markOnScroll) { + SharedItems.readItem(applicationContext, api, db, item) + } + } + + override fun onSaveInstanceState(oldInstanceState: Bundle) { + super.onSaveInstanceState(oldInstanceState) + oldInstanceState.clear() + } + + private inner class ScreenSlidePagerAdapter(fa: FragmentActivity) : + FragmentStateAdapter(fa) { + + override fun getItemCount(): Int = allItems.size + + override fun createFragment(position: Int): Fragment = ArticleFragment.newInstance(allItems[position]) + + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + return when (keyCode) { + KeyEvent.KEYCODE_VOLUME_DOWN -> { + val currentFragment = supportFragmentManager.findFragmentByTag("f" + binding.pager.currentItem) as ArticleFragment + currentFragment.scrollDown() + true + } + KeyEvent.KEYCODE_VOLUME_UP -> { + val currentFragment = supportFragmentManager.findFragmentByTag("f" + binding.pager.currentItem) as ArticleFragment + currentFragment.scrollUp() + true + } + else -> { + super.onKeyDown(keyCode, event) + } + } + } + + private fun alignmentMenu(showJustify: Boolean) { + toolbarMenu.findItem(R.id.align_left).isVisible = !showJustify + toolbarMenu.findItem(R.id.align_justify).isVisible = showJustify + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + val inflater = menuInflater + inflater.inflate(R.menu.reader_menu, menu) + toolbarMenu = menu + + if (allItems.isNotEmpty() && allItems[currentItem].starred) { + canRemoveFromFavorite() + } else { + canFavorite() + } + if (activeAlignment == JUSTIFY) { + alignmentMenu(false) + } else { + alignmentMenu(true) + } + + binding.pager.registerOnPageChangeCallback( + object : ViewPager2.OnPageChangeCallback() { + + override fun onPageSelected(position: Int) { + super.onPageSelected(position) + + if (allItems[position].starred) { + canRemoveFromFavorite() + } else { + canFavorite() + } + readItem(allItems[position]) + } + } + ) + + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + fun afterSave() { + allItems[binding.pager.currentItem] = + allItems[binding.pager.currentItem].toggleStar() + canRemoveFromFavorite() + } + + fun afterUnsave() { + allItems[binding.pager.currentItem] = allItems[binding.pager.currentItem].toggleStar() + canFavorite() + } + + when (item.itemId) { + android.R.id.home -> { + onBackPressed() + return true + } + R.id.star -> { + if (allItems[binding.pager.currentItem].starred) { + SharedItems.unstarItem( + this@ReaderActivity, + api, + db, + allItems[binding.pager.currentItem] + ) + afterUnsave() + } else { + SharedItems.starItem( + this@ReaderActivity, + api, + db, + allItems[binding.pager.currentItem] + ) + afterSave() + } + } + R.id.align_left -> { + editor.putInt("text_align", ALIGN_LEFT) + editor.apply() + alignmentMenu(true) + refreshFragment() + } + R.id.align_justify -> { + editor.putInt("text_align", JUSTIFY) + editor.apply() + alignmentMenu(false) + refreshFragment() + } + } + return super.onOptionsItemSelected(item) + } + + private fun refreshFragment() { + finish() + overridePendingTransition(0, 0) + startActivity(intent) + overridePendingTransition(0, 0) + } + + companion object { + var allItems: ArrayList = ArrayList() + } +} diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/SourcesActivity.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/SourcesActivity.kt new file mode 100644 index 0000000..ca9d5da --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/SourcesActivity.kt @@ -0,0 +1,109 @@ +package bou.amine.apps.readerforselfossv2.android + +import android.content.Context +import android.content.Intent +import android.content.res.ColorStateList +import android.os.Bundle +import androidx.preference.PreferenceManager +import androidx.appcompat.app.AppCompatActivity +import androidx.recyclerview.widget.LinearLayoutManager +import android.widget.Toast +import bou.amine.apps.readerforselfossv2.android.adapters.SourcesListAdapter +import bou.amine.apps.readerforselfossv2.android.api.selfoss.SelfossApi +import bou.amine.apps.readerforselfossv2.android.api.selfoss.Source +import bou.amine.apps.readerforselfossv2.android.databinding.ActivitySourcesBinding +import bou.amine.apps.readerforselfossv2.android.themes.AppColors +import bou.amine.apps.readerforselfossv2.android.themes.Toppings +import bou.amine.apps.readerforselfossv2.android.utils.Config +import bou.amine.apps.readerforselfossv2.android.utils.network.isNetworkAccessible +import com.ftinc.scoop.Scoop +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response + +class SourcesActivity : AppCompatActivity() { + + private lateinit var appColors: AppColors + private lateinit var binding: ActivitySourcesBinding + + override fun onCreate(savedInstanceState: Bundle?) { + appColors = AppColors(this@SourcesActivity) + binding = ActivitySourcesBinding.inflate(layoutInflater) + val view = binding.root + + val scoop = Scoop.getInstance() + scoop.bind(this, Toppings.PRIMARY.value, binding.toolbar) + scoop.bindStatusBar(this, Toppings.PRIMARY_DARK.value) + + super.onCreate(savedInstanceState) + + setContentView(view) + + setSupportActionBar(binding.toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.setDisplayShowHomeEnabled(true) + + binding.fab.rippleColor = appColors.colorAccentDark + binding.fab.backgroundTintList = ColorStateList.valueOf(appColors.colorAccent) + } + + override fun onStop() { + super.onStop() + binding.recyclerView.clearOnScrollListeners() + } + + override fun onResume() { + super.onResume() + val mLayoutManager = LinearLayoutManager(this) + + val settings = + getSharedPreferences(Config.settingsName, Context.MODE_PRIVATE) + val prefs = PreferenceManager.getDefaultSharedPreferences(this) + + val api = SelfossApi( + this, + this@SourcesActivity, + settings.getBoolean("isSelfSignedCert", false), + prefs.getString("api_timeout", "-1")!!.toLong() + ) + var items: ArrayList = ArrayList() + + binding.recyclerView.setHasFixedSize(true) + binding.recyclerView.layoutManager = mLayoutManager + + if (this@SourcesActivity.isNetworkAccessible(binding.recyclerView)) { + api.sources.enqueue(object : Callback> { + override fun onResponse( + call: Call>, + response: Response> + ) { + if (response.body() != null && response.body()!!.isNotEmpty()) { + items = response.body() as ArrayList + } + val mAdapter = SourcesListAdapter(this@SourcesActivity, items, api) + binding.recyclerView.adapter = mAdapter + mAdapter.notifyDataSetChanged() + if (items.isEmpty()) { + Toast.makeText( + this@SourcesActivity, + R.string.nothing_here, + Toast.LENGTH_SHORT + ).show() + } + } + + override fun onFailure(call: Call>, t: Throwable) { + Toast.makeText( + this@SourcesActivity, + R.string.cant_get_sources, + Toast.LENGTH_SHORT + ).show() + } + }) + } + + binding.fab.setOnClickListener { + startActivity(Intent(this@SourcesActivity, AddSourceActivity::class.java)) + } + } +} diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/adapters/ItemCardAdapter.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/adapters/ItemCardAdapter.kt new file mode 100644 index 0000000..9f20454 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/adapters/ItemCardAdapter.kt @@ -0,0 +1,153 @@ +package bou.amine.apps.readerforselfossv2.android.adapters + +import android.app.Activity +import android.content.Context +import androidx.recyclerview.widget.RecyclerView +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView.ScaleType +import bou.amine.apps.readerforselfossv2.android.R +import bou.amine.apps.readerforselfossv2.android.api.selfoss.Item +import bou.amine.apps.readerforselfossv2.android.api.selfoss.SelfossApi +import bou.amine.apps.readerforselfossv2.android.databinding.CardItemBinding +import bou.amine.apps.readerforselfossv2.android.persistence.database.AppDatabase +import bou.amine.apps.readerforselfossv2.android.themes.AppColors +import bou.amine.apps.readerforselfossv2.android.utils.Config +import bou.amine.apps.readerforselfossv2.android.utils.LinkOnTouchListener +import bou.amine.apps.readerforselfossv2.android.utils.SharedItems +import bou.amine.apps.readerforselfossv2.android.utils.buildCustomTabsIntent +import bou.amine.apps.readerforselfossv2.android.utils.customtabs.CustomTabActivityHelper +import bou.amine.apps.readerforselfossv2.android.utils.glide.bitmapCenterCrop +import bou.amine.apps.readerforselfossv2.android.utils.glide.circularBitmapDrawable +import bou.amine.apps.readerforselfossv2.android.utils.network.isNetworkAvailable +import bou.amine.apps.readerforselfossv2.android.utils.openInBrowserAsNewTask +import bou.amine.apps.readerforselfossv2.android.utils.openItemUrl +import bou.amine.apps.readerforselfossv2.android.utils.shareLink +import bou.amine.apps.readerforselfossv2.android.utils.sourceAndDateText +import bou.amine.apps.readerforselfossv2.android.utils.toTextDrawableString +import com.amulyakhare.textdrawable.TextDrawable +import com.amulyakhare.textdrawable.util.ColorGenerator +import com.bumptech.glide.Glide + +class ItemCardAdapter( + override val app: Activity, + override var items: ArrayList, + override val api: SelfossApi, + override val db: AppDatabase, + private val helper: CustomTabActivityHelper, + private val internalBrowser: Boolean, + private val articleViewer: Boolean, + private val fullHeightCards: Boolean, + override val appColors: AppColors, + override val userIdentifier: String, + override val config: Config, + override val updateItems: (ArrayList) -> Unit +) : ItemsAdapter() { + private val c: Context = app.baseContext + private val generator: ColorGenerator = ColorGenerator.MATERIAL + private val imageMaxHeight: Int = + c.resources.getDimension(R.dimen.card_image_max_height).toInt() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val binding = CardItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + with(holder) { + val itm = items[position] + + binding.favButton.isSelected = itm.starred + binding.title.text = itm.getTitleDecoded() + + binding.title.setOnTouchListener(LinkOnTouchListener()) + + binding.title.setLinkTextColor(appColors.colorAccent) + + binding.sourceTitleAndDate.text = itm.sourceAndDateText() + + if (!fullHeightCards) { + binding.itemImage.maxHeight = imageMaxHeight + binding.itemImage.scaleType = ScaleType.CENTER_CROP + } + + if (itm.getThumbnail(c).isEmpty()) { + binding.itemImage.visibility = View.GONE + Glide.with(c).clear(binding.itemImage) + binding.itemImage.setImageDrawable(null) + } else { + binding.itemImage.visibility = View.VISIBLE + c.bitmapCenterCrop(config, itm.getThumbnail(c), binding.itemImage) + } + + if (itm.getIcon(c).isEmpty()) { + val color = generator.getColor(itm.getSourceTitle()) + + val drawable = + TextDrawable + .builder() + .round() + .build(itm.getSourceTitle().toTextDrawableString(c), color) + binding.sourceImage.setImageDrawable(drawable) + } else { + c.circularBitmapDrawable(config, itm.getIcon(c), binding.sourceImage) + } + } + } + + override fun getItemCount(): Int { + return items.size + } + + inner class ViewHolder(val binding: CardItemBinding) : RecyclerView.ViewHolder(binding.root) { + init { + handleClickListeners() + handleCustomTabActions() + } + + private fun handleClickListeners() { + + binding.favButton.setOnClickListener { + val item = items[bindingAdapterPosition] + if (isNetworkAvailable(c)) { + if (item.starred) { + SharedItems.unstarItem(c, api, db, item) + item.starred = false + binding.favButton.isSelected = false + } else { + SharedItems.starItem(c, api, db, item) + item.starred = true + binding.favButton.isSelected = true + } + } + } + + binding.shareBtn.setOnClickListener { + val item = items[bindingAdapterPosition] + c.shareLink(item.getLinkDecoded(), item.getTitleDecoded()) + } + + binding.browserBtn.setOnClickListener { + c.openInBrowserAsNewTask(items[bindingAdapterPosition]) + } + } + + private fun handleCustomTabActions() { + val customTabsIntent = c.buildCustomTabsIntent() + helper.bindCustomTabsService(app) + + binding.root.setOnClickListener { + c.openItemUrl( + items, + bindingAdapterPosition, + items[bindingAdapterPosition].getLinkDecoded(), + customTabsIntent, + internalBrowser, + articleViewer, + app + ) + } + } + } +} diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/adapters/ItemListAdapter.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/adapters/ItemListAdapter.kt new file mode 100644 index 0000000..de7a516 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/adapters/ItemListAdapter.kt @@ -0,0 +1,105 @@ +package bou.amine.apps.readerforselfossv2.android.adapters + +import android.app.Activity +import android.content.Context +import androidx.recyclerview.widget.RecyclerView +import android.view.LayoutInflater +import android.view.ViewGroup +import bou.amine.apps.readerforselfossv2.android.api.selfoss.Item +import bou.amine.apps.readerforselfossv2.android.api.selfoss.SelfossApi +import bou.amine.apps.readerforselfossv2.android.databinding.ListItemBinding +import bou.amine.apps.readerforselfossv2.android.persistence.database.AppDatabase +import bou.amine.apps.readerforselfossv2.android.themes.AppColors +import bou.amine.apps.readerforselfossv2.android.utils.Config +import bou.amine.apps.readerforselfossv2.android.utils.LinkOnTouchListener +import bou.amine.apps.readerforselfossv2.android.utils.buildCustomTabsIntent +import bou.amine.apps.readerforselfossv2.android.utils.customtabs.CustomTabActivityHelper +import bou.amine.apps.readerforselfossv2.android.utils.glide.bitmapCenterCrop +import bou.amine.apps.readerforselfossv2.android.utils.glide.circularBitmapDrawable +import bou.amine.apps.readerforselfossv2.android.utils.openItemUrl +import bou.amine.apps.readerforselfossv2.android.utils.sourceAndDateText +import bou.amine.apps.readerforselfossv2.android.utils.toTextDrawableString +import com.amulyakhare.textdrawable.TextDrawable +import com.amulyakhare.textdrawable.util.ColorGenerator +import kotlin.collections.ArrayList + +class ItemListAdapter( + override val app: Activity, + override var items: ArrayList, + override val api: SelfossApi, + override val db: AppDatabase, + private val helper: CustomTabActivityHelper, + private val internalBrowser: Boolean, + private val articleViewer: Boolean, + override val userIdentifier: String, + override val appColors: AppColors, + override val config: Config, + override val updateItems: (ArrayList) -> Unit +) : ItemsAdapter() { + private val generator: ColorGenerator = ColorGenerator.MATERIAL + private val c: Context = app.baseContext + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val binding = ListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + with(holder) { + val itm = items[position] + + binding.title.text = itm.getTitleDecoded() + + binding.title.setOnTouchListener(LinkOnTouchListener()) + + binding.title.setLinkTextColor(appColors.colorAccent) + + binding.sourceTitleAndDate.text = itm.sourceAndDateText() + + if (itm.getThumbnail(c).isEmpty()) { + + if (itm.getIcon(c).isEmpty()) { + val color = generator.getColor(itm.getSourceTitle()) + + val drawable = + TextDrawable + .builder() + .round() + .build(itm.getSourceTitle().toTextDrawableString(c), color) + + binding.itemImage.setImageDrawable(drawable) + } else { + c.circularBitmapDrawable(config, itm.getIcon(c), binding.itemImage) + } + } else { + c.bitmapCenterCrop(config, itm.getThumbnail(c), binding.itemImage) + } + } + } + + override fun getItemCount(): Int = items.size + + inner class ViewHolder(val binding: ListItemBinding) : RecyclerView.ViewHolder(binding.root) { + + init { + handleCustomTabActions() + } + + private fun handleCustomTabActions() { + val customTabsIntent = c.buildCustomTabsIntent() + helper.bindCustomTabsService(app) + + binding.root.setOnClickListener { + c.openItemUrl( + items, + bindingAdapterPosition, + items[bindingAdapterPosition].getLinkDecoded(), + customTabsIntent, + internalBrowser, + articleViewer, + app + ) + } + } + } +} diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/adapters/ItemsAdapter.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/adapters/ItemsAdapter.kt new file mode 100644 index 0000000..79fb9c9 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/adapters/ItemsAdapter.kt @@ -0,0 +1,119 @@ +package bou.amine.apps.readerforselfossv2.android.adapters + +import android.app.Activity +import android.graphics.Color +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import bou.amine.apps.readerforselfossv2.android.R +import bou.amine.apps.readerforselfossv2.android.api.selfoss.Item +import bou.amine.apps.readerforselfossv2.android.api.selfoss.SelfossApi +import bou.amine.apps.readerforselfossv2.android.persistence.database.AppDatabase +import bou.amine.apps.readerforselfossv2.android.themes.AppColors +import bou.amine.apps.readerforselfossv2.android.utils.Config +import bou.amine.apps.readerforselfossv2.android.utils.SharedItems +import com.google.android.material.snackbar.Snackbar + +abstract class ItemsAdapter : RecyclerView.Adapter() { + abstract var items: ArrayList + abstract val api: SelfossApi + abstract val db: AppDatabase + abstract val userIdentifier: String + abstract val app: Activity + abstract val appColors: AppColors + abstract val config: Config + abstract val updateItems: (ArrayList) -> Unit + + fun updateAllItems() { + items = SharedItems.focusedItems + notifyDataSetChanged() + updateItems(items) + } + + private fun unmarkSnackbar(i: Item, position: Int) { + val s = Snackbar + .make( + app.findViewById(R.id.coordLayout), + R.string.marked_as_read, + Snackbar.LENGTH_LONG + ) + .setAction(R.string.undo_string) { + SharedItems.unreadItem(app, api, db, i) + if (SharedItems.displayedItems == "unread") { + addItemAtIndex(i, position) + } else { + notifyItemChanged(position) + } + } + + val view = s.view + val tv: TextView = view.findViewById(com.google.android.material.R.id.snackbar_text) + tv.setTextColor(Color.WHITE) + s.show() + } + + private fun markSnackbar(position: Int) { + val s = Snackbar + .make( + app.findViewById(R.id.coordLayout), + R.string.marked_as_unread, + Snackbar.LENGTH_LONG + ) + .setAction(R.string.undo_string) { + SharedItems.readItem(app, api, db, items[position]) + items = SharedItems.focusedItems + if (SharedItems.displayedItems == "unread") { + notifyItemRemoved(position) + updateItems(items) + } else { + notifyItemChanged(position) + } + } + + val view = s.view + val tv: TextView = view.findViewById(com.google.android.material.R.id.snackbar_text) + tv.setTextColor(Color.WHITE) + s.show() + } + + fun handleItemAtIndex(position: Int) { + if (SharedItems.unreadItemStatusAtIndex(position)) { + readItemAtIndex(position) + } else { + unreadItemAtIndex(position) + } + } + + private fun readItemAtIndex(position: Int) { + val i = items[position] + SharedItems.readItem(app, api, db, i) + if (SharedItems.displayedItems == "unread") { + items.remove(i) + notifyItemRemoved(position) + updateItems(items) + } else { + notifyItemChanged(position) + } + unmarkSnackbar(i, position) + } + + private fun unreadItemAtIndex(position: Int) { + SharedItems.unreadItem(app, api, db, items[position]) + notifyItemChanged(position) + markSnackbar(position) + } + + fun addItemAtIndex(item: Item, position: Int) { + items.add(position, item) + notifyItemInserted(position) + updateItems(items) + + } + + fun addItemsAtEnd(newItems: List) { + val oldSize = items.size + items.addAll(newItems) + notifyItemRangeInserted(oldSize, newItems.size) + updateItems(items) + + } +} \ No newline at end of file diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/adapters/SourcesListAdapter.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/adapters/SourcesListAdapter.kt new file mode 100644 index 0000000..d885e82 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/adapters/SourcesListAdapter.kt @@ -0,0 +1,106 @@ +package bou.amine.apps.readerforselfossv2.android.adapters + +import android.app.Activity +import android.content.Context +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.RecyclerView +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.Button +import android.widget.Toast +import bou.amine.apps.readerforselfossv2.android.R +import bou.amine.apps.readerforselfossv2.android.api.selfoss.SelfossApi +import bou.amine.apps.readerforselfossv2.android.api.selfoss.Source +import bou.amine.apps.readerforselfossv2.android.api.selfoss.SuccessResponse +import bou.amine.apps.readerforselfossv2.android.databinding.SourceListItemBinding +import bou.amine.apps.readerforselfossv2.android.utils.Config +import bou.amine.apps.readerforselfossv2.android.utils.glide.circularBitmapDrawable +import bou.amine.apps.readerforselfossv2.android.utils.network.isNetworkAccessible +import bou.amine.apps.readerforselfossv2.android.utils.toTextDrawableString +import com.amulyakhare.textdrawable.TextDrawable +import com.amulyakhare.textdrawable.util.ColorGenerator +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response + +class SourcesListAdapter( + private val app: Activity, + private val items: ArrayList, + private val api: SelfossApi +) : RecyclerView.Adapter() { + private val c: Context = app.baseContext + private val generator: ColorGenerator = ColorGenerator.MATERIAL + private lateinit var config: Config + private lateinit var binding: SourceListItemBinding + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + binding = SourceListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ViewHolder(binding.root) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val itm = items[position] + config = Config(c) + + if (itm.getIcon(c).isEmpty()) { + val color = generator.getColor(itm.getTitleDecoded()) + + val drawable = + TextDrawable + .builder() + .round() + .build(itm.getTitleDecoded().toTextDrawableString(c), color) + binding.itemImage.setImageDrawable(drawable) + } else { + c.circularBitmapDrawable(config, itm.getIcon(c), binding.itemImage) + } + + binding.sourceTitle.text = itm.getTitleDecoded() + } + + override fun getItemCount(): Int = items.size + + inner class ViewHolder(internal val mView: ConstraintLayout) : RecyclerView.ViewHolder(mView) { + + init { + handleClickListeners() + } + + private fun handleClickListeners() { + + val deleteBtn: Button = mView.findViewById(R.id.deleteBtn) + + deleteBtn.setOnClickListener { + if (c.isNetworkAccessible(null)) { + val (id) = items[adapterPosition] + api.deleteSource(id).enqueue(object : Callback { + override fun onResponse( + call: Call, + response: Response + ) { + if (response.body() != null && response.body()!!.isSuccess) { + items.removeAt(adapterPosition) + notifyItemRemoved(adapterPosition) + notifyItemRangeChanged(adapterPosition, itemCount) + } else { + Toast.makeText( + app, + R.string.can_delete_source, + Toast.LENGTH_SHORT + ).show() + } + } + + override fun onFailure(call: Call, t: Throwable) { + Toast.makeText( + app, + R.string.can_delete_source, + Toast.LENGTH_SHORT + ).show() + } + }) + } + } + } + } +} diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/api/mercury/MercuryApi.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/api/mercury/MercuryApi.kt new file mode 100644 index 0000000..46d32ff --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/api/mercury/MercuryApi.kt @@ -0,0 +1,35 @@ +package bou.amine.apps.readerforselfossv2.android.api.mercury + +import com.google.gson.GsonBuilder +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Call +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +class MercuryApi() { + private val service: MercuryService + + init { + + val interceptor = HttpLoggingInterceptor() + interceptor.level = HttpLoggingInterceptor.Level.NONE + val client = OkHttpClient.Builder().addInterceptor(interceptor).build() + + val gson = GsonBuilder() + .setLenient() + .create() + val retrofit = + Retrofit + .Builder() + .baseUrl("https://www.amine-bou.fr") + .client(client) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + service = retrofit.create(MercuryService::class.java) + } + + fun parseUrl(url: String): Call { + return service.parseUrl(url) + } +} diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/api/mercury/MercuryModels.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/api/mercury/MercuryModels.kt new file mode 100644 index 0000000..de4e5cc --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/api/mercury/MercuryModels.kt @@ -0,0 +1,59 @@ +package bou.amine.apps.readerforselfossv2.android.api.mercury + +import android.os.Parcel +import android.os.Parcelable +import com.google.gson.annotations.SerializedName + +class ParsedContent( + @SerializedName("title") val title: String, + @SerializedName("content") val content: String?, + @SerializedName("date_published") val date_published: String, + @SerializedName("lead_image_url") val lead_image_url: String?, + @SerializedName("dek") val dek: String, + @SerializedName("url") val url: String, + @SerializedName("domain") val domain: String, + @SerializedName("excerpt") val excerpt: String, + @SerializedName("total_pages") val total_pages: Int, + @SerializedName("rendered_pages") val rendered_pages: Int, + @SerializedName("next_page_url") val next_page_url: String +) : Parcelable { + + companion object { + @JvmField + val CREATOR: Parcelable.Creator = + object : Parcelable.Creator { + override fun createFromParcel(source: Parcel): ParsedContent = ParsedContent(source) + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } + + constructor(source: Parcel) : this( + title = source.readString().orEmpty(), + content = source.readString(), + date_published = source.readString().orEmpty(), + lead_image_url = source.readString(), + dek = source.readString().orEmpty(), + url = source.readString().orEmpty(), + domain = source.readString().orEmpty(), + excerpt = source.readString().orEmpty(), + total_pages = source.readInt(), + rendered_pages = source.readInt(), + next_page_url = source.readString().orEmpty() + ) + + override fun describeContents() = 0 + + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeString(title) + dest.writeString(content) + dest.writeString(date_published) + dest.writeString(lead_image_url) + dest.writeString(dek) + dest.writeString(url) + dest.writeString(domain) + dest.writeString(excerpt) + dest.writeInt(total_pages) + dest.writeInt(rendered_pages) + dest.writeString(next_page_url) + } +} diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/api/mercury/MercuryService.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/api/mercury/MercuryService.kt new file mode 100644 index 0000000..ce1b3e0 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/api/mercury/MercuryService.kt @@ -0,0 +1,10 @@ +package bou.amine.apps.readerforselfossv2.android.api.mercury + +import retrofit2.Call +import retrofit2.http.GET +import retrofit2.http.Query + +interface MercuryService { + @GET("parser.php") + fun parseUrl(@Query("link") link: String): Call +} diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/api/selfoss/BooleanTypeAdapter.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/api/selfoss/BooleanTypeAdapter.kt new file mode 100644 index 0000000..90d8a27 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/api/selfoss/BooleanTypeAdapter.kt @@ -0,0 +1,22 @@ +package bou.amine.apps.readerforselfossv2.android.api.selfoss + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonParseException +import java.lang.reflect.Type + +internal class BooleanTypeAdapter : JsonDeserializer { + + @Throws(JsonParseException::class) + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext + ): Boolean? = + try { + json.asInt == 1 + } catch (e: Exception) { + json.asBoolean + } +} diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/api/selfoss/SelfossApi.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/api/selfoss/SelfossApi.kt new file mode 100644 index 0000000..1f59981 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/api/selfoss/SelfossApi.kt @@ -0,0 +1,245 @@ +package bou.amine.apps.readerforselfossv2.android.api.selfoss + +import android.app.Activity +import android.content.Context +import bou.amine.apps.readerforselfossv2.android.utils.Config +import bou.amine.apps.readerforselfossv2.android.utils.SharedItems +import bou.amine.apps.readerforselfossv2.android.utils.getUnsafeHttpClient +import com.burgstaller.okhttp.AuthenticationCacheInterceptor +import com.burgstaller.okhttp.CachingAuthenticatorDecorator +import com.burgstaller.okhttp.DispatchingAuthenticator +import com.burgstaller.okhttp.basic.BasicAuthenticator +import com.burgstaller.okhttp.digest.CachingAuthenticator +import com.burgstaller.okhttp.digest.Credentials +import com.burgstaller.okhttp.digest.DigestAuthenticator +import com.google.gson.GsonBuilder +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.ResponseBody.Companion.toResponseBody +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Call +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.net.SocketTimeoutException +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit + +class SelfossApi( + c: Context, + callingActivity: Activity?, + isWithSelfSignedCert: Boolean, + timeout: Long +) { + + private lateinit var service: SelfossService + private val config: Config = Config(c) + private val userName: String + private val password: String + + fun OkHttpClient.Builder.maybeWithSelfSigned(isWithSelfSignedCert: Boolean): OkHttpClient.Builder = + if (isWithSelfSignedCert) { + getUnsafeHttpClient() + } else { + this + } + + fun OkHttpClient.Builder.maybeWithSettingsTimeout(timeout: Long): OkHttpClient.Builder = + if (timeout != -1L) { + this.readTimeout(timeout, TimeUnit.SECONDS) + .connectTimeout(timeout, TimeUnit.SECONDS) + } else { + this + } + + fun Credentials.createAuthenticator(): DispatchingAuthenticator = + DispatchingAuthenticator.Builder() + .with("digest", DigestAuthenticator(this)) + .with("basic", BasicAuthenticator(this)) + .build() + + fun DispatchingAuthenticator.getHttpClien(isWithSelfSignedCert: Boolean, timeout: Long): OkHttpClient.Builder { + val authCache = ConcurrentHashMap() + return OkHttpClient + .Builder() + .maybeWithSettingsTimeout(timeout) + .maybeWithSelfSigned(isWithSelfSignedCert) + .authenticator(CachingAuthenticatorDecorator(this, authCache)) + .addInterceptor(AuthenticationCacheInterceptor(authCache)) + .addInterceptor(object: Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request: Request = chain.request() + val response: Response = chain.proceed(request) + + if (response.code == 408) { + return response + } + return response + } + }) + } + + init { + userName = config.userLogin + password = config.userPassword + + val authenticator = + Credentials( + config.httpUserLogin, + config.httpUserPassword + ).createAuthenticator() + + val gson = + GsonBuilder() + .registerTypeAdapter(Boolean::class.javaPrimitiveType, BooleanTypeAdapter()) + .registerTypeAdapter(SelfossTagType::class.java, SelfossTagTypeTypeAdapter()) + .setLenient() + .create() + + val logging = HttpLoggingInterceptor() + + + logging.level = HttpLoggingInterceptor.Level.NONE + val httpClient = authenticator.getHttpClien(isWithSelfSignedCert, timeout) + + val timeoutCode = 504 + httpClient + .addInterceptor { chain -> + val res = chain.proceed(chain.request()) + if (res.code == timeoutCode) { + throw SocketTimeoutException("timeout") + } + res + } + .addInterceptor(logging) + .addInterceptor { chain -> + val request = chain.request() + try { + chain.proceed(request) + } catch (e: SocketTimeoutException) { + Response.Builder() + .code(timeoutCode) + .protocol(Protocol.HTTP_2) + .body("".toResponseBody("text/plain".toMediaTypeOrNull())) + .message("") + .request(request) + .build() + } + } + + try { + val retrofit = + Retrofit + .Builder() + .baseUrl(config.baseUrl) + .client(httpClient.build()) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + service = retrofit.create(SelfossService::class.java) + } catch (e: IllegalArgumentException) { + if (callingActivity != null) { + Config.logoutAndRedirect(c, callingActivity, config.settings.edit(), baseUrlFail = true) + } + } + } + + fun login(): Call = + service.loginToSelfoss(config.userLogin, config.userPassword) + + suspend fun readItems( + itemsNumber: Int, + offset: Int + ): retrofit2.Response> = + getItems("read", SharedItems.tagFilter, SharedItems.sourceIDFilter, SharedItems.searchFilter, itemsNumber, offset) + + suspend fun newItems( + itemsNumber: Int, + offset: Int + ): retrofit2.Response> = + getItems("unread", SharedItems.tagFilter, SharedItems.sourceIDFilter, SharedItems.searchFilter, itemsNumber, offset) + + suspend fun starredItems( + itemsNumber: Int, + offset: Int + ): retrofit2.Response> = + getItems("starred", SharedItems.tagFilter, SharedItems.sourceIDFilter, SharedItems.searchFilter, itemsNumber, offset) + + fun allItems(): Call> = + service.allItems(userName, password) + + suspend fun allNewItems(): retrofit2.Response> = + getItems("unread", null, null, null, 200, 0) + + suspend fun allReadItems(): retrofit2.Response> = + getItems("read", null, null, null, 200, 0) + + suspend fun allStarredItems(): retrofit2.Response> = + getItems("read", null, null, null, 200, 0) + + private suspend fun getItems( + type: String, + tag: String?, + sourceId: Long?, + search: String?, + items: Int, + offset: Int + ): retrofit2.Response> = + service.getItems(type, tag, sourceId, search, null, userName, password, items, offset) + + suspend fun updateItems( + updatedSince: String + ): retrofit2.Response> = + service.getItems("read", null, null, null, updatedSince, userName, password, 200, 0) + + fun markItem(itemId: String): Call = + service.markAsRead(itemId, userName, password) + + fun unmarkItem(itemId: String): Call = + service.unmarkAsRead(itemId, userName, password) + + suspend fun readAll(ids: List): SuccessResponse = + service.markAllAsRead(ids, userName, password) + + fun starrItem(itemId: String): Call = + service.starr(itemId, userName, password) + + fun unstarrItem(itemId: String): Call = + service.unstarr(itemId, userName, password) + + suspend fun stats(): retrofit2.Response = service.stats(userName, password) + + val tags: Call> + get() = service.tags(userName, password) + + fun update(): Call = + service.update(userName, password) + + val apiVersion: Call + get() = service.version() + + val sources: Call> + get() = service.sources(userName, password) + + fun deleteSource(id: String): Call = + service.deleteSource(id, userName, password) + + fun spouts(): Call> = + service.spouts(userName, password) + + fun createSource( + title: String, + url: String, + spout: String, + tags: String, + filter: String + ): Call = + service.createSource(title, url, spout, tags, filter, userName, password) + + fun createSourceApi2( + title: String, + url: String, + spout: String, + tags: List, + filter: String + ): Call = + service.createSourceApi2(title, url, spout, tags, filter, userName, password) +} diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/api/selfoss/SelfossFetching.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/api/selfoss/SelfossFetching.kt new file mode 100644 index 0000000..6ecbf29 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/api/selfoss/SelfossFetching.kt @@ -0,0 +1,134 @@ +package bou.amine.apps.readerforselfossv2.android.api.selfoss + +import android.content.Context +import bou.amine.apps.readerforselfossv2.android.persistence.database.AppDatabase +import bou.amine.apps.readerforselfossv2.android.utils.SharedItems +import bou.amine.apps.readerforselfossv2.android.utils.network.isNetworkAvailable +import kotlinx.coroutines.* +import retrofit2.Response + +suspend fun getAndStoreAllItems(context: Context, api: SelfossApi, db: AppDatabase) = withContext(Dispatchers.IO) { + if (isNetworkAvailable(context)) { + launch { + try { + enqueueArticles(api.allNewItems(), db, true) + } catch (e: Throwable) {} + } + launch { + try { + enqueueArticles(api.allReadItems(), db, false) + } catch (e: Throwable) {} + } + launch { + try { + enqueueArticles(api.allStarredItems(), db, false) + } catch (e: Throwable) {} + } + } else { + launch { SharedItems.updateDatabase(db) } + } +} + +suspend fun updateItems(context: Context, api: SelfossApi, db: AppDatabase) = coroutineScope { + if (isNetworkAvailable(context)) { + launch { + try { + enqueueArticles(api.updateItems(SharedItems.items[0].datetime), db, true) + } catch (e: Throwable) {} + } + } +} + +suspend fun refreshFocusedItems(context: Context, api: SelfossApi, db: AppDatabase, itemsNumber: Int) = withContext(Dispatchers.IO) { + if (isNetworkAvailable(context)) { + val response = when (SharedItems.displayedItems) { + "read" -> api.readItems(itemsNumber, 0) + "unread" -> api.newItems(itemsNumber, 0) + "starred" -> api.starredItems(itemsNumber, 0) + else -> api.readItems(itemsNumber, 0) + } + + if (response.isSuccessful) { + SharedItems.refreshFocusedItems(response.body() as ArrayList) + SharedItems.updateDatabase(db) + } + } +} + +suspend fun getReadItems(context: Context, api: SelfossApi, db: AppDatabase, itemsNumber: Int, offset: Int) = withContext(Dispatchers.IO) { + if (isNetworkAvailable(context)) { + try { + enqueueArticles(api.readItems( itemsNumber, offset), db, false) + SharedItems.fetchedAll = true + SharedItems.updateDatabase(db) + } catch (e: Throwable) {} + } +} + +suspend fun getUnreadItems(context: Context, api: SelfossApi, db: AppDatabase, itemsNumber: Int, offset: Int) = withContext(Dispatchers.IO) { + if (isNetworkAvailable(context)) { + try { + if (!SharedItems.fetchedUnread) { + SharedItems.clearDBItems(db) + } + enqueueArticles(api.newItems(itemsNumber, offset), db, false) + SharedItems.fetchedUnread = true + } catch (e: Throwable) {} + } + SharedItems.updateDatabase(db) +} + +suspend fun getStarredItems(context: Context, api: SelfossApi, db: AppDatabase, itemsNumber: Int, offset: Int) = withContext(Dispatchers.IO) { + if (isNetworkAvailable(context)) { + try { + enqueueArticles(api.starredItems(itemsNumber, offset), db, false) + SharedItems.fetchedStarred = true + SharedItems.updateDatabase(db) + } catch (e: Throwable) { + } + } +} + +suspend fun readAll(context: Context, api: SelfossApi, db: AppDatabase): Boolean { + var success = false + if (isNetworkAvailable(context)) { + try { + val ids = SharedItems.focusedItems.map { it.id } + if (ids.isNotEmpty()) { + val result = api.readAll(ids) + SharedItems.readItems(db, ids) + success = result.isSuccess + } + } catch (e: Throwable) {} + } + return success +} + +suspend fun reloadBadges(context: Context, api: SelfossApi) = withContext(Dispatchers.IO) { + if (isNetworkAvailable(context)) { + try { + val response = api.stats() + + if (response.isSuccessful) { + val badges = response.body() + SharedItems.badgeUnread = badges!!.unread + SharedItems.badgeAll = badges.total + SharedItems.badgeStarred = badges.starred + } + } catch (e: Throwable) {} + } else { + SharedItems.computeBadges() + } +} + +private fun enqueueArticles(response: Response>, db: AppDatabase, clearDatabase: Boolean) { + if (response.isSuccessful) { + if (clearDatabase) { + CoroutineScope(Dispatchers.IO).launch { + SharedItems.clearDBItems(db) + } + } + val allItems = response.body() as ArrayList + SharedItems.appendNewItems(allItems) + } +} \ No newline at end of file diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/api/selfoss/SelfossModels.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/api/selfoss/SelfossModels.kt new file mode 100644 index 0000000..455e0b9 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/api/selfoss/SelfossModels.kt @@ -0,0 +1,253 @@ +package bou.amine.apps.readerforselfossv2.android.api.selfoss + +import android.content.Context +import android.net.Uri +import android.os.Parcel +import android.os.Parcelable +import android.text.Html +import android.webkit.URLUtil +import org.jsoup.Jsoup + +import bou.amine.apps.readerforselfossv2.android.utils.Config +import bou.amine.apps.readerforselfossv2.android.utils.isEmptyOrNullOrNullString +import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.request.RequestOptions +import com.google.gson.annotations.SerializedName +import java.util.* +import kotlin.collections.ArrayList + +private fun constructUrl(config: Config?, path: String, file: String?): String { + return if (file.isEmptyOrNullOrNullString()) { + "" + } else { + val baseUriBuilder = Uri.parse(config!!.baseUrl).buildUpon() + baseUriBuilder.appendPath(path).appendPath(file) + + baseUriBuilder.toString() + } +} + +data class Tag( + @SerializedName("tag") val tag: String, + @SerializedName("color") val color: String, + @SerializedName("unread") val unread: Int +) { + fun getTitleDecoded(): String { + return Html.fromHtml(tag).toString() + } +} + +class SuccessResponse(@SerializedName("success") val success: Boolean) { + val isSuccess: Boolean + get() = success +} + +class Stats( + @SerializedName("total") val total: Int, + @SerializedName("unread") val unread: Int, + @SerializedName("starred") val starred: Int +) + +data class Spout( + @SerializedName("name") val name: String, + @SerializedName("description") val description: String +) + +data class ApiVersion( + @SerializedName("version") val version: String?, + @SerializedName("apiversion") val apiversion: String? +) { + fun getApiMajorVersion() : Int { + var versionNumber = 0 + if (apiversion != null) { + versionNumber = apiversion.substringBefore(".").toInt() + } + return versionNumber + } +} + +data class Source( + @SerializedName("id") val id: String, + @SerializedName("title") val title: String, + @SerializedName("tags") val tags: SelfossTagType, + @SerializedName("spout") val spout: String, + @SerializedName("error") val error: String, + @SerializedName("icon") val icon: String +) { + var config: Config? = null + + fun getIcon(app: Context): String { + if (config == null) { + config = Config(app) + } + return constructUrl(config, "favicons", icon) + } + + fun getTitleDecoded(): String { + return Html.fromHtml(title).toString() + } +} + +data class Item( + @SerializedName("id") val id: String, + @SerializedName("datetime") val datetime: String, + @SerializedName("title") val title: String, + @SerializedName("content") val content: String, + @SerializedName("unread") var unread: Boolean, + @SerializedName("starred") var starred: Boolean, + @SerializedName("thumbnail") val thumbnail: String?, + @SerializedName("icon") val icon: String?, + @SerializedName("link") val link: String, + @SerializedName("sourcetitle") val sourcetitle: String, + @SerializedName("tags") val tags: SelfossTagType +) : Parcelable { + + var config: Config? = null + + companion object { + @JvmField val CREATOR: Parcelable.Creator = object : Parcelable.Creator { + override fun createFromParcel(source: Parcel): Item = Item(source) + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } + + constructor(source: Parcel) : this( + id = source.readString().orEmpty(), + datetime = source.readString().orEmpty(), + title = source.readString().orEmpty(), + content = source.readString().orEmpty(), + unread = 0.toByte() != source.readByte(), + starred = 0.toByte() != source.readByte(), + thumbnail = source.readString(), + icon = source.readString(), + link = source.readString().orEmpty(), + sourcetitle = source.readString().orEmpty(), + tags = if (source.readParcelable(ClassLoader.getSystemClassLoader()) != null) source.readParcelable(ClassLoader.getSystemClassLoader())!! else SelfossTagType("") + ) + + override fun describeContents() = 0 + + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeString(id) + dest.writeString(datetime) + dest.writeString(title) + dest.writeString(content) + dest.writeByte((if (unread) 1 else 0)) + dest.writeByte((if (starred) 1 else 0)) + dest.writeString(thumbnail) + dest.writeString(icon) + dest.writeString(link) + dest.writeString(sourcetitle) + dest.writeParcelable(tags, flags) + } + + fun getIcon(app: Context): String { + if (config == null) { + config = Config(app) + } + return constructUrl(config, "favicons", icon) + } + + fun getThumbnail(app: Context): String { + if (config == null) { + config = Config(app) + } + return constructUrl(config, "thumbnails", thumbnail) + } + + fun getImages() : ArrayList { + val allImages = ArrayList() + + for ( image in Jsoup.parse(content).getElementsByTag("img")) { + val url = image.attr("src") + if (url.lowercase(Locale.US).contains(".jpg") || + url.lowercase(Locale.US).contains(".jpeg") || + url.lowercase(Locale.US).contains(".png") || + url.lowercase(Locale.US).contains(".webp")) + { + allImages.add(url) + } + } + return allImages + } + + fun preloadImages(context: Context) : Boolean { + val imageUrls = this.getImages() + + val glideOptions = RequestOptions.diskCacheStrategyOf(DiskCacheStrategy.ALL).timeout(10000) + + + try { + for (url in imageUrls) { + if ( URLUtil.isValidUrl(url)) { + val image = Glide.with(context).asBitmap() + .apply(glideOptions) + .load(url).submit() + } + } + } catch (e : Error) { + return false + } + + return true + } + + fun getTitleDecoded(): String { + return Html.fromHtml(title).toString() + } + + fun getSourceTitle(): String { + return Html.fromHtml(sourcetitle).toString() + } + + // TODO: maybe find a better way to handle these kind of urls + fun getLinkDecoded(): String { + var stringUrl: String + stringUrl = + if (link.startsWith("http://news.google.com/news/") || link.startsWith("https://news.google.com/news/")) { + if (link.contains("&url=")) { + link.substringAfter("&url=") + } else { + this.link.replace("&", "&") + } + } else { + this.link.replace("&", "&") + } + + // handle :443 => https + if (stringUrl.contains(":443")) { + stringUrl = stringUrl.replace(":443", "").replace("http://", "https://") + } + + // handle url not starting with http + if (stringUrl.startsWith("//")) { + stringUrl = "http:$stringUrl" + } + + return stringUrl + } +} + +data class SelfossTagType(val tags: String) : Parcelable { + + companion object { + @JvmField val CREATOR: Parcelable.Creator = + object : Parcelable.Creator { + override fun createFromParcel(source: Parcel): SelfossTagType = + SelfossTagType(source) + + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } + + constructor(source: Parcel) : this( + tags = source.readString().orEmpty() + ) + + override fun describeContents() = 0 + + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeString(tags) + } +} \ No newline at end of file diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/api/selfoss/SelfossService.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/api/selfoss/SelfossService.kt new file mode 100644 index 0000000..335c05e --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/api/selfoss/SelfossService.kt @@ -0,0 +1,141 @@ +package bou.amine.apps.readerforselfossv2.android.api.selfoss + +import retrofit2.Call +import retrofit2.Response +import retrofit2.http.DELETE +import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded +import retrofit2.http.GET +import retrofit2.http.Headers +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +internal interface SelfossService { + + @GET("login") + fun loginToSelfoss(@Query("username") username: String, @Query("password") password: String): Call + + @GET("items") + suspend fun getItems( + @Query("type") type: String, + @Query("tag") tag: String?, + @Query("source") source: Long?, + @Query("search") search: String?, + @Query("updatedsince") updatedSince: String?, + @Query("username") username: String, + @Query("password") password: String, + @Query("items") items: Int, + @Query("offset") offset: Int + ): Response> + + @GET("items") + fun allItems( + @Query("username") username: String, + @Query("password") password: String + ): Call> + + @Headers("Content-Type: application/x-www-form-urlencoded") + @POST("mark/{id}") + fun markAsRead( + @Path("id") id: String, + @Query("username") username: String, + @Query("password") password: String + ): Call + + @Headers("Content-Type: application/x-www-form-urlencoded") + @POST("unmark/{id}") + fun unmarkAsRead( + @Path("id") id: String, + @Query("username") username: String, + @Query("password") password: String + ): Call + + @FormUrlEncoded + @POST("mark") + suspend fun markAllAsRead( + @Field("ids[]") ids: List, + @Query("username") username: String, + @Query("password") password: String + ): SuccessResponse + + @Headers("Content-Type: application/x-www-form-urlencoded") + @POST("starr/{id}") + fun starr( + @Path("id") id: String, + @Query("username") username: String, + @Query("password") password: String + ): Call + + @Headers("Content-Type: application/x-www-form-urlencoded") + @POST("unstarr/{id}") + fun unstarr( + @Path("id") id: String, + @Query("username") username: String, + @Query("password") password: String + ): Call + + @GET("stats") + suspend fun stats( + @Query("username") username: String, + @Query("password") password: String + ): Response + + @GET("tags") + fun tags( + @Query("username") username: String, + @Query("password") password: String + ): Call> + + @GET("update") + fun update( + @Query("username") username: String, + @Query("password") password: String + ): Call + + @GET("sources/spouts") + fun spouts( + @Query("username") username: String, + @Query("password") password: String + ): Call> + + @GET("sources/list") + fun sources( + @Query("username") username: String, + @Query("password") password: String + ): Call> + + @GET("api/about") + fun version(): Call + + @DELETE("source/{id}") + fun deleteSource( + @Path("id") id: String, + @Query("username") username: String, + @Query("password") password: String + ): Call + + @FormUrlEncoded + @POST("source") + fun createSource( + @Field("title") title: String, + @Field("url") url: String, + @Field("spout") spout: String, + @Field("tags") tags: String, + @Field("filter") filter: String, + @Query("username") username: String, + @Query("password") password: String + ): Call + + @FormUrlEncoded + @POST("source") + fun createSourceApi2( + @Field("title") title: String, + @Field("url") url: String, + @Field("spout") spout: String, + @Field("tags[]") tags: List, + @Field("filter") filter: String, + @Query("username") username: String, + @Query("password") password: String + ): Call +} diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/api/selfoss/SelfossTagTypeTypeAdapter.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/api/selfoss/SelfossTagTypeTypeAdapter.kt new file mode 100644 index 0000000..318eeb0 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/api/selfoss/SelfossTagTypeTypeAdapter.kt @@ -0,0 +1,22 @@ +package bou.amine.apps.readerforselfossv2.android.api.selfoss + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonParseException +import java.lang.reflect.Type + +internal class SelfossTagTypeTypeAdapter : JsonDeserializer { + + @Throws(JsonParseException::class) + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext + ): SelfossTagType? = + if (json.isJsonArray) { + SelfossTagType(json.asJsonArray.joinToString(",") { it.toString() }) + } else { + SelfossTagType(json.toString()) + } +} diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/background/background.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/background/background.kt new file mode 100644 index 0000000..fd930a8 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/background/background.kt @@ -0,0 +1,169 @@ +package bou.amine.apps.readerforselfossv2.android.background + +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.preference.PreferenceManager +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.PRIORITY_DEFAULT +import androidx.core.app.NotificationCompat.PRIORITY_LOW +import androidx.room.Room +import androidx.work.Worker +import androidx.work.WorkerParameters +import bou.amine.apps.readerforselfossv2.android.MainActivity +import bou.amine.apps.readerforselfossv2.android.R +import bou.amine.apps.readerforselfossv2.android.api.selfoss.SelfossApi +import bou.amine.apps.readerforselfossv2.android.api.selfoss.getAndStoreAllItems +import bou.amine.apps.readerforselfossv2.android.persistence.database.AppDatabase +import bou.amine.apps.readerforselfossv2.android.persistence.entities.ActionEntity +import bou.amine.apps.readerforselfossv2.android.persistence.migrations.MIGRATION_1_2 +import bou.amine.apps.readerforselfossv2.android.persistence.migrations.MIGRATION_2_3 +import bou.amine.apps.readerforselfossv2.android.persistence.migrations.MIGRATION_3_4 +import bou.amine.apps.readerforselfossv2.android.utils.Config +import bou.amine.apps.readerforselfossv2.android.utils.SharedItems +import bou.amine.apps.readerforselfossv2.android.utils.network.isNetworkAvailable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import java.util.* +import kotlin.concurrent.schedule +import kotlin.concurrent.thread + +class LoadingWorker(val context: Context, params: WorkerParameters) : Worker(context, params) { + lateinit var db: AppDatabase + +override fun doWork(): Result { + val settings = + this.context.getSharedPreferences(Config.settingsName, Context.MODE_PRIVATE) + val sharedPref = PreferenceManager.getDefaultSharedPreferences(this.context) + val periodicRefresh = sharedPref.getBoolean("periodic_refresh", false) + if (periodicRefresh) { + val api = SelfossApi( + this.context, + null, + settings.getBoolean("isSelfSignedCert", false), + sharedPref.getString("api_timeout", "-1")!!.toLong() + ) + + if (isNetworkAvailable(context)) { + + CoroutineScope(Dispatchers.IO).launch { + val notificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + val notification = + NotificationCompat.Builder(applicationContext, Config.syncChannelId) + .setContentTitle(context.getString(R.string.loading_notification_title)) + .setContentText(context.getString(R.string.loading_notification_text)) + .setOngoing(true) + .setPriority(PRIORITY_LOW) + .setChannelId(Config.syncChannelId) + .setSmallIcon(R.drawable.ic_stat_cloud_download_black_24dp) + + notificationManager.notify(1, notification.build()) + + val notifyNewItems = sharedPref.getBoolean("notify_new_items", false) + + db = Room.databaseBuilder( + applicationContext, + AppDatabase::class.java, "selfoss-database" + ).addMigrations(MIGRATION_1_2).addMigrations(MIGRATION_2_3) + .addMigrations(MIGRATION_3_4).build() + + val actions = db.actionsDao().actions() + + actions.forEach { action -> + when { + action.read -> doAndReportOnFail( + api.markItem(action.articleId), + action + ) + action.unread -> doAndReportOnFail( + api.unmarkItem(action.articleId), + action + ) + action.starred -> doAndReportOnFail( + api.starrItem(action.articleId), + action + ) + action.unstarred -> doAndReportOnFail( + api.unstarrItem(action.articleId), + action + ) + } + } + + getAndStoreAllItems(context, api, db) + SharedItems.updateDatabase(db) + storeItems(notifyNewItems, notificationManager) + } + } + } + return Result.success() +} + + private fun storeItems(notifyNewItems: Boolean, notificationManager: NotificationManager) { + CoroutineScope(Dispatchers.IO).launch { + val apiItems = SharedItems.items + + + val newSize = apiItems.filter { it.unread }.size + if (notifyNewItems && newSize > 0) { + + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + val pflags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_IMMUTABLE + } else { + 0 + } + val pendingIntent: PendingIntent = PendingIntent.getActivity(context, 0, intent, pflags) + + val newItemsNotification = + NotificationCompat.Builder(applicationContext, Config.newItemsChannelId) + .setContentTitle(context.getString(R.string.new_items_notification_title)) + .setContentText( + context.getString( + R.string.new_items_notification_text, + newSize + ) + ) + .setPriority(PRIORITY_DEFAULT) + .setChannelId(Config.newItemsChannelId) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setSmallIcon(R.drawable.ic_tab_fiber_new_black_24dp) + + Timer("", false).schedule(4000) { + notificationManager.notify(2, newItemsNotification.build()) + } + } + apiItems.map { it.preloadImages(context) } + Timer("", false).schedule(4000) { + notificationManager.cancel(1) + } + } + } + + private fun doAndReportOnFail(call: Call, action: ActionEntity) { + call.enqueue(object : Callback { + override fun onResponse( + call: Call, + response: Response + ) { + thread { + db.actionsDao().delete(action) + } + } + + override fun onFailure(call: Call, t: Throwable) { + } + }) + } +} \ No newline at end of file diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/fragments/ArticleFragment.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/fragments/ArticleFragment.kt new file mode 100644 index 0000000..cd39850 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/fragments/ArticleFragment.kt @@ -0,0 +1,564 @@ +package bou.amine.apps.readerforselfossv2.android.fragments + +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.content.res.ColorStateList +import android.content.res.TypedArray +import android.graphics.Bitmap +import android.graphics.Typeface +import android.graphics.drawable.ColorDrawable +import android.net.Uri +import android.os.Bundle +import androidx.preference.PreferenceManager +import android.view.* +import android.webkit.* +import android.widget.Toast +import com.google.android.material.floatingactionbutton.FloatingActionButton +import androidx.fragment.app.Fragment +import androidx.core.widget.NestedScrollView +import androidx.appcompat.app.AlertDialog +import androidx.browser.customtabs.CustomTabsIntent +import androidx.core.content.res.ResourcesCompat +import androidx.room.Room +import bou.amine.apps.readerforselfossv2.android.ImageActivity +import bou.amine.apps.readerforselfossv2.android.R +import bou.amine.apps.readerforselfossv2.android.api.mercury.MercuryApi +import bou.amine.apps.readerforselfossv2.android.api.mercury.ParsedContent +import bou.amine.apps.readerforselfossv2.android.api.selfoss.Item +import bou.amine.apps.readerforselfossv2.android.api.selfoss.SelfossApi +import bou.amine.apps.readerforselfossv2.android.databinding.FragmentArticleBinding +import bou.amine.apps.readerforselfossv2.android.persistence.database.AppDatabase +import bou.amine.apps.readerforselfossv2.android.persistence.migrations.MIGRATION_1_2 +import bou.amine.apps.readerforselfossv2.android.persistence.migrations.MIGRATION_2_3 +import bou.amine.apps.readerforselfossv2.android.persistence.migrations.MIGRATION_3_4 +import bou.amine.apps.readerforselfossv2.android.themes.AppColors +import bou.amine.apps.readerforselfossv2.android.utils.* +import bou.amine.apps.readerforselfossv2.android.utils.customtabs.CustomTabActivityHelper +import bou.amine.apps.readerforselfossv2.android.utils.glide.loadMaybeBasicAuth +import bou.amine.apps.readerforselfossv2.android.utils.glide.getBitmapInputStream +import bou.amine.apps.readerforselfossv2.android.utils.network.isNetworkAccessible +import bou.amine.apps.readerforselfossv2.android.utils.* +import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.request.RequestOptions +import com.github.rubensousa.floatingtoolbar.FloatingToolbar +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import java.net.MalformedURLException +import java.net.URL +import java.util.* +import java.util.concurrent.ExecutionException +import kotlin.collections.ArrayList + +class ArticleFragment : Fragment() { + private var fontSize: Int = 16 + private lateinit var item: Item + private var mCustomTabActivityHelper: CustomTabActivityHelper? = null + private lateinit var url: String + private lateinit var contentText: String + private lateinit var contentSource: String + private lateinit var contentImage: String + private lateinit var contentTitle: String + private lateinit var allImages : ArrayList + private lateinit var editor: SharedPreferences.Editor + private lateinit var fab: FloatingActionButton + private lateinit var appColors: AppColors + private lateinit var db: AppDatabase + private lateinit var textAlignment: String + private lateinit var config: Config + private var _binding: FragmentArticleBinding? = null + private val binding get() = _binding!! + + private lateinit var prefs: SharedPreferences + + private var typeface: Typeface? = null + private var resId: Int = 0 + private var font = "" + private var staticBar = false + + override fun onStop() { + super.onStop() + if (mCustomTabActivityHelper != null) { + mCustomTabActivityHelper!!.unbindCustomTabsService(activity) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + appColors = AppColors(requireActivity()) + config = Config(requireActivity()) + + super.onCreate(savedInstanceState) + + item = requireArguments().getParcelable(ARG_ITEMS)!! + + db = Room.databaseBuilder( + requireContext(), + AppDatabase::class.java, "selfoss-database" + ).addMigrations(MIGRATION_1_2).addMigrations(MIGRATION_2_3).addMigrations(MIGRATION_3_4).build() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + try { + _binding = FragmentArticleBinding.inflate(inflater, container, false) + + url = item.getLinkDecoded() + contentText = item.content + contentTitle = item.getTitleDecoded() + contentImage = item.getThumbnail(requireActivity()) + contentSource = item.sourceAndDateText() + allImages = item.getImages() + + prefs = PreferenceManager.getDefaultSharedPreferences(activity) + editor = prefs.edit() + fontSize = prefs.getString("reader_font_size", "16")!!.toInt() + staticBar = prefs.getBoolean("reader_static_bar", false) + + font = prefs.getString("reader_font", "")!! + if (font.isNotEmpty()) { + resId = requireContext().resources.getIdentifier(font, "font", requireContext().packageName) + typeface = try { + ResourcesCompat.getFont(requireContext(), resId)!! + } catch (e: java.lang.Exception) { + // ACRA.getErrorReporter().maybeHandleSilentException(Throwable("Font loading issue: ${e.message}"), requireContext()) + // Just to be sure + null + } + } + + refreshAlignment() + + val settings = requireActivity().getSharedPreferences(Config.settingsName, Context.MODE_PRIVATE) + + val api = SelfossApi( + requireContext(), + requireActivity(), + settings.getBoolean("isSelfSignedCert", false), + prefs.getString("api_timeout", "-1")!!.toLong() + ) + + fab = binding.fab + + fab.backgroundTintList = ColorStateList.valueOf(appColors.colorAccent) + + fab.rippleColor = appColors.colorAccentDark + + val floatingToolbar: FloatingToolbar = binding.floatingToolbar + floatingToolbar.attachFab(fab) + + floatingToolbar.background = ColorDrawable(appColors.colorAccent) + + val customTabsIntent = requireActivity().buildCustomTabsIntent() + mCustomTabActivityHelper = CustomTabActivityHelper() + mCustomTabActivityHelper!!.bindCustomTabsService(activity) + + + floatingToolbar.setClickListener( + object : FloatingToolbar.ItemClickListener { + override fun onItemClick(item: MenuItem) { + when (item.itemId) { + R.id.more_action -> getContentFromMercury(customTabsIntent) + R.id.share_action -> requireActivity().shareLink(url, contentTitle) + R.id.open_action -> requireActivity().openInBrowserAsNewTask(this@ArticleFragment.item) + R.id.unread_action -> if (context != null) { + if (this@ArticleFragment.item.unread) { + SharedItems.readItem( + context!!, + api, + db, + this@ArticleFragment.item + ) + this@ArticleFragment.item.unread = false + Toast.makeText( + context, + R.string.marked_as_read, + Toast.LENGTH_LONG + ).show() + } else { + SharedItems.unreadItem( + context!!, + api, + db, + this@ArticleFragment.item + ) + this@ArticleFragment.item.unread = true + Toast.makeText( + context, + R.string.marked_as_unread, + Toast.LENGTH_LONG + ).show() + } + } + else -> Unit + } + } + + override fun onItemLongClick(item: MenuItem?) { + } + } + ) + + if (staticBar) { + fab.hide() + floatingToolbar.show() + } + + binding.source.text = contentSource + if (typeface != null) { + binding.source.typeface = typeface + } + + if (contentText.isEmptyOrNullOrNullString()) { + getContentFromMercury(customTabsIntent) + } else { + binding.titleView.text = contentTitle + if (typeface != null) { + binding.titleView.typeface = typeface + } + + htmlToWebview() + + if (!contentImage.isEmptyOrNullOrNullString() && context != null) { + binding.imageView.visibility = View.VISIBLE + Glide + .with(requireContext()) + .asBitmap() + .loadMaybeBasicAuth(config, contentImage) + .apply(RequestOptions.fitCenterTransform()) + .into(binding.imageView) + } else { + binding.imageView.visibility = View.GONE + } + } + + binding.nestedScrollView.setOnScrollChangeListener( + NestedScrollView.OnScrollChangeListener { _, _, scrollY, _, oldScrollY -> + if (scrollY > oldScrollY) { + floatingToolbar.hide() + fab.hide() + } else { + if (staticBar) { + floatingToolbar.show() + } else { + if (floatingToolbar.isShowing) floatingToolbar.hide() else fab.show() + } + } + } + ) + + } catch (e: InflateException) { + AlertDialog.Builder(requireContext()) + .setMessage(requireContext().getString(R.string.webview_dialog_issue_message)) + .setTitle(requireContext().getString(R.string.webview_dialog_issue_title)) + .setPositiveButton(android.R.string.ok + ) { _, _ -> + val sharedPref = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val editor = sharedPref.edit() + editor.putBoolean("prefer_article_viewer", false) + editor.apply() + requireActivity().finish() + } + .create() + .show() + } + + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private fun refreshAlignment() { + textAlignment = when (prefs.getInt("text_align", 1)) { + 1 -> "justify" + 2 -> "left" + else -> "justify" + } + } + + private fun getContentFromMercury(customTabsIntent: CustomTabsIntent) { + if ((context != null && requireContext().isNetworkAccessible(null)) || context == null) { + binding.progressBar.visibility = View.VISIBLE + val parser = MercuryApi() + + parser.parseUrl(url).enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response + ) { + // TODO: clean all the following after finding the mercury content issue + try { + if (response.body() != null && response.body()!!.content != null && !response.body()!!.content.isNullOrEmpty()) { + try { + binding.titleView.text = response.body()!!.title + if (typeface != null) { + binding.titleView.typeface = typeface + } + try { + // Note: Mercury may return relative urls... If it does the url val will not be changed. + URL(response.body()!!.url) + url = response.body()!!.url + } catch (e: MalformedURLException) { + // Mercury returned a relative url. We do nothing. + } + } catch (e: Exception) { + } + + try { + contentText = response.body()!!.content.orEmpty() + htmlToWebview() + } catch (e: Exception) { + } + + try { + if (response.body()!!.lead_image_url != null && !response.body()!!.lead_image_url.isNullOrEmpty() && context != null) { + binding.imageView.visibility = View.VISIBLE + try { + Glide + .with(requireContext()) + .asBitmap() + .loadMaybeBasicAuth(config, response.body()!!.lead_image_url.orEmpty()) + .apply(RequestOptions.fitCenterTransform()) + .into(binding.imageView) + } catch (e: Exception) { + } + } else { + binding.imageView.visibility = View.GONE + } + } catch (e: Exception) { + if (context != null) { + } + } + + try { + binding.nestedScrollView.scrollTo(0, 0) + + binding.progressBar.visibility = View.GONE + } catch (e: Exception) { + if (context != null) { + } + } + } else { + try { + openInBrowserAfterFailing(customTabsIntent) + } catch (e: Exception) { + if (context != null) { + } + } + } + } catch (e: Exception) { + if (context != null) { + } + } + } + + override fun onFailure( + call: Call, + t: Throwable + ) = openInBrowserAfterFailing(customTabsIntent) + } + ) + } + } + + private fun htmlToWebview() { + val stringColor = String.format("#%06X", 0xFFFFFF and appColors.colorAccent) + + val attrs: IntArray = intArrayOf(android.R.attr.fontFamily) + val a: TypedArray = requireContext().obtainStyledAttributes(resId, attrs) + + + binding.webcontent.settings.standardFontFamily = a.getString(0) + binding.webcontent.visibility = View.VISIBLE + + // TODO: Set the color strings programmatically + val (stringTextColor, stringBackgroundColor) = if (appColors.isDarkTheme) { + Pair("#FFFFFF", "#303030") + } else { + Pair("#212121", "#FAFAFA") + } + + binding.webcontent.settings.useWideViewPort = true + binding.webcontent.settings.loadWithOverviewMode = true + binding.webcontent.settings.javaScriptEnabled = false + + binding.webcontent.webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView?, url : String): Boolean { + if (binding.webcontent.hitTestResult.type != WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE) { + requireContext().startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + } + return true + } + + override fun shouldInterceptRequest(view: WebView?, url: String): WebResourceResponse? { + val glideOptions = RequestOptions.diskCacheStrategyOf(DiskCacheStrategy.ALL) + if (url.lowercase(Locale.US).contains(".jpg") || url.lowercase(Locale.US).contains(".jpeg")) { + try { + val image = Glide.with(view).asBitmap().apply(glideOptions).load(url).submit().get() + return WebResourceResponse("image/jpg", "UTF-8", getBitmapInputStream(image, Bitmap.CompressFormat.JPEG)) + }catch ( e : ExecutionException) {} + } + else if (url.lowercase(Locale.US).contains(".png")) { + try { + val image = Glide.with(view).asBitmap().apply(glideOptions).load(url).submit().get() + return WebResourceResponse("image/jpg", "UTF-8", getBitmapInputStream(image, Bitmap.CompressFormat.PNG)) + }catch ( e : ExecutionException) {} + } + else if (url.lowercase(Locale.US).contains(".webp")) { + try { + val image = Glide.with(view).asBitmap().apply(glideOptions).load(url).submit().get() + return WebResourceResponse("image/jpg", "UTF-8", getBitmapInputStream(image, Bitmap.CompressFormat.WEBP)) + }catch ( e : ExecutionException) {} + } + + return super.shouldInterceptRequest(view, url) + } + } + + val gestureDetector = GestureDetector(activity, object : GestureDetector.SimpleOnGestureListener() { + override fun onSingleTapUp(e: MotionEvent?): Boolean { + return performClick() + } + }) + + binding.webcontent.setOnTouchListener { _, event -> gestureDetector.onTouchEvent(event)} + + binding.webcontent.settings.layoutAlgorithm = + WebSettings.LayoutAlgorithm.TEXT_AUTOSIZING + + var baseUrl: String? = null + + try { + val itemUrl = URL(url) + baseUrl = itemUrl.protocol + "://" + itemUrl.host + } catch (e: MalformedURLException) { + } + + val fontName = when (font) { + getString(R.string.open_sans_font_id) -> "Open Sans" + getString(R.string.roboto_font_id) -> "Roboto" + else -> "" + } + + val fontLinkAndStyle = if (font.isNotEmpty()) { + """ + | + """.trimMargin() + } else { + "" + } + + binding.webcontent.loadDataWithBaseURL( + baseUrl, + """ + | + | + | + | $fontLinkAndStyle + | + | + | $contentText + |""".trimMargin(), + "text/html", + "utf-8", + null + ) + } + + fun scrollDown() { + val height = binding.nestedScrollView.measuredHeight + binding.nestedScrollView.smoothScrollBy(0, height/2) + } + + fun scrollUp() { + val height = binding.nestedScrollView.measuredHeight + binding.nestedScrollView.smoothScrollBy(0, -height/2) + } + + private fun openInBrowserAfterFailing(customTabsIntent: CustomTabsIntent) { + binding.progressBar.visibility = View.GONE + requireActivity().openItemUrlInternalBrowser( + url, + customTabsIntent, + requireActivity() + ) + } + + companion object { + private const val ARG_ITEMS = "items" + + fun newInstance( + item: Item + ): ArticleFragment { + val fragment = ArticleFragment() + val args = Bundle() + args.putParcelable(ARG_ITEMS, item) + fragment.arguments = args + return fragment + } + } + + fun performClick(): Boolean { + if (binding.webcontent.hitTestResult.type == WebView.HitTestResult.IMAGE_TYPE || + binding.webcontent.hitTestResult.type == WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE) { + + val position : Int = allImages.indexOf(binding.webcontent.hitTestResult.extra) + + val intent = Intent(activity, ImageActivity::class.java) + intent.putExtra("allImages", allImages) + intent.putExtra("position", position) + startActivity(intent) + return false + } + return false + } + + +} diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/fragments/ImageFragment.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/fragments/ImageFragment.kt new file mode 100644 index 0000000..50d1ea1 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/fragments/ImageFragment.kt @@ -0,0 +1,56 @@ +package bou.amine.apps.readerforselfossv2.android.fragments + +import android.os.Bundle +import android.view.* +import androidx.fragment.app.Fragment +import bou.amine.apps.readerforselfossv2.android.databinding.FragmentImageBinding +import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.request.RequestOptions + +class ImageFragment : Fragment() { + + private lateinit var imageUrl : String + private val glideOptions = RequestOptions.diskCacheStrategyOf(DiskCacheStrategy.ALL) + private var _binding: FragmentImageBinding? = null + private val binding get() = _binding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + imageUrl = requireArguments().getString("imageUrl")!! + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + _binding = FragmentImageBinding.inflate(inflater, container, false) + val view = binding?.root + + binding!!.photoView.visibility = View.VISIBLE + Glide.with(activity) + .asBitmap() + .apply(glideOptions) + .load(imageUrl) + .into(binding!!.photoView) + + return view + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + private const val ARG_IMAGE = "imageUrl" + + fun newInstance( + imageUrl : String + ): ImageFragment { + val fragment = ImageFragment() + val args = Bundle() + args.putString(ARG_IMAGE, imageUrl) + fragment.arguments = args + return fragment + } + } +} \ No newline at end of file diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/persistence/dao/ActionsDao.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/persistence/dao/ActionsDao.kt new file mode 100644 index 0000000..f834c22 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/persistence/dao/ActionsDao.kt @@ -0,0 +1,23 @@ +package bou.amine.apps.readerforselfossv2.android.persistence.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import bou.amine.apps.readerforselfossv2.android.persistence.entities.ActionEntity + +@Dao +interface ActionsDao { + @Query("SELECT * FROM actions order by id asc") + suspend fun actions(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertAllActions(vararg actions: ActionEntity) + + @Query("DELETE FROM actions WHERE articleid = :article_id AND read = 1") + fun deleteReadActionForArticle(article_id: String) + + @Delete + fun delete(action: ActionEntity) +} \ No newline at end of file diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/persistence/dao/DrawerDataDao.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/persistence/dao/DrawerDataDao.kt new file mode 100644 index 0000000..7b8c98c --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/persistence/dao/DrawerDataDao.kt @@ -0,0 +1,36 @@ +package bou.amine.apps.readerforselfossv2.android.persistence.dao + +import androidx.room.Delete +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import bou.amine.apps.readerforselfossv2.android.persistence.entities.SourceEntity +import bou.amine.apps.readerforselfossv2.android.persistence.entities.TagEntity + +@Dao +interface DrawerDataDao { + @Query("SELECT * FROM tags") + fun tags(): List + + @Query("SELECT * FROM sources") + fun sources(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertAllTags(vararg tags: TagEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertAllSources(vararg sources: SourceEntity) + + @Query("DELETE FROM tags") + fun deleteAllTags() + + @Query("DELETE FROM sources") + fun deleteAllSources() + + @Delete + fun deleteTag(tag: TagEntity) + + @Delete + fun deleteSource(source: SourceEntity) +} \ No newline at end of file diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/persistence/dao/ItemsDao.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/persistence/dao/ItemsDao.kt new file mode 100644 index 0000000..d85eb66 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/persistence/dao/ItemsDao.kt @@ -0,0 +1,29 @@ +package bou.amine.apps.readerforselfossv2.android.persistence.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import bou.amine.apps.readerforselfossv2.android.persistence.entities.ItemEntity +import androidx.room.Update + + + +@Dao +interface ItemsDao { + @Query("SELECT * FROM items order by id desc") + suspend fun items(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAllItems(vararg items: ItemEntity) + + @Query("DELETE FROM items") + suspend fun deleteAllItems() + + @Delete + suspend fun delete(item: ItemEntity) + + @Update + suspend fun updateItem(item: ItemEntity) +} \ No newline at end of file diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/persistence/database/AppDatabase.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/persistence/database/AppDatabase.kt new file mode 100644 index 0000000..20be67a --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/persistence/database/AppDatabase.kt @@ -0,0 +1,20 @@ +package bou.amine.apps.readerforselfossv2.android.persistence.database + +import androidx.room.RoomDatabase +import androidx.room.Database +import bou.amine.apps.readerforselfossv2.android.persistence.dao.ActionsDao +import bou.amine.apps.readerforselfossv2.android.persistence.dao.DrawerDataDao +import bou.amine.apps.readerforselfossv2.android.persistence.dao.ItemsDao +import bou.amine.apps.readerforselfossv2.android.persistence.entities.ActionEntity +import bou.amine.apps.readerforselfossv2.android.persistence.entities.ItemEntity +import bou.amine.apps.readerforselfossv2.android.persistence.entities.SourceEntity +import bou.amine.apps.readerforselfossv2.android.persistence.entities.TagEntity + +@Database(entities = [TagEntity::class, SourceEntity::class, ItemEntity::class, ActionEntity::class], version = 4) +abstract class AppDatabase : RoomDatabase() { + abstract fun drawerDataDao(): DrawerDataDao + + abstract fun itemsDao(): ItemsDao + + abstract fun actionsDao(): ActionsDao +} \ No newline at end of file diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/persistence/entities/ActionEntity.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/persistence/entities/ActionEntity.kt new file mode 100644 index 0000000..2a15e82 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/persistence/entities/ActionEntity.kt @@ -0,0 +1,22 @@ +package bou.amine.apps.readerforselfossv2.android.persistence.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "actions") +data class ActionEntity( + @ColumnInfo(name = "articleid") + val articleId: String, + @ColumnInfo(name = "read") + val read: Boolean, + @ColumnInfo(name = "unread") + val unread: Boolean, + @ColumnInfo(name = "starred") + var starred: Boolean, + @ColumnInfo(name = "unstarred") + var unstarred: Boolean +) { + @PrimaryKey(autoGenerate = true) + var id: Int = 0 +} \ No newline at end of file diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/persistence/entities/DrawerDataEntity.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/persistence/entities/DrawerDataEntity.kt new file mode 100644 index 0000000..cca0542 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/persistence/entities/DrawerDataEntity.kt @@ -0,0 +1,33 @@ +package bou.amine.apps.readerforselfossv2.android.persistence.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "tags") +data class TagEntity( + @PrimaryKey + @ColumnInfo(name = "tag") + val tag: String, + @ColumnInfo(name = "color") + val color: String, + @ColumnInfo(name = "unread") + val unread: Int +) + +@Entity(tableName = "sources") +data class SourceEntity( + @PrimaryKey + @ColumnInfo(name = "id") + val id: String, + @ColumnInfo(name = "title") + val title: String, + @ColumnInfo(name = "tags") + val tags: String, + @ColumnInfo(name = "spout") + val spout: String, + @ColumnInfo(name = "error") + val error: String, + @ColumnInfo(name = "icon") + val icon: String +) \ No newline at end of file diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/persistence/entities/ItemEntity.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/persistence/entities/ItemEntity.kt new file mode 100644 index 0000000..5b97779 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/persistence/entities/ItemEntity.kt @@ -0,0 +1,32 @@ +package bou.amine.apps.readerforselfossv2.android.persistence.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "items") +data class ItemEntity( + @PrimaryKey + @ColumnInfo(name = "id") + val id: String, + @ColumnInfo(name = "datetime") + val datetime: String, + @ColumnInfo(name = "title") + val title: String, + @ColumnInfo(name = "content") + val content: String, + @ColumnInfo(name = "unread") + val unread: Boolean, + @ColumnInfo(name = "starred") + var starred: Boolean, + @ColumnInfo(name = "thumbnail") + val thumbnail: String?, + @ColumnInfo(name = "icon") + val icon: String?, + @ColumnInfo(name = "link") + val link: String, + @ColumnInfo(name = "sourcetitle") + val sourcetitle: String, + @ColumnInfo(name = "tags") + val tags: String +) \ No newline at end of file diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/persistence/migrations/migrations.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/persistence/migrations/migrations.kt new file mode 100644 index 0000000..1636fd5 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/persistence/migrations/migrations.kt @@ -0,0 +1,34 @@ +package bou.amine.apps.readerforselfossv2.android.persistence.migrations + +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.room.migration.Migration + +val MIGRATION_1_2: Migration = object : Migration(1, 2) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE TABLE IF NOT EXISTS `items` (`id` TEXT NOT NULL, `datetime` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `unread` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `thumbnail` TEXT NOT NULL, `icon` TEXT NOT NULL, `link` TEXT NOT NULL, `sourcetitle` TEXT NOT NULL, `tags` TEXT NOT NULL, PRIMARY KEY(`id`))") + } +} + +val MIGRATION_2_3: Migration = object : Migration(2, 3) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE TABLE IF NOT EXISTS `actions` (`id` INTEGER NOT NULL, `articleid` TEXT NOT NULL, `read` INTEGER NOT NULL, `unread` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `unstarred` INTEGER NOT NULL, PRIMARY KEY(`id`))") + } +} + +val MIGRATION_3_4: Migration = object : Migration(3, 4) { + override fun migrate(database: SupportSQLiteDatabase) { + // @see https://stackoverflow.com/questions/57392015/how-to-migrate-not-null-table-column-into-null-in-android-room-database + // Create the new table + database.execSQL("CREATE TABLE IF NOT EXISTS `itemstmp` (`id` TEXT NOT NULL, `datetime` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `unread` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `thumbnail` TEXT, `icon` TEXT, `link` TEXT NOT NULL, `sourcetitle` TEXT NOT NULL, `tags` TEXT NOT NULL, PRIMARY KEY(`id`))") + + // Copy the data + database.execSQL( + "INSERT INTO itemstmp (`id`, `datetime`, `title`, `content`, `unread`, `starred`, `thumbnail`, `icon`, `link`, `sourcetitle`, `tags`) SELECT `id`, `datetime`, `title`, `content`, `unread`, `starred`, `thumbnail`, `icon`, `link`, `sourcetitle`, `tags` FROM items") + + // Remove the old table + database.execSQL("DROP TABLE items") + + // Change the table name to the correct one + database.execSQL("ALTER TABLE itemstmp RENAME TO items") + } +} diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/settings/SettingsActivity.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/settings/SettingsActivity.kt new file mode 100644 index 0000000..aa708e7 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/settings/SettingsActivity.kt @@ -0,0 +1,221 @@ +package bou.amine.apps.readerforselfossv2.android.settings + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.text.* +import androidx.preference.EditTextPreference +import androidx.preference.PreferenceManager +import android.view.* +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.widget.addTextChangedListener +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import bou.amine.apps.readerforselfossv2.android.R +import bou.amine.apps.readerforselfossv2.android.databinding.ActivitySettingsBinding +import bou.amine.apps.readerforselfossv2.android.themes.Toppings +import bou.amine.apps.readerforselfossv2.android.utils.Config +import com.ftinc.scoop.Scoop +import java.lang.NumberFormatException + +private const val TITLE_TAG = "settingsActivityTitle" + +class SettingsActivity : AppCompatActivity(), + PreferenceFragmentCompat.OnPreferenceStartFragmentCallback { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (PreferenceManager.getDefaultSharedPreferences(this).getBoolean("dark_theme", false)) { + setTheme(R.style.NoBarDark) + } + val binding = ActivitySettingsBinding.inflate(layoutInflater) + + val scoop = Scoop.getInstance() + scoop.bind(this, Toppings.PRIMARY.value, binding.toolbar) + scoop.bindStatusBar(this, Toppings.PRIMARY_DARK.value) + + setContentView(binding.root) + if (savedInstanceState == null) { + supportFragmentManager + .beginTransaction() + .replace(R.id.settings, MainPreferenceFragment()) + .commit() + } else { + title = savedInstanceState.getCharSequence(TITLE_TAG) + } + supportFragmentManager.addOnBackStackChangedListener { + if (supportFragmentManager.backStackEntryCount == 0) { + setTitle(R.string.title_activity_settings) + } + } + + setSupportActionBar(binding.toolbar) + + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.setDisplayShowHomeEnabled(true) + supportActionBar?.title = title + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + // Save current activity title so we can set it again after a configuration change + outState.putCharSequence(TITLE_TAG, title) + } + + override fun onSupportNavigateUp(): Boolean { + if (supportFragmentManager.popBackStackImmediate()) { + supportActionBar?.title = getText(R.string.title_activity_settings) + return true + } + return super.onSupportNavigateUp() + } + + override fun onPreferenceStartFragment( + caller: PreferenceFragmentCompat, + pref: Preference + ): Boolean { + // Instantiate the new Fragment + val args = pref.extras + val fragment = supportFragmentManager.fragmentFactory.instantiate( + classLoader, + pref.fragment + ).apply { + arguments = args + setTargetFragment(caller, 0) + } + // Replace the existing Fragment with the new Fragment + supportFragmentManager.beginTransaction() + .replace(R.id.settings, fragment) + .addToBackStack(null) + .commit() + title = pref.title + supportActionBar?.title = title + return true + } + + class MainPreferenceFragment : PreferenceFragmentCompat() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.pref_main, rootKey) + } + } + + class GeneralPreferenceFragment : PreferenceFragmentCompat() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.pref_general, rootKey) + + val editTextPreference = preferenceManager.findPreference("prefer_api_items_number") + editTextPreference?.setOnBindEditTextListener { editText -> + editText.inputType = InputType.TYPE_CLASS_NUMBER + editText.filters = arrayOf( + InputFilter { source, _, _, dest, _, _ -> + try { + val input: Int = (dest.toString() + source.toString()).toInt() + if (input in 1..200) return@InputFilter null + } catch (nfe: NumberFormatException) { + Toast.makeText(activity, R.string.items_number_should_be_number, Toast.LENGTH_LONG).show() + } + "" + } + ) + } + } + } + + class ArticleViewerPreferenceFragment : PreferenceFragmentCompat() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.pref_viewer, rootKey) + + val fontSize = preferenceManager.findPreference("reader_font_size") + fontSize?.setOnBindEditTextListener { editText -> + editText.inputType = InputType.TYPE_CLASS_NUMBER + editText.addTextChangedListener { object : TextWatcher { + override fun beforeTextChanged(charSequence: CharSequence, i: Int, i1: Int, i2: Int) {} + override fun onTextChanged(charSequence: CharSequence, i: Int, i1: Int, i2: Int) {} + override fun afterTextChanged(editable: Editable) { + try { + editText.textSize = editable.toString().toInt().toFloat() + } catch (e: NumberFormatException) { + } + } + } } + editText.filters = arrayOf( + InputFilter { source, _, _, dest, _, _ -> + try { + val input = (dest.toString() + source.toString()).toInt() + if (input > 0) return@InputFilter null + } catch (nfe: NumberFormatException) { + } + "" + } + ) + } + } + } + + class OfflinePreferenceFragment : PreferenceFragmentCompat() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.pref_offline, rootKey) + } + } + + class ThemePreferenceFragment : PreferenceFragmentCompat() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.pref_theme, rootKey) + setHasOptionsMenu(true) + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + inflater.inflate(R.menu.settings_theme, menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + val id = item.itemId + if (id == R.id.clear) { + val pref = PreferenceManager.getDefaultSharedPreferences(activity) + val editor = pref.edit() + editor.remove("color_primary") + editor.remove("color_primary_dark") + editor.remove("color_accent") + editor.remove("color_accent_dark") + editor.remove("dark_theme") + editor.apply() + requireActivity().recreate() + } + return super.onOptionsItemSelected(item) + } + } + + class LinksPreferenceFragment : PreferenceFragmentCompat() { + private fun openUrl(uri: Uri?) { + val browserIntent = Intent(Intent.ACTION_VIEW, uri) + startActivity(browserIntent) + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.pref_links, rootKey) + + preferenceManager.findPreference("trackerLink")?.onPreferenceClickListener = Preference.OnPreferenceClickListener { + openUrl(Uri.parse(Config.trackerUrl)) + true + } + + preferenceManager.findPreference("sourceLink")?.onPreferenceClickListener = Preference.OnPreferenceClickListener { + openUrl(Uri.parse(Config.sourceUrl)) + false + } + + preferenceManager.findPreference("translation")?.onPreferenceClickListener = Preference.OnPreferenceClickListener { + openUrl(Uri.parse(Config.translationUrl)) + false + } + } + } + + class ExperimentalPreferenceFragment : PreferenceFragmentCompat() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.pref_experimental, rootKey) + } + } +} \ No newline at end of file diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/themes/AppColors.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/themes/AppColors.kt new file mode 100644 index 0000000..321c400 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/themes/AppColors.kt @@ -0,0 +1,61 @@ +package bou.amine.apps.readerforselfossv2.android.themes + +import android.app.Activity +import androidx.annotation.ColorInt +import androidx.preference.PreferenceManager +import bou.amine.apps.readerforselfossv2.android.R + +class AppColors(a: Activity) { + + @ColorInt val colorPrimary: Int + @ColorInt val colorPrimaryDark: Int + @ColorInt val colorAccent: Int + @ColorInt val colorAccentDark: Int + @ColorInt val colorBackground: Int + @ColorInt val textColor: Int + val isDarkTheme: Boolean + + init { + val sharedPref = PreferenceManager.getDefaultSharedPreferences(a) + + colorPrimary = + sharedPref.getInt( + "color_primary", + a.resources.getColor(R.color.colorPrimary) + ) + colorPrimaryDark = + sharedPref.getInt( + "color_primary_dark", + a.resources.getColor(R.color.colorPrimaryDark) + ) + colorAccent = + sharedPref.getInt( + "color_accent", + a.resources.getColor(R.color.colorAccent) + ) + colorAccentDark = + sharedPref.getInt( + "color_accent_dark", + a.resources.getColor(R.color.colorAccentDark) + ) + isDarkTheme = + sharedPref.getBoolean( + "dark_theme", + false + ) + + colorBackground = if (isDarkTheme) { + a.setTheme(R.style.NoBarDark) + R.color.darkBackground + } else { + a.setTheme(R.style.NoBar) + R.color.grey_50 + } + + textColor = if (isDarkTheme) { + R.color.white + } else { + R.color.grey_900 + } + } +} diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/themes/Toppings.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/themes/Toppings.kt new file mode 100644 index 0000000..ecda680 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/themes/Toppings.kt @@ -0,0 +1,8 @@ +package bou.amine.apps.readerforselfossv2.android.themes + +enum class Toppings(val value: Int) { + PRIMARY(1), + PRIMARY_DARK(2), + ACCENT(3), + ACCENT_DARK(4) +} \ No newline at end of file diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/ApiUtils.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/ApiUtils.kt new file mode 100644 index 0000000..51a9458 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/ApiUtils.kt @@ -0,0 +1,7 @@ +package bou.amine.apps.readerforselfossv2.android.utils + +import bou.amine.apps.readerforselfossv2.android.api.selfoss.SuccessResponse +import retrofit2.Response + +fun Response.succeeded(): Boolean = + this.code() === 200 && this.body() != null && this.body()!!.isSuccess \ No newline at end of file diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/AppUtils.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/AppUtils.kt new file mode 100644 index 0000000..045b1d7 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/AppUtils.kt @@ -0,0 +1,41 @@ +package bou.amine.apps.readerforselfossv2.android.utils + +import android.content.Context +import android.content.Intent +import bou.amine.apps.readerforselfossv2.android.R + +fun String?.isEmptyOrNullOrNullString(): Boolean = + this == null || this == "null" || this.isEmpty() + +fun String.longHash(): Long { + var h = 98764321261L + val l = this.length + val chars = this.toCharArray() + + for (i in 0 until l) { + h = 31 * h + chars[i].code.toLong() + } + return h +} + +fun String.toStringUriWithHttp(): String = + if (!this.startsWith("https://") && !this.startsWith("http://")) { + "http://" + this + } else { + this + } + +fun Context.shareLink(itemUrl: String, itemTitle: String) { + val sendIntent = Intent() + sendIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + sendIntent.action = Intent.ACTION_SEND + sendIntent.putExtra(Intent.EXTRA_TEXT, itemUrl.toStringUriWithHttp()) + sendIntent.putExtra(Intent.EXTRA_SUBJECT, itemTitle) + sendIntent.type = "text/plain" + startActivity( + Intent.createChooser( + sendIntent, + getString(R.string.share) + ).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) +} \ No newline at end of file diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/Config.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/Config.kt new file mode 100644 index 0000000..849c261 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/Config.kt @@ -0,0 +1,64 @@ +package bou.amine.apps.readerforselfossv2.android.utils + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import androidx.preference.PreferenceManager +import bou.amine.apps.readerforselfossv2.android.LoginActivity + +class Config(c: Context) { + + val settings: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(c) + + val baseUrl: String + get() = settings.getString("url", "")!! + + val userLogin: String + get() = settings.getString("login", "")!! + + val userPassword: String + get() = settings.getString("password", "")!! + + val httpUserLogin: String + get() = settings.getString("httpUserName", "")!! + + val httpUserPassword: String + get() = settings.getString("httpPassword", "")!! + + companion object { + const val settingsName = "paramsselfoss" + + const val feedbackEmail = "aminecmi@gmail.com" + + const val translationUrl = "https://crwd.in/readerforselfoss" + + const val sourceUrl = "https://github.com/aminecmi/ReaderforSelfoss" + + const val trackerUrl = "https://github.com/aminecmi/ReaderforSelfoss/issues" + + const val syncChannelId = "sync-channel-id" + + const val newItemsChannelId = "new-items-channel-id" + + var apiVersion = 0 + + /* Execute logout and clear all settings to default */ + fun logoutAndRedirect( + c: Context, + callingActivity: Activity, + editor: SharedPreferences.Editor, + baseUrlFail: Boolean = false + ): Boolean { + val settings = PreferenceManager.getDefaultSharedPreferences(c) + settings.edit().clear().commit() + val intent = Intent(c, LoginActivity::class.java) + if (baseUrlFail) { + intent.putExtra("baseUrlFail", baseUrlFail) + } + c.startActivity(intent) + callingActivity.finish() + return true + } + } +} diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/DateUtils.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/DateUtils.kt new file mode 100644 index 0000000..a6419d2 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/DateUtils.kt @@ -0,0 +1,31 @@ +package bou.amine.apps.readerforselfossv2.android.utils + +import android.text.format.DateUtils +import java.time.Instant +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +fun parseDate(dateString: String): Instant { + + val FORMATTERV1 = "yyyy-MM-dd HH:mm:ss" + + return if (Config.apiVersion >= 4) { + OffsetDateTime.parse(dateString).toInstant() + } else { + LocalDateTime.parse(dateString, DateTimeFormatter.ofPattern(FORMATTERV1)).toInstant(ZoneOffset.UTC) + } +} + +fun parseRelativeDate(dateString: String): String { + + val date = parseDate(dateString) + + return " " + DateUtils.getRelativeTimeSpanString( + date.toEpochMilli(), + Instant.now().toEpochMilli(), + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE + ) +} \ No newline at end of file diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/HttpUtils.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/HttpUtils.kt new file mode 100644 index 0000000..46aa83f --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/HttpUtils.kt @@ -0,0 +1,43 @@ +package bou.amine.apps.readerforselfossv2.android.utils + +import okhttp3.OkHttpClient +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager + +fun getUnsafeHttpClient(): OkHttpClient.Builder = + try { + // Create a trust manager that does not validate certificate chains + val trustAllCerts = arrayOf(object : X509TrustManager { + override fun getAcceptedIssuers(): Array = + arrayOf() + + @Throws(CertificateException::class) + override fun checkClientTrusted( + chain: Array, + authType: String + ) { + } + + @Throws(CertificateException::class) + override fun checkServerTrusted( + chain: Array, + authType: String + ) { + } + }) + + // Install the all-trusting trust manager + val sslContext = SSLContext.getInstance("SSL") + sslContext.init(null, trustAllCerts, java.security.SecureRandom()) + + val sslSocketFactory = sslContext.socketFactory + + OkHttpClient.Builder() + .sslSocketFactory(sslSocketFactory, trustAllCerts[0] as X509TrustManager) + .hostnameVerifier { _, _ -> true } + } catch (e: Exception) { + throw RuntimeException(e) + } \ No newline at end of file diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/ItemsUtils.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/ItemsUtils.kt new file mode 100644 index 0000000..3bae704 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/ItemsUtils.kt @@ -0,0 +1,36 @@ +package bou.amine.apps.readerforselfossv2.android.utils + +import android.content.Context +import bou.amine.apps.readerforselfossv2.android.api.selfoss.Item +import bou.amine.apps.readerforselfossv2.android.api.selfoss.SelfossTagType + +fun String.toTextDrawableString(c: Context): String { + val textDrawable = StringBuilder() + for (s in this.split(" ".toRegex()).filter { it.isNotEmpty() }.toTypedArray()) { + try { + textDrawable.append(s[0]) + } catch (e: StringIndexOutOfBoundsException) { + } + } + return textDrawable.toString() +} + +fun Item.sourceAndDateText(): String { + val formattedDate = parseRelativeDate(this.datetime) + + return this.getSourceTitle() + formattedDate +} + +fun Item.toggleStar(): Item { + this.starred = !this.starred + return this +} + +fun List.flattenTags(): List = + this.flatMap { + val item = it + val tags: List = it.tags.tags.split(",") + tags.map { t -> + item.copy(tags = SelfossTagType(t.trim())) + } + } \ No newline at end of file diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/LinksUtils.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/LinksUtils.kt new file mode 100644 index 0000000..01cf692 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/LinksUtils.kt @@ -0,0 +1,219 @@ +package bou.amine.apps.readerforselfossv2.android.utils + +import android.app.Activity +import android.app.PendingIntent +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Build +import android.text.Spannable +import android.text.style.ClickableSpan +import androidx.browser.customtabs.CustomTabsIntent +import android.util.Patterns +import android.view.MotionEvent +import android.view.View +import android.widget.TextView +import android.widget.Toast +import bou.amine.apps.readerforselfossv2.android.R +import bou.amine.apps.readerforselfossv2.android.ReaderActivity +import bou.amine.apps.readerforselfossv2.android.api.selfoss.Item +import bou.amine.apps.readerforselfossv2.android.utils.customtabs.CustomTabActivityHelper +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +fun Context.buildCustomTabsIntent(): CustomTabsIntent { + + val actionIntent = Intent(Intent.ACTION_SEND) + actionIntent.type = "text/plain" + val pflags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_IMMUTABLE + } else { + 0 + } + val createPendingShareIntent: PendingIntent = PendingIntent.getActivity( + this, + 0, + actionIntent, + pflags + ) + + val intentBuilder = CustomTabsIntent.Builder() + + // TODO: change to primary when it's possible to customize custom tabs title color + //intentBuilder.setToolbarColor(c.getResources().getColor(R.color.colorPrimary)); + intentBuilder.setToolbarColor(resources.getColor(R.color.colorAccentDark)) + intentBuilder.setShowTitle(true) + + + intentBuilder.setStartAnimations( + this, + R.anim.slide_in_right, + R.anim.slide_out_left + ) + intentBuilder.setExitAnimations( + this, + android.R.anim.slide_in_left, + android.R.anim.slide_out_right + ) + + val closeicon = BitmapFactory.decodeResource(resources, R.drawable.ic_close_white_24dp) + intentBuilder.setCloseButtonIcon(closeicon) + + val shareLabel = this.getString(R.string.label_share) + val icon = BitmapFactory.decodeResource( + resources, + R.drawable.ic_share_white_24dp + ) + intentBuilder.setActionButton(icon, shareLabel, createPendingShareIntent) + + return intentBuilder.build() +} + +fun Context.openItemUrlInternally( + allItems: ArrayList, + currentItem: Int, + linkDecoded: String, + customTabsIntent: CustomTabsIntent, + articleViewer: Boolean, + app: Activity +) { + if (articleViewer) { + ReaderActivity.allItems = allItems + SharedItems.position = currentItem + val intent = Intent(this, ReaderActivity::class.java) + intent.putExtra("currentItem", currentItem) + app.startActivity(intent) + } else { + this.openItemUrlInternalBrowser( + linkDecoded, + customTabsIntent, + app) + } +} + +fun Context.openItemUrlInternalBrowser( + linkDecoded: String, + customTabsIntent: CustomTabsIntent, + app: Activity +) { + try { + CustomTabActivityHelper.openCustomTab( + app, + customTabsIntent, + Uri.parse(linkDecoded) + ) { _, uri -> + val intent = Intent(Intent.ACTION_VIEW, uri) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + startActivity(intent) + } + } catch (e: Exception) { + openInBrowser(linkDecoded, app) + } +} + +fun Context.openItemUrl( + allItems: ArrayList, + currentItem: Int, + linkDecoded: String, + customTabsIntent: CustomTabsIntent, + internalBrowser: Boolean, + articleViewer: Boolean, + app: Activity +) { + + if (!linkDecoded.isUrlValid()) { + Toast.makeText( + this, + this.getString(R.string.cant_open_invalid_url), + Toast.LENGTH_LONG + ).show() + } else { + if (!internalBrowser) { + openInBrowser(linkDecoded, app) + } else if (articleViewer) { + this.openItemUrlInternally( + allItems, + currentItem, + linkDecoded, + customTabsIntent, + articleViewer, + app + ) + } else { + this.openItemUrlInternalBrowser( + linkDecoded, + customTabsIntent, + app + ) + } + } +} + +private fun openInBrowser(linkDecoded: String, app: Activity) { + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(linkDecoded) + try { + app.startActivity(intent) + } catch (e: ActivityNotFoundException) { + Toast.makeText(app.baseContext, e.message, Toast.LENGTH_LONG).show() + } +} + +fun String.isUrlValid(): Boolean = + this.toHttpUrlOrNull() != null && Patterns.WEB_URL.matcher(this).matches() + +fun String.isBaseUrlValid(ctx: Context): Boolean { + val baseUrl = this.toHttpUrlOrNull() + var existsAndEndsWithSlash = false + if (baseUrl != null) { + val pathSegments = baseUrl.pathSegments + existsAndEndsWithSlash = "" == pathSegments[pathSegments.size - 1] + } + + return Patterns.WEB_URL.matcher(this).matches() && existsAndEndsWithSlash +} + +fun Context.openInBrowserAsNewTask(i: Item) { + val intent = Intent(Intent.ACTION_VIEW) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + intent.data = Uri.parse(i.getLinkDecoded().toStringUriWithHttp()) + startActivity(intent) +} + +class LinkOnTouchListener: View.OnTouchListener { + override fun onTouch(v: View?, event: MotionEvent?): Boolean { + var ret = false + val widget: TextView = v as TextView + val text: CharSequence = widget.text + val stext = Spannable.Factory.getInstance().newSpannable(text) + + val action = event!!.action + + if (action == MotionEvent.ACTION_UP || + action == MotionEvent.ACTION_DOWN) { + var x: Float = event.x + var y: Float = event.y + + x -= widget.totalPaddingLeft + y -= widget.totalPaddingTop + + x += widget.scrollX + y += widget.scrollY + + val layout = widget.layout + val line = layout.getLineForVertical(y.toInt()) + val off = layout.getOffsetForHorizontal(line, x) + + val link = stext.getSpans(off, off, ClickableSpan::class.java) + + if (link.isNotEmpty()) { + if (action == MotionEvent.ACTION_UP) { + link[0].onClick(widget) + } + ret = true + } + } + return ret + } +} diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/SharedItems.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/SharedItems.kt new file mode 100644 index 0000000..c8e7dda --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/SharedItems.kt @@ -0,0 +1,403 @@ +package bou.amine.apps.readerforselfossv2.android.utils + +import android.content.Context +import android.widget.Toast +import bou.amine.apps.readerforselfossv2.android.R +import bou.amine.apps.readerforselfossv2.android.api.selfoss.Item +import bou.amine.apps.readerforselfossv2.android.api.selfoss.SelfossApi +import bou.amine.apps.readerforselfossv2.android.api.selfoss.SuccessResponse +import bou.amine.apps.readerforselfossv2.android.persistence.database.AppDatabase +import bou.amine.apps.readerforselfossv2.android.persistence.entities.ActionEntity +import bou.amine.apps.readerforselfossv2.android.utils.persistence.toEntity +import bou.amine.apps.readerforselfossv2.android.utils.network.isNetworkAccessible +import bou.amine.apps.readerforselfossv2.android.utils.persistence.toView +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import kotlin.concurrent.thread + +/* +* Singleton class that contains the articles fetched from Selfoss, it allows sharing the items list +* between Activities and Fragments +*/ +object SharedItems { + var items: ArrayList = arrayListOf() + get() { + return ArrayList(field) + } + set(value) { + field = ArrayList(value) + } + var focusedItems: ArrayList = arrayListOf() + get() { + return ArrayList(field) + } + set(value) { + field = ArrayList(value) + } + var position = 0 + set(value) { + field = when { + value < 0 -> 0 + value > items.size -> items.size + else -> value + } + } + var displayedItems: String = "unread" + set(value) { + field = when (value) { + "all" -> "all" + "unread" -> "unread" + "read" -> "read" + "starred" -> "starred" + else -> "all" + } + } + + var searchFilter: String? = null + var sourceIDFilter: Long? = null + var sourceFilter: String? = null + var tagFilter: String? = null + var itemsCaching = false + + var fetchedUnread = false + var fetchedAll = false + var fetchedStarred = false + + var badgeUnread = -1 + var badgeAll = -1 + var badgeStarred = -1 + + /** + * Add new items to the SharedItems list + * + * The new items are considered more updated than the ones already in the list. + * The old items present in the new list are discarded and replaced by the new ones. + * Items are compared according to the selfoss id, which should always be unique. + */ + fun appendNewItems(newItems: ArrayList) { + var tmpItems = items + if (tmpItems != newItems) { + tmpItems = tmpItems.filter { item -> newItems.find { it.id == item.id } == null } as ArrayList + tmpItems.addAll(newItems) + items = tmpItems + + sortItems() + getFocusedItems() + } + } + + fun refreshFocusedItems(newItems: ArrayList) { + val tmpItems = items + tmpItems.removeAll(focusedItems) + + appendNewItems(newItems) + } + + suspend fun clearDBItems(db: AppDatabase) { + db.itemsDao().deleteAllItems() + } + + suspend fun updateDatabase(db: AppDatabase) { + if (itemsCaching) { + if (items.isEmpty()) { + getFromDB(db) + } + db.itemsDao().deleteAllItems() + db.itemsDao().insertAllItems(*(items.map { it.toEntity() }).toTypedArray()) + } + } + + fun filter() { + fun filterSearch(item: Item): Boolean { + return if (!searchFilter.isEmptyOrNullOrNullString()) { + var matched = item.title.contains(searchFilter.toString(), true) + matched = matched || item.content.contains(searchFilter.toString(), true) + matched = matched || item.sourcetitle.contains(searchFilter.toString(), true) + matched + } else { + true + } + } + + var tmpItems = focusedItems + if (tagFilter != null) { + tmpItems = tmpItems.filter { it.tags.tags.contains(tagFilter.toString()) } as ArrayList + } + if (searchFilter != null) { + tmpItems = tmpItems.filter { filterSearch(it) } as ArrayList + } + if (sourceFilter != null) { + tmpItems = tmpItems.filter { it.sourcetitle == sourceFilter } as ArrayList + } + focusedItems = tmpItems + } + + private fun getFocusedItems() { + when (displayedItems) { + "all" -> getAll() + "unread" -> getUnRead() + "read" -> getRead() + "starred" -> getStarred() + else -> getUnRead() + } + } + + fun getUnRead() { + displayedItems = "unread" + focusedItems = items.filter { item -> item.unread } as ArrayList + filter() + } + + fun getRead() { + displayedItems = "read" + focusedItems = items.filter { item -> !item.unread } as ArrayList + filter() + } + + fun getStarred() { + displayedItems = "starred" + focusedItems = items.filter { item -> item.starred } as ArrayList + filter() + } + + fun getAll() { + displayedItems = "all" + focusedItems = items + filter() + } + + suspend fun getFromDB(db: AppDatabase) { + if (itemsCaching) { + val dbItems = db.itemsDao().items().map { it.toView() } as ArrayList + appendNewItems(dbItems) + } + } + + private fun removeItemAtIndex(index: Int) { + val i = focusedItems[index] + val tmpItems = focusedItems + tmpItems.remove(i) + focusedItems = tmpItems + } + + fun addItemAtIndex(newItem: Item, index: Int) { + val tmpItems = focusedItems + tmpItems.add(index, newItem) + focusedItems = tmpItems + } + + fun readItem(app: Context, api: SelfossApi, db: AppDatabase, item: Item) { + if (items.contains(item)) { + position = items.indexOf(item) + readItemAtPosition(app, api, db) + } + } + + fun readItems(db: AppDatabase, ids: List) { + for (id in ids) { + val match = items.filter { it -> it.id == id } + if (match.isNotEmpty() && match.size == 1) { + position = items.indexOf(match[0]) + val tmpItems = items + tmpItems[position].unread = false + items = tmpItems + resetDBItem(db) + badgeUnread-- + } + } + } + + private fun readItemAtPosition(app: Context, api: SelfossApi, db: AppDatabase) { + val i = items[position] + + if (app.isNetworkAccessible(null)) { + api.markItem(i.id).enqueue(object : Callback { + override fun onResponse( + call: Call, + response: Response + ) { + + val tmpItems = items + tmpItems[position].unread = false + items = tmpItems + + resetDBItem(db) + getFocusedItems() + badgeUnread-- + } + + override fun onFailure(call: Call, t: Throwable) { + Toast.makeText( + app, + app.getString(R.string.cant_mark_read), + Toast.LENGTH_SHORT + ).show() + } + }) + } else if (itemsCaching) { + thread { + db.actionsDao().insertAllActions(ActionEntity(i.id, true, false, false, false)) + } + } + + if (position > items.size) { + position -= 1 + } + } + + fun unreadItem(app: Context, api: SelfossApi, db: AppDatabase, item: Item) { + if (items.contains(item) && !item.unread) { + position = items.indexOf(item) + unreadItemAtPosition(app, api, db) + } + } + + private fun unreadItemAtPosition(app: Context, api: SelfossApi, db: AppDatabase) { + val i = items[position] + + if (app.isNetworkAccessible(null)) { + api.unmarkItem(i.id).enqueue(object : Callback { + override fun onResponse( + call: Call, + response: Response + ) { + + val tmpItems = items + tmpItems[position].unread = true + items = tmpItems + + resetDBItem(db) + getFocusedItems() + badgeUnread++ + } + + override fun onFailure(call: Call, t: Throwable) { + Toast.makeText( + app, + app.getString(R.string.cant_mark_unread), + Toast.LENGTH_SHORT + ).show() + } + }) + } else if (itemsCaching) { + thread { + db.actionsDao().insertAllActions(ActionEntity(i.id, false, true, false, false)) + } + } + } + + fun starItem(app: Context, api: SelfossApi, db: AppDatabase, item: Item) { + if (items.contains(item) && !item.starred) { + position = items.indexOf(item) + starItemAtPosition(app, api, db) + } + } + + private fun starItemAtPosition(app: Context, api: SelfossApi, db: AppDatabase) { + val i = items[position] + + if (app.isNetworkAccessible(null)) { + api.starrItem(i.id).enqueue(object : Callback { + override fun onResponse( + call: Call, + response: Response + ) { + val tmpItems = items + tmpItems[position].starred = true + items = tmpItems + + resetDBItem(db) + getFocusedItems() + badgeStarred++ + } + + override fun onFailure( + call: Call, + t: Throwable + ) { + Toast.makeText( + app, + app.getString(R.string.cant_mark_favortie), + Toast.LENGTH_SHORT + ).show() + } + }) + } else { + thread { + db.actionsDao().insertAllActions(ActionEntity(i.id, false, false, true, false)) + } + } + } + + fun unstarItem(app: Context, api: SelfossApi, db: AppDatabase, item: Item) { + if (items.contains(item) && item.starred) { + position = items.indexOf(item) + unstarItemAtPosition(app, api, db) + } + } + + private fun unstarItemAtPosition(app: Context, api: SelfossApi, db: AppDatabase) { + val i = items[position] + + if (app.isNetworkAccessible(null)) { + api.unstarrItem(i.id).enqueue(object : Callback { + override fun onResponse( + call: Call, + response: Response + ) { + val tmpItems = items + tmpItems[position].starred = false + items = tmpItems + + resetDBItem(db) + getFocusedItems() + badgeStarred-- + } + + override fun onFailure( + call: Call, + t: Throwable + ) { + Toast.makeText( + app, + app.getString(R.string.cant_unmark_favortie), + Toast.LENGTH_SHORT + ).show() + } + }) + } else { + thread { + db.actionsDao().insertAllActions(ActionEntity(i.id, false, false, false, true)) + } + } + } + + private fun resetDBItem(db: AppDatabase) { + if (itemsCaching) { + val i = items[position] + CoroutineScope(Dispatchers.IO).launch { + db.itemsDao().delete(i.toEntity()) + db.itemsDao().insertAllItems(i.toEntity()) + } + } + } + + fun unreadItemStatusAtIndex(position: Int): Boolean { + return focusedItems[position].unread + } + + fun computeBadges() { + badgeUnread = items.filter { item -> item.unread }.size + badgeStarred = items.filter { item -> item.starred }.size + badgeAll = items.size + } + + private fun sortItems() { + val tmpItems = ArrayList(items.sortedByDescending { parseDate(it.datetime) }) + items = tmpItems + } +} \ No newline at end of file diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/SizeUtils.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/SizeUtils.kt new file mode 100644 index 0000000..1bf44e3 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/SizeUtils.kt @@ -0,0 +1,9 @@ +package bou.amine.apps.readerforselfossv2.android.utils + +import android.content.res.Resources + +val Int.toPx: Int + get() = (this * Resources.getSystem().displayMetrics.density).toInt() + +val Int.toDp: Int + get() = (this / Resources.getSystem().displayMetrics.density).toInt() diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/bottombar/BottomBarUtils.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/bottombar/BottomBarUtils.kt new file mode 100644 index 0000000..155a35c --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/bottombar/BottomBarUtils.kt @@ -0,0 +1,12 @@ +package bou.amine.apps.readerforselfossv2.android.utils.bottombar + +import com.ashokvarma.bottomnavigation.TextBadgeItem + +fun TextBadgeItem.removeBadge(): TextBadgeItem { + this.setText("") + this.hide() + return this +} + +fun TextBadgeItem.maybeShow(): TextBadgeItem = + if (this.isHidden) this.show() else this diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/customtabs/CustomTabActivityHelper.java b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/customtabs/CustomTabActivityHelper.java new file mode 100644 index 0000000..cfdd8d8 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/customtabs/CustomTabActivityHelper.java @@ -0,0 +1,153 @@ +package bou.amine.apps.readerforselfossv2.android.utils.customtabs; + + +import android.app.Activity; +import android.net.Uri; +import android.os.Bundle; +import androidx.browser.customtabs.CustomTabsClient; +import androidx.browser.customtabs.CustomTabsIntent; +import androidx.browser.customtabs.CustomTabsServiceConnection; +import androidx.browser.customtabs.CustomTabsSession; + +import java.util.List; + +/** + * This is a helper class to manage the connection to the Custom Tabs Service. + */ +public class CustomTabActivityHelper implements ServiceConnectionCallback { + private CustomTabsSession mCustomTabsSession; + private CustomTabsClient mClient; + private CustomTabsServiceConnection mConnection; + private ConnectionCallback mConnectionCallback; + + /** + * Opens the URL on a Custom Tab if possible. Otherwise fallsback to opening it on a WebView. + * + * @param activity The host activity. + * @param customTabsIntent a CustomTabsIntent to be used if Custom Tabs is available. + * @param uri the Uri to be opened. + * @param fallback a CustomTabFallback to be used if Custom Tabs is not available. + */ + public static void openCustomTab(Activity activity, + CustomTabsIntent customTabsIntent, + Uri uri, + CustomTabFallback fallback) { + String packageName = CustomTabsHelper.getPackageNameToUse(activity); + + //If we cant find a package name, it means theres no browser that supports + //Chrome Custom Tabs installed. So, we fallback to the webview + if (packageName == null) { + if (fallback != null) { + fallback.openUri(activity, uri); + } + } else { + customTabsIntent.intent.setPackage(packageName); + customTabsIntent.launchUrl(activity, uri); + } + } + + /** + * Unbinds the Activity from the Custom Tabs Service. + * + * @param activity the activity that is connected to the service. + */ + public void unbindCustomTabsService(Activity activity) { + if (mConnection == null) return; + activity.unbindService(mConnection); + mClient = null; + mCustomTabsSession = null; + mConnection = null; + } + + /** + * Creates or retrieves an exiting CustomTabsSession. + * + * @return a CustomTabsSession. + */ + public CustomTabsSession getSession() { + if (mClient == null) { + mCustomTabsSession = null; + } else if (mCustomTabsSession == null) { + mCustomTabsSession = mClient.newSession(null); + } + return mCustomTabsSession; + } + + /** + * Register a Callback to be called when connected or disconnected from the Custom Tabs Service. + * + * @param connectionCallback + */ + public void setConnectionCallback(ConnectionCallback connectionCallback) { + this.mConnectionCallback = connectionCallback; + } + + /** + * Binds the Activity to the Custom Tabs Service. + * + * @param activity the activity to be binded to the service. + */ + public void bindCustomTabsService(Activity activity) { + if (mClient != null) return; + + String packageName = CustomTabsHelper.getPackageNameToUse(activity); + if (packageName == null) return; + + mConnection = new ServiceConnection(this); + CustomTabsClient.bindCustomTabsService(activity, packageName, mConnection); + } + + /** + * @return true if call to mayLaunchUrl was accepted. + * @see {@link CustomTabsSession#mayLaunchUrl(Uri, Bundle, List)}. + */ + public boolean mayLaunchUrl(Uri uri, Bundle extras, List otherLikelyBundles) { + if (mClient == null) return false; + + CustomTabsSession session = getSession(); + return session != null && session.mayLaunchUrl(uri, extras, otherLikelyBundles); + + } + + @Override + public void onServiceConnected(CustomTabsClient client) { + mClient = client; + mClient.warmup(0L); + if (mConnectionCallback != null) mConnectionCallback.onCustomTabsConnected(); + } + + @Override + public void onServiceDisconnected() { + mClient = null; + mCustomTabsSession = null; + if (mConnectionCallback != null) mConnectionCallback.onCustomTabsDisconnected(); + } + + /** + * A Callback for when the service is connected or disconnected. Use those callbacks to + * handle UI changes when the service is connected or disconnected. + */ + public interface ConnectionCallback { + /** + * Called when the service is connected. + */ + void onCustomTabsConnected(); + + /** + * Called when the service is disconnected. + */ + void onCustomTabsDisconnected(); + } + + /** + * To be used as a fallback to open the Uri when Custom Tabs is not available. + */ + public interface CustomTabFallback { + /** + * @param activity The Activity that wants to open the Uri. + * @param uri The uri to be opened by the fallback. + */ + void openUri(Activity activity, Uri uri); + } + +} \ No newline at end of file diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/customtabs/CustomTabsHelper.java b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/customtabs/CustomTabsHelper.java new file mode 100644 index 0000000..5f07408 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/customtabs/CustomTabsHelper.java @@ -0,0 +1,130 @@ +package bou.amine.apps.readerforselfossv2.android.utils.customtabs; + + +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import androidx.browser.customtabs.CustomTabsService; +import android.text.TextUtils; +import android.util.Log; + +import java.util.ArrayList; +import java.util.List; + +import bou.amine.apps.readerforselfossv2.android.utils.customtabs.helpers.KeepAliveService; + +@SuppressWarnings("ALL") +class CustomTabsHelper { + private static final String TAG = "CustomTabsHelper"; + private static final String STABLE_PACKAGE = "com.android.chrome"; + private static final String BETA_PACKAGE = "com.chrome.beta"; + private static final String DEV_PACKAGE = "com.chrome.dev"; + private static final String LOCAL_PACKAGE = "com.google.android.apps.chrome"; + private static final String EXTRA_CUSTOM_TABS_KEEP_ALIVE = + "android.support.customtabs.extra.KEEP_ALIVE"; + + private static String sPackageNameToUse; + + private CustomTabsHelper() { + } + + public static void addKeepAliveExtra(Context context, Intent intent) { + Intent keepAliveIntent = new Intent().setClassName( + context.getPackageName(), KeepAliveService.class.getCanonicalName()); + intent.putExtra(EXTRA_CUSTOM_TABS_KEEP_ALIVE, keepAliveIntent); + } + + /** + * Goes through all apps that handle VIEW intents and have a warmup service. Picks + * the one chosen by the user if there is one, otherwise makes a best effort to return a + * valid package name. + *

+ * This is not threadsafe. + * + * @param context {@link Context} to use for accessing {@link PackageManager}. + * @return The package name recommended to use for connecting to custom tabs related components. + */ + public static String getPackageNameToUse(Context context) { + if (sPackageNameToUse != null) return sPackageNameToUse; + + PackageManager pm = context.getPackageManager(); + // Get default VIEW intent handler. + Intent activityIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("http://www.example.com")); + ResolveInfo defaultViewHandlerInfo = pm.resolveActivity(activityIntent, 0); + String defaultViewHandlerPackageName = null; + if (defaultViewHandlerInfo != null) { + defaultViewHandlerPackageName = defaultViewHandlerInfo.activityInfo.packageName; + } + + // Get all apps that can handle VIEW intents. + List resolvedActivityList = pm.queryIntentActivities(activityIntent, 0); + List packagesSupportingCustomTabs = new ArrayList<>(); + for (ResolveInfo info : resolvedActivityList) { + Intent serviceIntent = new Intent(); + serviceIntent.setAction(CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION); + serviceIntent.setPackage(info.activityInfo.packageName); + if (pm.resolveService(serviceIntent, 0) != null) { + packagesSupportingCustomTabs.add(info.activityInfo.packageName); + } + } + + // Now packagesSupportingCustomTabs contains all apps that can handle both VIEW intents + // and service calls. + if (packagesSupportingCustomTabs.isEmpty()) { + sPackageNameToUse = null; + } else if (packagesSupportingCustomTabs.size() == 1) { + sPackageNameToUse = packagesSupportingCustomTabs.get(0); + } else if (!TextUtils.isEmpty(defaultViewHandlerPackageName) + && !hasSpecializedHandlerIntents(context, activityIntent) + && packagesSupportingCustomTabs.contains(defaultViewHandlerPackageName)) { + sPackageNameToUse = defaultViewHandlerPackageName; + } else if (packagesSupportingCustomTabs.contains(STABLE_PACKAGE)) { + sPackageNameToUse = STABLE_PACKAGE; + } else if (packagesSupportingCustomTabs.contains(BETA_PACKAGE)) { + sPackageNameToUse = BETA_PACKAGE; + } else if (packagesSupportingCustomTabs.contains(DEV_PACKAGE)) { + sPackageNameToUse = DEV_PACKAGE; + } else if (packagesSupportingCustomTabs.contains(LOCAL_PACKAGE)) { + sPackageNameToUse = LOCAL_PACKAGE; + } + return sPackageNameToUse; + } + + /** + * Used to check whether there is a specialized handler for a given intent. + * + * @param intent The intent to check with. + * @return Whether there is a specialized handler for the given intent. + */ + private static boolean hasSpecializedHandlerIntents(Context context, Intent intent) { + try { + PackageManager pm = context.getPackageManager(); + List handlers = pm.queryIntentActivities( + intent, + PackageManager.GET_RESOLVED_FILTER); + if (handlers == null || handlers.isEmpty()) { + return false; + } + for (ResolveInfo resolveInfo : handlers) { + IntentFilter filter = resolveInfo.filter; + if (filter == null) continue; + if (filter.countDataAuthorities() == 0 || filter.countDataPaths() == 0) continue; + if (resolveInfo.activityInfo == null) continue; + return true; + } + } catch (RuntimeException e) { + Log.e(TAG, "Runtime exception while getting specialized handlers"); + } + return false; + } + + /** + * @return All possible chrome package names that provide custom tabs feature. + */ + public static String[] getPackages() { + return new String[]{"", STABLE_PACKAGE, BETA_PACKAGE, DEV_PACKAGE, LOCAL_PACKAGE}; + } +} diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/customtabs/ServiceConnection.java b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/customtabs/ServiceConnection.java new file mode 100644 index 0000000..f1c2214 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/customtabs/ServiceConnection.java @@ -0,0 +1,33 @@ +package bou.amine.apps.readerforselfossv2.android.utils.customtabs; + + +import android.content.ComponentName; +import androidx.browser.customtabs.CustomTabsClient; +import androidx.browser.customtabs.CustomTabsServiceConnection; + +import java.lang.ref.WeakReference; + +/** + * Implementation for the CustomTabsServiceConnection that avoids leaking the + * ServiceConnectionCallback + */ +public class ServiceConnection extends CustomTabsServiceConnection { + // A weak reference to the ServiceConnectionCallback to avoid leaking it. + private WeakReference mConnectionCallback; + + public ServiceConnection(ServiceConnectionCallback connectionCallback) { + mConnectionCallback = new WeakReference<>(connectionCallback); + } + + @Override + public void onCustomTabsServiceConnected(ComponentName name, CustomTabsClient client) { + ServiceConnectionCallback connectionCallback = mConnectionCallback.get(); + if (connectionCallback != null) connectionCallback.onServiceConnected(client); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + ServiceConnectionCallback connectionCallback = mConnectionCallback.get(); + if (connectionCallback != null) connectionCallback.onServiceDisconnected(); + } +} \ No newline at end of file diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/customtabs/ServiceConnectionCallback.java b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/customtabs/ServiceConnectionCallback.java new file mode 100644 index 0000000..78a34fa --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/customtabs/ServiceConnectionCallback.java @@ -0,0 +1,19 @@ +package bou.amine.apps.readerforselfossv2.android.utils.customtabs; + + +import androidx.browser.customtabs.CustomTabsClient; + + +public interface ServiceConnectionCallback { + /** + * Called when the service is connected. + * + * @param client a CustomTabsClient + */ + void onServiceConnected(CustomTabsClient client); + + /** + * Called when the service is disconnected. + */ + void onServiceDisconnected(); +} diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/customtabs/helpers/KeepAliveService.java b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/customtabs/helpers/KeepAliveService.java new file mode 100644 index 0000000..c754941 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/customtabs/helpers/KeepAliveService.java @@ -0,0 +1,15 @@ +package bou.amine.apps.readerforselfossv2.android.utils.customtabs.helpers; + +import android.app.Service; +import android.content.Intent; +import android.os.Binder; +import android.os.IBinder; + +public class KeepAliveService extends Service { + private static final Binder sBinder = new Binder(); + + @Override + public IBinder onBind(Intent intent) { + return sBinder; + } +} diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/drawer/CustomBaseViewHolder.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/drawer/CustomBaseViewHolder.kt new file mode 100644 index 0000000..1bc0f86 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/drawer/CustomBaseViewHolder.kt @@ -0,0 +1,15 @@ +/* From https://github.com/mikepenz/MaterialDrawer/blob/develop/app/src/main/java/com/mikepenz/materialdrawer/app/drawerItems/CustomBaseViewHolder.java */ +package bou.amine.apps.readerforselfossv2.android.utils.drawer + +import androidx.recyclerview.widget.RecyclerView +import android.view.View +import android.widget.ImageView +import android.widget.TextView + +import bou.amine.apps.readerforselfossv2.android.R + +open class CustomBaseViewHolder(var view: View) : RecyclerView.ViewHolder(view) { + var icon: ImageView = view.findViewById(R.id.material_drawer_icon) + var name: TextView = view.findViewById(R.id.material_drawer_name) + var description: TextView = view.findViewById(R.id.material_drawer_description) +} diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/glide/GlideUtils.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/glide/GlideUtils.kt new file mode 100644 index 0000000..be63485 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/glide/GlideUtils.kt @@ -0,0 +1,69 @@ +package bou.amine.apps.readerforselfossv2.android.utils.glide + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.util.Base64 +import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory +import android.widget.ImageView +import bou.amine.apps.readerforselfossv2.android.utils.Config +import com.bumptech.glide.Glide +import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.RequestManager +import com.bumptech.glide.load.model.GlideUrl +import com.bumptech.glide.load.model.LazyHeaders +import com.bumptech.glide.request.RequestOptions +import com.bumptech.glide.request.target.BitmapImageViewTarget +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.InputStream + +fun Context.bitmapCenterCrop(config: Config, url: String, iv: ImageView) = + Glide.with(this) + .asBitmap() + .loadMaybeBasicAuth(config, url) + .apply(RequestOptions.centerCropTransform()) + .into(iv) + +fun Context.circularBitmapDrawable(config: Config, url: String, iv: ImageView) = + Glide.with(this) + .asBitmap() + .loadMaybeBasicAuth(config, url) + .apply(RequestOptions.centerCropTransform()) + .into(object : BitmapImageViewTarget(iv) { + override fun setResource(resource: Bitmap?) { + val circularBitmapDrawable = RoundedBitmapDrawableFactory.create( + resources, + resource + ) + circularBitmapDrawable.isCircular = true + iv.setImageDrawable(circularBitmapDrawable) + } + }) + +fun RequestBuilder.loadMaybeBasicAuth(config: Config, url: String): RequestBuilder { + val builder: LazyHeaders.Builder = LazyHeaders.Builder() + if (config.httpUserLogin.isNotEmpty() || config.httpUserPassword.isNotEmpty()) { + val basicAuth = "Basic " + Base64.encodeToString("${config.httpUserLogin}:${config.httpUserPassword}".toByteArray(), Base64.NO_WRAP) + builder.addHeader("Authorization", basicAuth) + } + val glideUrl = GlideUrl(url, builder.build()) + return this.load(glideUrl) +} + +fun RequestManager.loadMaybeBasicAuth(config: Config, url: String): RequestBuilder { + val builder: LazyHeaders.Builder = LazyHeaders.Builder() + if (config.httpUserLogin.isNotEmpty() || config.httpUserPassword.isNotEmpty()) { + val basicAuth = "Basic " + Base64.encodeToString("${config.httpUserLogin}:${config.httpUserPassword}".toByteArray(), Base64.NO_WRAP) + builder.addHeader("Authorization", basicAuth) + } + val glideUrl = GlideUrl(url, builder.build()) + return this.load(glideUrl) +} + +fun getBitmapInputStream(bitmap:Bitmap,compressFormat: Bitmap.CompressFormat): InputStream { + val byteArrayOutputStream = ByteArrayOutputStream() + bitmap.compress(compressFormat, 80, byteArrayOutputStream) + val bitmapData: ByteArray = byteArrayOutputStream.toByteArray() + return ByteArrayInputStream(bitmapData) +} \ No newline at end of file diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/glide/SelfSignedGlideModule.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/glide/SelfSignedGlideModule.kt new file mode 100644 index 0000000..8449013 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/glide/SelfSignedGlideModule.kt @@ -0,0 +1,33 @@ +package bou.amine.apps.readerforselfossv2.android.utils.glide + +import android.content.Context +import bou.amine.apps.readerforselfossv2.android.utils.Config +import bou.amine.apps.readerforselfossv2.android.utils.getUnsafeHttpClient +import com.bumptech.glide.Glide +import com.bumptech.glide.GlideBuilder +import com.bumptech.glide.Registry +import com.bumptech.glide.load.model.GlideUrl +import com.bumptech.glide.module.GlideModule +import java.io.InputStream + +class SelfSignedGlideModule : GlideModule { + + override fun applyOptions(context: Context?, builder: GlideBuilder?) { + } + + override fun registerComponents(context: Context?, glide: Glide?, registry: Registry?) { + + if (context != null) { + val pref = context?.getSharedPreferences(Config.settingsName, Context.MODE_PRIVATE) + if (pref.getBoolean("isSelfSignedCert", false)) { + val client = getUnsafeHttpClient().build() + + registry?.append( + GlideUrl::class.java, + InputStream::class.java, + com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader.Factory(client) + ) + } + } + } +} \ No newline at end of file diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/network/NetworkUtils.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/network/NetworkUtils.kt new file mode 100644 index 0000000..9f7cf2e --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/network/NetworkUtils.kt @@ -0,0 +1,64 @@ +package bou.amine.apps.readerforselfossv2.android.utils.network + +import android.content.Context +import android.graphics.Color +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build +import android.view.View +import android.widget.TextView +import bou.amine.apps.readerforselfossv2.android.R +import com.google.android.material.snackbar.Snackbar + +var snackBarShown = false +var view: View? = null +lateinit var s: Snackbar + +fun Context.isNetworkAccessible(v: View?, overrideOffline: Boolean = false): Boolean { + val networkIsAccessible = isNetworkAvailable(this) + + if (v != null && (!networkIsAccessible || overrideOffline) && (!snackBarShown || v != view)) { + view = v + s = Snackbar + .make( + v, + R.string.no_network_connectivity, + Snackbar.LENGTH_INDEFINITE + ) + + s.setAction(android.R.string.ok) { + snackBarShown = false + s.dismiss() + } + + val view = s.view + val tv: TextView = view.findViewById(com.google.android.material.R.id.snackbar_text) + tv.setTextColor(Color.WHITE) + s.show() + snackBarShown = true + } + if (snackBarShown && networkIsAccessible && !overrideOffline) { + s.dismiss() + } + return if(overrideOffline) overrideOffline else networkIsAccessible +} + +fun isNetworkAvailable(context: Context): Boolean { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val network = connectivityManager.activeNetwork ?: return false + val networkCapabilities = connectivityManager.getNetworkCapabilities(network) ?: return false + + return when { + networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH) -> true + networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true + networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true + networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true + else -> false + } + } else { + val network = connectivityManager.activeNetworkInfo ?: return false + return network.isConnectedOrConnecting + } +} \ No newline at end of file diff --git a/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/persistence/EntitiesUtils.kt b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/persistence/EntitiesUtils.kt new file mode 100644 index 0000000..4faaf44 --- /dev/null +++ b/androidApp/src/main/java/bou/amine/apps/readerforselfossv2/android/utils/persistence/EntitiesUtils.kt @@ -0,0 +1,73 @@ +package bou.amine.apps.readerforselfossv2.android.utils.persistence + +import bou.amine.apps.readerforselfossv2.android.api.selfoss.Item +import bou.amine.apps.readerforselfossv2.android.api.selfoss.SelfossTagType +import bou.amine.apps.readerforselfossv2.android.api.selfoss.Source +import bou.amine.apps.readerforselfossv2.android.api.selfoss.Tag +import bou.amine.apps.readerforselfossv2.android.persistence.entities.ItemEntity +import bou.amine.apps.readerforselfossv2.android.persistence.entities.SourceEntity +import bou.amine.apps.readerforselfossv2.android.persistence.entities.TagEntity + +fun TagEntity.toView(): Tag = + Tag( + this.tag, + this.color, + this.unread + ) + +fun SourceEntity.toView(): Source = + Source( + this.id, + this.title, + SelfossTagType(this.tags), + this.spout, + this.error, + this.icon + ) + +fun Source.toEntity(): SourceEntity = + SourceEntity( + this.id, + this.getTitleDecoded(), + this.tags.tags, + this.spout, + this.error, + this.icon.orEmpty() + ) + +fun Tag.toEntity(): TagEntity = + TagEntity( + this.tag, + this.color, + this.unread + ) + +fun ItemEntity.toView(): Item = + Item( + this.id, + this.datetime, + this.title, + this.content, + this.unread, + this.starred, + this.thumbnail, + this.icon, + this.link, + this.sourcetitle, + SelfossTagType(this.tags) + ) + +fun Item.toEntity(): ItemEntity = + ItemEntity( + this.id, + this.datetime, + this.getTitleDecoded(), + this.content, + this.unread, + this.starred, + this.thumbnail, + this.icon, + this.link, + this.getSourceTitle(), + this.tags.tags + ) \ No newline at end of file diff --git a/androidApp/src/main/res/anim/slide_in_right.xml b/androidApp/src/main/res/anim/slide_in_right.xml new file mode 100644 index 0000000..3189c25 --- /dev/null +++ b/androidApp/src/main/res/anim/slide_in_right.xml @@ -0,0 +1,16 @@ + + + + + \ No newline at end of file diff --git a/androidApp/src/main/res/anim/slide_out_left.xml b/androidApp/src/main/res/anim/slide_out_left.xml new file mode 100644 index 0000000..0ec7682 --- /dev/null +++ b/androidApp/src/main/res/anim/slide_out_left.xml @@ -0,0 +1,16 @@ + + + + + \ No newline at end of file diff --git a/androidApp/src/main/res/color/ic_menu_heart_color.xml b/androidApp/src/main/res/color/ic_menu_heart_color.xml new file mode 100644 index 0000000..dd8fa5f --- /dev/null +++ b/androidApp/src/main/res/color/ic_menu_heart_color.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/androidApp/src/main/res/drawable-anydpi/ic_format_align_justify.xml b/androidApp/src/main/res/drawable-anydpi/ic_format_align_justify.xml new file mode 100644 index 0000000..0001f38 --- /dev/null +++ b/androidApp/src/main/res/drawable-anydpi/ic_format_align_justify.xml @@ -0,0 +1,9 @@ + + + diff --git a/androidApp/src/main/res/drawable-anydpi/ic_format_align_left.xml b/androidApp/src/main/res/drawable-anydpi/ic_format_align_left.xml new file mode 100644 index 0000000..0e0782a --- /dev/null +++ b/androidApp/src/main/res/drawable-anydpi/ic_format_align_left.xml @@ -0,0 +1,9 @@ + + + diff --git a/androidApp/src/main/res/drawable/background_splash.xml b/androidApp/src/main/res/drawable/background_splash.xml new file mode 100644 index 0000000..32241ec --- /dev/null +++ b/androidApp/src/main/res/drawable/background_splash.xml @@ -0,0 +1,13 @@ + + + + + + + + + + \ No newline at end of file diff --git a/androidApp/src/main/res/drawable/bg.png b/androidApp/src/main/res/drawable/bg.png new file mode 100644 index 0000000..d93c122 Binary files /dev/null and b/androidApp/src/main/res/drawable/bg.png differ diff --git a/androidApp/src/main/res/drawable/ic_add_white_24dp.xml b/androidApp/src/main/res/drawable/ic_add_white_24dp.xml new file mode 100644 index 0000000..e3979cd --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_add_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/androidApp/src/main/res/drawable/ic_baseline_white_eye_24dp.xml b/androidApp/src/main/res/drawable/ic_baseline_white_eye_24dp.xml new file mode 100644 index 0000000..b2add52 --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_baseline_white_eye_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/androidApp/src/main/res/drawable/ic_bug_report_black_24dp.xml b/androidApp/src/main/res/drawable/ic_bug_report_black_24dp.xml new file mode 100644 index 0000000..4d83902 --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_bug_report_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/androidApp/src/main/res/drawable/ic_bug_report_white_24dp.xml b/androidApp/src/main/res/drawable/ic_bug_report_white_24dp.xml new file mode 100644 index 0000000..5c8f5bc --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_bug_report_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/androidApp/src/main/res/drawable/ic_chrome_reader_mode_black_24dp.xml b/androidApp/src/main/res/drawable/ic_chrome_reader_mode_black_24dp.xml new file mode 100644 index 0000000..99b5867 --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_chrome_reader_mode_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/androidApp/src/main/res/drawable/ic_chrome_reader_mode_white_24dp.xml b/androidApp/src/main/res/drawable/ic_chrome_reader_mode_white_24dp.xml new file mode 100644 index 0000000..43fd20a --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_chrome_reader_mode_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/androidApp/src/main/res/drawable/ic_close_white_24dp.xml b/androidApp/src/main/res/drawable/ic_close_white_24dp.xml new file mode 100644 index 0000000..0c8775c --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_close_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/androidApp/src/main/res/drawable/ic_color_lens_black_24dp.xml b/androidApp/src/main/res/drawable/ic_color_lens_black_24dp.xml new file mode 100644 index 0000000..f75e2fb --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_color_lens_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/androidApp/src/main/res/drawable/ic_color_lens_white_24dp.xml b/androidApp/src/main/res/drawable/ic_color_lens_white_24dp.xml new file mode 100644 index 0000000..4abeea5 --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_color_lens_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/androidApp/src/main/res/drawable/ic_history_white_24dp.xml b/androidApp/src/main/res/drawable/ic_history_white_24dp.xml new file mode 100644 index 0000000..de25eb4 --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_history_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/androidApp/src/main/res/drawable/ic_info_black_24dp.xml b/androidApp/src/main/res/drawable/ic_info_black_24dp.xml new file mode 100644 index 0000000..cc94088 --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_info_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/androidApp/src/main/res/drawable/ic_info_outline_white_24dp.xml b/androidApp/src/main/res/drawable/ic_info_outline_white_24dp.xml new file mode 100644 index 0000000..af0d4d0 --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_info_outline_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/androidApp/src/main/res/drawable/ic_menu_done_all_white_24dp.xml b/androidApp/src/main/res/drawable/ic_menu_done_all_white_24dp.xml new file mode 100644 index 0000000..2479e86 --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_menu_done_all_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/androidApp/src/main/res/drawable/ic_menu_heart_60dp.xml b/androidApp/src/main/res/drawable/ic_menu_heart_60dp.xml new file mode 100644 index 0000000..9cee08b --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_menu_heart_60dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/androidApp/src/main/res/drawable/ic_menu_refresh_white_24dp.xml b/androidApp/src/main/res/drawable/ic_menu_refresh_white_24dp.xml new file mode 100644 index 0000000..cc2d1e0 --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_menu_refresh_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/androidApp/src/main/res/drawable/ic_menu_search_white_24dp.xml b/androidApp/src/main/res/drawable/ic_menu_search_white_24dp.xml new file mode 100644 index 0000000..be5ad99 --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_menu_search_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/androidApp/src/main/res/drawable/ic_open_in_browser_black_24dp.xml b/androidApp/src/main/res/drawable/ic_open_in_browser_black_24dp.xml new file mode 100644 index 0000000..3fb9799 --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_open_in_browser_black_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/androidApp/src/main/res/drawable/ic_open_in_browser_white_24dp.xml b/androidApp/src/main/res/drawable/ic_open_in_browser_white_24dp.xml new file mode 100644 index 0000000..3fb9799 --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_open_in_browser_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/androidApp/src/main/res/drawable/ic_remove_circle_outline_black_24dp.xml b/androidApp/src/main/res/drawable/ic_remove_circle_outline_black_24dp.xml new file mode 100644 index 0000000..9af9456 --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_remove_circle_outline_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/androidApp/src/main/res/drawable/ic_settings_black_24dp.xml b/androidApp/src/main/res/drawable/ic_settings_black_24dp.xml new file mode 100644 index 0000000..ace746c --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_settings_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/androidApp/src/main/res/drawable/ic_share_black_24dp.xml b/androidApp/src/main/res/drawable/ic_share_black_24dp.xml new file mode 100644 index 0000000..e3fe874 --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_share_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/androidApp/src/main/res/drawable/ic_share_white_24dp.xml b/androidApp/src/main/res/drawable/ic_share_white_24dp.xml new file mode 100644 index 0000000..045bbc0 --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_share_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/androidApp/src/main/res/drawable/ic_signal_wifi_off_black_24dp.xml b/androidApp/src/main/res/drawable/ic_signal_wifi_off_black_24dp.xml new file mode 100644 index 0000000..8339d79 --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_signal_wifi_off_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/androidApp/src/main/res/drawable/ic_stat_cloud_download_black_24dp.xml b/androidApp/src/main/res/drawable/ic_stat_cloud_download_black_24dp.xml new file mode 100644 index 0000000..261c312 --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_stat_cloud_download_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/androidApp/src/main/res/drawable/ic_tab_archive_black_24dp.xml b/androidApp/src/main/res/drawable/ic_tab_archive_black_24dp.xml new file mode 100644 index 0000000..8b18a9d --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_tab_archive_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/androidApp/src/main/res/drawable/ic_tab_favorite_black_24dp.xml b/androidApp/src/main/res/drawable/ic_tab_favorite_black_24dp.xml new file mode 100644 index 0000000..cfba5d8 --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_tab_favorite_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/androidApp/src/main/res/drawable/ic_tab_fiber_new_black_24dp.xml b/androidApp/src/main/res/drawable/ic_tab_fiber_new_black_24dp.xml new file mode 100644 index 0000000..6097dff --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_tab_fiber_new_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/androidApp/src/main/res/drawable/ic_widgets_black_24dp.xml b/androidApp/src/main/res/drawable/ic_widgets_black_24dp.xml new file mode 100644 index 0000000..4abb823 --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_widgets_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/androidApp/src/main/res/font/open_sans.xml b/androidApp/src/main/res/font/open_sans.xml new file mode 100644 index 0000000..f9284b2 --- /dev/null +++ b/androidApp/src/main/res/font/open_sans.xml @@ -0,0 +1,7 @@ + + + diff --git a/androidApp/src/main/res/font/roboto.xml b/androidApp/src/main/res/font/roboto.xml new file mode 100644 index 0000000..2641caf --- /dev/null +++ b/androidApp/src/main/res/font/roboto.xml @@ -0,0 +1,7 @@ + + + diff --git a/androidApp/src/main/res/layout/activity_add_source.xml b/androidApp/src/main/res/layout/activity_add_source.xml new file mode 100644 index 0000000..7bc31e1 --- /dev/null +++ b/androidApp/src/main/res/layout/activity_add_source.xml @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + +