xiongzhu 2 vuotta sitten
commit
1fe107ba71
100 muutettua tiedostoa jossa 14264 lisäystä ja 0 poistoa
  1. 10 0
      .editorconfig
  2. 2 0
      .github/FUNDING.yml
  3. 34 0
      .github/ISSUE_TEMPLATE.md
  4. 40 0
      .github/PULL_REQUEST_TEMPLATE.md
  5. 16 0
      .gitignore
  6. 12 0
      .travis.yml
  7. 201 0
      LICENSE
  8. 87 0
      README.md
  9. 1 0
      app/.gitignore
  10. 69 0
      app/build.gradle
  11. 41 0
      app/proguard-rules.pro
  12. 234 0
      app/src/main/AndroidManifest.xml
  13. BIN
      app/src/main/ic_launcher-web.png
  14. BIN
      app/src/main/ic_launcher_calculator-playstore.png
  15. 42 0
      app/src/main/java/com/dar/nbook/BookmarkActivity.java
  16. 138 0
      app/src/main/java/com/dar/nbook/CommentActivity.java
  17. 30 0
      app/src/main/java/com/dar/nbook/CopyToClipboardActivity.java
  18. 163 0
      app/src/main/java/com/dar/nbook/FavoriteActivity.java
  19. 511 0
      app/src/main/java/com/dar/nbook/GalleryActivity.java
  20. 70 0
      app/src/main/java/com/dar/nbook/HistoryActivity.java
  21. 240 0
      app/src/main/java/com/dar/nbook/LocalActivity.java
  22. 93 0
      app/src/main/java/com/dar/nbook/LoginActivity.java
  23. 868 0
      app/src/main/java/com/dar/nbook/MainActivity.java
  24. 138 0
      app/src/main/java/com/dar/nbook/PINActivity.java
  25. 130 0
      app/src/main/java/com/dar/nbook/RandomActivity.java
  26. 414 0
      app/src/main/java/com/dar/nbook/SearchActivity.java
  27. 175 0
      app/src/main/java/com/dar/nbook/SettingsActivity.java
  28. 43 0
      app/src/main/java/com/dar/nbook/StatusManagerActivity.java
  29. 115 0
      app/src/main/java/com/dar/nbook/StatusViewerActivity.java
  30. 262 0
      app/src/main/java/com/dar/nbook/TagFilterActivity.java
  31. 450 0
      app/src/main/java/com/dar/nbook/ZoomActivity.java
  32. 100 0
      app/src/main/java/com/dar/nbook/adapters/BookmarkAdapter.java
  33. 121 0
      app/src/main/java/com/dar/nbook/adapters/CommentAdapter.java
  34. 215 0
      app/src/main/java/com/dar/nbook/adapters/FavoriteAdapter.java
  35. 422 0
      app/src/main/java/com/dar/nbook/adapters/GalleryAdapter.java
  36. 95 0
      app/src/main/java/com/dar/nbook/adapters/GenericAdapter.java
  37. 106 0
      app/src/main/java/com/dar/nbook/adapters/HistoryAdapter.java
  38. 221 0
      app/src/main/java/com/dar/nbook/adapters/ListAdapter.java
  39. 507 0
      app/src/main/java/com/dar/nbook/adapters/LocalAdapter.java
  40. 156 0
      app/src/main/java/com/dar/nbook/adapters/StatusManagerAdapter.java
  41. 123 0
      app/src/main/java/com/dar/nbook/adapters/StatusViewerAdapter.java
  42. 237 0
      app/src/main/java/com/dar/nbook/adapters/TagsAdapter.java
  43. 519 0
      app/src/main/java/com/dar/nbook/api/InspectorV3.java
  44. 63 0
      app/src/main/java/com/dar/nbook/api/RandomLoader.java
  45. 212 0
      app/src/main/java/com/dar/nbook/api/SimpleGallery.java
  46. 100 0
      app/src/main/java/com/dar/nbook/api/comments/Comment.java
  47. 68 0
      app/src/main/java/com/dar/nbook/api/comments/CommentsFetcher.java
  48. 79 0
      app/src/main/java/com/dar/nbook/api/comments/User.java
  49. 374 0
      app/src/main/java/com/dar/nbook/api/components/Gallery.java
  50. 365 0
      app/src/main/java/com/dar/nbook/api/components/GalleryData.java
  51. 46 0
      app/src/main/java/com/dar/nbook/api/components/GenericGallery.java
  52. 167 0
      app/src/main/java/com/dar/nbook/api/components/Page.java
  53. 158 0
      app/src/main/java/com/dar/nbook/api/components/Ranges.java
  54. 197 0
      app/src/main/java/com/dar/nbook/api/components/Tag.java
  55. 125 0
      app/src/main/java/com/dar/nbook/api/components/TagList.java
  56. 45 0
      app/src/main/java/com/dar/nbook/api/enums/ApiRequestType.java
  57. 21 0
      app/src/main/java/com/dar/nbook/api/enums/ImageExt.java
  58. 5 0
      app/src/main/java/com/dar/nbook/api/enums/ImageType.java
  59. 5 0
      app/src/main/java/com/dar/nbook/api/enums/Language.java
  60. 47 0
      app/src/main/java/com/dar/nbook/api/enums/SortType.java
  61. 8 0
      app/src/main/java/com/dar/nbook/api/enums/SpecialTagIds.java
  62. 5 0
      app/src/main/java/com/dar/nbook/api/enums/TagStatus.java
  63. 84 0
      app/src/main/java/com/dar/nbook/api/enums/TagType.java
  64. 5 0
      app/src/main/java/com/dar/nbook/api/enums/TitleType.java
  65. 57 0
      app/src/main/java/com/dar/nbook/api/local/FakeInspector.java
  66. 261 0
      app/src/main/java/com/dar/nbook/api/local/LocalGallery.java
  67. 50 0
      app/src/main/java/com/dar/nbook/api/local/LocalSortType.java
  68. 44 0
      app/src/main/java/com/dar/nbook/async/MetadataFetcher.java
  69. 126 0
      app/src/main/java/com/dar/nbook/async/ScrapeTags.java
  70. 254 0
      app/src/main/java/com/dar/nbook/async/VersionChecker.java
  71. 154 0
      app/src/main/java/com/dar/nbook/async/converters/CreatePDF.java
  72. 136 0
      app/src/main/java/com/dar/nbook/async/converters/CreateZIP.java
  73. 182 0
      app/src/main/java/com/dar/nbook/async/database/DatabaseHelper.java
  74. 1042 0
      app/src/main/java/com/dar/nbook/async/database/Queries.java
  75. 152 0
      app/src/main/java/com/dar/nbook/async/database/export/Exporter.java
  76. 120 0
      app/src/main/java/com/dar/nbook/async/database/export/Importer.java
  77. 38 0
      app/src/main/java/com/dar/nbook/async/database/export/Manager.java
  78. 139 0
      app/src/main/java/com/dar/nbook/async/downloader/DownloadGalleryV2.java
  79. 13 0
      app/src/main/java/com/dar/nbook/async/downloader/DownloadObserver.java
  80. 96 0
      app/src/main/java/com/dar/nbook/async/downloader/DownloadQueue.java
  81. 183 0
      app/src/main/java/com/dar/nbook/async/downloader/GalleryDownloaderManager.java
  82. 376 0
      app/src/main/java/com/dar/nbook/async/downloader/GalleryDownloaderV2.java
  83. 9 0
      app/src/main/java/com/dar/nbook/async/downloader/PageChecker.java
  84. 103 0
      app/src/main/java/com/dar/nbook/components/CookieInterceptor.java
  85. 92 0
      app/src/main/java/com/dar/nbook/components/CustomCookieJar.java
  86. 68 0
      app/src/main/java/com/dar/nbook/components/GlideX.java
  87. 20 0
      app/src/main/java/com/dar/nbook/components/LocaleManager.java
  88. 4 0
      app/src/main/java/com/dar/nbook/components/Module.java
  89. 54 0
      app/src/main/java/com/dar/nbook/components/ThreadAsyncTask.java
  90. 57 0
      app/src/main/java/com/dar/nbook/components/activities/BaseActivity.java
  91. 133 0
      app/src/main/java/com/dar/nbook/components/activities/CrashApplication.java
  92. 69 0
      app/src/main/java/com/dar/nbook/components/activities/GeneralActivity.java
  93. 67 0
      app/src/main/java/com/dar/nbook/components/classes/Bookmark.java
  94. 25 0
      app/src/main/java/com/dar/nbook/components/classes/ConnectivityReceiver.java
  95. 97 0
      app/src/main/java/com/dar/nbook/components/classes/CustomSSLSocketFactory.java
  96. 59 0
      app/src/main/java/com/dar/nbook/components/classes/History.java
  97. 224 0
      app/src/main/java/com/dar/nbook/components/classes/MultichoiceAdapter.java
  98. 44 0
      app/src/main/java/com/dar/nbook/components/classes/MySender.java
  99. 22 0
      app/src/main/java/com/dar/nbook/components/classes/MySenderFactory.java
  100. 64 0
      app/src/main/java/com/dar/nbook/components/classes/Size.java

+ 10 - 0
.editorconfig

@@ -0,0 +1,10 @@
+# editorconfig.org
+root = true
+
+[*]
+charset = utf-8
+indent_style = space
+indent_size = 4
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true

+ 2 - 0
.github/FUNDING.yml

@@ -0,0 +1,2 @@
+liberapay: Dar9586
+custom: ['https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=CVMR2STUSVE6U']

+ 34 - 0
.github/ISSUE_TEMPLATE.md

@@ -0,0 +1,34 @@
+Your issue may already be reported!
+Please search on the [issue tracker](../) before creating one.
+
+## Expected Behavior
+<!--- If you're describing a bug, tell us what should happen -->
+<!--- If you're suggesting a change/improvement, tell us how it should work -->
+
+## Current Behavior
+<!--- If describing a bug, tell us what happens instead of the expected behavior -->
+<!--- If suggesting a change/improvement, explain the difference from current behavior -->
+
+## Possible Solution
+<!--- Not obligatory, but suggest a fix/reason for the bug, -->
+<!--- or ideas how to implement the addition or change -->
+
+## Steps to Reproduce (for bugs)
+<!--- Provide a link to a live example, or an unambiguous set of steps to -->
+<!--- reproduce this bug. Include code to reproduce, if relevant -->
+1.
+2.
+3.
+4.
+
+## Context
+<!--- How has this issue affected you? What are you trying to accomplish? -->
+<!--- Providing context helps us come up with a solution that is most useful in the real world -->
+
+## Your Environment
+<!--- Include as many relevant details about the environment you experienced the bug in -->
+* Version used:
+* Operating System version:
+<!--- If a particular search or particular gallery causes problem you can post its URL -->
+<!--- The URL can be obtained by Opening in browser / Share -->
+* NHentai link (if relevant):

+ 40 - 0
.github/PULL_REQUEST_TEMPLATE.md

@@ -0,0 +1,40 @@
+A similar PR may already be submitted!
+Please search among the [Pull request](../) before creating one.
+
+Thanks for submitting a pull request! Please provide enough information so that others can review your pull request:
+
+For more information, see the `CONTRIBUTING` guide.
+
+
+**Summary**
+
+<!-- Summary of the PR -->
+
+This PR fixes/implements the following **bugs/features**
+
+* [ ] Bug 1
+* [ ] Bug 2
+* [ ] Feature 1
+* [ ] Feature 2
+* [ ] Breaking changes
+
+<!-- You can skip this if you're fixing a typo or adding an app to the Showcase. -->
+
+Explain the **motivation** for making this change. What existing problem does the pull request solve?
+
+<!-- Example: When "Adding a function to do X", explain why it is necessary to have a way to do X. -->
+
+**Test plan (required)**
+
+Demonstrate the code is solid. Example: The exact commands you ran and their output, screenshots / videos if the pull request changes UI.
+
+<!-- Make sure tests pass on both Travis and Circle CI. -->
+
+**Code formatting**
+
+<!-- See the simple style guide. -->
+
+**Closing issues**
+
+<!-- Put `closes #XXXX` in your comment to auto-close the issue that your PR fixes (if such). -->
+Fixes #

+ 16 - 0
.gitignore

@@ -0,0 +1,16 @@
+*.iml
+*.apk
+.gradle
+/local.properties
+/.idea
+output.json
+.DS_Store
+/build
+/captures
+/svgs
+/app/release
+/app/debug
+/app/schemas
+.externalNativeBuild
+/crowdin.properties
+

+ 12 - 0
.travis.yml

@@ -0,0 +1,12 @@
+language: android
+dist: trusty
+android:
+  components:
+     - tools
+     - platform-tools
+     - tools
+
+    - build-tools-29.0.2
+
+    - android-29
+

+ 201 - 0
LICENSE

@@ -0,0 +1,201 @@
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright 2021 Dar9586
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.

+ 87 - 0
README.md

@@ -0,0 +1,87 @@
+# NClientV2
+
+[![Github](https://img.shields.io/github/v/release/Dar9586/NClientV2.svg?logo=github)](https://github.com/Dar9586/NClientV2/releases/latest) [![F-Droid](https://img.shields.io/f-droid/v/com.dar.nclientv2)](https://f-droid.org/en/packages/com.dar.nclientv2/) ![Bitrise](https://img.shields.io/bitrise/0a79e29cfda80c5f?token=BrSKdUUfKb97MHigL9nA1w)
+
+An unofficial NHentai Android Client.
+This app  works for devices from API 14 (Android 4.0) and above.
+Releases: <https://github.com/Dar9586/NClientV2/releases>
+
+## Translation
+
+You can translate this app on [Crowdin](https://crowdin.com/project/nclientv2)
+
+## API Features
+
+- Browse main page
+- Search by query or tags
+- Include or exclude tags
+- Blur or hide excluded tags
+- Download manga
+- Favorite galleries
+- Enable PIN to access the app
+
+## Custom feature
+
+- Share galleries
+- Open in browser
+- Bookmark
+
+## App Screen
+
+Main page|Lateral menu
+:-:|:-:
+![Main page](https://raw.githubusercontent.com/Dar9586/NClientV2/master/fastlane/metadata/android/en-US/images/phoneScreenshots/img1.jpg)|![Lateral menu](https://media.discordapp.net/attachments/608725424092086280/720369411030253578/Screenshot_20200610-230229_NClientV2.jpg?width=360&height=658)
+Search|Random manga
+![Search](https://media.discordapp.net/attachments/608725424092086280/720369411030253578/Screenshot_20200610-230229_NClientV2.jpg?width=360&height=658)|![Random manga](https://raw.githubusercontent.com/Dar9586/NClientV2/master/fastlane/metadata/android/en-US/images/phoneScreenshots/img4.jpg)
+
+## Contributors
+
+- [Still34](https://github.com/Still34) for code cleanup & Traditional Chinese translation
+- [TacoTheDank](https://github.com/TacoTheDank) for XML and gradle cleanup
+- [hmaltr](https://github.com/hmaltr) for Turkish translation and issue moderation
+- [ZerOri](https://github.com/ZerOri) and [linsui](https://github.com/linsui) for Chinese translation
+- [herrsunchess](https://github.com/herrsunchess) for German translation
+- [eme22](https://github.com/herrsunchess) for Spanish translation
+- [velosipedistufa](https://github.com/velosipedistufa) for Russian translation
+- [bottomtextboy](https://github.com/bottomtextboy) for Arabic translation
+- [MaticBabnik](https://github.com/MaticBabnik) for bug fixes
+- [DontPayAttention](https://github.com/DontPayAttention) for French translation
+- [kuragehimekurara1](https://github.com/kuragehimekurara1) for Japanese translation
+- [chayleaf](https://github.com/chayleaf) for Cloudflare bypass
+- [Atmosphelen](https://github.com/Atmosphelen) for Ukrainian translation
+
+
+
+## Libraries
+
+- PersistentCookieJar ([License](https://github.com/franmontiel/PersistentCookieJar/blob/master/LICENSE.txt))
+- OKHttp ([License](https://github.com/square/okhttp/blob/master/LICENSE.txt))
+- multiline-collapsingtoolbar ([License](https://github.com/opacapp/multiline-collapsingtoolbar/blob/master/LICENSE))
+- PhotoView ([License](https://github.com/chrisbanes/PhotoView/blob/master/LICENSE))
+- JSoup ([License](https://github.com/jhy/jsoup/blob/master/LICENSE))
+- ACRA ([License](https://github.com/ACRA/acra/blob/master/LICENSE))
+- Glide ([License](https://github.com/bumptech/glide/blob/master/LICENSE))
+
+## Donation
+
+Paypal|Liberapay|Bitcoin
+:-:|:-:|:-:
+[![Paypal](https://www.paypalobjects.com/en_US/i/btn/btn_donate_SM.gif)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=CVMR2STUSVE6U)|[![Donate using Liberapay](https://liberapay.com/assets/widgets/donate.svg)](https://liberapay.com/Dar9586/donate)|[113U1W3BxrAzyFWgP7HXqMfB3nF6MpHj6p](https://www.blockchain.com/btc/address/113U1W3BxrAzyFWgP7HXqMfB3nF6MpHj6p)
+
+## License
+
+```text
+   Copyright 2021 Dar9586
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+```

+ 1 - 0
app/.gitignore

@@ -0,0 +1 @@
+/build

+ 69 - 0
app/build.gradle

@@ -0,0 +1,69 @@
+apply plugin: 'com.android.application'
+
+android {
+    compileSdkVersion 33
+    buildToolsVersion '30.0.3'
+    defaultConfig {
+        applicationId "com.dar.nbook"
+        minSdkVersion 14
+        targetSdkVersion 33
+        versionCode 301
+        multiDexEnabled true
+        versionName "3.0.1-stable"
+        vectorDrawables.useSupportLibrary true
+        proguardFiles 'proguard-rules.pro'
+    }
+    buildTypes {
+        release {
+            minifyEnabled true
+            shrinkResources true
+            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt')
+            versionNameSuffix "-release"
+            resValue "string", "app_name", "NBook"
+        }
+        debug {
+            applicationIdSuffix ".debug"
+            versionNameSuffix "-debug"
+            resValue "string", "app_name", "NBook Debug"
+        }
+    }
+    compileOptions {
+        sourceCompatibility JavaVersion.VERSION_1_8
+        targetCompatibility JavaVersion.VERSION_1_8
+    }
+    lint {
+        abortOnError false
+        checkReleaseBuilds false
+    }
+    namespace 'com.dar.nbook'
+}
+
+dependencies {
+// AndroidX
+    implementation 'androidx.appcompat:appcompat:1.6.1'
+    implementation 'androidx.cardview:cardview:1.0.0'
+    implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
+    implementation 'androidx.fragment:fragment:1.5.6'
+    implementation 'androidx.preference:preference:1.2.0'
+    implementation 'androidx.viewpager2:viewpager2:1.0.0'
+    implementation 'androidx.recyclerview:recyclerview:1.3.0'
+    implementation 'com.google.android.material:material:1.8.0'
+
+// Other
+    implementation 'com.squareup.okhttp3:okhttp-urlconnection:3.12.12'//Because of min SDK
+    implementation 'com.github.franmontiel:PersistentCookieJar:v1.0.1'
+    //implementation 'com.github.chrisbanes:PhotoView:2.3.0'
+    implementation 'org.jsoup:jsoup:1.15.4'
+    implementation 'ch.acra:acra-core:5.7.0'
+    implementation('com.github.bumptech.glide:glide:4.15.0') {
+        exclude group: "com.android.support"
+    }
+    implementation "androidx.multidex:multidex:2.0.1"
+    implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
+    annotationProcessor 'com.github.bumptech.glide:compiler:4.15.0'
+    implementation 'com.github.yukuku:ambilwarna:2.0.1'
+    implementation 'me.zhanghai.android.fastscroll:library:1.2.0'
+
+    implementation 'net.opacapp:multiline-collapsingtoolbar:27.1.1'
+    implementation 'com.zeugmasolutions.localehelper:locale-helper-android:1.5.1'
+}

+ 41 - 0
app/proguard-rules.pro

@@ -0,0 +1,41 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+#   http://developer.android.com/guide/developing/tools/proguard.html
+
+# 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
+
+-ignorewarnings
+-keep class * {
+    public private *;
+}
+#glide proguard
+-keep public class * implements com.bumptech.glide.module.GlideModule
+-keep public class * extends com.bumptech.glide.module.AppGlideModule
+-keep public enum com.bumptech.glide.load.ImageHeaderParser$** {
+  **[] $VALUES;
+  public *;
+}
+
+-assumenosideeffects class com.dar.nbook.utility.LogUtility {
+    public static void d(...);
+    public static void i(...);
+    public static void e(...);
+}
+-keep public class * implements com.bumptech.glide.module.GlideModule
+-dontwarn com.bumptech.glide.load.resource.bitmap.VideoDecoder

+ 234 - 0
app/src/main/AndroidManifest.xml

@@ -0,0 +1,234 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools">
+
+    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
+    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
+    <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
+    <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.WAKE_LOCK" />
+    <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
+
+    <uses-sdk tools:overrideLibrary="me.zhanghai.android.fastscroll" />
+
+    <application
+        android:name=".components.activities.CrashApplication"
+        android:allowBackup="true"
+        android:fullBackupContent="@xml/backup_content"
+        android:icon="@mipmap/ic_launcher"
+        android:label="@string/app_name"
+        android:requestLegacyExternalStorage="true"
+        android:roundIcon="@mipmap/ic_launcher"
+        android:supportsRtl="true"
+        android:theme="@style/DarkTheme">
+
+        <activity android:name=".StatusManagerActivity" />
+        <activity
+            android:name=".PINActivity"
+            android:configChanges="orientation|screenSize"
+            android:exported="true"
+            android:label="@string/app_name"
+            android:theme="@style/DarkTheme">
+            <intent-filter>
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+
+        <activity-alias
+            android:name=".components.launcher.LauncherReal"
+            android:enabled="true"
+            android:exported="true"
+            android:icon="@mipmap/ic_launcher"
+            android:label="@string/app_name"
+            android:roundIcon="@mipmap/ic_launcher"
+            android:targetActivity=".PINActivity">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity-alias>
+        <activity-alias
+            android:name=".components.launcher.LauncherCalculator"
+            android:enabled="false"
+            android:exported="true"
+            android:icon="@mipmap/ic_launcher_calculator"
+            android:label="@string/app_name_fake_calculator"
+            android:roundIcon="@mipmap/ic_launcher_calculator_round"
+            android:targetActivity=".PINActivity">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity-alias>
+
+        <activity android:name=".StatusViewerActivity" />
+        <activity android:name=".HistoryActivity" />
+        <activity android:name=".BookmarkActivity" />
+        <activity android:name=".CommentActivity" />
+        <activity android:name=".SearchActivity" />
+        <activity
+            android:name=".MainActivity"
+            android:configChanges="orientation|screenSize"
+            android:exported="true"
+            android:hardwareAccelerated="true"
+            android:label="@string/app_name"
+            android:parentActivityName=".PINActivity"
+            android:theme="@style/DarkTheme">
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW" />
+
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.BROWSABLE" />
+
+                <data android:scheme="http" />
+                <data android:scheme="https" />
+                <data android:host="nhentai.net" />
+                <data android:path="/" />
+                <data android:pathPrefix="/search/" />
+                <data android:pathPrefix="/parody/" />
+                <data android:pathPrefix="/character/" />
+                <data android:pathPrefix="/tag/" />
+                <data android:pathPrefix="/artist/" />
+                <data android:pathPrefix="/favorites/" />
+                <data android:pathPrefix="/group/" />
+                <data android:pathPrefix="/language/" />
+                <data android:pathPrefix="/category/" />
+            </intent-filter>
+        </activity>
+        <activity
+            android:name=".GalleryActivity"
+            android:exported="true"
+            android:label="@string/title_activity_gallery"
+            android:parentActivityName=".MainActivity"
+            android:theme="@style/DarkTheme">
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW" />
+
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.BROWSABLE" />
+
+                <data android:scheme="http" />
+                <data android:scheme="https" />
+                <data
+                    android:host="nhentai.net"
+                    android:pathPattern="/g/.*" />
+            </intent-filter>
+
+            <meta-data
+                android:name="android.support.PARENT_ACTIVITY"
+                android:value="com.dar.nbook.MainActivity" />
+        </activity>
+        <activity
+            android:name=".ZoomActivity"
+            android:configChanges="orientation|screenSize"
+            android:label="@string/title_activity_zoom"
+            android:parentActivityName=".GalleryActivity"
+            android:theme="@style/DarkTheme.NoTitle" />
+        <activity
+            android:name=".LocalActivity"
+            android:configChanges="orientation|screenSize"
+            android:parentActivityName=".MainActivity"
+            android:theme="@style/DarkTheme" />
+        <activity
+            android:name=".TagFilterActivity"
+            android:configChanges="orientation|screenSize"
+            android:exported="true"
+            android:label="@string/title_activity_tag_filter"
+            android:parentActivityName=".MainActivity"
+            android:theme="@style/DarkTheme">
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW" />
+
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.BROWSABLE" />
+
+                <data android:scheme="http" />
+                <data android:scheme="https" />
+                <data android:host="nhentai.net" />
+                <data android:pathPrefix="/tags" />
+                <data android:pathPrefix="/artists" />
+                <data android:pathPrefix="/characters" />
+                <data android:pathPrefix="/parodies" />
+                <data android:pathPrefix="/groups" />
+            </intent-filter>
+
+            <meta-data
+                android:name="android.support.PARENT_ACTIVITY"
+                android:value="com.dar.nbook.MainActivity" />
+        </activity>
+        <activity android:name=".SettingsActivity" />
+        <activity
+            android:name=".RandomActivity"
+            android:exported="true">
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW" />
+
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.BROWSABLE" />
+
+                <data android:pathPrefix="/random" />
+                <data android:scheme="http" />
+                <data android:scheme="https" />
+                <data android:host="nhentai.net" />
+            </intent-filter>
+        </activity>
+        <activity
+            android:name=".FavoriteActivity"
+            android:configChanges="orientation|screenSize"
+            android:parentActivityName=".MainActivity" />
+        <activity
+            android:name=".CopyToClipboardActivity"
+            android:icon="@drawable/ic_content_copy"
+            android:label="@string/copyURL" />
+        <activity
+            android:name=".LoginActivity"
+            android:label="@string/title_activity_login"
+            android:parentActivityName=".MainActivity">
+            <meta-data
+                android:name="android.support.PARENT_ACTIVITY"
+                android:value="com.dar.nbook.MainActivity" />
+        </activity>
+
+        <provider
+            android:name="androidx.core.content.FileProvider"
+            android:authorities="${applicationId}.provider"
+            android:exported="false"
+            android:grantUriPermissions="true">
+            <meta-data
+                android:name="android.support.FILE_PROVIDER_PATHS"
+                android:resource="@xml/provider_paths" />
+        </provider>
+
+        <service
+            android:name=".async.downloader.DownloadGalleryV2"
+            android:enabled="true"
+            android:exported="true"
+            android:permission="android.permission.BIND_JOB_SERVICE" />
+        <service
+            android:name=".async.converters.CreatePDF"
+            android:enabled="true"
+            android:exported="true"
+            android:permission="android.permission.BIND_JOB_SERVICE" />
+        <service
+            android:name=".async.converters.CreateZIP"
+            android:enabled="true"
+            android:exported="true"
+            android:permission="android.permission.BIND_JOB_SERVICE" />
+        <service
+            android:name=".async.ScrapeTags"
+            android:enabled="true"
+            android:exported="true"
+            android:permission="android.permission.BIND_JOB_SERVICE" />
+    </application>
+
+    <queries>
+        <intent>
+            <action android:name="android.intent.action.VIEW" />
+            <data android:mimeType="application/pdf" />
+        </intent>
+    </queries>
+    <meta-data
+        android:name="com.dar.nbook.components.classes.integration.OkHttpGlideModule"
+        android:value="GlideModule" />
+</manifest>

BIN
app/src/main/ic_launcher-web.png


BIN
app/src/main/ic_launcher_calculator-playstore.png


+ 42 - 0
app/src/main/java/com/dar/nbook/BookmarkActivity.java

@@ -0,0 +1,42 @@
+package com.dar.nbook;
+
+import android.os.Bundle;
+import android.view.MenuItem;
+
+import androidx.appcompat.widget.Toolbar;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.dar.nbook.adapters.BookmarkAdapter;
+import com.dar.nbook.components.activities.GeneralActivity;
+import com.dar.nbook.components.widgets.CustomLinearLayoutManager;
+
+public class BookmarkActivity extends GeneralActivity {
+    BookmarkAdapter adapter;
+    RecyclerView recycler;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        //Global.initActivity(this);
+        setContentView(R.layout.activity_bookmark);
+        Toolbar toolbar = findViewById(R.id.toolbar);
+        setSupportActionBar(toolbar);
+        getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+        getSupportActionBar().setDisplayShowTitleEnabled(true);
+        getSupportActionBar().setTitle(R.string.manage_bookmarks);
+
+        recycler = findViewById(R.id.recycler);
+        adapter = new BookmarkAdapter(this);
+        recycler.setLayoutManager(new CustomLinearLayoutManager(this));
+        recycler.setAdapter(adapter);
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        if (item.getItemId() == android.R.id.home) {
+            finish();
+            return true;
+        }
+        return super.onOptionsItemSelected(item);
+    }
+}

+ 138 - 0
app/src/main/java/com/dar/nbook/CommentActivity.java

@@ -0,0 +1,138 @@
+package com.dar.nbook;
+
+import android.content.res.Configuration;
+import android.os.Bundle;
+import android.util.JsonReader;
+import android.util.JsonToken;
+import android.util.JsonWriter;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.EditText;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.widget.Toolbar;
+import androidx.recyclerview.widget.DividerItemDecoration;
+
+import com.dar.nbook.adapters.CommentAdapter;
+import com.dar.nbook.api.comments.Comment;
+import com.dar.nbook.api.comments.CommentsFetcher;
+import com.dar.nbook.components.activities.BaseActivity;
+import com.dar.nbook.settings.AuthRequest;
+import com.dar.nbook.settings.Login;
+import com.dar.nbook.utility.Utility;
+
+import java.io.IOException;
+import java.io.StringWriter;
+import java.util.Locale;
+
+import okhttp3.Call;
+import okhttp3.Callback;
+import okhttp3.MediaType;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+
+
+public class CommentActivity extends BaseActivity {
+    private static final int MINIUM_MESSAGE_LENGHT = 10;
+    private CommentAdapter adapter;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        //Global.initActivity(this);
+        setContentView(R.layout.activity_comment);
+        Toolbar toolbar = findViewById(R.id.toolbar);
+        setSupportActionBar(toolbar);
+        getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+        getSupportActionBar().setDisplayShowTitleEnabled(true);
+        getSupportActionBar().setTitle(R.string.comments);
+        findViewById(R.id.page_switcher).setVisibility(View.GONE);
+        int id = getIntent().getIntExtra(getPackageName() + ".GALLERYID", -1);
+        if (id == -1) {
+            finish();
+            return;
+        }
+        recycler = findViewById(R.id.recycler);
+        refresher = findViewById(R.id.refresher);
+        refresher.setOnRefreshListener(() -> new CommentsFetcher(CommentActivity.this, id).start());
+        EditText commentText = findViewById(R.id.commentText);
+        findViewById(R.id.card).setVisibility(Login.isLogged() ? View.VISIBLE : View.GONE);
+        findViewById(R.id.sendButton).setOnClickListener(v -> {
+            if (commentText.getText().toString().length() < MINIUM_MESSAGE_LENGHT) {
+                Toast.makeText(this, getString(R.string.minimum_comment_length, MINIUM_MESSAGE_LENGHT), Toast.LENGTH_SHORT).show();
+                return;
+            }
+            String refererUrl = String.format(Locale.US, Utility.getBaseUrl() + "g/%d/", id);
+            String submitUrl = String.format(Locale.US, Utility.getBaseUrl() + "api/gallery/%d/comments/submit", id);
+            String requestString = createRequestString(commentText.getText().toString());
+            commentText.setText("");
+            RequestBody body = RequestBody.create(MediaType.get("application/json"), requestString);
+            new AuthRequest(refererUrl, submitUrl, new Callback() {
+                @Override
+                public void onFailure(@NonNull Call call, @NonNull IOException e) {
+
+                }
+
+                @Override
+                public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
+                    JsonReader reader = new JsonReader(response.body().charStream());
+                    Comment comment = null;
+                    reader.beginObject();
+                    while (reader.peek() != JsonToken.END_OBJECT) {
+                        if ("comment".equals(reader.nextName())) {
+                            comment = new Comment(reader);
+                        } else {
+                            reader.skipValue();
+                        }
+                    }
+                    reader.close();
+                    if (comment != null && adapter != null)
+                        adapter.addComment(comment);
+                }
+            }).setMethod("POST", body).start();
+        });
+        changeLayout(getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE);
+        recycler.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL));
+        refresher.setRefreshing(true);
+        new CommentsFetcher(CommentActivity.this, id).start();
+    }
+
+    public void setAdapter(CommentAdapter adapter) {
+        this.adapter = adapter;
+    }
+
+    private String createRequestString(String text) {
+        try {
+            StringWriter writer = new StringWriter();
+            JsonWriter json = new JsonWriter(writer);
+            json.beginObject();
+            json.name("body").value(text);
+            json.endObject();
+            String finalText = writer.toString();
+            json.close();
+            return finalText;
+        } catch (IOException ignore) {
+        }
+        return "";
+    }
+
+    @Override
+    protected int getPortraitColumnCount() {
+        return 1;
+    }
+
+    @Override
+    protected int getLandscapeColumnCount() {
+        return 2;
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        if (item.getItemId() == android.R.id.home) {
+            onBackPressed();
+            return true;
+        }
+        return super.onOptionsItemSelected(item);
+    }
+}

+ 30 - 0
app/src/main/java/com/dar/nbook/CopyToClipboardActivity.java

@@ -0,0 +1,30 @@
+package com.dar.nbook;
+
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.net.Uri;
+import android.os.Bundle;
+import android.widget.Toast;
+
+import com.dar.nbook.components.activities.GeneralActivity;
+
+public class CopyToClipboardActivity extends GeneralActivity {
+    public static void copyTextToClipboard(Context context, String text) {
+        ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
+        ClipData clip = ClipData.newPlainText("text", text);
+        if (clipboard != null)
+            clipboard.setPrimaryClip(clip);
+    }
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        Uri uri = getIntent().getData();
+        if (uri != null) {
+            copyTextToClipboard(this, uri.toString());
+            Toast.makeText(this, R.string.link_copied_to_clipboard, Toast.LENGTH_SHORT).show();
+        }
+        finish();
+    }
+}

+ 163 - 0
app/src/main/java/com/dar/nbook/FavoriteActivity.java

@@ -0,0 +1,163 @@
+package com.dar.nbook;
+
+import android.content.Intent;
+import android.content.res.Configuration;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuItem;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.widget.SearchView;
+import androidx.appcompat.widget.Toolbar;
+
+import com.dar.nbook.adapters.FavoriteAdapter;
+import com.dar.nbook.api.components.Gallery;
+import com.dar.nbook.async.database.Queries;
+import com.dar.nbook.async.downloader.DownloadGalleryV2;
+import com.dar.nbook.components.activities.BaseActivity;
+import com.dar.nbook.components.views.PageSwitcher;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.utility.Utility;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+
+public class FavoriteActivity extends BaseActivity {
+    private static final int ENTRY_PER_PAGE = 24;
+    private FavoriteAdapter adapter = null;
+    private boolean sortByTitle = false;
+    private PageSwitcher pageSwitcher;
+    private SearchView searchView;
+
+    public static int getEntryPerPage() {
+        return Global.isInfiniteScrollFavorite() ? Integer.MAX_VALUE : ENTRY_PER_PAGE;
+    }
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        //Global.initActivity(this);
+
+        setContentView(R.layout.app_bar_main);
+        Toolbar toolbar = findViewById(R.id.toolbar);
+        setSupportActionBar(toolbar);
+        getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+        getSupportActionBar().setDisplayShowTitleEnabled(true);
+        getSupportActionBar().setTitle(R.string.favorite_manga);
+        pageSwitcher = findViewById(R.id.page_switcher);
+        recycler = findViewById(R.id.recycler);
+        refresher = findViewById(R.id.refresher);
+        refresher.setRefreshing(true);
+        adapter = new FavoriteAdapter(this);
+
+
+        refresher.setOnRefreshListener(adapter::forceReload);
+        changeLayout(getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE);
+        recycler.setAdapter(adapter);
+        pageSwitcher.setPages(1, 1);
+        pageSwitcher.setChanger(new PageSwitcher.DefaultPageChanger() {
+            @Override
+            public void pageChanged(PageSwitcher switcher, int page) {
+                if (adapter != null) adapter.changePage();
+            }
+        });
+
+    }
+
+    public int getActualPage() {
+        return pageSwitcher.getActualPage();
+    }
+
+    public void changePages(int totalPages, int actualPages) {
+        pageSwitcher.setPages(totalPages, actualPages);
+    }
+
+    @Override
+    protected int getLandscapeColumnCount() {
+        return Global.getColLandFavorite();
+    }
+
+    @Override
+    protected int getPortraitColumnCount() {
+        return Global.getColPortFavorite();
+    }
+
+    private int calculatePages(@Nullable String text) {
+        int perPage = getEntryPerPage();
+        int totalEntries = Queries.FavoriteTable.countFavorite(text);
+        int div = totalEntries / perPage;
+        int mod = totalEntries % perPage;
+        return div + (mod == 0 ? 0 : 1);
+    }
+
+    @Override
+    protected void onResume() {
+        refresher.setEnabled(true);
+        refresher.setRefreshing(true);
+        String query = searchView == null ? null : searchView.getQuery().toString();
+        pageSwitcher.setTotalPage(calculatePages(query));
+        adapter.forceReload();
+        super.onResume();
+    }
+
+    @Override
+    public boolean onCreateOptionsMenu(Menu menu) {
+        getMenuInflater().inflate(R.menu.main, menu);
+        menu.findItem(R.id.download_page).setVisible(true);
+        menu.findItem(R.id.sort_by_name).setVisible(true);
+        menu.findItem(R.id.by_popular).setVisible(false);
+        menu.findItem(R.id.only_language).setVisible(false);
+        menu.findItem(R.id.add_bookmark).setVisible(false);
+
+        searchView = (androidx.appcompat.widget.SearchView) menu.findItem(R.id.search).getActionView();
+        searchView.setOnQueryTextListener(new androidx.appcompat.widget.SearchView.OnQueryTextListener() {
+            @Override
+            public boolean onQueryTextSubmit(String query) {
+                return true;
+            }
+
+            @Override
+            public boolean onQueryTextChange(String newText) {
+                pageSwitcher.setTotalPage(calculatePages(newText));
+                if (adapter != null)
+                    adapter.getFilter().filter(newText);
+                return true;
+            }
+        });
+        Utility.tintMenu(menu);
+
+        return true;
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        Intent i;
+//        if (item.getItemId() == R.id.open_browser) {
+//            i = new Intent(Intent.ACTION_VIEW, Uri.parse(Utility.getBaseUrl() + "favorites/"));
+//            startActivity(i);
+//        } else
+        if (item.getItemId() == R.id.download_page) {
+            if (adapter != null) showDialogDownloadAll();
+        } else if (item.getItemId() == R.id.sort_by_name) {
+            sortByTitle = !sortByTitle;
+            adapter.setSortByTitle(sortByTitle);
+            item.setTitle(sortByTitle ? R.string.sort_by_latest : R.string.sort_by_title);
+        } else if (item.getItemId() == R.id.random_favorite) {
+            adapter.randomGallery();
+        }
+        return super.onOptionsItemSelected(item);
+    }
+
+    private void showDialogDownloadAll() {
+        MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
+        builder
+            .setTitle(R.string.download_all_galleries_in_this_page)
+            .setIcon(R.drawable.ic_file)
+            .setNegativeButton(R.string.cancel, null)
+            .setPositiveButton(R.string.ok, (dialog, which) -> {
+                for (Gallery g : adapter.getAllGalleries())
+                    DownloadGalleryV2.downloadGallery(this, g);
+            });
+        builder.show();
+    }
+}

+ 511 - 0
app/src/main/java/com/dar/nbook/GalleryActivity.java

@@ -0,0 +1,511 @@
+package com.dar.nbook;
+
+import android.Manifest;
+import android.annotation.TargetApi;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.graphics.Color;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.WindowManager;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.CheckedTextView;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.widget.Toolbar;
+import androidx.core.content.FileProvider;
+
+import com.dar.nbook.adapters.GalleryAdapter;
+import com.dar.nbook.api.InspectorV3;
+import com.dar.nbook.api.components.Gallery;
+import com.dar.nbook.api.components.GenericGallery;
+import com.dar.nbook.async.database.Queries;
+import com.dar.nbook.components.activities.BaseActivity;
+import com.dar.nbook.components.status.Status;
+import com.dar.nbook.components.status.StatusManager;
+import com.dar.nbook.components.views.RangeSelector;
+import com.dar.nbook.components.widgets.CustomGridLayoutManager;
+import com.dar.nbook.settings.AuthRequest;
+import com.dar.nbook.settings.Favorites;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.settings.Login;
+import com.dar.nbook.utility.LogUtility;
+import com.dar.nbook.utility.Utility;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.snackbar.Snackbar;
+
+import net.opacapp.multilinecollapsingtoolbar.CollapsingToolbarLayout;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+import java.util.Locale;
+
+import okhttp3.Call;
+import okhttp3.Callback;
+import okhttp3.Response;
+import yuku.ambilwarna.AmbilWarnaDialog;
+
+public class GalleryActivity extends BaseActivity {
+    @NonNull
+    private GenericGallery gallery = Gallery.emptyGallery();
+    private boolean isLocal;
+    private GalleryAdapter adapter;
+    private int zoom;
+    private boolean isLocalFavorite;
+    private Toolbar toolbar;
+    private MenuItem onlineFavoriteItem;
+    private String statusString;
+
+    private int newStatusColor;
+    private String newStatusName;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        //Global.initActivity(this);
+        setContentView(R.layout.activity_gallery);
+        if (Global.isLockScreen())
+            getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+        toolbar = findViewById(R.id.toolbar);
+        setSupportActionBar(toolbar);
+        getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+        getSupportActionBar().setDisplayShowTitleEnabled(true);
+        recycler = findViewById(R.id.recycler);
+        refresher = findViewById(R.id.refresher);
+        masterLayout = findViewById(R.id.master_layout);
+        GenericGallery gal = getIntent().getParcelableExtra(getPackageName() + ".GALLERY");
+        if (gal == null && !tryLoadFromURL()) {
+            finish();
+            return;
+        }
+        if (gal != null) this.gallery = gal;
+        if (gallery.getType() != GenericGallery.Type.LOCAL) {
+            Queries.HistoryTable.addGallery(((Gallery) gallery).toSimpleGallery());
+        }
+        LogUtility.d("" + gallery);
+        if (Global.useRtl()) recycler.setRotationY(180);
+        isLocal = getIntent().getBooleanExtra(getPackageName() + ".ISLOCAL", false);
+        zoom = getIntent().getIntExtra(getPackageName() + ".ZOOM", 0);
+        refresher.setEnabled(false);
+        recycler.setLayoutManager(new CustomGridLayoutManager(this, Global.getColumnCount()));
+
+        loadGallery(gallery, zoom);//if already has gallery
+    }
+
+    private boolean tryLoadFromURL() {
+        Uri data = getIntent().getData();
+        if (data != null && data.getPathSegments().size() >= 2) {//if using an URL
+            List<String> params = data.getPathSegments();
+            LogUtility.d(params.size() + ": " + params);
+            int id;
+            try {//if not an id return
+                id = Integer.parseInt(params.get(1));
+            } catch (NumberFormatException ignore) {
+                return false;
+            }
+            if (params.size() > 2) {//check if it has a specific page
+                try {
+                    zoom = Integer.parseInt(params.get(2));
+                } catch (NumberFormatException e) {
+                    LogUtility.e(e.getLocalizedMessage(), e);
+                    zoom = 0;
+                }
+            }
+            InspectorV3.galleryInspector(this, id, new InspectorV3.DefaultInspectorResponse() {
+                @Override
+                public void onSuccess(List<GenericGallery> galleries) {
+                    if (galleries.size() > 0) {
+                        Intent intent = new Intent(GalleryActivity.this, GalleryActivity.class);
+                        intent.putExtra(getPackageName() + ".GALLERY", galleries.get(0));
+                        intent.putExtra(getPackageName() + ".ZOOM", zoom);
+                        startActivity(intent);
+                    }
+                    finish();
+                }
+            }).start();
+            return true;
+        }
+        return false;
+    }
+
+    private void lookup() {
+        CustomGridLayoutManager manager = (CustomGridLayoutManager) recycler.getLayoutManager();
+        GalleryAdapter adapter = (GalleryAdapter) recycler.getAdapter();
+        manager.setSpanSizeLookup(new CustomGridLayoutManager.SpanSizeLookup() {
+            @Override
+            public int getSpanSize(int position) {
+                return adapter.positionToType(position) == GalleryAdapter.Type.PAGE ? 1 : manager.getSpanCount();
+            }
+        });
+    }
+
+    private void loadGallery(GenericGallery gall, int zoom) {
+        this.gallery = gall;
+        if (getSupportActionBar() != null) {
+            applyTitle();
+        }
+        adapter = new GalleryAdapter(this, gallery, Global.getColumnCount());
+        recycler.setAdapter(adapter);
+        lookup();
+        if (zoom > 0 && Global.getDownloadPolicy() != Global.DataUsageType.NONE) {
+            Intent intent = new Intent(this, ZoomActivity.class);
+            intent.putExtra(getPackageName() + ".GALLERY", this.gallery);
+            intent.putExtra(getPackageName() + ".DIRECTORY", adapter.getDirectory());
+            intent.putExtra(getPackageName() + ".PAGE", zoom);
+            startActivity(intent);
+        }
+        checkBookmark();
+    }
+
+    private void checkBookmark() {
+        int page = Queries.ResumeTable.pageFromId(gallery.getId());
+        if (page < 0) return;
+        Snackbar snack = Snackbar.make(toolbar, getString(R.string.resume_from_page, page), Snackbar.LENGTH_LONG);
+        //Should be already compensated
+        snack.setAction(R.string.resume, v -> new Thread(() -> {
+            runOnUiThread(() -> recycler.scrollToPosition(page));
+            if (Global.getColumnCount() != 1) return;
+            Utility.threadSleep(500);
+            runOnUiThread(() -> recycler.scrollToPosition(page));
+        }).start());
+        snack.show();
+    }
+
+    private void applyTitle() {
+        CollapsingToolbarLayout collapsing = findViewById(R.id.collapsing);
+        ActionBar actionBar = getSupportActionBar();
+        final String title = gallery.getTitle();
+        if (collapsing == null || actionBar == null) return;
+        View.OnLongClickListener listener = v -> {
+            CopyToClipboardActivity.copyTextToClipboard(GalleryActivity.this, title);
+            GalleryActivity.this.runOnUiThread(
+                () -> Toast.makeText(GalleryActivity.this, R.string.title_copied_to_clipboard, Toast.LENGTH_SHORT).show()
+            );
+            return true;
+        };
+
+        collapsing.setOnLongClickListener(listener);
+        findViewById(R.id.toolbar).setOnLongClickListener(listener);
+        if (title.length() > 100) {
+            collapsing.setExpandedTitleTextAppearance(android.R.style.TextAppearance_DeviceDefault_Medium);
+            collapsing.setMaxLines(5);
+        } else {
+            collapsing.setExpandedTitleTextAppearance(android.R.style.TextAppearance_DeviceDefault_Large);
+            collapsing.setMaxLines(4);
+        }
+        actionBar.setTitle(title);
+
+    }
+
+    @Override
+    protected int getPortraitColumnCount() {
+        return 0;
+    }
+
+    @Override
+    protected int getLandscapeColumnCount() {
+        return 0;
+    }
+
+
+    public void initFavoriteIcon(Menu menu) {
+        boolean onlineFavorite = !isLocal && ((Gallery) gallery).isOnlineFavorite();
+        boolean unknown = getIntent().getBooleanExtra(getPackageName() + ".UNKNOWN", false);
+        MenuItem item = menu.findItem(R.id.add_online_gallery);
+
+        item.setIcon(onlineFavorite ? R.drawable.ic_star : R.drawable.ic_star_border);
+
+        if (unknown) item.setTitle(R.string.toggle_online_favorite);
+        else if (onlineFavorite) item.setTitle(R.string.remove_from_online_favorites);
+        else item.setTitle(R.string.add_to_online_favorite);
+    }
+
+    @Override
+    public boolean onCreateOptionsMenu(Menu menu) {
+        getMenuInflater().inflate(R.menu.gallery, menu);
+        isLocalFavorite = Favorites.isFavorite(gallery);
+
+        menu.findItem(R.id.favorite_manager).setIcon(isLocalFavorite ? R.drawable.ic_favorite : R.drawable.ic_favorite_border);
+        menuItemsVisible(menu);
+        initFavoriteIcon(menu);
+        Utility.tintMenu(menu);
+        updateColumnCount(false);
+        return true;
+    }
+
+    private void menuItemsVisible(Menu menu) {
+        boolean isLogged = Login.isLogged();
+        boolean isValidOnline = gallery.isValid() && !isLocal;
+        onlineFavoriteItem = menu.findItem(R.id.add_online_gallery);
+        onlineFavoriteItem.setVisible(isValidOnline && isLogged);
+        menu.findItem(R.id.favorite_manager).setVisible(isValidOnline);
+        menu.findItem(R.id.download_gallery).setVisible(isValidOnline);
+        menu.findItem(R.id.related).setVisible(isValidOnline);
+        menu.findItem(R.id.comments).setVisible(isValidOnline);
+        menu.findItem(R.id.download_torrent).setVisible(isLogged);
+
+        menu.findItem(R.id.share).setVisible(gallery.isValid());
+        menu.findItem(R.id.load_internet).setVisible(isLocal && gallery.isValid());
+    }
+
+    @Override
+    protected void onResume() {
+        super.onResume();
+        updateColumnCount(false);
+        if (isLocal) supportInvalidateOptionsMenu();
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(final MenuItem item) {
+        int id = item.getItemId();
+        if (id == R.id.download_gallery) {
+            if (Global.hasStoragePermission(this))
+                new RangeSelector(this, (Gallery) gallery).show();
+            else
+                requestStorage();
+        } else if (id == R.id.add_online_gallery) addToFavorite(item);
+        else if (id == R.id.change_view) updateColumnCount(true);
+        else if (id == R.id.download_torrent) downloadTorrent();
+        else if (id == R.id.load_internet) toInternet();
+        else if (id == R.id.manage_status) updateStatus();
+        else if (id == R.id.share) Global.shareGallery(this, gallery);
+        else if (id == R.id.comments) {
+            Intent i = new Intent(this, CommentActivity.class);
+            i.putExtra(getPackageName() + ".GALLERYID", gallery.getId());
+            startActivity(i);
+        } else if (id == R.id.related) {
+            recycler.smoothScrollToPosition(recycler.getAdapter().getItemCount());
+        } else if (id == R.id.favorite_manager) {
+            if (isLocalFavorite) {
+                if (Favorites.removeFavorite(gallery)) isLocalFavorite = !isLocalFavorite;
+            } else if (Favorites.addFavorite((Gallery) gallery)) {
+                isLocalFavorite = !isLocalFavorite;
+            }
+            item.setIcon(isLocalFavorite ? R.drawable.ic_favorite : R.drawable.ic_favorite_border);
+            Global.setTint(item.getIcon());
+        } else if (id == android.R.id.home) {
+            onBackPressed();
+            return true;
+        }
+
+        return super.onOptionsItemSelected(item);
+    }
+
+    private void downloadTorrent() {
+        if(!Global.hasStoragePermission(this)){
+            return;
+        }
+
+        String url = String.format(Locale.US, Utility.getBaseUrl() + "g/%d/download", gallery.getId());
+        String referer = String.format(Locale.US, Utility.getBaseUrl() + "g/%d/", gallery.getId());
+
+        new AuthRequest(referer, url, new Callback() {
+            @Override
+            public void onFailure(@NonNull Call call,@NonNull  IOException e) {
+                GalleryActivity.this.runOnUiThread(() ->
+                    Toast.makeText(GalleryActivity.this, R.string.failed, Toast.LENGTH_SHORT).show()
+                );
+            }
+
+            @Override
+            public void onResponse(@NonNull Call call,@NonNull Response response) throws IOException {
+                File file=new File(Global.TORRENTFOLDER,gallery.getId()+".torrent");
+                Utility.writeStreamToFile(response.body().byteStream(), file);
+                Intent intent=new Intent(Intent.ACTION_VIEW);
+                Uri torrentUri;
+                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+                    torrentUri = FileProvider.getUriForFile(GalleryActivity.this, GalleryActivity.this.getPackageName() + ".provider", file);
+                    intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+                }else{
+                    torrentUri=Uri.fromFile(file);
+                }
+                intent.setDataAndType(torrentUri, "application/x-bittorrent");
+                try {
+                    GalleryActivity.this.startActivity(intent);
+                }catch (RuntimeException ignore){
+                    runOnUiThread(() ->
+                        Toast.makeText(GalleryActivity.this, R.string.failed, Toast.LENGTH_SHORT).show()
+                    );
+
+                }
+                file.deleteOnExit();
+            }
+        }).setMethod("GET",null).start();
+    }
+
+    private void updateStatus() {
+        List<String> statuses = StatusManager.getNames();
+        MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
+        statusString = Queries.StatusMangaTable.getStatus(gallery.getId()).name;
+        ArrayAdapter<String> adapter = new ArrayAdapter<String>(this, android.R.layout.select_dialog_singlechoice, statuses) {
+            @NonNull
+            @Override
+            public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
+                CheckedTextView textView = (CheckedTextView) super.getView(position, convertView, parent);
+                textView.setTextColor(StatusManager.getByName(statuses.get(position)).opaqueColor());
+                return textView;
+            }
+        };
+        builder.setSingleChoiceItems(adapter, statuses.indexOf(statusString), (dialog, which) -> statusString = statuses.get(which));
+        builder
+            .setNeutralButton(R.string.add, (dialog, which) -> createNewStatusDialog())
+            .setNegativeButton(R.string.remove_status, (dialog, which) -> Queries.StatusMangaTable.remove(gallery.getId()))
+            .setPositiveButton(R.string.ok, (dialog, which) -> Queries.StatusMangaTable.insert(gallery, statusString))
+            .setTitle(R.string.change_status_title)
+            .show();
+    }
+
+    private void createNewStatusDialog() {
+        MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
+        LinearLayout layout = (LinearLayout) View.inflate(this, R.layout.dialog_add_status, null);
+        EditText name = layout.findViewById(R.id.name);
+        Button btnColor = layout.findViewById(R.id.color);
+        do {
+            newStatusColor = Utility.RANDOM.nextInt() | 0xff000000;
+        } while (newStatusColor == Color.BLACK || newStatusColor == Color.WHITE);
+        btnColor.setBackgroundColor(newStatusColor);
+        btnColor.setOnClickListener(v -> new AmbilWarnaDialog(GalleryActivity.this, newStatusColor, false, new AmbilWarnaDialog.OnAmbilWarnaListener() {
+            @Override
+            public void onCancel(AmbilWarnaDialog dialog) {
+            }
+
+            @Override
+            public void onOk(AmbilWarnaDialog dialog, int color) {
+                if (color == Color.WHITE || color == Color.BLACK) {
+                    Toast.makeText(GalleryActivity.this, R.string.invalid_color_selected, Toast.LENGTH_SHORT).show();
+                    return;
+                }
+                newStatusColor = color;
+                btnColor.setBackgroundColor(color);
+            }
+        }).show());
+        builder.setView(layout);
+        builder.setTitle(R.string.create_new_status);
+        builder.setPositiveButton(R.string.ok, (dialog, which) -> {
+            String newName = name.getText().toString();
+            if (newName.length() < 2) {
+                Toast.makeText(this, R.string.name_too_short, Toast.LENGTH_SHORT).show();
+                return;
+            }
+            if (StatusManager.getByName(newName) != null) {
+                Toast.makeText(this, R.string.duplicated_name, Toast.LENGTH_SHORT).show();
+                return;
+            }
+            Status status = StatusManager.add(name.getText().toString(), newStatusColor);
+            Queries.StatusMangaTable.insert(gallery, status);
+        });
+        builder.setNegativeButton(R.string.cancel, (dialog, which) -> updateStatus());
+        builder.setOnCancelListener(dialog -> updateStatus());
+        builder.show();
+    }
+
+    private void updateIcon(boolean nowIsFavorite) {
+        GalleryActivity.this.runOnUiThread(() -> {
+            onlineFavoriteItem.setIcon(!nowIsFavorite ? R.drawable.ic_star_border : R.drawable.ic_star);
+            onlineFavoriteItem.setTitle(!nowIsFavorite ? R.string.add_to_online_favorite : R.string.remove_from_online_favorites);
+        });
+    }
+
+    private void addToFavorite(final MenuItem item) {
+
+        boolean wasFavorite = onlineFavoriteItem.getTitle().equals(getString(R.string.remove_from_online_favorites));
+        String url = String.format(Locale.US, Utility.getBaseUrl() + "api/gallery/%d/%sfavorite", gallery.getId(), wasFavorite ? "un" : "");
+        String galleryUrl = String.format(Locale.US, Utility.getBaseUrl() + "g/%d/", gallery.getId());
+        LogUtility.d("Calling: " + url);
+        new AuthRequest(galleryUrl, url, new Callback() {
+            @Override
+            public void onFailure(@NonNull Call call, @NonNull IOException e) {
+
+            }
+
+            @Override
+            public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
+                assert response.body() != null;
+                String responseString = response.body().string();
+                boolean nowIsFavorite = responseString.contains("true");
+                updateIcon(nowIsFavorite);
+            }
+        }).setMethod("POST", AuthRequest.EMPTY_BODY).start();
+    }
+
+    private void updateColumnCount(boolean increase) {
+        int x = Global.getColumnCount();
+        CustomGridLayoutManager manager = (CustomGridLayoutManager) recycler.getLayoutManager();
+        if (manager == null) return;
+        MenuItem item = ((Toolbar) findViewById(R.id.toolbar)).getMenu().findItem(R.id.change_view);
+        if (increase || manager.getSpanCount() != x) {
+            if (increase) x = x % 4 + 1;
+            int pos = manager.findFirstVisibleItemPosition();
+            Global.updateColumnCount(this, x);
+
+            recycler.setLayoutManager(new CustomGridLayoutManager(this, x));
+            LogUtility.d("Span count: " + manager.getSpanCount());
+            if (adapter != null) {
+                adapter.setColCount(Global.getColumnCount());
+                recycler.setAdapter(adapter);
+                lookup();
+                recycler.scrollToPosition(pos);
+                adapter.setMaxImageSize(null);
+
+            }
+        }
+
+        if (item != null) {
+            switch (x) {
+                case 1:
+                    item.setIcon(R.drawable.ic_view_1);
+                    break;
+                case 2:
+                    item.setIcon(R.drawable.ic_view_2);
+                    break;
+                case 3:
+                    item.setIcon(R.drawable.ic_view_3);
+                    break;
+                case 4:
+                    item.setIcon(R.drawable.ic_view_4);
+                    break;
+            }
+            Global.setTint(item.getIcon());
+
+        }
+    }
+
+    private void toInternet() {
+        refresher.setEnabled(true);
+        InspectorV3.galleryInspector(this, gallery.getId(), new InspectorV3.DefaultInspectorResponse() {
+            @Override
+            public void onSuccess(List<GenericGallery> galleries) {
+                if (galleries.size() == 0) return;
+                Intent intent = new Intent(GalleryActivity.this, GalleryActivity.class);
+                LogUtility.d(galleries.get(0).toString());
+                intent.putExtra(getPackageName() + ".GALLERY", galleries.get(0));
+                runOnUiThread(() -> startActivity(intent));
+            }
+        }).start();
+    }
+
+    @TargetApi(Build.VERSION_CODES.M)
+    private void requestStorage() {
+        requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE}, 1);
+    }
+
+    @Override
+    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+        Global.initStorage(this);
+        if (requestCode == 1 && grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED)
+            new RangeSelector(this, (Gallery) gallery).show();
+    }
+}

+ 70 - 0
app/src/main/java/com/dar/nbook/HistoryActivity.java

@@ -0,0 +1,70 @@
+package com.dar.nbook;
+
+import android.content.res.Configuration;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuItem;
+
+import androidx.appcompat.widget.Toolbar;
+
+import com.dar.nbook.adapters.ListAdapter;
+import com.dar.nbook.async.database.Queries;
+import com.dar.nbook.components.activities.BaseActivity;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.utility.Utility;
+
+import java.util.ArrayList;
+
+public class HistoryActivity extends BaseActivity {
+    ListAdapter adapter;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        //Global.initActivity(this);
+        setContentView(R.layout.activity_bookmark);
+        Toolbar toolbar = findViewById(R.id.toolbar);
+        setSupportActionBar(toolbar);
+        getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+        getSupportActionBar().setDisplayShowTitleEnabled(true);
+        getSupportActionBar().setTitle(R.string.history);
+        recycler = findViewById(R.id.recycler);
+        masterLayout = findViewById(R.id.master_layout);
+        adapter = new ListAdapter(this);
+        adapter.addGalleries(new ArrayList<>(Queries.HistoryTable.getHistory()));
+        changeLayout(getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE);
+        recycler.setAdapter(adapter);
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        if (item.getItemId() == android.R.id.home) {
+            finish();
+            return true;
+        } else if (item.getItemId() == R.id.cancelAll) {
+            Queries.HistoryTable.emptyHistory();
+            adapter.restartDataset(new ArrayList<>(1));
+            return true;
+
+        }
+        return super.onOptionsItemSelected(item);
+    }
+
+    @Override
+    protected int getPortraitColumnCount() {
+        return Global.getColPortHistory();
+    }
+
+    @Override
+    protected int getLandscapeColumnCount() {
+        return Global.getColLandHistory();
+    }
+
+    @Override
+    public boolean onCreateOptionsMenu(Menu menu) {
+        super.onCreateOptionsMenu(menu);
+        getMenuInflater().inflate(R.menu.history, menu);
+        Utility.tintMenu(menu);
+        return true;
+    }
+}

+ 240 - 0
app/src/main/java/com/dar/nbook/LocalActivity.java

@@ -0,0 +1,240 @@
+package com.dar.nbook;
+
+import android.content.res.Configuration;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.ArrayAdapter;
+import android.widget.LinearLayout;
+
+import androidx.appcompat.widget.Toolbar;
+
+import com.dar.nbook.adapters.LocalAdapter;
+import com.dar.nbook.api.local.FakeInspector;
+import com.dar.nbook.api.local.LocalGallery;
+import com.dar.nbook.api.local.LocalSortType;
+import com.dar.nbook.async.converters.CreatePDF;
+import com.dar.nbook.async.downloader.GalleryDownloaderV2;
+import com.dar.nbook.components.activities.BaseActivity;
+import com.dar.nbook.components.classes.MultichoiceAdapter;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.utility.Utility;
+import com.google.android.material.chip.ChipGroup;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.switchmaterial.SwitchMaterial;
+
+import java.io.File;
+import java.util.List;
+
+public class LocalActivity extends BaseActivity {
+    private Menu optionMenu;
+    private LocalAdapter adapter;
+    private final MultichoiceAdapter.MultichoiceListener listener = new MultichoiceAdapter.DefaultMultichoiceListener() {
+
+        @Override
+        public void choiceChanged() {
+            setMenuVisibility(optionMenu);
+        }
+    };
+    private Toolbar toolbar;
+    private int colCount;
+    private int idGalleryPosition = -1;
+    private File folder = Global.MAINFOLDER;
+    private androidx.appcompat.widget.SearchView searchView;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        //Global.initActivity(this);
+        setContentView(R.layout.app_bar_main);
+        toolbar = findViewById(R.id.toolbar);
+        setSupportActionBar(toolbar);
+        getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+        getSupportActionBar().setDisplayShowTitleEnabled(true);
+        getSupportActionBar().setTitle(R.string.downloaded_manga);
+        findViewById(R.id.page_switcher).setVisibility(View.GONE);
+        recycler = findViewById(R.id.recycler);
+        refresher = findViewById(R.id.refresher);
+        refresher.setOnRefreshListener(() -> new FakeInspector(this, folder).execute(this));
+        changeLayout(getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE);
+        new FakeInspector(this, folder).execute(this);
+    }
+
+    public void setAdapter(LocalAdapter adapter) {
+        this.adapter = adapter;
+        this.adapter.addListener(listener);
+        recycler.setAdapter(adapter);
+    }
+
+    public void setIdGalleryPosition(int idGalleryPosition) {
+        this.idGalleryPosition = idGalleryPosition;
+    }
+
+    @Override
+    public boolean onCreateOptionsMenu(Menu menu) {
+        // Inflate the menu; this adds items to the action bar if it is present.
+        getMenuInflater().inflate(R.menu.download, menu);
+        getMenuInflater().inflate(R.menu.local_multichoice, menu);
+        this.optionMenu = menu;
+        setMenuVisibility(menu);
+        searchView = (androidx.appcompat.widget.SearchView) menu.findItem(R.id.search).getActionView();
+        searchView.setOnQueryTextListener(new androidx.appcompat.widget.SearchView.OnQueryTextListener() {
+            @Override
+            public boolean onQueryTextSubmit(String query) {
+                return true;
+            }
+
+            @Override
+            public boolean onQueryTextChange(String newText) {
+                if (recycler.getAdapter() != null)
+                    ((LocalAdapter) recycler.getAdapter()).getFilter().filter(newText);
+                return true;
+            }
+        });
+
+        Utility.tintMenu(menu);
+
+        return true;
+    }
+
+    private void setMenuVisibility(Menu menu) {
+        if (menu == null) return;
+        MultichoiceAdapter.Mode mode = adapter == null ? MultichoiceAdapter.Mode.NORMAL : adapter.getMode();
+        boolean hasGallery = false;
+        boolean hasDownloads = false;
+        if (mode == MultichoiceAdapter.Mode.SELECTING) {
+            hasGallery = adapter.hasSelectedClass(LocalGallery.class);
+            hasDownloads = adapter.hasSelectedClass(GalleryDownloaderV2.class);
+        }
+
+        menu.findItem(R.id.search).setVisible(mode == MultichoiceAdapter.Mode.NORMAL);
+        menu.findItem(R.id.sort_by_name).setVisible(mode == MultichoiceAdapter.Mode.NORMAL);
+        menu.findItem(R.id.folder_choose).setVisible(mode == MultichoiceAdapter.Mode.NORMAL && Global.getUsableFolders(this).size() > 1);
+        menu.findItem(R.id.random_favorite).setVisible(mode == MultichoiceAdapter.Mode.NORMAL);
+
+        menu.findItem(R.id.delete_all).setVisible(mode == MultichoiceAdapter.Mode.SELECTING);
+        menu.findItem(R.id.select_all).setVisible(mode == MultichoiceAdapter.Mode.SELECTING);
+        menu.findItem(R.id.pause_all).setVisible(mode == MultichoiceAdapter.Mode.SELECTING && !hasGallery && hasDownloads);
+        menu.findItem(R.id.start_all).setVisible(mode == MultichoiceAdapter.Mode.SELECTING && !hasGallery && hasDownloads);
+        menu.findItem(R.id.pdf_all).setVisible(mode == MultichoiceAdapter.Mode.SELECTING && hasGallery && !hasDownloads && CreatePDF.hasPDFCapabilities());
+        menu.findItem(R.id.zip_all).setVisible(mode == MultichoiceAdapter.Mode.SELECTING && hasGallery && !hasDownloads);
+    }
+
+    @Override
+    protected void onDestroy() {
+        if (adapter != null) adapter.removeObserver();
+        super.onDestroy();
+    }
+
+    @Override
+    protected void changeLayout(boolean landscape) {
+        colCount = (landscape ? getLandscapeColumnCount() : getPortraitColumnCount());
+        if (adapter != null) adapter.setColCount(colCount);
+        super.changeLayout(landscape);
+    }
+
+    public int getColCount() {
+        return colCount;
+    }
+
+    @Override
+    protected void onResume() {
+        super.onResume();
+        if (idGalleryPosition != -1) {
+            adapter.updateColor(idGalleryPosition);
+            idGalleryPosition = -1;
+        }
+    }
+
+    public boolean onOptionsItemSelected(MenuItem item) {
+        if (item.getItemId() == android.R.id.home) {
+            onBackPressed();
+            return true;
+        } else if (item.getItemId() == R.id.pause_all) {
+            adapter.pauseSelected();
+        } else if (item.getItemId() == R.id.start_all) {
+            adapter.startSelected();
+        } else if (item.getItemId() == R.id.delete_all) {
+            adapter.deleteSelected();
+        } else if (item.getItemId() == R.id.pdf_all) {
+            adapter.pdfSelected();
+        } else if (item.getItemId() == R.id.zip_all) {
+            adapter.zipSelected();
+        } else if (item.getItemId() == R.id.select_all) {
+            adapter.selectAll();
+        } else if (item.getItemId() == R.id.folder_choose) {
+            showDialogFolderChoose();
+        } else if (item.getItemId() == R.id.random_favorite) {
+            if (adapter != null) adapter.viewRandom();
+        } else if (item.getItemId() == R.id.sort_by_name) {
+            dialogSortType();
+        }
+        return super.onOptionsItemSelected(item);
+    }
+
+    @Override
+    public void onBackPressed() {
+        if (adapter != null && adapter.getMode() == MultichoiceAdapter.Mode.SELECTING)
+            adapter.deselectAll();
+        else
+            super.onBackPressed();
+    }
+
+    private void showDialogFolderChoose() {
+        List<File> strings = Global.getUsableFolders(this);
+        ArrayAdapter adapter = new ArrayAdapter(this, android.R.layout.select_dialog_singlechoice, strings);
+        MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
+        builder.setTitle(R.string.choose_directory).setIcon(R.drawable.ic_folder);
+        builder.setAdapter(adapter, (dialog, which) -> {
+            folder = new File(strings.get(which), "NClientV2");
+            new FakeInspector(this, folder).execute(this);
+        }).setNegativeButton(R.string.cancel, null).show();
+    }
+
+    private void dialogSortType() {
+        LocalSortType sortType = Global.getLocalSortType();
+        MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
+        LinearLayout view = (LinearLayout) LayoutInflater.from(this).inflate(R.layout.local_sort_type, toolbar, false);
+        ChipGroup group = view.findViewById(R.id.chip_group);
+        SwitchMaterial switchMaterial = view.findViewById(R.id.ascending);
+        group.check(group.getChildAt(sortType.type.ordinal()).getId());
+        switchMaterial.setChecked(sortType.descending);
+        builder.setView(view);
+        builder.setPositiveButton(R.string.ok, (dialog, which) -> {
+            int typeSelectedIndex = group.indexOfChild(group.findViewById(group.getCheckedChipId()));
+            LocalSortType.Type typeSelected = LocalSortType.Type.values()[typeSelectedIndex];
+            boolean descending = switchMaterial.isChecked();
+            LocalSortType newSortType = new LocalSortType(typeSelected, descending);
+            if (sortType.equals(newSortType)) return;
+            Global.setLocalSortType(LocalActivity.this, newSortType);
+            if (adapter != null) adapter.sortChanged();
+        })
+            .setNeutralButton(R.string.cancel, null)
+            .setTitle(R.string.sort_select_type)
+            .show();
+
+
+       /* boolean sortByName=Global.isLocalSortByName();
+        item.setIcon(sortByName?R.drawable.ic_sort_by_alpha:R.drawable.ic_access_time);
+        item.setTitle(sortByName?R.string.sort_by_title:R.string.sort_by_latest);
+        Global.setTint(item.getIcon());*/
+    }
+
+    @Override
+    protected int getPortraitColumnCount() {
+        return Global.getColPortDownload();
+    }
+
+    @Override
+    protected int getLandscapeColumnCount() {
+        return Global.getColLandDownload();
+    }
+
+    public String getQuery() {
+        if (searchView == null) return "";
+        CharSequence query = searchView.getQuery();
+        return query == null ? "" : query.toString();
+    }
+}

+ 93 - 0
app/src/main/java/com/dar/nbook/LoginActivity.java

@@ -0,0 +1,93 @@
+package com.dar.nbook;
+
+import android.os.Bundle;
+import android.view.MenuItem;
+import android.webkit.CookieManager;
+import android.webkit.WebView;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.widget.Toolbar;
+
+import com.dar.nbook.components.activities.GeneralActivity;
+import com.dar.nbook.loginapi.User;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.settings.Login;
+import com.dar.nbook.utility.LogUtility;
+import com.dar.nbook.utility.Utility;
+
+import java.util.Collections;
+
+import okhttp3.Cookie;
+
+/**
+ * A login screen that offers login via email/password.
+ */
+public class LoginActivity extends GeneralActivity {
+    public TextView invalid;
+    CookieWaiter waiter;
+    WebView webView;
+
+    @Override
+    protected void onCreate(@Nullable Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        //Global.initActivity(this);
+        setContentView(R.layout.activity_login);
+        final Toolbar toolbar = findViewById(R.id.toolbar);
+        webView = findViewById(R.id.webView);
+        setSupportActionBar(toolbar);
+        toolbar.setTitle(R.string.title_activity_login);
+        assert getSupportActionBar() != null;
+        getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+        getSupportActionBar().setDisplayShowTitleEnabled(true);
+        webView.loadUrl(Utility.getBaseUrl() + "login/");
+        waiter = new CookieWaiter();
+        waiter.start();
+    }
+
+    @Override
+    protected void onDestroy() {
+        if (waiter != null && waiter.isAlive())
+            waiter.interrupt();
+        super.onDestroy();
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(@NonNull MenuItem item) {
+        if (item.getItemId() == android.R.id.home) finish();
+        return super.onOptionsItemSelected(item);
+    }
+
+    class CookieWaiter extends Thread {
+        @Override
+        public void run() {
+            CookieManager manager = CookieManager.getInstance();
+            String cookies = "";
+            while (cookies == null || !cookies.contains("sessionid")) {
+                Utility.threadSleep(100);
+                if (isInterrupted()) return;
+                cookies = manager.getCookie(Utility.getBaseUrl());
+            }
+            LogUtility.d("Cookie string: " + cookies);
+            String session = fetchCookie(cookies);
+            applyCookie(session);
+            runOnUiThread(LoginActivity.this::finish);
+        }
+
+        private void applyCookie(String session) {
+            Cookie cookie = Cookie.parse(Login.BASE_HTTP_URL, "sessionid=" + session + "; HttpOnly; Max-Age=1209600; Path=/; SameSite=Lax");
+            Global.client.cookieJar().saveFromResponse(Login.BASE_HTTP_URL, Collections.singletonList(cookie));
+            User.createUser(null);
+            finish();
+        }
+
+        String fetchCookie(String cookies) {
+            int start = cookies.indexOf("sessionid");
+            start = cookies.indexOf('=', start) + 1;
+            int end = cookies.indexOf(';', start);
+            return cookies.substring(start, end == -1 ? cookies.length() : end);
+        }
+    }
+}
+

+ 868 - 0
app/src/main/java/com/dar/nbook/MainActivity.java

@@ -0,0 +1,868 @@
+package com.dar.nbook;
+
+import android.Manifest;
+import android.annotation.TargetApi;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.res.Configuration;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.webkit.CookieManager;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.ImageView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.StringRes;
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.app.ActionBarDrawerToggle;
+import androidx.appcompat.widget.SearchView;
+import androidx.appcompat.widget.Toolbar;
+import androidx.core.view.GravityCompat;
+import androidx.drawerlayout.widget.DrawerLayout;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.bumptech.glide.RequestManager;
+import com.dar.nbook.adapters.ListAdapter;
+import com.dar.nbook.api.InspectorV3;
+import com.dar.nbook.api.components.Gallery;
+import com.dar.nbook.api.components.GenericGallery;
+import com.dar.nbook.api.components.Ranges;
+import com.dar.nbook.api.components.Tag;
+import com.dar.nbook.api.enums.ApiRequestType;
+import com.dar.nbook.api.enums.Language;
+import com.dar.nbook.api.enums.SortType;
+import com.dar.nbook.api.enums.SpecialTagIds;
+import com.dar.nbook.api.enums.TagStatus;
+import com.dar.nbook.api.enums.TagType;
+import com.dar.nbook.async.ScrapeTags;
+import com.dar.nbook.async.VersionChecker;
+import com.dar.nbook.async.database.Queries;
+import com.dar.nbook.async.downloader.DownloadGalleryV2;
+import com.dar.nbook.components.CookieInterceptor;
+import com.dar.nbook.components.GlideX;
+import com.dar.nbook.components.activities.BaseActivity;
+import com.dar.nbook.components.views.PageSwitcher;
+import com.dar.nbook.components.widgets.CustomGridLayoutManager;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.settings.Login;
+import com.dar.nbook.settings.TagV2;
+import com.dar.nbook.utility.ImageDownloadUtility;
+import com.dar.nbook.utility.LogUtility;
+import com.dar.nbook.utility.Utility;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.navigation.NavigationView;
+import com.google.android.material.snackbar.Snackbar;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+
+import okhttp3.Cookie;
+
+public class MainActivity extends BaseActivity
+    implements NavigationView.OnNavigationItemSelectedListener {
+
+    private static final int CHANGE_LANGUAGE_DELAY = 1000;
+    private static boolean firstTime = true;//true only when app starting
+    private final InspectorV3.InspectorResponse startGallery = new MainInspectorResponse() {
+        @Override
+        public void onSuccess(List<GenericGallery> galleries) {
+            Gallery g = galleries.size() == 1 ? (Gallery) galleries.get(0) : Gallery.emptyGallery();
+            Intent intent = new Intent(MainActivity.this, GalleryActivity.class);
+            LogUtility.d(g.toString());
+            intent.putExtra(getPackageName() + ".GALLERY", g);
+            runOnUiThread(() -> {
+                startActivity(intent);
+                finish();
+            });
+            LogUtility.d("STARTED");
+        }
+    };
+    private final CookieInterceptor.Manager MANAGER = new CookieInterceptor.Manager() {
+        boolean tokenFound = false;
+
+        @Override
+        public void applyCookie(String key, String value) {
+            Cookie cookie = Cookie.parse(Login.BASE_HTTP_URL, key + "=" + value + "; Max-Age=31449600; Path=/; SameSite=Lax");
+            Global.client.cookieJar().saveFromResponse(Login.BASE_HTTP_URL, Collections.singletonList(cookie));
+            tokenFound |= key.equals("csrftoken");
+        }
+
+        @Override
+        public boolean endInterceptor() {
+            if (tokenFound) return true;
+            String cookies = CookieManager.getInstance().getCookie(Utility.getBaseUrl());
+            if (cookies == null) return false;
+            return cookies.contains("csrftoken");
+        }
+
+        @Override
+        public void onFinish() {
+            inspector = inspector.cloneInspector(MainActivity.this, resetDataset);
+            inspector.start();
+        }
+    };
+    private final Handler changeLanguageTimeHandler = new Handler(Looper.myLooper());
+    public ListAdapter adapter;
+    private final InspectorV3.InspectorResponse addDataset = new MainInspectorResponse() {
+        @Override
+        public void onSuccess(List<GenericGallery> galleries) {
+            adapter.addGalleries(galleries);
+        }
+    };
+    //views
+    public MenuItem loginItem, onlineFavoriteManager;
+    private InspectorV3 inspector = null;
+    private NavigationView navigationView;
+    private ModeType modeType = ModeType.UNKNOWN;
+    private int idOpenedGallery = -1;//Position in the recycler of the opened gallery
+    private boolean inspecting = false, filteringTag = false;
+    private SortType temporaryType;
+    private Snackbar snackbar = null;
+    private PageSwitcher pageSwitcher;
+    private final InspectorV3.InspectorResponse
+        resetDataset = new MainInspectorResponse() {
+        @Override
+        public void onSuccess(List<GenericGallery> galleries) {
+            super.onSuccess(galleries);
+            adapter.restartDataset(galleries);
+            showPageSwitcher(inspector.getPage(), inspector.getPageCount());
+            runOnUiThread(() -> recycler.smoothScrollToPosition(0));
+        }
+    };
+    final Runnable changeLanguageRunnable = () -> {
+        useNormalMode();
+        inspector.start();
+    };
+    private DrawerLayout drawerLayout;
+    private Toolbar toolbar;
+    private Setting setting = null;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.activity_main);
+        //load inspector
+        selectStartMode(getIntent(), getPackageName());
+        LogUtility.d("Main started with mode " + modeType);
+        //init views and actions
+        findUsefulViews();
+        initializeToolbar();
+        initializeNavigationView();
+        initializeRecyclerView();
+        initializePageSwitcherActions();
+        loadStringLogin();
+        refresher.setOnRefreshListener(() -> {
+            inspector = inspector.cloneInspector(MainActivity.this, resetDataset);
+            if (Global.isInfiniteScrollMain()) inspector.setPage(1);
+            inspector.start();
+        });
+
+        manageDrawer();
+        setActivityTitle();
+        if (firstTime) checkUpdate();
+        if (inspector != null) {
+            inspector.start();
+        } else {
+            LogUtility.e(getIntent().getExtras());
+        }
+
+
+        ImageView ivAd = findViewById(R.id.iv_ad);
+        GlideX
+            .with(this)
+            .load("https://cdn.raex.vip/image/2023-05-05-15-57-18XvQPwCSt.png")
+            .centerCrop()
+            .placeholder(R.drawable.ic_logo)
+            .into(ivAd);
+    }
+
+    private void manageDrawer() {
+        if (modeType != ModeType.NORMAL) {
+            drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED);
+        } else {
+            ActionBarDrawerToggle toggle = new ActionBarDrawerToggle(this, drawerLayout, toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close);
+            drawerLayout.addDrawerListener(toggle);
+            toggle.syncState();
+        }
+    }
+
+    private void setActivityTitle() {
+        switch (modeType) {
+            case FAVORITE:
+                getSupportActionBar().setTitle(R.string.favorite_online_manga);
+                break;
+            case SEARCH:
+                getSupportActionBar().setTitle(inspector.getSearchTitle());
+                break;
+            case TAG:
+                getSupportActionBar().setTitle(inspector.getTag().getName());
+                break;
+            case NORMAL:
+                getSupportActionBar().setTitle(R.string.app_name);
+                break;
+            default:
+                getSupportActionBar().setTitle("WTF");
+                break;
+        }
+    }
+
+    private void initializeToolbar() {
+        setSupportActionBar(toolbar);
+        ActionBar bar = getSupportActionBar();
+        assert bar != null;
+        bar.setDisplayShowTitleEnabled(true);
+        bar.setTitle(R.string.app_name);
+    }
+
+    private void initializePageSwitcherActions() {
+        pageSwitcher.setChanger(new PageSwitcher.DefaultPageChanger() {
+            @Override
+            public void pageChanged(PageSwitcher switcher, int page) {
+                inspector = inspector.cloneInspector(MainActivity.this, resetDataset);
+                inspector.setPage(pageSwitcher.getActualPage());
+                inspector.start();
+            }
+        });
+    }
+
+    private void initializeRecyclerView() {
+        adapter = new ListAdapter(this);
+        recycler.setAdapter(adapter);
+        recycler.setHasFixedSize(true);
+        //recycler.setItemViewCacheSize(24);
+        recycler.addOnScrollListener(new RecyclerView.OnScrollListener() {
+            @Override
+            public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
+                if (inspecting) return;
+                if (!Global.isInfiniteScrollMain()) return;
+                if (refresher.isRefreshing()) return;
+
+                CustomGridLayoutManager manager = (CustomGridLayoutManager) recycler.getLayoutManager();
+                assert manager != null;
+                if (!pageSwitcher.lastPageReached() && lastGalleryReached(manager)) {
+                    inspecting = true;
+                    inspector = inspector.cloneInspector(MainActivity.this, addDataset);
+                    inspector.setPage(inspector.getPage() + 1);
+                    inspector.start();
+                }
+            }
+        });
+        changeLayout(getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE);
+    }
+
+    /**
+     * Check if the last gallery has been shown
+     **/
+    private boolean lastGalleryReached(CustomGridLayoutManager manager) {
+        return manager.findLastVisibleItemPosition() >= (recycler.getAdapter().getItemCount() - 1 - manager.getSpanCount());
+    }
+
+    private void initializeNavigationView() {
+        changeNavigationImage(navigationView);
+        toolbar.setNavigationIcon(R.drawable.ic_arrow_back);
+        toolbar.setNavigationOnClickListener(v -> finish());
+        navigationView.setNavigationItemSelectedListener(this);
+        onlineFavoriteManager.setVisible(com.dar.nbook.settings.Login.isLogged());
+    }
+
+    public void setIdOpenedGallery(int idOpenedGallery) {
+        this.idOpenedGallery = idOpenedGallery;
+    }
+
+    private void findUsefulViews() {
+        masterLayout = findViewById(R.id.master_layout);
+        toolbar = findViewById(R.id.toolbar);
+        navigationView = findViewById(R.id.nav_view);
+        recycler = findViewById(R.id.recycler);
+        refresher = findViewById(R.id.refresher);
+        pageSwitcher = findViewById(R.id.page_switcher);
+        drawerLayout = findViewById(R.id.drawer_layout);
+        loginItem = navigationView.getMenu().findItem(R.id.action_login);
+        onlineFavoriteManager = navigationView.getMenu().findItem(R.id.online_favorite_manager);
+    }
+
+    private void loadStringLogin() {
+        if (loginItem == null) return;
+        if (com.dar.nbook.settings.Login.getUser() != null)
+            loginItem.setTitle(getString(R.string.login_formatted, com.dar.nbook.settings.Login.getUser().getUsername()));
+        else
+            loginItem.setTitle(com.dar.nbook.settings.Login.isLogged() ? R.string.logout : R.string.login);
+
+    }
+
+    private void hideError() {
+        //errorText.setVisibility(View.GONE);
+        runOnUiThread(() -> {
+            if (snackbar != null && snackbar.isShown()) {
+                snackbar.dismiss();
+                snackbar = null;
+            }
+        });
+    }
+
+    private void showError(@Nullable String text, @Nullable View.OnClickListener listener) {
+        if (text == null) {
+            hideError();
+            return;
+        }
+        if (listener == null) {
+            snackbar = Snackbar.make(masterLayout, text, Snackbar.LENGTH_SHORT);
+        } else {
+            snackbar = Snackbar.make(masterLayout, text, Snackbar.LENGTH_INDEFINITE);
+            snackbar.setAction(R.string.retry, listener);
+        }
+        snackbar.show();
+    }
+
+    private void showError(@StringRes int text, View.OnClickListener listener) {
+        showError(getString(text), listener);
+    }
+
+    private void checkUpdate() {
+        if (Global.shouldCheckForUpdates(this))
+            new VersionChecker(this, true);
+        ScrapeTags.startWork(this);
+        firstTime = false;
+    }
+
+    private void selectStartMode(Intent intent, String packageName) {
+        Uri data = intent.getData();
+        if (intent.getBooleanExtra(packageName + ".ISBYTAG", false))
+            useTagMode(intent, packageName);
+        else if (intent.getBooleanExtra(packageName + ".SEARCHMODE", false))
+            useSearchMode(intent, packageName);
+        else if (intent.getBooleanExtra(packageName + ".FAVORITE", false)) useFavoriteMode(1);
+        else if (intent.getBooleanExtra(packageName + ".BYBOOKMARK", false))
+            useBookmarkMode(intent, packageName);
+        else if (data != null) manageDataStart(data);
+        else useNormalMode();
+    }
+
+    private void useNormalMode() {
+        inspector = InspectorV3.basicInspector(this, 1, resetDataset);
+        modeType = ModeType.NORMAL;
+    }
+
+    private void useBookmarkMode(Intent intent, String packageName) {
+        inspector = intent.getParcelableExtra(packageName + ".INSPECTOR");
+        assert inspector != null;
+        inspector.initialize(this, resetDataset);
+        modeType = ModeType.BOOKMARK;
+        ApiRequestType type = inspector.getRequestType();
+        if (type == ApiRequestType.BYTAG) modeType = ModeType.TAG;
+        else if (type == ApiRequestType.BYALL) modeType = ModeType.NORMAL;
+        else if (type == ApiRequestType.BYSEARCH) modeType = ModeType.SEARCH;
+        else if (type == ApiRequestType.FAVORITE) modeType = ModeType.FAVORITE;
+
+    }
+
+    private void useFavoriteMode(int page) {
+        //instantiateWebView();
+        inspector = InspectorV3.favoriteInspector(this, null, page, resetDataset);
+        modeType = ModeType.FAVORITE;
+    }
+
+    private void useSearchMode(Intent intent, String packageName) {
+        String query = intent.getStringExtra(packageName + ".QUERY");
+        boolean ok = tryOpenId(query);
+        if (!ok) createSearchInspector(intent, packageName, query);
+    }
+
+    private void createSearchInspector(Intent intent, String packageName, String query) {
+        boolean advanced = intent.getBooleanExtra(packageName + ".ADVANCED", false);
+        ArrayList<Tag> tagArrayList = intent.getParcelableArrayListExtra(packageName + ".TAGS");
+        Ranges ranges = intent.getParcelableExtra(getPackageName() + ".RANGES");
+        HashSet<Tag> tags = null;
+        query = query.trim();
+        if (advanced) {
+            assert tagArrayList != null;//tags is always not null when advanced is set
+            tags = new HashSet<>(tagArrayList);
+        }
+        inspector = InspectorV3.searchInspector(this, query, tags, 1, Global.getSortType(), ranges, resetDataset);
+        modeType = ModeType.SEARCH;
+    }
+
+    private boolean tryOpenId(String query) {
+        try {
+            int id = Integer.parseInt(query);
+            inspector = InspectorV3.galleryInspector(this, id, startGallery);
+            modeType = ModeType.ID;
+            return true;
+        } catch (NumberFormatException ignore) {
+        }
+        return false;
+    }
+
+    private void useTagMode(Intent intent, String packageName) {
+        Tag t = intent.getParcelableExtra(packageName + ".TAG");
+        inspector = InspectorV3.tagInspector(this, t, 1, Global.getSortType(), resetDataset);
+        modeType = ModeType.TAG;
+    }
+
+    /**
+     * Load inspector from an URL, it can be either a tag or a search
+     */
+    private void manageDataStart(Uri data) {
+        List<String> datas = data.getPathSegments();
+        TagType dataType;
+
+        LogUtility.d("Datas: " + datas);
+        if (datas.size() == 0) {
+            useNormalMode();
+            return;
+        }
+        dataType = TagType.typeByName(datas.get(0));
+        if (dataType != TagType.UNKNOWN) useDataTagMode(datas, dataType);
+        else useDataSearchMode(data, datas);
+    }
+
+    private void useDataSearchMode(Uri data, List<String> datas) {
+        String query = data.getQueryParameter("q");
+        String pageParam = data.getQueryParameter("page");
+        boolean favorite = "favorites".equals(datas.get(0));
+        SortType type = SortType.findFromAddition(data.getQueryParameter("sort"));
+        int page = 1;
+
+        if (pageParam != null) page = Integer.parseInt(pageParam);
+
+        if (favorite) {
+            if (com.dar.nbook.settings.Login.isLogged()) useFavoriteMode(page);
+            else {
+                Intent intent = new Intent(this, FavoriteActivity.class);
+                startActivity(intent);
+                finish();
+            }
+            return;
+        }
+
+        inspector = InspectorV3.searchInspector(this, query, null, page, type, null, resetDataset);
+        modeType = ModeType.SEARCH;
+    }
+
+    private void useDataTagMode(List<String> datas, TagType type) {
+        String query = datas.get(1);
+        Tag tag = Queries.TagTable.getTagFromTagName(query);
+        if (tag == null)
+            tag = new Tag(query, -1, SpecialTagIds.INVALID_ID, type, TagStatus.DEFAULT);
+        SortType sortType = SortType.RECENT_ALL_TIME;
+        if (datas.size() == 3) {
+            sortType = SortType.findFromAddition(datas.get(2));
+        }
+        inspector = InspectorV3.tagInspector(this, tag, 1, sortType, resetDataset);
+        modeType = ModeType.TAG;
+    }
+
+    private void changeNavigationImage(NavigationView navigationView) {
+        boolean light = Global.getTheme() == Global.ThemeScheme.LIGHT;
+        View view = navigationView.getHeaderView(0);
+        ImageView imageView = view.findViewById(R.id.imageView);
+        View layoutHeader = view.findViewById(R.id.layout_header);
+        ImageDownloadUtility.loadImage(light ? R.drawable.ic_logo_dark : R.drawable.ic_logo, imageView);
+        layoutHeader.setBackgroundResource(light ? R.drawable.side_nav_bar_light : R.drawable.side_nav_bar_dark);
+    }
+
+    private void changeUsedLanguage(MenuItem item) {
+        switch (Global.getOnlyLanguage()) {
+            case ENGLISH:
+                Global.updateOnlyLanguage(this, Language.JAPANESE);
+                break;
+            case JAPANESE:
+                Global.updateOnlyLanguage(this, Language.CHINESE);
+                break;
+            case CHINESE:
+                Global.updateOnlyLanguage(this, Language.ALL);
+                break;
+            case ALL:
+                Global.updateOnlyLanguage(this, Language.ENGLISH);
+                break;
+        }
+        //wait 250ms to reduce the requests
+        changeLanguageTimeHandler.removeCallbacks(changeLanguageRunnable);
+        changeLanguageTimeHandler.postDelayed(changeLanguageRunnable, CHANGE_LANGUAGE_DELAY);
+
+    }
+
+    @Override
+    public void onBackPressed() {
+        if (drawerLayout.isDrawerOpen(GravityCompat.START))
+            drawerLayout.closeDrawer(GravityCompat.START);
+        else super.onBackPressed();
+    }
+
+    public void hidePageSwitcher() {
+        runOnUiThread(() -> pageSwitcher.setVisibility(View.GONE));
+    }
+
+    public void showPageSwitcher(final int actualPage, final int totalPage) {
+        pageSwitcher.setPages(totalPage, actualPage);
+
+
+        if (Global.isInfiniteScrollMain()) {
+            hidePageSwitcher();
+        }
+
+    }
+
+
+    private void showLogoutForm() {
+        MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
+        builder.setIcon(R.drawable.ic_exit_to_app).setTitle(R.string.logout).setMessage(R.string.are_you_sure);
+        builder.setPositiveButton(R.string.yes, (dialogInterface, i) -> {
+            Login.logout(this);
+            onlineFavoriteManager.setVisible(false);
+            loginItem.setTitle(R.string.login);
+        }).setNegativeButton(R.string.no, null).show();
+    }
+
+    @Override
+    protected void onResume() {
+        super.onResume();
+        Global.updateACRAReportStatus(this);
+        com.dar.nbook.settings.Login.initLogin(this);
+        if (idOpenedGallery != -1) {
+            adapter.updateColor(idOpenedGallery);
+            idOpenedGallery = -1;
+        }
+        loadStringLogin();
+        onlineFavoriteManager.setVisible(com.dar.nbook.settings.Login.isLogged());
+        if (setting != null) {
+            Global.initFromShared(this);//restart all settings
+            inspector = inspector.cloneInspector(this, resetDataset);
+            inspector.start();//restart inspector
+            if (setting.theme != Global.getTheme() || !Objects.equals(setting.locale, Global.initLanguage(this))) {
+                RequestManager manager = GlideX.with(getApplicationContext());
+                if (manager != null) manager.pauseAllRequestsRecursive();
+                recreate();
+            }
+            adapter.notifyDataSetChanged();//restart adapter
+            adapter.resetStatuses();
+            showPageSwitcher(inspector.getPage(), inspector.getPageCount());//restart page switcher
+            changeLayout(getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE);
+            setting = null;
+        } else if (filteringTag) {
+            inspector = InspectorV3.basicInspector(this, 1, resetDataset);
+            inspector.start();
+            filteringTag = false;
+        }
+        invalidateOptionsMenu();
+    }
+
+    @Override
+    public void onConfigurationChanged(@NonNull Configuration newConfig) {
+        super.onConfigurationChanged(newConfig);
+    }
+
+    @Override
+    public boolean onCreateOptionsMenu(Menu menu) {
+        getMenuInflater().inflate(R.menu.main, menu);
+        popularItemDispay(menu.findItem(R.id.by_popular));
+
+        showLanguageIcon(menu.findItem(R.id.only_language));
+
+        menu.findItem(R.id.only_language).setVisible(modeType == ModeType.NORMAL);
+        menu.findItem(R.id.random_favorite).setVisible(modeType == ModeType.FAVORITE);
+
+        initializeSearchItem(menu.findItem(R.id.search));
+
+
+        if (modeType == ModeType.TAG) {
+            MenuItem item = menu.findItem(R.id.tag_manager);
+            item.setVisible(inspector.getTag().getId() > 0);
+            TagStatus ts = inspector.getTag().getStatus();
+            updateTagStatus(item, ts);
+        }
+        Utility.tintMenu(menu);
+        return true;
+    }
+
+    private void initializeSearchItem(MenuItem item) {
+        if (modeType != ModeType.FAVORITE)
+            item.setActionView(null);
+        else {
+            ((SearchView) item.getActionView()).setOnQueryTextListener(new SearchView.OnQueryTextListener() {
+                @Override
+                public boolean onQueryTextSubmit(String query) {
+                    inspector = InspectorV3.favoriteInspector(MainActivity.this, query, 1, resetDataset);
+                    inspector.start();
+                    getSupportActionBar().setTitle(query);
+                    return true;
+                }
+
+                @Override
+                public boolean onQueryTextChange(String newText) {
+                    return false;
+                }
+            });
+        }
+    }
+
+    private void popularItemDispay(MenuItem item) {
+        item.setTitle(getString(R.string.sort_type_title_format, getString(Global.getSortType().getNameId())));
+        Global.setTint(item.getIcon());
+    }
+
+    private void showLanguageIcon(MenuItem item) {
+        switch (Global.getOnlyLanguage()) {
+            case JAPANESE:
+                item.setTitle(R.string.only_japanese);
+                item.setIcon(R.drawable.ic_jpbw);
+                break;
+            case CHINESE:
+                item.setTitle(R.string.only_chinese);
+                item.setIcon(R.drawable.ic_cnbw);
+                break;
+            case ENGLISH:
+                item.setTitle(R.string.only_english);
+                item.setIcon(R.drawable.ic_gbbw);
+                break;
+            case ALL:
+                item.setTitle(R.string.all_languages);
+                item.setIcon(R.drawable.ic_world);
+                break;
+        }
+        Global.setTint(item.getIcon());
+    }
+
+    @Override
+    protected int getPortraitColumnCount() {
+        return Global.getColPortMain();
+    }
+
+    @Override
+    protected int getLandscapeColumnCount() {
+        return Global.getColLandMain();
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        Intent i;
+        LogUtility.d("Pressed item: " + item.getItemId());
+        if (item.getItemId() == R.id.by_popular) {
+            updateSortType(item);
+        } else if (item.getItemId() == R.id.only_language) {
+            changeUsedLanguage(item);
+            showLanguageIcon(item);
+        } else if (item.getItemId() == R.id.search) {
+            if (modeType != ModeType.FAVORITE) {//show textbox or start search activity
+                i = new Intent(this, SearchActivity.class);
+                startActivity(i);
+            }
+        }
+//        else if (item.getItemId() == R.id.open_browser) {
+//            if (inspector != null) {
+//                i = new Intent(Intent.ACTION_VIEW, Uri.parse(inspector.getUrl()));
+//                startActivity(i);
+//            }
+//        }
+        else if (item.getItemId() == R.id.random_favorite) {
+            inspector = InspectorV3.randomInspector(this, startGallery, true);
+            inspector.start();
+        } else if (item.getItemId() == R.id.download_page) {
+            if (inspector.getGalleries() != null)
+                showDialogDownloadAll();
+        } else if (item.getItemId() == R.id.add_bookmark) {
+            Queries.BookmarkTable.addBookmark(inspector);
+        } else if (item.getItemId() == R.id.tag_manager) {
+            TagStatus ts = TagV2.updateStatus(inspector.getTag());
+            updateTagStatus(item, ts);
+        } else if (item.getItemId() == android.R.id.home) {
+            finish();
+        }
+        return super.onOptionsItemSelected(item);
+    }
+
+    private void updateSortType(MenuItem item) {
+        ArrayAdapter<String> adapter = new ArrayAdapter<>(this, android.R.layout.select_dialog_singlechoice);
+        MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
+        for (SortType type : SortType.values())
+            adapter.add(getString(type.getNameId()));
+        temporaryType = Global.getSortType();
+        builder.setIcon(R.drawable.ic_sort).setTitle(R.string.sort_select_type);
+        builder.setSingleChoiceItems(adapter, temporaryType.ordinal(), (dialog, which) -> temporaryType = SortType.values()[which]);
+        builder.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
+            @Override
+            public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
+                temporaryType = SortType.values()[position];
+                parent.setSelection(position);
+            }
+
+            @Override
+            public void onNothingSelected(AdapterView<?> parent) {
+            }
+        });
+        builder.setPositiveButton(R.string.ok, (dialog, which) -> {
+            Global.updateSortType(MainActivity.this, temporaryType);
+            popularItemDispay(item);
+            inspector = inspector.cloneInspector(MainActivity.this, resetDataset);
+            inspector.setSortType(temporaryType);
+            inspector.start();
+        });
+        builder.setNegativeButton(R.string.cancel, null);
+        builder.show();
+    }
+
+    private void showDialogDownloadAll() {
+        MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
+        builder
+            .setTitle(R.string.download_all_galleries_in_this_page)
+            .setIcon(R.drawable.ic_file)
+            .setNegativeButton(R.string.cancel, null)
+            .setPositiveButton(R.string.ok, (dialog, which) -> {
+                for (GenericGallery g : inspector.getGalleries())
+                    DownloadGalleryV2.downloadGallery(MainActivity.this, g);
+            });
+        builder.show();
+    }
+
+    private void updateTagStatus(MenuItem item, TagStatus ts) {
+        switch (ts) {
+            case DEFAULT:
+                item.setIcon(R.drawable.ic_help);
+                break;
+            case AVOIDED:
+                item.setIcon(R.drawable.ic_close);
+                break;
+            case ACCEPTED:
+                item.setIcon(R.drawable.ic_check);
+                break;
+        }
+        Global.setTint(item.getIcon());
+    }
+
+    @Override
+    public boolean onNavigationItemSelected(@NonNull MenuItem item) {
+        Intent intent;
+        if (item.getItemId() == R.id.downloaded) {
+            if (Global.hasStoragePermission(this)) startLocalActivity();
+            else requestStorage();
+        } else if (item.getItemId() == R.id.bookmarks) {
+            intent = new Intent(this, BookmarkActivity.class);
+            startActivity(intent);
+        } else if (item.getItemId() == R.id.history) {
+            intent = new Intent(this, HistoryActivity.class);
+            startActivity(intent);
+
+        } else if (item.getItemId() == R.id.favorite_manager) {
+            intent = new Intent(this, FavoriteActivity.class);
+            startActivity(intent);
+        } else if (item.getItemId() == R.id.action_settings) {
+            setting = new Setting();
+            intent = new Intent(this, SettingsActivity.class);
+            startActivity(intent);
+        } else if (item.getItemId() == R.id.online_favorite_manager) {
+            intent = new Intent(this, MainActivity.class);
+            intent.putExtra(getPackageName() + ".FAVORITE", true);
+            startActivity(intent);
+        } else if (item.getItemId() == R.id.action_login) {
+            if (Login.isLogged())
+                showLogoutForm();
+            else {
+                intent = new Intent(this, LoginActivity.class);
+                startActivity(intent);
+            }
+        } else if (item.getItemId() == R.id.random) {
+            intent = new Intent(this, RandomActivity.class);
+            startActivity(intent);
+        } else if (item.getItemId() == R.id.tag_manager) {
+            intent = new Intent(this, TagFilterActivity.class);
+            filteringTag = true;
+            startActivity(intent);
+        } else if (item.getItemId() == R.id.status_manager) {
+            intent = new Intent(this, StatusViewerActivity.class);
+            startActivity(intent);
+        }
+        //drawerLayout.closeDrawer(GravityCompat.START);
+        return true;
+    }
+
+    @TargetApi(Build.VERSION_CODES.M)
+    private void requestStorage() {
+        requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE}, 1);
+    }
+
+    @Override
+    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+        Global.initStorage(this);
+        if (requestCode == 1 && grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED)
+            startLocalActivity();
+        if (requestCode == 2 && grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED)
+            new VersionChecker(this, true);
+    }
+
+    private void startLocalActivity() {
+        Intent i = new Intent(this, LocalActivity.class);
+        startActivity(i);
+    }
+
+    /**
+     * UNKNOWN in case of error
+     * NORMAL when in main page
+     * TAG when searching for a specific tag
+     * FAVORITE when using online favorite button
+     * SEARCH when used SearchActivity
+     * BOOKMARK when loaded a bookmark
+     * ID when searched for an ID
+     */
+    private enum ModeType {UNKNOWN, NORMAL, TAG, FAVORITE, SEARCH, BOOKMARK, ID}
+
+    abstract class MainInspectorResponse extends InspectorV3.DefaultInspectorResponse {
+        @Override
+        public void onSuccess(List<GenericGallery> galleries) {
+            super.onSuccess(galleries);
+            if (adapter != null) adapter.resetStatuses();
+            if (galleries.size() == 0)
+                showError(R.string.no_entry_found, null);
+        }
+
+        @Override
+        public void onStart() {
+            runOnUiThread(() -> refresher.setRefreshing(true));
+            hideError();
+        }
+
+        @Override
+        public void onEnd() {
+            runOnUiThread(() -> refresher.setRefreshing(false));
+            inspecting = false;
+        }
+
+        @Override
+        public void onFailure(Exception e) {
+            super.onFailure(e);
+            showError(R.string.unable_to_connect_to_the_site, v -> {
+                inspector = inspector.cloneInspector(MainActivity.this, inspector.getResponse());
+                inspector.start();
+            });
+        }
+
+        @Override
+        public boolean shouldStart(InspectorV3 inspector) {
+            return true;
+            //loadWebVewUrl(inspector.getUrl());
+            //return inspector.canParseDocument();
+        }
+    }
+
+    private class Setting {
+        final Global.ThemeScheme theme;
+        final Locale locale;
+
+        Setting() {
+            this.theme = Global.getTheme();
+            this.locale = Global.initLanguage(MainActivity.this);
+        }
+    }
+}

+ 138 - 0
app/src/main/java/com/dar/nbook/PINActivity.java

@@ -0,0 +1,138 @@
+package com.dar.nbook;
+
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.widget.Button;
+import android.widget.ImageButton;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.dar.nbook.components.activities.GeneralActivity;
+import com.dar.nbook.settings.Global;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+public class PINActivity extends GeneralActivity {
+    private static final int PIN_LENGHT = 4;
+    private List<Button> numbers;
+    private List<TextView> texts;
+    private ImageButton cancelButton;
+    private TextView text;
+    private String pin = "";
+    private String confirmPin = null;
+    private boolean setMode;
+    private SharedPreferences preferences;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        //Global.initActivity(this);
+        setContentView(R.layout.activity_pin);
+        preferences = getSharedPreferences("Settings", 0);
+        setMode = getIntent().getBooleanExtra(getPackageName() + ".SET", false);
+        if (!setMode && !hasPin()) {
+            finish();
+            return;
+        }
+        ImageView logo = findViewById(R.id.imageView);
+        logo.setImageResource(Global.getTheme() == Global.ThemeScheme.LIGHT ? R.drawable.ic_logo_dark : R.drawable.ic_logo);
+        LinearLayout linear = findViewById(R.id.linearLayout);
+        text = findViewById(R.id.textView);
+        cancelButton = findViewById(R.id.cancelButton);
+        texts = new ArrayList<>(PIN_LENGHT);
+        for (int i = 0; i < PIN_LENGHT; i++) texts.add((TextView) linear.getChildAt(i));
+        numbers = Arrays.asList(
+            findViewById(R.id.btn0), findViewById(R.id.btn1), findViewById(R.id.btn2),
+            findViewById(R.id.btn3), findViewById(R.id.btn4), findViewById(R.id.btn5),
+            findViewById(R.id.btn6), findViewById(R.id.btn7), findViewById(R.id.btn8),
+            findViewById(R.id.btn9)
+        );
+        for (int i = 0; i < numbers.size(); i++) {
+            final int ind = i;
+            numbers.get(i).setOnClickListener(v -> {
+                pin += ind;
+                applyPinMask();
+                if (pin.length() == 4)
+                    checkPin();
+            });
+        }
+        cancelButton.setOnClickListener(v -> {
+            if (pin.length() == 0) return;
+            pin = pin.substring(0, pin.length() - 1);
+            applyPinMask();
+        });
+        cancelButton.setOnLongClickListener(v -> {
+            pin = "";
+            applyPinMask();
+            return true;
+        });
+        ImageButton utility = findViewById(R.id.utility);
+        utility.setOnClickListener(v -> {
+            if (setMode && isConfirming()) {
+                checkPin();//will go in wrong branch
+            } else {
+                finish();
+            }
+        });
+
+    }
+
+    private boolean isConfirming() {
+        return confirmPin != null;
+    }
+
+    private boolean hasPin() {
+        return preferences.getBoolean("has_pin", false);
+    }
+
+    private void setPin(String pin) {
+        SharedPreferences.Editor editor = preferences.edit();
+        editor.putBoolean("has_pin", true);
+        editor.putString("pin", pin);
+        editor.apply();
+    }
+
+    @Override
+    public void finish() {
+        Intent i = new Intent(this, setMode ? SettingsActivity.class : MainActivity.class);
+        if (setMode || !hasPin() || pin.equals(getTruePin())) startActivity(i);
+        super.finish();
+    }
+
+    private String getTruePin() {
+        return preferences.getString("pin", null);
+    }
+
+    private void checkPin() {
+        if (setMode) {//if password should be set
+            if (!isConfirming()) {//now password must be confirmed
+                confirmPin = pin;
+                pin = "";
+                text.setText(R.string.confirm_pin);
+            } else if (confirmPin.equals(pin)) {//password confirmed
+                setPin(confirmPin);
+                finish();
+            } else {//wrong confirmed password
+                confirmPin = null;
+                text.setText(R.string.insert_pin);
+                pin = "";
+            }
+        } else if (pin.equals(getTruePin())) {//right password
+            finish();
+        } else {//wrong password
+            text.setText(R.string.wrong_pin);
+            pin = "";
+        }
+        applyPinMask();
+    }
+
+    private void applyPinMask() {
+        int i, len = Math.min(pin.length(), PIN_LENGHT);
+        for (i = 0; i < len; i++) texts.get(i).setText(R.string.full_circle);
+        for (; i < PIN_LENGHT; i++) texts.get(i).setText(R.string.empty_circle);
+    }
+}

+ 130 - 0
app/src/main/java/com/dar/nbook/RandomActivity.java

@@ -0,0 +1,130 @@
+package com.dar.nbook;
+
+import android.content.Intent;
+import android.content.res.ColorStateList;
+import android.graphics.Color;
+import android.os.Bundle;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.ImageButton;
+import android.widget.TextView;
+
+import androidx.appcompat.widget.Toolbar;
+import androidx.core.widget.ImageViewCompat;
+
+import com.dar.nbook.api.RandomLoader;
+import com.dar.nbook.api.components.Gallery;
+import com.dar.nbook.components.activities.GeneralActivity;
+import com.dar.nbook.settings.Favorites;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.utility.ImageDownloadUtility;
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+
+public class RandomActivity extends GeneralActivity {
+    public static Gallery loadedGallery = null;
+    private TextView language;
+    private ImageButton thumbnail;
+    private ImageButton favorite;
+    private TextView title;
+    private TextView page;
+    private View censor;
+    private RandomLoader loader = null;
+    private boolean isFavorite;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        //Global.initActivity(this);
+        setContentView(R.layout.activity_random);
+        loader = new RandomLoader(this);
+
+
+        //init components id
+        Toolbar toolbar = findViewById(R.id.toolbar);
+        FloatingActionButton shuffle = findViewById(R.id.shuffle);
+        ImageButton share = findViewById(R.id.share);
+        censor = findViewById(R.id.censor);
+        language = findViewById(R.id.language);
+        thumbnail = findViewById(R.id.thumbnail);
+        favorite = findViewById(R.id.favorite);
+        title = findViewById(R.id.title);
+        page = findViewById(R.id.pages);
+
+        //init toolbar
+        setSupportActionBar(toolbar);
+        getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+        getSupportActionBar().setDisplayShowTitleEnabled(true);
+        getSupportActionBar().setTitle(R.string.random_manga);
+
+
+        if (loadedGallery != null) loadGallery(loadedGallery);
+
+        shuffle.setOnClickListener(v -> loader.requestGallery());
+
+        thumbnail.setOnClickListener(v -> {
+            if (loadedGallery != null) {
+                Intent intent = new Intent(RandomActivity.this, GalleryActivity.class);
+                intent.putExtra(RandomActivity.this.getPackageName() + ".GALLERY", loadedGallery);
+                RandomActivity.this.startActivity(intent);
+            }
+        });
+        share.setOnClickListener(v -> {
+            if (loadedGallery != null) Global.shareGallery(RandomActivity.this, loadedGallery);
+        });
+        censor.setOnClickListener(v -> censor.setVisibility(View.GONE));
+
+        favorite.setOnClickListener(v -> {
+            if (loadedGallery != null) {
+                if (isFavorite) {
+                    if (Favorites.removeFavorite(loadedGallery)) isFavorite = false;
+                } else if (Favorites.addFavorite(loadedGallery)) isFavorite = true;
+            }
+            favoriteUpdateButton();
+        });
+
+        ColorStateList colorStateList = ColorStateList.valueOf(Global.getTheme() == Global.ThemeScheme.LIGHT ? Color.WHITE : Color.BLACK);
+
+        ImageViewCompat.setImageTintList(shuffle, colorStateList);
+        ImageViewCompat.setImageTintList(share, colorStateList);
+        ImageViewCompat.setImageTintList(favorite, colorStateList);
+
+        Global.setTint(shuffle.getContentBackground());
+        Global.setTint(share.getDrawable());
+        Global.setTint(favorite.getDrawable());
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        if (item.getItemId() == android.R.id.home) {
+            finish();
+            return true;
+        }
+        return super.onOptionsItemSelected(item);
+    }
+
+
+    public void loadGallery(Gallery gallery) {
+        loadedGallery = gallery;
+        if (Global.isDestroyed(this)) return;
+        ImageDownloadUtility.loadImage(this, gallery.getCover(), thumbnail);
+        language.setText(Global.getLanguageFlag(gallery.getLanguage()));
+        isFavorite = Favorites.isFavorite(loadedGallery);
+        favoriteUpdateButton();
+        title.setText(gallery.getTitle());
+        page.setText(getString(R.string.page_count_format, gallery.getPageCount()));
+        censor.setVisibility(gallery.hasIgnoredTags() ? View.VISIBLE : View.GONE);
+    }
+
+    private void favoriteUpdateButton() {
+        runOnUiThread(() -> {
+            ImageDownloadUtility.loadImage(isFavorite ? R.drawable.ic_favorite : R.drawable.ic_favorite_border, favorite);
+            Global.setTint(favorite.getDrawable());
+        });
+    }
+
+    @Override
+    public void onBackPressed() {
+        loadedGallery = null;
+        super.onBackPressed();
+    }
+}

+ 414 - 0
app/src/main/java/com/dar/nbook/SearchActivity.java

@@ -0,0 +1,414 @@
+package com.dar.nbook;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.widget.AppCompatAutoCompleteTextView;
+import androidx.appcompat.widget.SearchView;
+import androidx.appcompat.widget.Toolbar;
+import androidx.recyclerview.widget.DividerItemDecoration;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.dar.nbook.adapters.HistoryAdapter;
+import com.dar.nbook.api.components.Ranges;
+import com.dar.nbook.api.components.Tag;
+import com.dar.nbook.api.enums.Language;
+import com.dar.nbook.api.enums.SpecialTagIds;
+import com.dar.nbook.api.enums.TagStatus;
+import com.dar.nbook.api.enums.TagType;
+import com.dar.nbook.async.database.Queries;
+import com.dar.nbook.components.activities.GeneralActivity;
+import com.dar.nbook.components.widgets.ChipTag;
+import com.dar.nbook.components.widgets.CustomLinearLayoutManager;
+import com.dar.nbook.settings.DefaultDialogs;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.settings.Login;
+import com.dar.nbook.utility.LogUtility;
+import com.dar.nbook.utility.Utility;
+import com.google.android.material.chip.Chip;
+import com.google.android.material.chip.ChipGroup;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+public class SearchActivity extends GeneralActivity {
+    public static final int CUSTOM_ID_START = 100000000;
+    private static int customId = CUSTOM_ID_START;
+    private final Ranges ranges = new Ranges();
+    private final ArrayList<ChipTag> tags = new ArrayList<>();
+    private final Chip[] addChip = new Chip[TagType.values.length];
+    private ChipGroup[] groups;
+    private SearchView searchView;
+    private AppCompatAutoCompleteTextView autoComplete;
+    private TagType loadedTag = null;
+    private HistoryAdapter adapter;
+    private boolean advanced = false;
+    private Ranges.TimeUnit temporaryUnit;
+    private InputMethodManager inputMethodManager;
+    private AlertDialog alertDialog;
+
+    public void setQuery(String str, boolean submit) {
+        runOnUiThread(() -> searchView.setQuery(str, submit));
+    }
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        //Global.initActivity(this);
+        setContentView(R.layout.activity_search);
+        //init toolbar
+        Toolbar toolbar = findViewById(R.id.toolbar);
+        setSupportActionBar(toolbar);
+        assert getSupportActionBar() != null;
+        getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+        getSupportActionBar().setDisplayShowTitleEnabled(false);
+
+        inputMethodManager = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
+
+        //find IDs
+        searchView = findViewById(R.id.search);
+        RecyclerView recyclerView = findViewById(R.id.recycler);
+
+        groups = new ChipGroup[]{
+            null,
+            findViewById(R.id.parody_group),
+            findViewById(R.id.character_group),
+            findViewById(R.id.tag_group),
+            findViewById(R.id.artist_group),
+            findViewById(R.id.group_group),
+            findViewById(R.id.language_group),
+            findViewById(R.id.category_group),
+        };
+        initRanges();
+        adapter = new HistoryAdapter(this);
+        autoComplete = (AppCompatAutoCompleteTextView) getLayoutInflater().inflate(R.layout.autocomplete_entry, findViewById(R.id.appbar), false);
+        autoComplete.setOnEditorActionListener((v, actionId, event) -> {
+            if (actionId == EditorInfo.IME_ACTION_SEND) {
+                alertDialog.dismiss();
+                createChip();
+                return true;
+            }
+            return false;
+        });
+
+        //init recyclerview
+        recyclerView.setLayoutManager(new CustomLinearLayoutManager(this));
+        recyclerView.setAdapter(adapter);
+        recyclerView.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL));
+
+
+        searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
+            @Override
+            public boolean onQueryTextSubmit(String query) {
+                query = query.trim();
+                if (query.length() == 0 && !advanced) return true;
+                if (query.length() > 0) adapter.addHistory(query);
+                final Intent i = new Intent(SearchActivity.this, MainActivity.class);
+                i.putExtra(getPackageName() + ".SEARCHMODE", true);
+                i.putExtra(getPackageName() + ".QUERY", query);
+                i.putExtra(getPackageName() + ".ADVANCED", advanced);
+                if (advanced) {
+                    ArrayList<Tag> tt = new ArrayList<>(tags.size());
+                    for (ChipTag t : tags)
+                        if (t.getTag().getStatus() == TagStatus.ACCEPTED) tt.add(t.getTag());
+                    i.putParcelableArrayListExtra(getPackageName() + ".TAGS", tt);
+                    i.putExtra(getPackageName() + ".RANGES", ranges);
+                }
+                SearchActivity.this.runOnUiThread(() -> {
+                    startActivity(i);
+                    finish();
+                });
+                return true;
+            }
+
+            @Override
+            public boolean onQueryTextChange(String newText) {
+                return false;
+            }
+        });
+
+        populateGroup();
+        searchView.requestFocus();
+    }
+
+    private void createPageBuilder(int title, int min, int max, int actual, DefaultDialogs.DialogResults results) {
+        min = Math.max(1, min);
+        actual = Math.max(actual, min);
+        DefaultDialogs.pageChangerDialog(new DefaultDialogs.Builder(this)
+            .setTitle(title)
+            .setMax(max)
+            .setMin(min)
+            .setDrawable(R.drawable.ic_search)
+            .setActual(actual)
+            .setYesbtn(R.string.ok)
+            .setNobtn(R.string.cancel)
+            .setMaybebtn(R.string.reset)
+            .setDialogs(results));
+    }
+
+    private void initRanges() {
+        LinearLayout pageRangeLayout = findViewById(R.id.page_range);
+        LinearLayout uploadRangeLayout = findViewById(R.id.upload_range);
+        ((TextView) pageRangeLayout.findViewById(R.id.title)).setText(R.string.page_range);
+        ((TextView) uploadRangeLayout.findViewById(R.id.title)).setText(R.string.upload_time);
+        Button fromPage = pageRangeLayout.findViewById(R.id.fromButton);
+        Button toPage = pageRangeLayout.findViewById(R.id.toButton);
+        Button fromDate = uploadRangeLayout.findViewById(R.id.fromButton);
+        Button toDate = uploadRangeLayout.findViewById(R.id.toButton);
+        fromPage.setOnClickListener(v -> {
+            createPageBuilder(R.string.from_page, 0, 2000, ranges.getFromPage(), new DefaultDialogs.CustomDialogResults() {
+                @Override
+                public void positive(int actual) {
+                    ranges.setFromPage(actual);
+                    fromPage.setText(String.format(Locale.US, "%d", actual));
+                    if (ranges.getFromPage() > ranges.getToPage()) {
+                        ranges.setToPage(Ranges.UNDEFINED);
+                        toPage.setText("");
+                    }
+                    advanced = true;
+                }
+
+                @Override
+                public void neutral() {
+                    ranges.setFromPage(Ranges.UNDEFINED);
+                    fromPage.setText("");
+                }
+            });
+        });
+        toPage.setOnClickListener(v -> {
+            createPageBuilder(R.string.to_page, ranges.getFromPage(), 2000, ranges.getToPage(), new DefaultDialogs.CustomDialogResults() {
+                @Override
+                public void positive(int actual) {
+                    ranges.setToPage(actual);
+                    toPage.setText(String.format(Locale.US, "%d", actual));
+                    advanced = true;
+                }
+
+                @Override
+                public void neutral() {
+                    ranges.setToPage(Ranges.UNDEFINED);
+                    toPage.setText("");
+                }
+            });
+        });
+        fromDate.setOnClickListener(v -> showUnitDialog(fromDate, true));
+        toDate.setOnClickListener(v -> showUnitDialog(toDate, false));
+    }
+
+    private void showUnitDialog(Button button, boolean from) {
+        int i = 0;
+        String[] strings = new String[Ranges.TimeUnit.values().length];
+        for (Ranges.TimeUnit unit : Ranges.TimeUnit.values())
+            strings[i++] = getString(unit.getString());
+
+        MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
+        builder.setTitle(R.string.choose_unit);
+        builder.setIcon(R.drawable.ic_search);
+        builder.setAdapter(new ArrayAdapter<>(this, android.R.layout.simple_dropdown_item_1line, strings), (dialog, which) -> {
+            temporaryUnit = Ranges.TimeUnit.values()[which];
+            createPageBuilder(from ? R.string.from_time : R.string.to_time, 1, temporaryUnit == Ranges.TimeUnit.YEAR ? 10 : 100, 1, new DefaultDialogs.CustomDialogResults() {
+                @Override
+                public void positive(int actual) {
+                    if (from) {
+                        ranges.setFromDateUnit(temporaryUnit);
+                        ranges.setFromDate(actual);
+                    } else {
+                        ranges.setToDateUnit(temporaryUnit);
+                        ranges.setToDate(actual);
+                    }
+                    button.setText(String.format(Locale.US, "%d %c", actual, Character.toUpperCase(temporaryUnit.getVal())));
+                    advanced = true;
+                }
+
+                @Override
+                public void neutral() {
+                    if (from) {
+                        ranges.setFromDateUnit(Ranges.UNDEFINED_DATE);
+                        ranges.setFromDate(Ranges.UNDEFINED);
+                    } else {
+                        ranges.setToDateUnit(Ranges.UNDEFINED_DATE);
+                        ranges.setToDate(Ranges.UNDEFINED);
+                    }
+                    button.setText("");
+                }
+            });
+        });
+        builder.setNeutralButton(R.string.reset, (dialog, which) -> {
+            if (from) {
+                ranges.setFromDateUnit(Ranges.UNDEFINED_DATE);
+                ranges.setFromDate(Ranges.UNDEFINED);
+            } else {
+                ranges.setToDateUnit(Ranges.UNDEFINED_DATE);
+                ranges.setToDate(Ranges.UNDEFINED);
+            }
+            button.setText("");
+        });
+        builder.show();
+    }
+
+    private void populateGroup() {
+        //add top tags
+        for (TagType type : new TagType[]{TagType.TAG, TagType.PARODY, TagType.CHARACTER, TagType.ARTIST, TagType.GROUP}) {
+            for (Tag t : Queries.TagTable.getTopTags(type, Global.getFavoriteLimit(this)))
+                addChipTag(t, true, true);
+        }
+        //add already filtered tags
+        for (Tag t : Queries.TagTable.getAllFiltered())
+            if (!tagAlreadyExist(t)) addChipTag(t, true, true);
+        //add categories
+        for (Tag t : Queries.TagTable.getTrueAllType(TagType.CATEGORY)) addChipTag(t, false, false);
+        //add languages
+        for (Tag t : Queries.TagTable.getTrueAllType(TagType.LANGUAGE)) {
+            if (t.getId() == SpecialTagIds.LANGUAGE_ENGLISH && Global.getOnlyLanguage() == Language.ENGLISH)
+                t.setStatus(TagStatus.ACCEPTED);
+            else if (t.getId() == SpecialTagIds.LANGUAGE_JAPANESE && Global.getOnlyLanguage() == Language.JAPANESE)
+                t.setStatus(TagStatus.ACCEPTED);
+            else if (t.getId() == SpecialTagIds.LANGUAGE_CHINESE && Global.getOnlyLanguage() == Language.CHINESE)
+                t.setStatus(TagStatus.ACCEPTED);
+            addChipTag(t, false, false);
+        }
+        //add online tags
+        if (Login.useAccountTag()) for (Tag t : Queries.TagTable.getAllOnlineBlacklisted())
+            if (!tagAlreadyExist(t))
+                addChipTag(t, true, true);
+        //add + button
+        for (TagType type : TagType.values) {
+            //ignore these tags
+            if (type == TagType.UNKNOWN || type == TagType.LANGUAGE || type == TagType.CATEGORY) {
+                addChip[type.getId()] = null;
+                continue;
+            }
+            ChipGroup cg = getGroup(type);
+            Chip add = createAddChip(type, cg);
+            addChip[type.getId()] = add;
+            cg.addView(add);
+        }
+    }
+
+    private Chip createAddChip(TagType type, ChipGroup group) {
+        Chip c = (Chip) getLayoutInflater().inflate(R.layout.chip_layout, group, false);
+        c.setCloseIconVisible(false);
+        c.setChipIconResource(R.drawable.ic_add);
+        c.setText(getString(R.string.add));
+        c.setOnClickListener(v -> loadTag(type));
+        Global.setTint(c.getChipIcon());
+        return c;
+    }
+
+    private boolean tagAlreadyExist(Tag tag) {
+        for (ChipTag t : tags) {
+            if (t.getTag().getName().equals(tag.getName())) return true;
+        }
+        return false;
+    }
+
+    private void addChipTag(Tag t, boolean close, boolean canBeAvoided) {
+        ChipGroup cg = getGroup(t.getType());
+        ChipTag c = (ChipTag) getLayoutInflater().inflate(R.layout.chip_layout_entry, cg, false);
+        c.init(t, close, canBeAvoided);
+        c.setOnCloseIconClickListener(v -> {
+            cg.removeView(c);
+            tags.remove(c);
+            advanced = true;
+        });
+        c.setOnClickListener(v -> {
+            c.updateStatus();
+            advanced = true;
+        });
+        cg.addView(c);
+        tags.add(c);
+    }
+
+    private void loadDropdown(TagType type) {
+        List<Tag> allTags = Queries.TagTable.getAllTagOfType(type);
+        String[] tagNames = new String[allTags.size()];
+        int i = 0;
+        for (Tag t : allTags) tagNames[i++] = t.getName();
+        autoComplete.setAdapter(new ArrayAdapter<>(SearchActivity.this, android.R.layout.simple_dropdown_item_1line, tagNames));
+        loadedTag = type;
+    }
+
+    private void loadTag(TagType type) {
+        if (type != loadedTag) loadDropdown(type);
+        addDialog();
+        autoComplete.requestFocus();
+        inputMethodManager.showSoftInput(autoComplete, InputMethodManager.SHOW_IMPLICIT);
+    }
+
+    private ChipGroup getGroup(TagType type) {
+        return groups[type.getId()];
+    }
+
+    private void addDialog() {
+        MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
+        builder.setView(autoComplete);
+        autoComplete.setText("");
+        builder.setPositiveButton(R.string.ok, (dialog, which) -> createChip());
+        builder.setCancelable(true).setNegativeButton(R.string.cancel, null);
+        builder.setTitle(R.string.insert_tag_name);
+        try {
+            alertDialog = builder.show();
+        } catch (IllegalStateException e) {//the autoComplete is still attached to another View
+            ((ViewGroup) autoComplete.getParent()).removeView(autoComplete);
+            alertDialog = builder.show();
+        }
+
+    }
+
+    private void createChip() {
+        String name = autoComplete.getText().toString().toLowerCase(Locale.US);
+        Tag tag = Queries.TagTable.searchTag(name, loadedTag);
+        if (tag == null) tag = new Tag(name, 0, customId++, loadedTag, TagStatus.ACCEPTED);
+        LogUtility.d("CREATED WITH ID: " + tag.getId());
+        if (tagAlreadyExist(tag)) return;
+        //remove add, insert new tag, reinsert add
+        if (getGroup(loadedTag) != null) getGroup(loadedTag).removeView(addChip[loadedTag.getId()]);
+        addChipTag(tag, true, true);
+        getGroup(loadedTag).addView(addChip[loadedTag.getId()]);
+
+        inputMethodManager.hideSoftInputFromWindow(searchView.getWindowToken(), InputMethodManager.SHOW_IMPLICIT);
+        autoComplete.setText("");
+        advanced = true;
+        if (autoComplete.getParent() != null)
+            ((ViewGroup) autoComplete.getParent()).removeView(autoComplete);
+    }
+
+    @Override
+    public boolean onCreateOptionsMenu(Menu menu) {
+        getMenuInflater().inflate(R.menu.search, menu);
+        Utility.tintMenu(menu);
+        return super.onCreateOptionsMenu(menu);
+
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        if (item.getItemId() == android.R.id.home) {
+            finish();
+            return true;
+        } else if (item.getItemId() == R.id.view_groups) {
+            View v = findViewById(R.id.groups);
+            boolean isVisible = v.getVisibility() == View.VISIBLE;
+            v.setVisibility(isVisible ? View.GONE : View.VISIBLE);
+            item.setIcon(isVisible ? R.drawable.ic_add : R.drawable.ic_close);
+            Global.setTint(item.getIcon());
+        }
+        return super.onOptionsItemSelected(item);
+    }
+
+
+}

+ 175 - 0
app/src/main/java/com/dar/nbook/SettingsActivity.java

@@ -0,0 +1,175 @@
+package com.dar.nbook;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.provider.Settings;
+import android.view.MenuItem;
+import android.widget.Toast;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContract;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.appcompat.widget.Toolbar;
+
+import com.dar.nbook.async.database.export.Exporter;
+import com.dar.nbook.async.database.export.Manager;
+import com.dar.nbook.components.activities.GeneralActivity;
+import com.dar.nbook.components.views.GeneralPreferenceFragment;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.utility.LogUtility;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import java.io.File;
+
+public class SettingsActivity extends GeneralActivity {
+    GeneralPreferenceFragment fragment;
+    private ActivityResultLauncher<String> IMPORT_ZIP;
+    private ActivityResultLauncher<String> SAVE_SETTINGS;
+    private ActivityResultLauncher<Object> REQUEST_STORAGE_MANAGER;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        registerActivities();
+        //Global.initActivity(this);
+        setContentView(R.layout.activity_settings);
+
+        Toolbar toolbar = findViewById(R.id.toolbar);
+        setSupportActionBar(toolbar);
+        getSupportActionBar().setTitle(R.string.settings);
+        getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+        getSupportActionBar().setDisplayShowTitleEnabled(true);
+        fragment = (GeneralPreferenceFragment) getSupportFragmentManager().findFragmentById(R.id.fragment);
+        fragment.setAct(this);
+        fragment.setType(SettingsActivity.Type.values()[getIntent().getIntExtra(getPackageName() + ".TYPE", SettingsActivity.Type.MAIN.ordinal())]);
+
+    }
+
+    private int selectedItem;
+
+    private void registerActivities() {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
+            IMPORT_ZIP = registerForActivityResult(new ActivityResultContracts.GetContent(), selectedFile -> {
+                if (selectedFile == null) return;
+                importSettings(selectedFile);
+            });
+            SAVE_SETTINGS = registerForActivityResult(new ActivityResultContracts.CreateDocument() {
+                @NonNull
+                @Override
+                public Intent createIntent(@NonNull Context context, @NonNull String input) {
+                    Intent i = super.createIntent(context, input);
+                    i.setType("application/zip");
+                    return i;
+                }
+            }, selectedFile -> {
+                if (selectedFile == null) return;
+
+                exportSettings(selectedFile);
+
+            });
+        }
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+            REQUEST_STORAGE_MANAGER = registerForActivityResult(new ActivityResultContract<Object, Object>() {
+
+                @RequiresApi(api = Build.VERSION_CODES.R)
+                @NonNull
+                @Override
+                public Intent createIntent(@NonNull Context context, Object input) {
+                    Intent i = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION);
+                    i.setData(Uri.parse("package:" + getPackageName()));
+                    return i;
+                }
+
+                @Override
+                public Object parseResult(int resultCode, @Nullable Intent intent) {
+                    return null;
+                }
+            }, result -> {
+                if (Global.isExternalStorageManager()) {
+                    fragment.manageCustomPath();
+                }
+            });
+        }
+    }
+
+    private void importSettings(Uri selectedFile) {
+        new Manager(selectedFile, this, false, () -> {
+            Toast.makeText(this, R.string.import_finished, Toast.LENGTH_SHORT).show();
+            finish();
+        }).start();
+    }
+
+    private void exportSettings(Uri selectedFile) {
+        new Manager(selectedFile, this, true, () -> {
+            Toast.makeText(this, R.string.export_finished, Toast.LENGTH_SHORT).show();
+        }).start();
+    }
+
+    public void importSettings() {
+        if (IMPORT_ZIP != null) {
+            IMPORT_ZIP.launch("application/zip");
+        } else {
+            importOldVersion();
+        }
+    }
+
+    private void importOldVersion() {
+        String[] files = Global.BACKUPFOLDER.list();
+        if (files == null || files.length == 0) return;
+        selectedItem = 0;
+        MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
+        builder.setSingleChoiceItems(files, 0, (dialog, which) -> {
+            LogUtility.d(which);
+            selectedItem = which;
+        });
+
+        builder.setPositiveButton(R.string.ok, (dialog, which) -> {
+            importSettings(Uri.fromFile(new File(Global.BACKUPFOLDER, files[selectedItem])));
+        }).setNegativeButton(R.string.cancel, null);
+        builder.show();
+    }
+
+    public void exportSettings() {
+        String name = Exporter.defaultExportName(this);
+        if (SAVE_SETTINGS != null)
+            SAVE_SETTINGS.launch(name);
+        else {
+            File f = new File(Global.BACKUPFOLDER, name);
+            exportSettings(Uri.fromFile(f));
+        }
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        if (item.getItemId() == android.R.id.home) {
+            finish();
+            return true;
+        }
+        return super.onOptionsItemSelected(item);
+    }
+
+    @TargetApi(Build.VERSION_CODES.R)
+    public void requestStorageManager() {
+        if (REQUEST_STORAGE_MANAGER == null) {
+            Toast.makeText(this, R.string.failed, Toast.LENGTH_SHORT).show();
+            return;
+        }
+        MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
+        builder.setIcon(R.drawable.ic_file);
+        builder.setTitle(R.string.requesting_storage_access);
+        builder.setMessage(R.string.request_storage_manager_summary);
+        builder.setPositiveButton(R.string.ok, (dialog, which) -> {
+            REQUEST_STORAGE_MANAGER.launch(null);
+        }).setNegativeButton(R.string.cancel, null).show();
+    }
+
+    public enum Type {MAIN, COLUMN, DATA}
+
+}

+ 43 - 0
app/src/main/java/com/dar/nbook/StatusManagerActivity.java

@@ -0,0 +1,43 @@
+package com.dar.nbook;
+
+import android.os.Bundle;
+import android.view.MenuItem;
+
+import androidx.appcompat.widget.Toolbar;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.dar.nbook.adapters.StatusManagerAdapter;
+import com.dar.nbook.components.activities.GeneralActivity;
+import com.dar.nbook.components.widgets.CustomLinearLayoutManager;
+
+public class StatusManagerActivity extends GeneralActivity {
+
+    StatusManagerAdapter adapter;
+    RecyclerView recycler;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        //Global.initActivity(this);
+        setContentView(R.layout.activity_bookmark);
+        Toolbar toolbar = findViewById(R.id.toolbar);
+        setSupportActionBar(toolbar);
+        getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+        getSupportActionBar().setDisplayShowTitleEnabled(true);
+        getSupportActionBar().setTitle(R.string.manage_statuses);
+
+        recycler = findViewById(R.id.recycler);
+        adapter = new StatusManagerAdapter(this);
+        recycler.setLayoutManager(new CustomLinearLayoutManager(this));
+        recycler.setAdapter(adapter);
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        if (item.getItemId() == android.R.id.home) {
+            finish();
+            return true;
+        }
+        return super.onOptionsItemSelected(item);
+    }
+}

+ 115 - 0
app/src/main/java/com/dar/nbook/StatusViewerActivity.java

@@ -0,0 +1,115 @@
+package com.dar.nbook;
+
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuItem;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.widget.SearchView;
+import androidx.appcompat.widget.Toolbar;
+import androidx.viewpager2.widget.ViewPager2;
+
+import com.dar.nbook.components.activities.GeneralActivity;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.ui.main.PlaceholderFragment;
+import com.dar.nbook.ui.main.SectionsPagerAdapter;
+import com.dar.nbook.utility.LogUtility;
+import com.dar.nbook.utility.Utility;
+import com.google.android.material.tabs.TabLayout;
+import com.google.android.material.tabs.TabLayoutMediator;
+
+public class StatusViewerActivity extends GeneralActivity {
+    private boolean sortByTitle = false;
+    private String query;
+    private ViewPager2 viewPager;
+    private Toolbar toolbar;
+    private SectionsPagerAdapter sectionsPagerAdapter;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.activity_status_viewer);
+        toolbar = findViewById(R.id.toolbar);
+        setSupportActionBar(toolbar);
+        getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+        getSupportActionBar().setDisplayShowTitleEnabled(true);
+        getSupportActionBar().setTitle(R.string.manage_statuses);
+        viewPager = findViewById(R.id.view_pager);
+        sectionsPagerAdapter = new SectionsPagerAdapter(this);
+
+        viewPager.setAdapter(sectionsPagerAdapter);
+
+        viewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
+
+            @Override
+            public void onPageSelected(int position) {
+                PlaceholderFragment fragment = getPositionFragment(position);
+                if (fragment != null) fragment.reload(query, sortByTitle);
+            }
+        });
+
+        TabLayout tabs = findViewById(R.id.tabs);
+        new TabLayoutMediator(tabs, viewPager, true, (tab, position) -> tab.setText(sectionsPagerAdapter.getPageTitle(position))).attach();
+    }
+
+    @Override
+    protected void onResume() {
+        super.onResume();
+        TabLayout tabs = findViewById(R.id.tabs);
+        for (int i = 0; i < tabs.getTabCount(); i++) {
+            tabs.getTabAt(i).setText(sectionsPagerAdapter.getPageTitle(i));
+        }
+        PlaceholderFragment fragment = getActualFragment();
+        if (fragment != null) fragment.reload(query, sortByTitle);
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        if (item.getItemId() == android.R.id.home) {
+            finish();
+            return true;
+        } else if (item.getItemId() == R.id.sort_by_name) {
+            sortByTitle = !sortByTitle;
+            PlaceholderFragment fragment = getActualFragment();
+            if (fragment != null) fragment.changeSort(sortByTitle);
+            item.setTitle(sortByTitle ? R.string.sort_by_latest : R.string.sort_by_title);
+            item.setIcon(sortByTitle ? R.drawable.ic_sort_by_alpha : R.drawable.ic_access_time);
+            Global.setTint(item.getIcon());
+        }
+        return super.onOptionsItemSelected(item);
+    }
+
+    @Nullable
+    private PlaceholderFragment getActualFragment() {
+        return getPositionFragment(viewPager.getCurrentItem());
+    }
+
+    @Nullable
+    private PlaceholderFragment getPositionFragment(int position) {
+        PlaceholderFragment f = (PlaceholderFragment) getSupportFragmentManager().findFragmentByTag("f" + position);
+        LogUtility.d(f);
+        return f;
+    }
+
+    @Override
+    public boolean onCreateOptionsMenu(Menu menu) {
+        getMenuInflater().inflate(R.menu.status_viewer, menu);
+        final SearchView searchView = (androidx.appcompat.widget.SearchView) menu.findItem(R.id.search).getActionView();
+        searchView.setOnQueryTextListener(new androidx.appcompat.widget.SearchView.OnQueryTextListener() {
+            @Override
+            public boolean onQueryTextSubmit(String query) {
+                return false;
+            }
+
+            @Override
+            public boolean onQueryTextChange(String newText) {
+                query = newText;
+                PlaceholderFragment fragment = getActualFragment();
+                if (fragment != null) fragment.changeQuery(query);
+                return true;
+            }
+        });
+        Utility.tintMenu(menu);
+        return super.onCreateOptionsMenu(menu);
+    }
+}

+ 262 - 0
app/src/main/java/com/dar/nbook/TagFilterActivity.java

@@ -0,0 +1,262 @@
+package com.dar.nbook;
+
+import android.content.res.Configuration;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuItem;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatDelegate;
+import androidx.appcompat.widget.SearchView;
+import androidx.appcompat.widget.Toolbar;
+import androidx.fragment.app.Fragment;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.viewpager2.adapter.FragmentStateAdapter;
+import androidx.viewpager2.widget.ViewPager2;
+
+import com.dar.nbook.adapters.TagsAdapter;
+import com.dar.nbook.components.activities.GeneralActivity;
+import com.dar.nbook.components.widgets.CustomGridLayoutManager;
+import com.dar.nbook.components.widgets.TagTypePage;
+import com.dar.nbook.settings.DefaultDialogs;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.settings.Login;
+import com.dar.nbook.settings.TagV2;
+import com.dar.nbook.utility.LogUtility;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.tabs.TabLayout;
+import com.google.android.material.tabs.TabLayoutMediator;
+
+import java.util.List;
+
+public class TagFilterActivity extends GeneralActivity {
+    static {
+        AppCompatDelegate.setCompatVectorFromResourcesEnabled(true);
+    }
+
+    private SearchView searchView;
+    private ViewPager2 mViewPager;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        //Global.initActivity(this);
+        setContentView(R.layout.activity_tag_filter);
+
+        //init toolbar
+        Toolbar toolbar = findViewById(R.id.toolbar);
+        setSupportActionBar(toolbar);
+        getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+
+        // Create the adapter that will return a fragment for each of the three
+        // primary sections of the activity.
+        TagTypePageAdapter mTagTypePageAdapter = new TagTypePageAdapter(this);
+        mViewPager = findViewById(R.id.container);
+        mViewPager.setAdapter(mTagTypePageAdapter);
+        mViewPager.setOffscreenPageLimit(1);
+
+        TabLayout tabLayout = findViewById(R.id.tabs);
+
+
+        LogUtility.d("ISNULL?" + (tabLayout == null));
+        mViewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
+            @Override
+            public void onPageSelected(int position) {
+                TagTypePage page = getFragment(position);
+                if (page != null) {
+                    ((TagsAdapter) page.getRecyclerView().getAdapter()).addItem();
+                }
+            }
+        });
+
+
+        new TabLayoutMediator(tabLayout, mViewPager, (tab, position) -> {
+            int id = 0;
+            switch (position) {
+                case 0:
+                    id = R.string.applied_filters;
+                    break;
+                case 1:
+                    id = R.string.tags;
+                    break;
+                case 2:
+                    id = R.string.artists;
+                    break;
+                case 3:
+                    id = R.string.characters;
+                    break;
+                case 4:
+                    id = R.string.parodies;
+                    break;
+                case 5:
+                    id = R.string.groups;
+                    break;
+                case 6:
+                    id = R.string.online_tags;
+                    break;
+            }
+            tab.setText(id);
+        }).attach();
+        mViewPager.setCurrentItem(getPage());
+    }
+
+    @Nullable
+    private TagTypePage getActualFragment() {
+        return getFragment(mViewPager.getCurrentItem());
+    }
+
+    @Nullable
+    private TagTypePage getFragment(int position) {
+        return (TagTypePage) getSupportFragmentManager().findFragmentByTag("f" + position);
+    }
+
+    private int getPage() {
+        Uri data = getIntent().getData();
+        if (data != null) {
+            List<String> params = data.getPathSegments();
+            for (String x : params) LogUtility.i(x);
+            if (params.size() > 0) {
+                switch (params.get(0)) {
+                    case "tags":
+                        return 1;
+                    case "artists":
+                        return 2;
+                    case "characters":
+                        return 3;
+                    case "parodies":
+                        return 4;
+                    case "groups":
+                        return 5;
+                }
+            }
+        }
+        return 0;
+    }
+
+    private void updateSortItem(MenuItem item) {
+        item.setIcon(TagV2.isSortedByName() ? R.drawable.ic_sort_by_alpha : R.drawable.ic_sort);
+        item.setTitle(TagV2.isSortedByName() ? R.string.sort_by_title : R.string.sort_by_popular);
+        Global.setTint(item.getIcon());
+    }
+
+    @Override
+    public boolean onCreateOptionsMenu(Menu menu) {
+        // Inflate the menu; this adds items to the action bar if it is present.
+        getMenuInflater().inflate(R.menu.menu_tag_filter, menu);
+        updateSortItem(menu.findItem(R.id.sort_by_name));
+        searchView = (androidx.appcompat.widget.SearchView) menu.findItem(R.id.search).getActionView();
+        searchView.setOnQueryTextListener(new androidx.appcompat.widget.SearchView.OnQueryTextListener() {
+            @Override
+            public boolean onQueryTextSubmit(String query) {
+                return true;
+            }
+
+            @Override
+            public boolean onQueryTextChange(String newText) {
+                TagTypePage page = getActualFragment();
+                if (page != null) {
+                    page.refilter(newText);
+                }
+                return true;
+            }
+        });
+        return true;
+    }
+
+    private void createDialog() {
+        MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
+        builder.setTitle(R.string.are_you_sure).setMessage(getString(R.string.clear_this_list)).setIcon(R.drawable.ic_help);
+        builder.setPositiveButton(R.string.yes, (dialog, which) -> {
+            TagTypePage page = getActualFragment();
+            if (page != null) {
+                page.reset();
+
+            }
+        }).setNegativeButton(R.string.no, null).setCancelable(true);
+        builder.show();
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        // Handle action bar item clicks here. The action bar will
+        // automatically handle clicks on the Home/Up button, so long
+        // as you specify a parent activity in AndroidManifest.xml.
+        int id = item.getItemId();
+        TagTypePage page = getActualFragment();
+        if (id == R.id.reset_tags) createDialog();
+        else if (id == R.id.set_min_count) minCountBuild();
+        else if (id == R.id.sort_by_name) {
+            TagV2.updateSortByName(this);
+            updateSortItem(item);
+            if (page != null)
+                page.refilter(searchView.getQuery().toString());
+        }
+
+        return super.onOptionsItemSelected(item);
+    }
+
+    private void minCountBuild() {
+        int min = TagV2.getMinCount();
+        DefaultDialogs.Builder builder = new DefaultDialogs.Builder(this);
+        builder.setActual(min).setMax(100).setMin(2);
+        builder.setYesbtn(R.string.ok).setNobtn(R.string.cancel);
+        builder.setTitle(R.string.set_minimum_count).setDialogs(new DefaultDialogs.CustomDialogResults() {
+            @Override
+            public void positive(int actual) {
+                LogUtility.d("ACTUAL: " + actual);
+                TagV2.updateMinCount(TagFilterActivity.this, actual);
+                TagTypePage page = getActualFragment();
+                if (page != null) {
+                    page.changeSize();
+                }
+            }
+        });
+        DefaultDialogs.pageChangerDialog(builder);
+    }
+
+
+    @Override
+    public void onConfigurationChanged(@NonNull Configuration newConfig) {
+        super.onConfigurationChanged(newConfig);
+        if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) {
+            changeLayout(true);
+        } else if (newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) {
+            changeLayout(false);
+        }
+    }
+
+    private void changeLayout(boolean landscape) {
+        final int count = landscape ? 4 : 2;
+        TagTypePage page = getActualFragment();
+        if (page != null) {
+            RecyclerView recycler = page.getRecyclerView();
+            if (recycler != null) {
+                RecyclerView.Adapter adapter = recycler.getAdapter();
+                CustomGridLayoutManager gridLayoutManager = new CustomGridLayoutManager(this, count);
+                recycler.setLayoutManager(gridLayoutManager);
+                recycler.setAdapter(adapter);
+            }
+        }
+    }
+
+
+    static class TagTypePageAdapter extends FragmentStateAdapter {
+
+        TagTypePageAdapter(TagFilterActivity activity) {
+            super(activity.getSupportFragmentManager(), activity.getLifecycle());
+        }
+
+        @NonNull
+        @Override
+        public Fragment createFragment(int position) {
+            return TagTypePage.newInstance(position);
+        }
+
+        @Override
+        public int getItemCount() {
+            return Login.isLogged() ? 7 : 6;
+        }
+    }
+}

+ 450 - 0
app/src/main/java/com/dar/nbook/ZoomActivity.java

@@ -0,0 +1,450 @@
+package com.dar.nbook;
+
+import android.Manifest;
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.annotation.TargetApi;
+import android.content.SharedPreferences;
+import android.content.pm.PackageManager;
+import android.content.res.Configuration;
+import android.os.Build;
+import android.os.Bundle;
+import android.view.KeyEvent;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewConfiguration;
+import android.view.WindowManager;
+import android.widget.SeekBar;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.widget.Toolbar;
+import androidx.constraintlayout.widget.ConstraintLayout;
+import androidx.fragment.app.Fragment;
+import androidx.viewpager2.adapter.FragmentStateAdapter;
+import androidx.viewpager2.widget.ViewPager2;
+
+import com.bumptech.glide.Priority;
+import com.dar.nbook.api.components.GenericGallery;
+import com.dar.nbook.async.database.Queries;
+import com.dar.nbook.components.activities.GeneralActivity;
+import com.dar.nbook.components.views.ZoomFragment;
+import com.dar.nbook.files.GalleryFolder;
+import com.dar.nbook.settings.DefaultDialogs;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.utility.LogUtility;
+import com.dar.nbook.utility.Utility;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import java.io.File;
+
+public class ZoomActivity extends GeneralActivity {
+    @TargetApi(Build.VERSION_CODES.JELLY_BEAN)
+    private final static int hideFlags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE
+        | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
+        | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+        | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
+        | View.SYSTEM_UI_FLAG_FULLSCREEN
+        | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT ? View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY : 0);
+    @TargetApi(Build.VERSION_CODES.JELLY_BEAN)
+    private final static int showFlags = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_STABLE;
+    private static final String VOLUME_SIDE_KEY = "volumeSide";
+    private static final String SCROLL_TYPE_KEY = "zoomScrollType";
+    private GenericGallery gallery;
+    private int actualPage = 0;
+    private boolean isHidden = false;
+    private ViewPager2 mViewPager;
+    private TextView pageManagerLabel, cornerPageViewer;
+    private View pageSwitcher;
+    private SeekBar seekBar;
+    private Toolbar toolbar;
+    private View view;
+    private GalleryFolder directory;
+    @ViewPager2.Orientation
+    private int tmpScrollType;
+    private boolean up = false, down = false, side;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        //Global.initActivity(this);
+        SharedPreferences preferences = getSharedPreferences("Settings", 0);
+        side = preferences.getBoolean(VOLUME_SIDE_KEY, true);
+        setContentView(R.layout.activity_zoom);
+
+        //read arguments
+        gallery = getIntent().getParcelableExtra(getPackageName() + ".GALLERY");
+        final int page = getIntent().getExtras().getInt(getPackageName() + ".PAGE", 1) - 1;
+        directory = gallery.getGalleryFolder();
+        //toolbar setup
+        toolbar = findViewById(R.id.toolbar);
+        setSupportActionBar(toolbar);
+        getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+        getSupportActionBar().setDisplayShowTitleEnabled(true);
+        setTitle(gallery.getTitle());
+
+        getWindow().setFlags(
+            WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
+                | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
+            WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
+                | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
+        if (Global.isLockScreen())
+            getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+
+
+        //find views
+        SectionsPagerAdapter mSectionsPagerAdapter = new SectionsPagerAdapter(this);
+        mViewPager = findViewById(R.id.container);
+        mViewPager.setAdapter(mSectionsPagerAdapter);
+        mViewPager.setOrientation(preferences.getInt(SCROLL_TYPE_KEY, ScrollType.HORIZONTAL.ordinal()));
+        mViewPager.setOffscreenPageLimit(Global.getOffscreenLimit());
+        pageSwitcher = findViewById(R.id.page_switcher);
+        pageManagerLabel = findViewById(R.id.pages);
+        cornerPageViewer = findViewById(R.id.page_text);
+        seekBar = findViewById(R.id.seekBar);
+        view = findViewById(R.id.view);
+
+        //initial setup for views
+        changeLayout(getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE);
+        mViewPager.setKeepScreenOn(Global.isLockScreen());
+        findViewById(R.id.prev).setOnClickListener(v -> changeClosePage(false));
+        findViewById(R.id.next).setOnClickListener(v -> changeClosePage(true));
+        seekBar.setMax(gallery.getPageCount() - 1);
+        if (Global.useRtl()) {
+            seekBar.setRotationY(180);
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
+                mViewPager.setLayoutDirection(ViewPager2.LAYOUT_DIRECTION_RTL);
+            }
+        }
+
+        //Adding listeners
+        mViewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
+            @Override
+            public void onPageSelected(int newPage) {
+                int oldPage = actualPage;
+                actualPage = newPage;
+                LogUtility.d("Page selected: " + newPage + " from page " + oldPage);
+                setPageText(newPage + 1);
+                seekBar.setProgress(newPage);
+                clearFarRequests(oldPage, newPage);
+                makeNearRequests(newPage);
+            }
+        });
+        pageManagerLabel.setOnClickListener(v -> DefaultDialogs.pageChangerDialog(
+            new DefaultDialogs.Builder(this)
+                .setActual(actualPage + 1)
+                .setMin(1)
+                .setMax(gallery.getPageCount())
+                .setTitle(R.string.change_page)
+                .setDrawable(R.drawable.ic_find_in_page)
+                .setDialogs(new DefaultDialogs.CustomDialogResults() {
+                    @Override
+                    public void positive(int actual) {
+                        changePage(actual - 1);
+                    }
+                })
+        ));
+        seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
+            @Override
+            public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
+                if (fromUser) {
+                    setPageText(progress + 1);
+                }
+            }
+
+            @Override
+            public void onStartTrackingTouch(SeekBar seekBar) {
+            }
+
+            @Override
+            public void onStopTrackingTouch(SeekBar seekBar) {
+                changePage(seekBar.getProgress());
+            }
+        });
+
+
+        changePage(page);
+        setPageText(page + 1);
+        seekBar.setProgress(page);
+    }
+
+    private void setUserInput(boolean enabled) {
+        mViewPager.setUserInputEnabled(enabled);
+    }
+
+    private void setPageText(int page) {
+        pageManagerLabel.setText(getString(R.string.page_format, page, gallery.getPageCount()));
+        cornerPageViewer.setText(getString(R.string.page_format, page, gallery.getPageCount()));
+    }
+
+
+    @Override
+    public boolean onKeyUp(int keyCode, KeyEvent event) {
+        if (Global.volumeOverride()) {
+            switch (keyCode) {
+                case KeyEvent.KEYCODE_VOLUME_UP:
+                    up = false;
+                    return true;
+                case KeyEvent.KEYCODE_VOLUME_DOWN:
+                    down = false;
+                    return true;
+            }
+        }
+        return super.onKeyUp(keyCode, event);
+    }
+
+    @Override
+    public boolean onKeyDown(int keyCode, KeyEvent event) {
+        if (Global.volumeOverride()) {
+            switch (keyCode) {
+                case KeyEvent.KEYCODE_VOLUME_UP:
+                    up = true;
+                    changeClosePage(side);
+                    if (up && down) changeSide();
+                    return true;
+                case KeyEvent.KEYCODE_VOLUME_DOWN:
+                    down = true;
+                    changeClosePage(!side);
+                    if (up && down) changeSide();
+                    return true;
+            }
+        }
+        return super.onKeyDown(keyCode, event);
+    }
+
+    private void changeSide() {
+        getSharedPreferences("Settings", 0).edit().putBoolean(VOLUME_SIDE_KEY, side = !side).apply();
+        Toast.makeText(this, side ? R.string.next_page_volume_up : R.string.next_page_volume_down, Toast.LENGTH_SHORT).show();
+    }
+
+    public void changeClosePage(boolean next) {
+        if (Global.useRtl()) next = !next;
+        if (next && mViewPager.getCurrentItem() < (mViewPager.getAdapter().getItemCount() - 1))
+            changePage(mViewPager.getCurrentItem() + 1);
+        if (!next && mViewPager.getCurrentItem() > 0) changePage(mViewPager.getCurrentItem() - 1);
+    }
+
+    @Override
+    public void onConfigurationChanged(@NonNull Configuration newConfig) {
+        super.onConfigurationChanged(newConfig);
+        changeLayout(newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE);
+    }
+
+    private boolean hardwareKeys() {
+        return ViewConfiguration.get(this).hasPermanentMenuKey();
+    }
+
+    private void applyMargin(boolean landscape, View view) {
+        ConstraintLayout.LayoutParams lp = (ConstraintLayout.LayoutParams) view.getLayoutParams();
+        lp.setMargins(0, 0, landscape && !hardwareKeys() ? Global.getNavigationBarHeight(this) : 0, 0);
+        view.setLayoutParams(lp);
+    }
+
+    public ViewPager2 geViewPager() {
+        return mViewPager;
+    }
+
+    private void changeLayout(boolean landscape) {
+        int statusBarHeight = Global.getStatusBarHeight(this);
+        applyMargin(landscape, findViewById(R.id.master_layout));
+        applyMargin(landscape, toolbar);
+        pageSwitcher.setPadding(0, 0, 0, landscape ? 0 : statusBarHeight);
+    }
+
+    private void changePage(int newPage) {
+        mViewPager.setCurrentItem(newPage);
+    }
+
+    private void changeScrollTypeDialog() {
+        MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
+        int scrollType = mViewPager.getOrientation();
+        tmpScrollType = mViewPager.getOrientation();
+        builder.setTitle(getString(R.string.change_scroll_type) + ":");
+        builder.setSingleChoiceItems(R.array.scroll_type, scrollType, (dialog, which) -> tmpScrollType = which);
+        builder.setPositiveButton(R.string.ok, (dialog, which) -> {
+            if (tmpScrollType != scrollType) {
+                mViewPager.setOrientation(tmpScrollType);
+                getSharedPreferences("Settings", 0).edit().putInt(SCROLL_TYPE_KEY, tmpScrollType).apply();
+                int page = actualPage;
+                changePage(page + 1);
+                changePage(page);
+            }
+        }).setNegativeButton(R.string.cancel, null);
+        builder.show();
+    }
+
+    @Override
+    public boolean onCreateOptionsMenu(Menu menu) {
+        // Inflate the menu; this adds items to the action bar if it is present.
+        getMenuInflater().inflate(R.menu.menu_zoom, menu);
+        Utility.tintMenu(menu);
+        return true;
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        // Handle action bar item clicks here. The action bar will
+        // automatically handle clicks on the Home/Up button, so long
+        // as you specify a parent activity in AndroidManifest.xml.
+        int id = item.getItemId();
+        if (id == R.id.rotate) {
+            getActualFragment().rotate();
+        } else if (id == R.id.save_page) {
+            if (Global.hasStoragePermission(this)) {
+                downloadPage();
+            } else requestStorage();
+        } else if (id == R.id.share) {
+            if (gallery.getId() <= 0) sendImage(false);
+            else openSendImageDialog();
+        } else if (id == android.R.id.home) {
+            finish();
+            return true;
+        } else if (id == R.id.bookmark) {
+            Queries.ResumeTable.insert(gallery.getId(), actualPage + 1);
+        } else if (id == R.id.scrollType) {
+            changeScrollTypeDialog();
+        }
+
+        return super.onOptionsItemSelected(item);
+    }
+
+    private void openSendImageDialog() {
+        MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
+        builder.setPositiveButton(R.string.yes, (dialog, which) -> sendImage(true))
+            .setNegativeButton(R.string.no, (dialog, which) -> sendImage(false))
+            .setCancelable(true).setTitle(R.string.send_with_title)
+            .setMessage(R.string.caption_send_with_title)
+            .show();
+    }
+
+    @TargetApi(Build.VERSION_CODES.M)
+    private void requestStorage() {
+        requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE}, 1);
+    }
+
+    @Override
+    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+        Global.initStorage(this);
+        if (requestCode == 1 && grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED)
+            downloadPage();
+
+    }
+
+    private ZoomFragment getActualFragment() {
+        return getActualFragment(mViewPager.getCurrentItem());
+    }
+
+    private void makeNearRequests(int newPage) {
+        ZoomFragment fragment;
+        int offScreenLimit = Global.getOffscreenLimit();
+        for (int i = newPage - offScreenLimit; i <= newPage + offScreenLimit; i++) {
+            fragment = getActualFragment(i);
+            if (fragment == null) continue;
+            if (i == newPage) fragment.loadImage(Priority.IMMEDIATE);
+            else fragment.loadImage();
+        }
+    }
+
+    private void clearFarRequests(int oldPage, int newPage) {
+        ZoomFragment fragment;
+        int offScreenLimit = Global.getOffscreenLimit();
+        for (int i = oldPage - offScreenLimit; i <= oldPage + offScreenLimit; i++) {
+            if (i >= newPage - offScreenLimit && i <= newPage + offScreenLimit) continue;
+            fragment = getActualFragment(i);
+            if (fragment == null) continue;
+            fragment.cancelRequest();
+        }
+
+    }
+
+    private ZoomFragment getActualFragment(int position) {
+        return (ZoomFragment) getSupportFragmentManager().findFragmentByTag("f" + position);
+    }
+
+    private void sendImage(boolean withText) {
+        int pageNum = mViewPager.getCurrentItem();
+        Utility.sendImage(this, getActualFragment().getDrawable(), withText ? gallery.sharePageUrl(pageNum) : null);
+    }
+
+    private void downloadPage() {
+        final File output = new File(Global.SCREENFOLDER, gallery.getId() + "-" + (mViewPager.getCurrentItem() + 1) + ".jpg");
+        Utility.saveImage(getActualFragment().getDrawable(), output);
+    }
+
+    private void animateLayout() {
+
+        AnimatorListenerAdapter adapter = new AnimatorListenerAdapter() {
+            @Override
+            public void onAnimationEnd(Animator animation) {
+                if (isHidden) {
+                    pageSwitcher.setVisibility(View.GONE);
+                    toolbar.setVisibility(View.GONE);
+                    view.setVisibility(View.GONE);
+                    cornerPageViewer.setVisibility(View.VISIBLE);
+                }
+            }
+        };
+
+        pageSwitcher.setVisibility(View.VISIBLE);
+        toolbar.setVisibility(View.VISIBLE);
+        view.setVisibility(View.VISIBLE);
+        cornerPageViewer.setVisibility(View.GONE);
+
+        pageSwitcher.animate().alpha(isHidden ? 0f : 0.75f).setDuration(150).setListener(adapter).start();
+        view.animate().alpha(isHidden ? 0f : 0.75f).setDuration(150).setListener(adapter).start();
+        toolbar.animate().alpha(isHidden ? 0f : 0.75f).setDuration(150).setListener(adapter).start();
+    }
+
+    private void applyVisibilityFlag() {
+        if (Build.VERSION.SDK_INT > Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1) {
+            getWindow().getDecorView().setSystemUiVisibility(isHidden ? hideFlags : showFlags);
+        } else {
+            getWindow().addFlags(isHidden ? WindowManager.LayoutParams.FLAG_FULLSCREEN : WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN);
+            getWindow().clearFlags(isHidden ? WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN : WindowManager.LayoutParams.FLAG_FULLSCREEN);
+        }
+    }
+
+    private enum ScrollType {HORIZONTAL, VERTICAL}
+
+    public class SectionsPagerAdapter extends FragmentStateAdapter {
+        public SectionsPagerAdapter(ZoomActivity activity) {
+            super(activity.getSupportFragmentManager(), activity.getLifecycle());
+
+        }
+
+        private boolean allowScroll = true;
+
+        @NonNull
+        @Override
+        public Fragment createFragment(int position) {
+            ZoomFragment f = ZoomFragment.newInstance(gallery, position, directory);
+
+            f.setZoomChangeListener((v, zoomLevel) -> {
+                try {
+                    boolean _allowScroll = zoomLevel < 1.1f;
+                    if (_allowScroll != allowScroll) {
+                        setUserInput(!allowScroll);
+                        allowScroll = _allowScroll;
+                    }
+                } catch (Exception ex) {
+                }
+            });
+
+            f.setClickListener(v -> {
+                isHidden = !isHidden;
+                LogUtility.d("Clicked " + isHidden);
+                applyVisibilityFlag();
+                animateLayout();
+            });
+            return f;
+        }
+
+        @Override
+        public int getItemCount() {
+            return gallery.getPageCount();
+        }
+    }
+}

+ 100 - 0
app/src/main/java/com/dar/nbook/adapters/BookmarkAdapter.java

@@ -0,0 +1,100 @@
+package com.dar.nbook.adapters;
+
+import android.content.Intent;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.widget.AppCompatImageButton;
+import androidx.constraintlayout.widget.ConstraintLayout;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.dar.nbook.BookmarkActivity;
+import com.dar.nbook.MainActivity;
+import com.dar.nbook.R;
+import com.dar.nbook.async.database.Queries;
+import com.dar.nbook.components.classes.Bookmark;
+import com.dar.nbook.utility.IntentUtility;
+
+import java.util.List;
+
+public class BookmarkAdapter extends RecyclerView.Adapter<BookmarkAdapter.ViewHolder> {
+    private static final int LAYOUT = R.layout.bookmark_layout;
+
+    private final List<Bookmark> bookmarks;
+    private final BookmarkActivity bookmarkActivity;
+
+    public BookmarkAdapter(BookmarkActivity bookmarkActivity) {
+        this.bookmarkActivity = bookmarkActivity;
+        this.bookmarks = Queries.BookmarkTable.getBookmarks();
+    }
+
+
+    @NonNull
+    @Override
+    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(LAYOUT, parent, false));
+    }
+
+
+    @Override
+    public void onBindViewHolder(@NonNull ViewHolder holder, int pos) {
+        final int position = holder.getBindingAdapterPosition();
+        Bookmark bookmark = bookmarks.get(position);
+
+        holder.queryText.setText(bookmark.toString());
+        holder.pageLabel.setText(bookmarkActivity.getString(R.string.bookmark_page_format, bookmark.page));
+
+        holder.deleteButton.setOnClickListener(v -> removeBookmarkAtPosition(position));
+
+        holder.rootLayout.setOnClickListener(v -> loadBookmark(bookmark));
+    }
+
+    /**
+     * Start an {@link MainActivity} with <code>bookmark</code> as query and page
+     *
+     * @param bookmark bookmark to load
+     */
+    private void loadBookmark(Bookmark bookmark) {
+        Intent i = new Intent(bookmarkActivity, MainActivity.class);
+        i.putExtra(bookmarkActivity.getPackageName() + ".BYBOOKMARK", true);
+        i.putExtra(bookmarkActivity.getPackageName() + ".INSPECTOR", bookmark.createInspector(bookmarkActivity, null));
+        IntentUtility.startAnotherActivity(bookmarkActivity, i);
+    }
+
+    /**
+     * remove bookmark from the adapter at <code>position</code>
+     *
+     * @param position index to delete
+     */
+    private void removeBookmarkAtPosition(int position) {
+        if (position >= bookmarks.size()) return;
+        Bookmark bookmark = bookmarks.get(position);
+        bookmark.deleteBookmark();
+        bookmarks.remove(bookmark);
+        bookmarkActivity.runOnUiThread(() -> notifyItemRemoved(position));
+    }
+
+    @Override
+    public int getItemCount() {
+        return bookmarks.size();
+    }
+
+    static class ViewHolder extends RecyclerView.ViewHolder {
+        final AppCompatImageButton deleteButton;
+        final TextView queryText;
+        final TextView pageLabel;
+        final ConstraintLayout rootLayout;
+
+        ViewHolder(@NonNull View itemView) {
+            super(itemView);
+            deleteButton = itemView.findViewById(R.id.remove_button);
+            pageLabel = itemView.findViewById(R.id.page);
+            queryText = itemView.findViewById(R.id.title);
+            rootLayout = itemView.findViewById(R.id.master_layout);
+        }
+    }
+
+}

+ 121 - 0
app/src/main/java/com/dar/nbook/adapters/CommentAdapter.java

@@ -0,0 +1,121 @@
+package com.dar.nbook.adapters;
+
+import android.os.Build;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageButton;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.constraintlayout.widget.ConstraintLayout;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.dar.nbook.R;
+import com.dar.nbook.api.comments.Comment;
+import com.dar.nbook.settings.AuthRequest;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.settings.Login;
+import com.dar.nbook.utility.ImageDownloadUtility;
+import com.dar.nbook.utility.Utility;
+
+import java.io.IOException;
+import java.text.DateFormat;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+import okhttp3.Call;
+import okhttp3.Callback;
+import okhttp3.Response;
+
+public class CommentAdapter extends RecyclerView.Adapter<CommentAdapter.ViewHolder> {
+    private final List<Comment> comments;
+    private final DateFormat format;
+    private final int userId;
+    private final int galleryId;
+    private final AppCompatActivity context;
+
+    public CommentAdapter(AppCompatActivity context, List<Comment> comments, int galleryId) {
+        this.context = context;
+        format = android.text.format.DateFormat.getDateFormat(context);
+        this.galleryId = galleryId;
+        this.comments = comments == null ? new ArrayList<>() : comments;
+        if (Login.isLogged() && Login.getUser() != null) {
+            userId = Login.getUser().getId();
+        } else userId = -1;
+    }
+
+    @NonNull
+    @Override
+    public CommentAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.comment_layout, parent, false));
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull CommentAdapter.ViewHolder holder, int pos) {
+        int position = holder.getBindingAdapterPosition();
+        Comment c = comments.get(position);
+        holder.layout.setOnClickListener(v1 -> {
+            if (Build.VERSION.SDK_INT > Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1) {
+                context.runOnUiThread(() -> holder.body.setMaxLines(holder.body.getMaxLines() == 7 ? 999 : 7));
+            }
+        });
+        holder.close.setVisibility(c.getPosterId() != userId ? View.GONE : View.VISIBLE);
+        holder.user.setText(c.getUsername());
+        holder.body.setText(c.getComment());
+        holder.date.setText(format.format(c.getPostDate()));
+        holder.close.setOnClickListener(v -> {
+            String refererUrl = String.format(Locale.US, Utility.getBaseUrl() + "g/%d/", galleryId);
+            String submitUrl = String.format(Locale.US, Utility.getBaseUrl() + "api/comments/%d/delete", c.getId());
+            new AuthRequest(refererUrl, submitUrl, new Callback() {
+                @Override
+                public void onFailure(@NonNull Call call, @NonNull IOException e) {
+
+                }
+
+                @Override
+                public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
+                    if (response.body().string().contains("true")) {
+                        comments.remove(position);
+                        context.runOnUiThread(() -> notifyItemRemoved(position));
+                    }
+                }
+            }).setMethod("POST", AuthRequest.EMPTY_BODY).start();
+        });
+        if (c.getAvatarUrl() == null || Global.getDownloadPolicy() != Global.DataUsageType.FULL)
+            ImageDownloadUtility.loadImage(R.drawable.ic_person, holder.userImage);
+        else
+            ImageDownloadUtility.loadImage(context, c.getAvatarUrl(), holder.userImage);
+    }
+
+    @Override
+    public int getItemCount() {
+        return comments.size();
+    }
+
+    public void addComment(Comment c) {
+        comments.add(0, c);
+        context.runOnUiThread(() -> notifyItemInserted(0));
+    }
+
+    public static class ViewHolder extends RecyclerView.ViewHolder {
+        final ImageButton userImage;
+        final ImageButton close;
+        final TextView user;
+        final TextView body;
+        final TextView date;
+        final ConstraintLayout layout;
+
+        public ViewHolder(@NonNull View v) {
+            super(v);
+            layout = v.findViewById(R.id.master_layout);
+            userImage = v.findViewById(R.id.propic);
+            close = v.findViewById(R.id.close);
+            user = v.findViewById(R.id.username);
+            body = v.findViewById(R.id.body);
+            date = v.findViewById(R.id.date);
+        }
+    }
+}

+ 215 - 0
app/src/main/java/com/dar/nbook/adapters/FavoriteAdapter.java

@@ -0,0 +1,215 @@
+package com.dar.nbook.adapters;
+
+import android.content.Intent;
+import android.database.Cursor;
+import android.os.Build;
+import android.text.Layout;
+import android.util.SparseIntArray;
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+import android.widget.Filter;
+import android.widget.Filterable;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.dar.nbook.FavoriteActivity;
+import com.dar.nbook.GalleryActivity;
+import com.dar.nbook.R;
+import com.dar.nbook.api.components.Gallery;
+import com.dar.nbook.async.database.Queries;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.utility.ImageDownloadUtility;
+import com.dar.nbook.utility.LogUtility;
+import com.dar.nbook.utility.Utility;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Locale;
+
+public class FavoriteAdapter extends RecyclerView.Adapter<GenericAdapter.ViewHolder> implements Filterable {
+    private final int perPage = FavoriteActivity.getEntryPerPage();
+    private final SparseIntArray statuses = new SparseIntArray();
+    private final FavoriteActivity activity;
+    private Gallery[] galleries;
+    private CharSequence lastQuery;
+    private Cursor cursor;
+    private boolean force = false;
+    private boolean sortByTitle = false;
+
+    public FavoriteAdapter(FavoriteActivity activity) {
+        boolean online = false;
+        this.activity = activity;
+        this.lastQuery = "";
+        setHasStableIds(true);
+    }
+
+    @Override
+    public long getItemId(int position) {
+        cursor.moveToPosition(position);
+        return cursor.getInt(cursor.getColumnIndex(Queries.GalleryTable.IDGALLERY));
+    }
+
+    @NonNull
+    @Override
+    public GenericAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        return new GenericAdapter.ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.entry_layout, parent, false));
+    }
+
+    @Nullable
+    private Gallery galleryFromPosition(int position) {
+        if (galleries[position] != null) return galleries[position];
+        cursor.moveToPosition(position);
+        try {
+            Gallery g = Queries.GalleryTable.cursorToGallery(cursor);
+            galleries[position] = g;
+            return g;
+        } catch (IOException e) {
+            e.printStackTrace();
+            return null;
+        }
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull final GenericAdapter.ViewHolder holder, int position) {
+        final Gallery ent = galleryFromPosition(holder.getBindingAdapterPosition());
+        if (ent == null) return;
+        ImageDownloadUtility.loadImage(activity, ent.getThumbnail(), holder.imgView);
+        holder.pages.setText(String.format(Locale.US, "%d", ent.getPageCount()));
+        holder.title.setText(ent.getTitle());
+        holder.flag.setText(Global.getLanguageFlag(ent.getLanguage()));
+        holder.title.setOnClickListener(v -> {
+            Layout layout = holder.title.getLayout();
+            if (Build.VERSION.SDK_INT > Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1) {
+                if (layout.getEllipsisCount(layout.getLineCount() - 1) > 0)
+                    holder.title.setMaxLines(7);
+                else if (holder.title.getMaxLines() == 7) holder.title.setMaxLines(3);
+                else holder.layout.performClick();
+            } else holder.layout.performClick();
+        });
+        holder.layout.setOnClickListener(v -> {
+            //Global.setLoadedGallery(ent);
+            startGallery(ent);
+        });
+        holder.layout.setOnLongClickListener(v -> {
+            holder.title.animate().alpha(holder.title.getAlpha() == 0f ? 1f : 0f).setDuration(100).start();
+            holder.flag.animate().alpha(holder.flag.getAlpha() == 0f ? 1f : 0f).setDuration(100).start();
+            holder.pages.animate().alpha(holder.pages.getAlpha() == 0f ? 1f : 0f).setDuration(100).start();
+            return true;
+        });
+        int statusColor = statuses.get(ent.getId(), 0);
+        if (statusColor == 0) {
+            statusColor = Queries.StatusMangaTable.getStatus(ent.getId()).color;
+            statuses.put(ent.getId(), statusColor);
+        }
+        holder.title.setBackgroundColor(statusColor);
+    }
+
+    private void startGallery(Gallery ent) {
+        Intent intent = new Intent(activity, GalleryActivity.class);
+        LogUtility.d(ent + "");
+        intent.putExtra(activity.getPackageName() + ".GALLERY", ent);
+        intent.putExtra(activity.getPackageName() + ".UNKNOWN", true);
+        activity.startActivity(intent);
+    }
+
+    public void changePage() {
+        forceReload();
+    }
+
+    public void updateColor(int position) {
+        Gallery ent = galleryFromPosition(position);
+        if (ent == null) return;
+        int id = ent.getId();
+        statuses.put(id, Queries.StatusMangaTable.getStatus(id).color);
+        notifyItemChanged(position);
+    }
+
+    @Override
+    public int getItemCount() {
+        return cursor == null ? 0 : cursor.getCount();
+    }
+
+    @Override
+    public Filter getFilter() {
+        return new Filter() {
+            @Override
+            protected FilterResults performFiltering(CharSequence constraint) {
+                constraint = constraint.toString().toLowerCase(Locale.US);
+                if ((!force && lastQuery.equals(constraint))) return null;
+                LogUtility.d("FILTERING");
+                setRefresh(true);
+                FilterResults results = new FilterResults();
+                lastQuery = constraint.toString();
+                LogUtility.d(lastQuery + "LASTQERY");
+                force = false;
+                Cursor c = Queries.FavoriteTable.getAllFavoriteGalleriesCursor(lastQuery, sortByTitle, perPage, (activity.getActualPage() - 1) * perPage);
+                results.count = c.getCount();
+                results.values = c;
+                LogUtility.d("FILTERING3");
+                LogUtility.e(results.count + ";" + results.values);
+                setRefresh(false);
+                return results;
+            }
+
+            @Override
+            protected void publishResults(CharSequence constraint, FilterResults results) {
+                if (results == null) return;
+                setRefresh(true);
+                LogUtility.d("After called2");
+                final int oldSize = getItemCount(), newSize = results.count;
+                updateCursor((Cursor) results.values);
+                //not in runOnUIThread because is always executed on UI
+                if (oldSize > newSize) notifyItemRangeRemoved(newSize, oldSize - newSize);
+                else notifyItemRangeInserted(oldSize, newSize - oldSize);
+                notifyItemRangeChanged(0, Math.min(newSize, oldSize));
+
+                setRefresh(false);
+            }
+        };
+    }
+
+    public void setSortByTitle(boolean sortByTitle) {
+        this.sortByTitle = sortByTitle;
+        forceReload();
+    }
+
+    public void forceReload() {
+        force = true;
+        activity.runOnUiThread(() -> getFilter().filter(lastQuery));
+    }
+
+    public void setRefresh(boolean refresh) {
+        activity.runOnUiThread(() -> activity.getRefresher().setRefreshing(refresh));
+    }
+
+    public void clearGalleries() {
+        Queries.FavoriteTable.removeAllFavorite();
+        int s = getItemCount();
+        updateCursor(null);
+        activity.runOnUiThread(() -> notifyItemRangeRemoved(0, s));
+    }
+
+    private void updateCursor(@Nullable Cursor c) {
+        if (cursor != null) cursor.close();
+        galleries = new Gallery[c == null ? 0 : c.getCount()];
+        cursor = c;
+        statuses.clear();
+    }
+
+    public Collection<Gallery> getAllGalleries() {
+        if (cursor == null) return Collections.emptyList();
+        int count = cursor.getCount();
+        ArrayList<Gallery> galleries = new ArrayList<>(count);
+        for (int i = 0; i < count; i++) galleries.add(galleryFromPosition(i));
+        return galleries;
+    }
+
+    public void randomGallery() {
+        if (cursor == null || cursor.getCount() < 1) return;
+        startGallery(galleryFromPosition(Utility.RANDOM.nextInt(cursor.getCount())));
+    }
+}

+ 422 - 0
app/src/main/java/com/dar/nbook/adapters/GalleryAdapter.java

@@ -0,0 +1,422 @@
+package com.dar.nbook.adapters;
+
+import android.content.Intent;
+import android.util.SparseIntArray;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.ImageView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.dar.nbook.CopyToClipboardActivity;
+import com.dar.nbook.GalleryActivity;
+import com.dar.nbook.MainActivity;
+import com.dar.nbook.R;
+import com.dar.nbook.ZoomActivity;
+import com.dar.nbook.api.components.Gallery;
+import com.dar.nbook.api.components.GalleryData;
+import com.dar.nbook.api.components.GenericGallery;
+import com.dar.nbook.api.components.Tag;
+import com.dar.nbook.api.components.TagList;
+import com.dar.nbook.api.enums.SpecialTagIds;
+import com.dar.nbook.api.enums.TagType;
+import com.dar.nbook.api.local.LocalGallery;
+import com.dar.nbook.async.database.Queries;
+import com.dar.nbook.components.classes.Size;
+import com.dar.nbook.components.widgets.CustomGridLayoutManager;
+import com.dar.nbook.files.GalleryFolder;
+import com.dar.nbook.github.chrisbanes.photoview.PhotoView;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.utility.ImageDownloadUtility;
+import com.dar.nbook.utility.LogUtility;
+import com.dar.nbook.utility.Utility;
+import com.google.android.material.chip.Chip;
+import com.google.android.material.chip.ChipGroup;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Locale;
+
+public class GalleryAdapter extends RecyclerView.Adapter<GalleryAdapter.ViewHolder> {
+    private static final int[] TAG_NAMES = {
+        R.string.unknown,
+        R.string.tag_parody_gallery,
+        R.string.tag_character_gallery,
+        R.string.tag_tag_gallery,
+        R.string.tag_artist_gallery,
+        R.string.tag_group_gallery,
+        R.string.tag_language_gallery,
+        R.string.tag_category_gallery,
+    };
+    private static final int TOLERANCE = 1000;
+    private final Size maxSize;
+    private final Size minSize;
+    private final SparseIntArray angles = new SparseIntArray();
+    private final GalleryActivity context;
+    private final GenericGallery gallery;
+    private GalleryFolder directory = null;
+    private Size maxImageSize = null;
+    private Policy policy;
+    private int colCount;
+
+    public GalleryAdapter(GalleryActivity cont, GenericGallery gallery, int colCount) {
+        this.context = cont;
+        this.gallery = gallery;
+        maxSize = gallery.getMaxSize();
+        minSize = gallery.getMinSize();
+        setColCount(colCount);
+        try {
+            if (gallery instanceof LocalGallery) {
+                directory = gallery.getGalleryFolder();
+            } else if (Global.hasStoragePermission(cont)) {
+                if (gallery.getId() != -1) {
+                    File f = Global.findGalleryFolder(context, gallery.getId());
+                    if (f != null) directory = new GalleryFolder(f);
+                } else {
+                    directory = new GalleryFolder(gallery.getTitle());
+                }
+            }
+        } catch (IllegalArgumentException ignore) {
+            directory = null;
+        }
+        LogUtility.d("Max maxSize: " + maxSize + ", min maxSize: " + gallery.getMinSize());
+    }
+
+    public Type positionToType(int pos) {
+        if (pos == 0) return Type.TAG;
+        if (pos > gallery.getPageCount()) return Type.RELATED;
+        return Type.PAGE;
+    }
+
+    public void setColCount(int colCount) {
+        this.colCount = colCount;
+        applyProportionPolicy();
+    }
+
+    private void applyProportionPolicy() {
+        if (colCount == 1) policy = Policy.FULL;
+        else if (maxSize.getHeight() - minSize.getHeight() < TOLERANCE) policy = Policy.MAX;
+        else policy = Policy.PROPORTION;
+        LogUtility.d("NEW POLICY: " + policy);
+    }
+
+    public GalleryFolder getDirectory() {
+        return directory;
+    }
+
+    @NonNull
+    @Override
+    public GalleryAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        int id = 0;
+        switch (Type.values()[viewType]) {
+            case TAG:
+                id = R.layout.tags_layout;
+                break;
+            case PAGE:
+                switch (policy) {
+                    case MAX:
+                        id = R.layout.image_void;
+                        break;
+                    case FULL:
+                        id = R.layout.image_void_full;
+                        break;
+                    case PROPORTION:
+                        id = R.layout.image_void_static;
+                        break;
+                }
+                break;
+            case RELATED:
+                id = R.layout.related_recycler;
+                break;
+        }
+        return new GalleryAdapter.ViewHolder(LayoutInflater.from(parent.getContext()).inflate(id, parent, false), Type.values()[viewType]);
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull final GalleryAdapter.ViewHolder holder, int position) {
+
+        switch (positionToType(holder.getBindingAdapterPosition())) {
+            case TAG:
+                loadTagLayout(holder);
+                break;
+            case PAGE:
+                loadPageLayout(holder);
+                break;
+            case RELATED:
+                loadRelatedLayout(holder);
+                break;
+        }
+    }
+
+    private void loadRelatedLayout(ViewHolder holder) {
+        LogUtility.d("Called RElated");
+        final RecyclerView recyclerView = holder.master.findViewById(R.id.recycler);
+        if (gallery.isLocal()) {
+            holder.master.setVisibility(View.GONE);
+            return;
+        }
+        final Gallery gallery = (Gallery) this.gallery;
+        if (!gallery.isRelatedLoaded() || gallery.getRelated().size() == 0) {
+            holder.master.setVisibility(View.GONE);
+            return;
+        } else holder.master.setVisibility(View.VISIBLE);
+        recyclerView.setLayoutManager(new CustomGridLayoutManager(context, 1, RecyclerView.HORIZONTAL, false));
+        if (gallery.isRelatedLoaded()) {
+            ListAdapter adapter = new ListAdapter(context);
+            adapter.addGalleries(new ArrayList<>(gallery.getRelated()));
+            recyclerView.setAdapter(adapter);
+        }
+    }
+
+    private void loadTagLayout(ViewHolder holder) {
+        final ViewGroup vg = holder.master.findViewById(R.id.tag_master);
+        final TextView idContainer = holder.master.findViewById(R.id.id_num);
+        initializeIdContainer(idContainer);
+        if (!hasTags()) {
+            ViewGroup.LayoutParams layoutParams = vg.getLayoutParams();
+            layoutParams.height = 0;
+            vg.setLayoutParams(layoutParams);
+            return;
+        }
+        final LayoutInflater inflater = context.getLayoutInflater();
+
+        int tagCount, idStringTagName;
+        ViewGroup lay;
+        ChipGroup cg;
+        TagList tagList = this.gallery.getGalleryData().getTags();
+        for (TagType type : TagType.values) {
+            idStringTagName = TAG_NAMES[type.getId()];
+            tagCount = tagList.getCount(type);
+            lay = (ViewGroup) vg.getChildAt(type.getId());
+            cg = lay.findViewById(R.id.chip_group);
+            if (cg.getChildCount() != 0) continue;
+            lay.setVisibility(tagCount == 0 ? View.GONE : View.VISIBLE);
+            ((TextView) lay.findViewById(R.id.title)).setText(idStringTagName);
+            for (int a = 0; a < tagCount; a++) {
+                final Tag tag = tagList.getTag(type, a);
+                Chip c = (Chip) inflater.inflate(R.layout.chip_layout, cg, false);
+                c.setText(tag.getName());
+                c.setOnClickListener(v -> {
+                    Intent intent = new Intent(context, MainActivity.class);
+                    intent.putExtra(context.getPackageName() + ".TAG", tag);
+                    intent.putExtra(context.getPackageName() + ".ISBYTAG", true);
+                    context.startActivity(intent);
+                });
+                c.setOnLongClickListener(v -> {
+                    CopyToClipboardActivity.copyTextToClipboard(context, tag.getName());
+                    Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show();
+                    return true;
+                });
+                cg.addView(c);
+            }
+            addInfoLayout(holder, gallery.getGalleryData());
+        }
+    }
+
+    private void initializeIdContainer(TextView idContainer) {
+        if (gallery.getId() <= 0) {
+            idContainer.setVisibility(View.GONE);
+            return;
+        }
+        String id = Integer.toString(gallery.getId());
+        idContainer.setText(id);
+        idContainer.setVisibility(gallery.getId() != SpecialTagIds.INVALID_ID ? View.VISIBLE : View.GONE);
+        idContainer.setOnClickListener(v -> {
+            CopyToClipboardActivity.copyTextToClipboard(context, id);
+            context.runOnUiThread(() ->
+                Toast.makeText(context, R.string.id_copied_to_clipboard, Toast.LENGTH_SHORT).show()
+            );
+        });
+    }
+
+    private void addInfoLayout(ViewHolder holder, GalleryData gallery) {
+        TextView text = holder.master.findViewById(R.id.page_count);
+        text.setText(context.getString(R.string.page_count_format, gallery.getPageCount()));
+        text = holder.master.findViewById(R.id.upload_date);
+        text.setText(
+            context.getString(R.string.upload_date_format,
+                android.text.format.DateFormat.getDateFormat(context).format(gallery.getUploadDate()),
+                android.text.format.DateFormat.getTimeFormat(context).format(gallery.getUploadDate())
+            ));
+        text = holder.master.findViewById(R.id.favorite_count);
+        text.setText(context.getString(R.string.favorite_count_format, gallery.getFavoriteCount()));
+
+    }
+
+    public void setMaxImageSize(Size maxImageSize) {
+        this.maxImageSize = maxImageSize;
+        context.runOnUiThread(() -> notifyItemRangeChanged(0, getItemCount()));
+    }
+
+    private void loadPageLayout(ViewHolder holder) {
+        final int pos = holder.getBindingAdapterPosition();
+        final ImageView imgView = holder.master.findViewById(R.id.image);
+
+        imgView.setOnClickListener(v -> startGallery(holder.getBindingAdapterPosition()));
+        imgView.setOnLongClickListener(null);
+        holder.master.setOnClickListener(v -> startGallery(holder.getBindingAdapterPosition()));
+        holder.master.setOnLongClickListener(null);
+
+        holder.pageNumber.setText(String.format(Locale.US, "%d", pos));
+
+
+        if (policy == Policy.MAX)
+            holder.itemView.post(() -> {//find the max size and apply proportion
+                if (maxImageSize != null) return;
+                int cellWidth = holder.itemView.getWidth();// this will give you cell width dynamically
+                LogUtility.d(String.format(Locale.US, "Setting: %d,%s", cellWidth, maxSize.toString()));
+                if (maxSize.getWidth() > 10 && maxSize.getHeight() > 10) {
+                    int hei = (maxSize.getHeight() * cellWidth) / maxSize.getWidth();
+                    if (hei >= 100)
+                        setMaxImageSize(new Size(cellWidth, hei));
+                }
+            });
+
+        if (policy == Policy.MAX && maxImageSize != null) {
+            ViewGroup.LayoutParams params = imgView.getLayoutParams();
+            params.height = maxImageSize.getHeight();
+            params.width = maxImageSize.getWidth();
+            imgView.setLayoutParams(params);
+        }
+
+        if (policy == Policy.FULL) {
+            PhotoView photoView = (PhotoView) imgView;
+            photoView.setZoomable(Global.isZoomOneColumn());
+            photoView.setOnMatrixChangeListener(rect -> photoView.setAllowParentInterceptOnEdge(photoView.getScale() <= 1f));
+            photoView.setOnClickListener(v -> {
+                if (photoView.getScale() <= 1f)
+                    startGallery(holder.getBindingAdapterPosition());
+            });
+            View.OnLongClickListener listener = v -> {
+                optionDialog(imgView, pos);
+                return true;
+            };
+            imgView.setOnLongClickListener(listener);
+            holder.master.setOnLongClickListener(listener);
+        }
+
+        loadImageOnPolicy(imgView, pos);
+
+
+    }
+
+    private void optionDialog(ImageView imgView, final int pos) {
+        ArrayAdapter<String> adapter = new ArrayAdapter<>(context, android.R.layout.select_dialog_item);
+        adapter.add(context.getString(R.string.share));
+        adapter.add(context.getString(R.string.rotate_image));
+        adapter.add(context.getString(R.string.bookmark_here));
+        if (Global.hasStoragePermission(context))
+            adapter.add(context.getString(R.string.save_page));
+        MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(context);
+        builder.setTitle(R.string.settings).setIcon(R.drawable.ic_share);
+        builder.setAdapter(adapter, (dialog, which) -> {
+            switch (which) {
+                case 0:
+                    openSendImageDialog(imgView, pos);
+                    break;
+                case 1:
+                    rotate(pos);
+                    break;
+                case 2:
+                    Queries.ResumeTable.insert(gallery.getId(), pos);
+                    break;
+                case 3:
+                    String name = String.format(Locale.US, "%d-%d.jpg", gallery.getId(), pos);
+                    Utility.saveImage(imgView.getDrawable(), new File(Global.SCREENFOLDER, name));
+                    break;
+            }
+        }).show();
+    }
+
+    private void openSendImageDialog(ImageView img, int pos) {
+        MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(context);
+        builder.setPositiveButton(R.string.yes, (dialog, which) -> sendImage(img, pos, true))
+            .setNegativeButton(R.string.no, (dialog, which) -> sendImage(img, pos, false))
+            .setCancelable(true).setTitle(R.string.send_with_title)
+            .setMessage(R.string.caption_send_with_title)
+            .show();
+    }
+
+    private void sendImage(ImageView img, int pos, boolean text) {
+        Utility.sendImage(context, img.getDrawable(), text ? gallery.sharePageUrl(pos - 1) : null);
+    }
+
+    private void rotate(int pos) {
+        angles.append(pos, (angles.get(pos) + 270) % 360);
+        context.runOnUiThread(() -> notifyItemChanged(pos));
+    }
+
+    private void startGallery(int page) {
+        if (!gallery.isLocal() && Global.getDownloadPolicy() == Global.DataUsageType.NONE) {
+            context.runOnUiThread(() ->
+                Toast.makeText(context, R.string.enable_network_to_continue, Toast.LENGTH_SHORT).show()
+            );
+            return;
+        }
+        Intent intent = new Intent(context, ZoomActivity.class);
+        intent.putExtra(context.getPackageName() + ".GALLERY", gallery);
+        intent.putExtra(context.getPackageName() + ".DIRECTORY", directory);
+        intent.putExtra(context.getPackageName() + ".PAGE", page);
+        context.startActivity(intent);
+    }
+
+    private void loadImageOnPolicy(ImageView imgView, int pos) {
+        final File file = directory == null ? null : directory.getPage(pos);
+        int angle = angles.get(pos);
+
+        if (policy == Policy.FULL) {
+            if (file != null && file.exists())
+                ImageDownloadUtility.loadImageOp(context, imgView, file, angle);
+            else if (!gallery.isLocal()) {
+                Gallery ent = (Gallery) gallery;
+                ImageDownloadUtility.loadImageOp(context, imgView, ent, pos - 1, angle);
+            } else ImageDownloadUtility.loadImage(R.mipmap.ic_launcher, imgView);
+        } else {
+            if (file != null && file.exists())
+                ImageDownloadUtility.loadImage(context, file, imgView);
+            else if (!gallery.isLocal()) {
+                Gallery ent = (Gallery) gallery;
+                ImageDownloadUtility.downloadPage(context, imgView, ent, pos - 1, false);
+            } else ImageDownloadUtility.loadImage(R.mipmap.ic_launcher, imgView);
+        }
+    }
+
+
+    private boolean hasTags() {
+        return gallery.hasGalleryData();
+    }
+
+    @Override
+    public int getItemViewType(int position) {
+        return positionToType(position).ordinal();
+    }
+
+    @Override
+    public int getItemCount() {
+        return gallery.getPageCount() + 2;
+    }
+
+    public enum Type {TAG, PAGE, RELATED}
+
+    public enum Policy {PROPORTION, MAX, FULL}
+
+    static class ViewHolder extends RecyclerView.ViewHolder {
+        final View master;
+        final TextView pageNumber;
+
+        ViewHolder(View v, Type type) {
+            super(v);
+            master = v.findViewById(R.id.master);
+            pageNumber = v.findViewById(R.id.page_number);
+            if (Global.useRtl()) v.setRotationY(180);
+            if (type == Type.RELATED) Global.applyFastScroller(master.findViewById(R.id.recycler));
+        }
+    }
+
+}

+ 95 - 0
app/src/main/java/com/dar/nbook/adapters/GenericAdapter.java

@@ -0,0 +1,95 @@
+package com.dar.nbook.adapters;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Filter;
+import android.widget.Filterable;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.dar.nbook.R;
+import com.dar.nbook.api.components.GenericGallery;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+
+public abstract class GenericAdapter<T extends GenericGallery> extends RecyclerView.Adapter<GenericAdapter.ViewHolder> implements Filterable {
+    final List<T> dataset;
+    List<T> filter;
+    String lastQuery = "";
+
+    GenericAdapter(List<T> dataset) {
+        this.dataset = dataset;
+        Collections.sort(dataset, (o1, o2) -> o1.getTitle().compareTo(o2.getTitle()));
+        filter = new ArrayList<>(dataset);
+    }
+
+    @NonNull
+    @Override
+    public GenericAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        return new GenericAdapter.ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.entry_layout, parent, false));
+    }
+
+    @Override
+    public int getItemCount() {
+        return filter.size();
+    }
+
+    @Override
+    public Filter getFilter() {
+        return new Filter() {
+            @Override
+            protected FilterResults performFiltering(CharSequence constraint) {
+                String query = constraint.toString().toLowerCase(Locale.US);
+                if (lastQuery.equals(query)) return null;
+                FilterResults results = new FilterResults();
+                results.count = filter.size();
+                lastQuery = query;
+                List<T> filter = new ArrayList<>();
+                for (T gallery : dataset)
+                    if (gallery.getTitle().toLowerCase(Locale.US).contains(query))
+                        filter.add(gallery);
+                results.values = filter;
+                return results;
+            }
+
+            @Override
+            protected void publishResults(CharSequence constraint, FilterResults results) {
+                if (results != null) {
+                    filter = (List<T>) results.values;
+                    if (filter.size() > results.count)
+                        notifyItemRangeInserted(results.count, filter.size() - results.count);
+                    else if (filter.size() < results.count)
+                        notifyItemRangeRemoved(filter.size(), results.count - filter.size());
+                    notifyItemRangeRemoved(filter.size(), results.count);
+                    notifyItemRangeChanged(0, filter.size() - 1);
+                }
+            }
+        };
+    }
+
+    static class ViewHolder extends RecyclerView.ViewHolder {
+        final ImageView imgView;
+        final View overlay;
+        final TextView title, pages, flag;
+        final View layout;
+
+        ViewHolder(View v) {
+            super(v);
+            imgView = v.findViewById(R.id.image);
+            title = v.findViewById(R.id.title);
+            pages = v.findViewById(R.id.pages);
+            layout = v.findViewById(R.id.master_layout);
+            flag = v.findViewById(R.id.flag);
+            overlay = v.findViewById(R.id.overlay);
+        }
+    }
+
+
+}

+ 106 - 0
app/src/main/java/com/dar/nbook/adapters/HistoryAdapter.java

@@ -0,0 +1,106 @@
+package com.dar.nbook.adapters;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageButton;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.constraintlayout.widget.ConstraintLayout;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.dar.nbook.R;
+import com.dar.nbook.SearchActivity;
+import com.dar.nbook.components.classes.History;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.utility.ImageDownloadUtility;
+
+import java.util.HashSet;
+import java.util.List;
+
+public class HistoryAdapter extends RecyclerView.Adapter<HistoryAdapter.ViewHolder> {
+    private final List<History> history;
+    private final SearchActivity context;
+    private int remove = -1;
+
+    public HistoryAdapter(SearchActivity context) {
+        this.context = context;
+        if (!Global.isKeepHistory())
+            context.getSharedPreferences("History", 0).edit().clear().apply();
+        history = Global.isKeepHistory() ? History.setToList(context.getSharedPreferences("History", 0).getStringSet("history", new HashSet<>())) : null;
+    }
+
+    @NonNull
+    @Override
+    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.entry_history, parent, false));
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+        ImageDownloadUtility.loadImage(remove == holder.getBindingAdapterPosition() ? R.drawable.ic_close : R.drawable.ic_mode_edit, holder.imageButton);
+        String entry = history.get(holder.getBindingAdapterPosition()).getValue();
+        holder.text.setText(entry);
+        holder.master.setOnClickListener(v -> context.setQuery(entry, true));
+        holder.imageButton.setOnLongClickListener(v -> {
+            context.runOnUiThread(() -> {
+                if (remove == holder.getBindingAdapterPosition()) {
+                    remove = -1;
+                    notifyItemChanged(holder.getBindingAdapterPosition());
+                } else {
+                    if (remove != -1) {
+                        int l = remove;
+                        remove = -1;
+                        notifyItemChanged(l);
+                    }
+                    remove = holder.getBindingAdapterPosition();
+                    notifyItemChanged(holder.getBindingAdapterPosition());
+                }
+            });
+            return true;
+        });
+        holder.imageButton.setOnClickListener(v -> {
+            if (remove == holder.getBindingAdapterPosition()) {
+                removeHistory(remove);
+                remove = -1;
+            } else {
+                context.setQuery(entry, false);
+            }
+        });
+    }
+
+    @Override
+    public int getItemCount() {
+        return history == null ? 0 : history.size();
+    }
+
+    public void addHistory(String value) {
+        if (!Global.isKeepHistory()) return;
+        History history = new History(value, false);
+        int pos = this.history.indexOf(history);
+        if (pos >= 0) this.history.set(pos, history);
+        else this.history.add(history);
+        context.getSharedPreferences("History", 0).edit().putStringSet("history", History.listToSet(this.history)).apply();
+    }
+
+    public void removeHistory(int pos) {
+        if (pos < 0 || pos >= history.size()) return;
+        history.remove(pos);
+        context.getSharedPreferences("History", 0).edit().putStringSet("history", History.listToSet(this.history)).apply();
+        context.runOnUiThread(() -> notifyItemRemoved(pos));
+    }
+
+    public static class ViewHolder extends RecyclerView.ViewHolder {
+        final ConstraintLayout master;
+        final TextView text;
+        final ImageButton imageButton;
+
+        public ViewHolder(@NonNull View itemView) {
+            super(itemView);
+            this.master = itemView.findViewById(R.id.master_layout);
+            this.text = itemView.findViewById(R.id.text);
+            this.imageButton = itemView.findViewById(R.id.edit);
+        }
+    }
+}

+ 221 - 0
app/src/main/java/com/dar/nbook/adapters/ListAdapter.java

@@ -0,0 +1,221 @@
+package com.dar.nbook.adapters;
+
+import android.content.Intent;
+import android.os.Build;
+import android.text.Layout;
+import android.util.SparseIntArray;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.cardview.widget.CardView;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.dar.nbook.GalleryActivity;
+import com.dar.nbook.MainActivity;
+import com.dar.nbook.R;
+import com.dar.nbook.api.InspectorV3;
+import com.dar.nbook.api.SimpleGallery;
+import com.dar.nbook.api.components.GenericGallery;
+import com.dar.nbook.api.enums.Language;
+import com.dar.nbook.async.database.Queries;
+import com.dar.nbook.components.activities.BaseActivity;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.settings.TagV2;
+import com.dar.nbook.utility.ImageDownloadUtility;
+import com.dar.nbook.utility.LogUtility;
+import com.google.android.material.snackbar.Snackbar;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+public class ListAdapter extends RecyclerView.Adapter<GenericAdapter.ViewHolder> {
+    private final SparseIntArray statuses = new SparseIntArray();
+    private final List<SimpleGallery> mDataset;
+    private final BaseActivity context;
+    private final boolean storagePermission;
+    private final String queryString;
+
+    public ListAdapter(BaseActivity cont) {
+        this.context = cont;
+        this.mDataset = new ArrayList<SimpleGallery>(){
+            @Override
+            public SimpleGallery get(int index) {
+                try {
+                    return super.get(index);
+                }catch (ArrayIndexOutOfBoundsException ignore){
+                    return null;
+                }
+            }
+        };
+        storagePermission = Global.hasStoragePermission(context);
+        queryString = TagV2.getAvoidedTags();
+    }
+
+    @NonNull
+    @Override
+    public GenericAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        return new GenericAdapter.ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.entry_layout, parent, false));
+    }
+
+    private void loadGallery(final GenericAdapter.ViewHolder holder, SimpleGallery ent) {
+        if (context.isFinishing()) return;
+        try {
+            if (Global.isDestroyed(context)) return;
+
+            ImageDownloadUtility.loadImage(context, ent.getThumbnail(), holder.imgView);
+        } catch (VerifyError ignore) {
+        }
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull final GenericAdapter.ViewHolder holder, int position) {
+        int holderPos = holder.getBindingAdapterPosition();
+        if (holderPos >= mDataset.size()) return;
+        final SimpleGallery ent = mDataset.get(holderPos);
+        if (ent == null) return;
+        if (!Global.showTitles()) {
+            holder.title.setAlpha(0f);
+            holder.flag.setAlpha(0f);
+        } else {
+            holder.title.setAlpha(1f);
+            holder.flag.setAlpha(1f);
+        }
+        if (context instanceof GalleryActivity) {
+            CardView card = (CardView) holder.layout.getParent();
+            ViewGroup.LayoutParams params = card.getLayoutParams();
+            params.width = Global.getGalleryWidth();
+            params.height = Global.getGalleryHeight();
+            card.setLayoutParams(params);
+        }
+        holder.overlay.setVisibility((queryString != null && ent.hasIgnoredTags(queryString)) ? View.VISIBLE : View.GONE);
+        loadGallery(holder, ent);
+        holder.pages.setVisibility(View.GONE);
+        holder.title.setText(ent.getTitle());
+        holder.flag.setVisibility(View.VISIBLE);
+        if (Global.getOnlyLanguage() == Language.ALL || context instanceof GalleryActivity) {
+            holder.flag.setText(Global.getLanguageFlag(ent.getLanguage()));
+        } else holder.flag.setVisibility(View.GONE);
+        holder.title.setOnClickListener(v -> {
+            Layout layout = holder.title.getLayout();
+            if (Build.VERSION.SDK_INT > Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1) {
+                if (layout.getEllipsisCount(layout.getLineCount() - 1) > 0)
+                    holder.title.setMaxLines(7);
+                else if (holder.title.getMaxLines() == 7) holder.title.setMaxLines(3);
+                else holder.layout.performClick();
+            } else holder.layout.performClick();
+        });
+        holder.layout.setOnClickListener(v -> {
+              /*Intent intent = new Intent(context, GalleryActivity.class);
+              intent.putExtra(context.getPackageName() + ".ID", ent.getId());
+              context.startActivity(intent);*/
+            if (context instanceof MainActivity)
+                ((MainActivity) context).setIdOpenedGallery(ent.getId());
+            downloadGallery(ent);
+            holder.overlay.setVisibility((queryString != null && ent.hasIgnoredTags(queryString)) ? View.VISIBLE : View.GONE);
+        });
+        holder.overlay.setOnClickListener(v -> holder.overlay.setVisibility(View.GONE));
+        holder.layout.setOnLongClickListener(v -> {
+            holder.title.animate().alpha(holder.title.getAlpha() == 0f ? 1f : 0f).setDuration(100).start();
+            holder.flag.animate().alpha(holder.flag.getAlpha() == 0f ? 1f : 0f).setDuration(100).start();
+            holder.pages.animate().alpha(holder.pages.getAlpha() == 0f ? 1f : 0f).setDuration(100).start();
+            return true;
+        });
+        int statusColor = statuses.get(ent.getId(), 0);
+        if (statusColor == 0) {
+            statusColor = Queries.StatusMangaTable.getStatus(ent.getId()).color;
+            statuses.put(ent.getId(), statusColor);
+        }
+        holder.title.setBackgroundColor(statusColor);
+    }
+
+    public void updateColor(int id) {
+        if (id < 0) return;
+        int position = -1;
+        statuses.put(id, Queries.StatusMangaTable.getStatus(id).color);
+        for (int i = 0; i < mDataset.size(); i++) {
+            SimpleGallery gallery= mDataset.get(i);
+            if (gallery != null && gallery.getId() == id) {
+                position = id;
+                break;
+            }
+        }
+        if (position >= 0) notifyItemChanged(position);
+    }
+
+    private void downloadGallery(final SimpleGallery ent) {
+        InspectorV3.galleryInspector(context, ent.getId(), new InspectorV3.DefaultInspectorResponse() {
+            @Override
+            public void onFailure(Exception e) {
+                super.onFailure(e);
+                File file = Global.findGalleryFolder(context, ent.getId());
+                if (file != null) {
+                    LocalAdapter.startGallery(context, file);
+                } else if (context.getMasterLayout() != null) {
+                    context.runOnUiThread(() -> {
+                            Snackbar snackbar = Snackbar.make(context.getMasterLayout(), R.string.unable_to_connect_to_the_site, Snackbar.LENGTH_SHORT);
+                            snackbar.setAction(R.string.retry, v -> downloadGallery(ent));
+                            snackbar.show();
+                        }
+                    );
+                }
+            }
+
+            @Override
+            public void onSuccess(List<GenericGallery> galleries) {
+                if (galleries.size() != 1) {
+                    if (context.getMasterLayout() != null) {
+                        context.runOnUiThread(() ->
+                            Snackbar.make(context.getMasterLayout(), R.string.no_entry_found, Snackbar.LENGTH_SHORT).show()
+                        );
+                    }
+                    return;
+                }
+                Intent intent = new Intent(context, GalleryActivity.class);
+                LogUtility.d(galleries.get(0).toString());
+                intent.putExtra(context.getPackageName() + ".GALLERY", galleries.get(0));
+                context.runOnUiThread(() -> context.startActivity(intent));
+            }
+        }).start();
+    }
+
+
+    @Override
+    public int getItemCount() {
+        return mDataset == null ? 0 : mDataset.size();
+    }
+
+    public void addGalleries(List<GenericGallery> galleries) {
+        int c = mDataset.size();
+        for (GenericGallery g : galleries) {
+            mDataset.add((SimpleGallery) g);
+
+            LogUtility.d("Simple: " + g);
+        }
+        LogUtility.d(String.format(Locale.US, "%s,old:%d,new:%d,len%d", this, c, mDataset.size(), galleries.size()));
+        context.runOnUiThread(() -> notifyItemRangeInserted(c, galleries.size()));
+    }
+
+    public void restartDataset(List<GenericGallery> galleries) {
+        /*int c=mDataset.size();
+        if(c>0) {
+            mDataset.clear();
+            context.runOnUiThread(() -> notifyItemRangeRemoved(0, c));
+        }
+        mDataset.addAll(galleries);
+        context.runOnUiThread(()->notifyItemRangeInserted(0,galleries.size()));*/
+        mDataset.clear();
+        for (GenericGallery g : galleries)
+            if (g instanceof SimpleGallery)
+                mDataset.add((SimpleGallery) g);
+        context.runOnUiThread(this::notifyDataSetChanged);
+    }
+
+
+    public void resetStatuses() {
+        statuses.clear();
+    }
+}

+ 507 - 0
app/src/main/java/com/dar/nbook/adapters/LocalAdapter.java

@@ -0,0 +1,507 @@
+package com.dar.nbook.adapters;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Build;
+import android.text.Layout;
+import android.util.SparseIntArray;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Filter;
+import android.widget.Filterable;
+import android.widget.ImageButton;
+import android.widget.ImageView;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.dar.nbook.GalleryActivity;
+import com.dar.nbook.LocalActivity;
+import com.dar.nbook.R;
+import com.dar.nbook.api.local.LocalGallery;
+import com.dar.nbook.api.local.LocalSortType;
+import com.dar.nbook.async.converters.CreatePDF;
+import com.dar.nbook.async.converters.CreateZIP;
+import com.dar.nbook.async.database.Queries;
+import com.dar.nbook.async.downloader.DownloadGalleryV2;
+import com.dar.nbook.async.downloader.DownloadObserver;
+import com.dar.nbook.async.downloader.DownloadQueue;
+import com.dar.nbook.async.downloader.GalleryDownloaderV2;
+import com.dar.nbook.components.classes.MultichoiceAdapter;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.utility.ImageDownloadUtility;
+import com.dar.nbook.utility.LogUtility;
+import com.dar.nbook.utility.Utility;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+public class LocalAdapter extends MultichoiceAdapter<Object, LocalAdapter.ViewHolder> implements Filterable {
+    private final SparseIntArray statuses = new SparseIntArray();
+    private final LocalActivity context;
+    private final List<LocalGallery> dataset;
+    private final List<GalleryDownloaderV2> galleryDownloaders;
+    private final Comparator<Object> comparatorByName = (o1, o2) -> {
+        if (o1 == o2) return 0;
+        boolean b1 = o1 instanceof LocalGallery;
+        boolean b2 = o2 instanceof LocalGallery;
+        String s1 = b1 ? ((LocalGallery) o1).getTitle() : ((GalleryDownloaderV2) o1).getPathTitle();
+        String s2 = b2 ? ((LocalGallery) o2).getTitle() : ((GalleryDownloaderV2) o2).getPathTitle();
+        return s1.compareTo(s2);
+    };
+    private final Comparator<Object> comparatorBySize = (o1, o2) -> {
+        if (o1 == o2) return 0;
+        long page1 = o1 instanceof LocalGallery ? Global.recursiveSize(((LocalGallery) o1).getDirectory()) : 0;
+        long page2 = o2 instanceof LocalGallery ? Global.recursiveSize(((LocalGallery) o2).getDirectory()) : 0;
+        return Long.compare(page1, page2);
+    };
+    private final Comparator<Object> comparatorByPageCount = (o1, o2) -> {
+        if (o1 == o2) return 0;
+        int page1 = o1 instanceof LocalGallery ? ((LocalGallery) o1).getPageCount() : 0;
+        int page2 = o2 instanceof LocalGallery ? ((LocalGallery) o2).getPageCount() : 0;
+        return page1 - page2;
+    };
+    private final Comparator<Object> comparatorByDate = (o1, o2) -> {
+        if (o1 == o2) return 0;
+        boolean b1 = o1 instanceof LocalGallery;
+        boolean b2 = o2 instanceof LocalGallery;
+        //downloading manga are newer
+        if (b1 && !b2) return -1;
+        if (!b1 && b2) return 1;
+        if (b1/*&&b2*/) {
+            long res = ((LocalGallery) o1).getDirectory().lastModified() - ((LocalGallery) o2).getDirectory().lastModified();
+            if (res != 0) return res < 0 ? -1 : 1;
+        }
+        String s1 = b1 ? ((LocalGallery) o1).getTitle() : ((GalleryDownloaderV2) o1).getPathTitle();
+        String s2 = b2 ? ((LocalGallery) o2).getTitle() : ((GalleryDownloaderV2) o2).getPathTitle();
+        return s1.compareTo(s2);
+    };
+
+    private List<Object> filter;
+    @NonNull
+    private String lastQuery = "";
+    private final DownloadObserver observer = new DownloadObserver() {
+        private void updatePosition(GalleryDownloaderV2 downloader) {
+            final int id = filter.indexOf(downloader);
+            if (id >= 0) context.runOnUiThread(() -> notifyItemChanged(id));
+        }
+
+        @Override
+        public void triggerStartDownload(GalleryDownloaderV2 downloader) {
+            updatePosition(downloader);
+        }
+
+        @Override
+        public void triggerUpdateProgress(GalleryDownloaderV2 downloader, int reach, int total) {
+            updatePosition(downloader);
+        }
+
+        @Override
+        public void triggerEndDownload(GalleryDownloaderV2 downloader) {
+            LocalGallery l = downloader.localGallery();
+            galleryDownloaders.remove(downloader);
+            if (l != null) {
+                dataset.remove(l);
+                dataset.add(l);
+                LogUtility.d(l);
+                sortElements();
+            }
+            context.runOnUiThread(() -> notifyItemRangeChanged(0, getItemCount()));
+        }
+
+        @Override
+        public void triggerCancelDownload(GalleryDownloaderV2 downloader) {
+            removeDownloader(downloader);
+        }
+
+        @Override
+        public void triggerPauseDownload(GalleryDownloaderV2 downloader) {
+            context.runOnUiThread(() -> notifyItemChanged(filter.indexOf(downloader)));
+        }
+    };
+    private int colCount;
+
+    public LocalAdapter(LocalActivity cont, ArrayList<LocalGallery> myDataset) {
+        this.context = cont;
+        dataset = new CopyOnWriteArrayList<>(myDataset);
+        colCount = cont.getColCount();
+        galleryDownloaders = DownloadQueue.getDownloaders();
+        lastQuery = cont.getQuery();
+        filter = new ArrayList<>(myDataset);
+        filter.addAll(galleryDownloaders);
+
+        DownloadQueue.addObserver(observer);
+        sortElements();
+    }
+
+    static void startGallery(Activity context, File directory) {
+        if (!directory.isDirectory()) return;
+        LocalGallery ent = new LocalGallery(directory);
+        ent.calculateSizes();
+        new Thread(() -> {
+            Intent intent = new Intent(context, GalleryActivity.class);
+            intent.putExtra(context.getPackageName() + ".GALLERY", ent);
+            intent.putExtra(context.getPackageName() + ".ISLOCAL", true);
+            context.runOnUiThread(() -> context.startActivity(intent));
+        }).start();
+    }
+
+    @Override
+    protected ViewGroup getMaster(ViewHolder holder) {
+        return holder.layout;
+    }
+
+    @Override
+    protected Object getItemAt(int position) {
+        return filter.get(position);
+    }
+
+    private CopyOnWriteArrayList<Object> createHash(List<GalleryDownloaderV2> galleryDownloaders, List<LocalGallery> dataset) {
+        HashMap<String, Object> hashMap = new HashMap<>(dataset.size() + galleryDownloaders.size());
+        for (LocalGallery gall : dataset) {
+            if (gall != null && gall.getTitle().toLowerCase(Locale.US).contains(lastQuery))
+                hashMap.put(gall.getTrueTitle(), gall);
+        }
+
+        for (GalleryDownloaderV2 gall : galleryDownloaders) {
+            if (gall != null && gall.getPathTitle().toLowerCase(Locale.US).contains(lastQuery))
+                hashMap.put(gall.getTruePathTitle(), gall);
+        }
+
+        ArrayList<Object> arr = new ArrayList<>(hashMap.values());
+
+        sortItems(arr);
+
+        return new CopyOnWriteArrayList<>(arr);
+    }
+
+    private void sortItems(ArrayList<Object> arr) {
+        LocalSortType type = Global.getLocalSortType();
+        if (type.type == LocalSortType.Type.RANDOM) {
+            Collections.shuffle(arr, Utility.RANDOM);
+        } else {
+            Collections.sort(arr, getComparator(type.type));
+            if (type.descending) Collections.reverse(arr);
+        }
+    }
+
+    private Comparator<Object> getComparator(LocalSortType.Type type) {
+        switch (type) {
+            case DATE:
+                return comparatorByDate;
+            case TITLE:
+                return comparatorByName;
+            case PAGE_COUNT:
+                return comparatorByPageCount;
+            //case SIZE:return comparatorBySize;
+        }
+        return comparatorByName;
+    }
+
+    public void setColCount(int colCount) {
+        this.colCount = colCount;
+    }
+
+    private void sortElements() {
+        filter = createHash(galleryDownloaders, dataset);
+    }
+
+    @NonNull
+    @Override
+    protected ViewHolder onCreateMultichoiceViewHolder(@NonNull ViewGroup parent, int viewType) {
+        int id = 0;
+        switch (viewType) {
+            case 0:
+                id = colCount == 1 ? R.layout.entry_layout_single : R.layout.entry_layout;
+                break;
+            case 1:
+                id = colCount == 1 ? R.layout.entry_download_layout : R.layout.entry_download_layout_compact;
+                break;
+        }
+        return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(id, parent, false));
+    }
+
+    @Override
+    public int getItemViewType(int position) {
+        return filter.get(position) instanceof LocalGallery ? 0 : 1;
+    }
+
+    private void bindGallery(@NonNull final ViewHolder holder, int position, LocalGallery ent) {
+        if (holder.flag != null) holder.flag.setVisibility(View.GONE);
+        ImageDownloadUtility.loadImage(context, ent.getPage(ent.getMin()), holder.imgView);
+        holder.title.setText(ent.getTitle());
+        if (colCount == 1)
+            holder.pages.setText(context.getString(R.string.page_count_format, ent.getPageCount()));
+        else holder.pages.setText(String.format(Locale.US, "%d", ent.getPageCount()));
+
+        holder.title.setOnClickListener(v -> {
+            Layout layout = holder.title.getLayout();
+            if (Build.VERSION.SDK_INT > Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1) {
+                if (layout.getEllipsisCount(layout.getLineCount() - 1) > 0)
+                    holder.title.setMaxLines(7);
+                else if (holder.title.getMaxLines() == 7) holder.title.setMaxLines(3);
+                else holder.layout.performClick();
+            } else holder.layout.performClick();
+        });
+
+        /*holder.layout.setOnLongClickListener(v -> {
+            createContextualMenu(position);
+            return true;
+        });*/
+        int statusColor = statuses.get(ent.getId(), 0);
+        if (statusColor == 0) {
+            statusColor = Queries.StatusMangaTable.getStatus(ent.getId()).color;
+            statuses.put(ent.getId(), statusColor);
+        }
+        holder.title.setBackgroundColor(statusColor);
+    }
+
+    public void updateColor(int id) {
+        if (id < 0) return;
+        statuses.put(id, Queries.StatusMangaTable.getStatus(id).color);
+        for (int i = 0; i < filter.size(); i++) {
+            Object o = filter.get(i);
+            if (!(o instanceof LocalGallery)) continue;
+            LocalGallery lg = (LocalGallery) o;
+            if (lg.getId() == id) notifyItemChanged(i);
+        }
+    }
+
+    @Override
+    protected void defaultMasterAction(int position) {
+        if (position < 0 || filter.size() <= position) return;
+        if (!(filter.get(position) instanceof LocalGallery)) return;
+        LocalGallery lg = (LocalGallery) filter.get(position);
+        startGallery(context, lg.getDirectory());
+        context.setIdGalleryPosition(lg.getId());
+    }
+
+    private void bindDownload(@NonNull final ViewHolder holder, int position, GalleryDownloaderV2 downloader) {
+        int percentage = downloader.getPercentage();
+        //if (!downloader.hasData())return;
+        ImageDownloadUtility.loadImage(context, downloader.getThumbnail(), holder.imgView);
+        holder.title.setText(downloader.getPathTitle());
+        holder.cancelButton.setOnClickListener(v -> removeDownloader(downloader));
+        switch (downloader.getStatus()) {
+            case PAUSED:
+                holder.playButton.setImageResource(R.drawable.ic_play);
+                holder.playButton.setOnClickListener(v -> {
+                    downloader.setStatus(GalleryDownloaderV2.Status.NOT_STARTED);
+                    DownloadGalleryV2.startWork(context);
+                    notifyItemChanged(position);
+                });
+                break;
+            case DOWNLOADING:
+                holder.playButton.setImageResource(R.drawable.ic_pause);
+                holder.playButton.setOnClickListener(v -> {
+                    downloader.setStatus(GalleryDownloaderV2.Status.PAUSED);
+                    notifyItemChanged(position);
+                });
+                break;
+            case NOT_STARTED:
+                holder.playButton.setImageResource(R.drawable.ic_play);
+                holder.playButton.setOnClickListener(v -> DownloadQueue.givePriority(downloader));
+                break;
+        }
+        holder.progress.setText(context.getString(R.string.percentage_format, percentage));
+        holder.progress.setVisibility(downloader.getStatus() == GalleryDownloaderV2.Status.NOT_STARTED ? View.GONE : View.VISIBLE);
+        holder.progressBar.setProgress(percentage);
+        holder.progressBar.setIndeterminate(downloader.getStatus() == GalleryDownloaderV2.Status.NOT_STARTED);
+        Global.setTint(holder.playButton.getDrawable());
+        Global.setTint(holder.cancelButton.getDrawable());
+    }
+
+    private void removeDownloader(GalleryDownloaderV2 downloader) {
+        int position = filter.indexOf(downloader);
+        if (position < 0) return;
+        filter.remove(position);
+        DownloadQueue.remove(downloader, true);
+        galleryDownloaders.remove(downloader);
+        context.runOnUiThread(() -> notifyItemRemoved(position));
+
+    }
+
+    @Override
+    public long getItemId(int position) {
+        if (position == -1) return -1;
+        return filter.get(position).hashCode();
+    }
+
+    @Override
+    public void onBindMultichoiceViewHolder(@NonNull ViewHolder holder, int position) {
+        if (filter.get(position) instanceof LocalGallery)
+            bindGallery(holder, position, (LocalGallery) filter.get(position));
+        else
+            bindDownload(holder, position, (GalleryDownloaderV2) filter.get(position));
+    }
+
+    private void showDialogDelete() {
+        MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(context);
+        builder.setTitle(R.string.delete_galleries).setMessage(getAllGalleries());
+        builder.setPositiveButton(R.string.yes, (dialog, which) -> {
+            ArrayList<Object> coll = new ArrayList<>(getSelected());
+            for (Object o : coll) {
+                filter.remove(o);
+                if (o instanceof LocalGallery) {
+                    dataset.remove(o);
+                    Global.recursiveDelete(((LocalGallery) o).getDirectory());
+                } else if (o instanceof DownloadGalleryV2) {
+                    DownloadQueue.remove((GalleryDownloaderV2) o, true);
+                }
+            }
+            context.runOnUiThread(this::notifyDataSetChanged);
+        }).setNegativeButton(R.string.no, null).setCancelable(true);
+        builder.show();
+    }
+
+    private String getAllGalleries() {
+        StringBuilder builder = new StringBuilder();
+        for (Object o : getSelected()) {
+            if (o instanceof LocalGallery) builder.append(((LocalGallery) o).getTitle());
+            else builder.append(((GalleryDownloaderV2) o).getTitle());
+            builder.append('\n');
+        }
+        return builder.toString();
+    }
+
+    private void showDialogPDF() {
+        MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(context);
+        builder.setTitle(R.string.create_pdf).setMessage(getAllGalleries());
+        builder.setPositiveButton(R.string.yes, (dialog, which) -> {
+            for (Object o : getSelected()) {
+                if (!(o instanceof LocalGallery)) continue;
+                CreatePDF.startWork(context, (LocalGallery) o);
+            }
+        }).setNegativeButton(R.string.no, null).setCancelable(true);
+        builder.show();
+    }
+
+
+    private void showDialogZip() {
+        MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(context);
+        builder.setTitle(R.string.create_zip).setMessage(getAllGalleries());
+        builder.setPositiveButton(R.string.yes, (dialog, which) -> {
+            for (Object o : getSelected()) {
+                if (!(o instanceof LocalGallery)) continue;
+                CreateZIP.startWork(context, (LocalGallery) o);
+            }
+        }).setNegativeButton(R.string.no, null).setCancelable(true);
+        builder.show();
+
+    }
+
+    public boolean hasSelectedClass(Class<?> c) {
+        for (Object x : getSelected()) if (x.getClass() == c) return true;
+        return false;
+    }
+
+    @Override
+    public int getItemCount() {
+        return filter.size();
+    }
+
+    @Override
+    public Filter getFilter() {
+        return new Filter() {
+            @Override
+            protected FilterResults performFiltering(CharSequence constraint) {
+                String query = constraint.toString().toLowerCase(Locale.US);
+                if (lastQuery.equals(query)) return null;
+                FilterResults results = new FilterResults();
+                lastQuery = query;
+                results.values = createHash(galleryDownloaders, dataset);
+                return results;
+            }
+
+            @Override
+            protected void publishResults(CharSequence constraint, FilterResults results) {
+                if (results != null) {
+                    filter = (CopyOnWriteArrayList<Object>) results.values;
+                    context.runOnUiThread(() -> notifyDataSetChanged());
+                }
+            }
+        };
+    }
+
+    public void removeObserver() {
+        DownloadQueue.removeObserver(observer);
+    }
+
+    public void viewRandom() {
+        if (dataset.size() == 0) return;
+        int x = Utility.RANDOM.nextInt(dataset.size());
+        startGallery(context, dataset.get(x).getDirectory());
+    }
+
+    public void sortChanged() {
+        sortElements();
+        context.runOnUiThread(() -> notifyItemRangeChanged(0, getItemCount()));
+    }
+
+    public void startSelected() {
+        for (Object o : getSelected()) {
+            if (!(o instanceof GalleryDownloaderV2)) continue;
+            GalleryDownloaderV2 d = (GalleryDownloaderV2) o;
+            if (d.getStatus() == GalleryDownloaderV2.Status.PAUSED)
+                d.setStatus(GalleryDownloaderV2.Status.NOT_STARTED);
+        }
+        context.runOnUiThread(this::notifyDataSetChanged);
+    }
+
+    public void pauseSelected() {
+        for (Object o : getSelected()) {
+            if (!(o instanceof GalleryDownloaderV2)) continue;
+            GalleryDownloaderV2 d = (GalleryDownloaderV2) o;
+            d.setStatus(GalleryDownloaderV2.Status.PAUSED);
+        }
+        context.runOnUiThread(this::notifyDataSetChanged);
+    }
+
+    public void deleteSelected() {
+        showDialogDelete();
+    }
+
+    public void zipSelected() {
+        showDialogZip();
+    }
+
+    public void pdfSelected() {
+        showDialogPDF();
+    }
+
+    static class ViewHolder extends RecyclerView.ViewHolder {
+        final ImageView imgView;
+        final View overlay;
+        final TextView title, pages, flag, progress;
+        final ViewGroup layout;
+        final ImageButton playButton, cancelButton;
+        final ProgressBar progressBar;
+
+        ViewHolder(View v) {
+            super(v);
+            //Both
+            imgView = v.findViewById(R.id.image);
+            title = v.findViewById(R.id.title);
+            //Local
+            pages = v.findViewById(R.id.pages);
+            layout = v.findViewById(R.id.master_layout);
+            flag = v.findViewById(R.id.flag);
+            overlay = v.findViewById(R.id.overlay);
+            //Downloader
+            progress = itemView.findViewById(R.id.progress);
+            progressBar = itemView.findViewById(R.id.progressBar);
+            playButton = itemView.findViewById(R.id.playButton);
+            cancelButton = itemView.findViewById(R.id.cancelButton);
+        }
+    }
+}

+ 156 - 0
app/src/main/java/com/dar/nbook/adapters/StatusManagerAdapter.java

@@ -0,0 +1,156 @@
+package com.dar.nbook.adapters;
+
+import android.app.Activity;
+import android.graphics.Color;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.ImageButton;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.dar.nbook.R;
+import com.dar.nbook.components.status.Status;
+import com.dar.nbook.components.status.StatusManager;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.utility.Utility;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import java.util.Collections;
+import java.util.List;
+
+import yuku.ambilwarna.AmbilWarnaDialog;
+
+public class StatusManagerAdapter extends RecyclerView.Adapter<StatusManagerAdapter.ViewHolder> {
+    private List<Status> statusList;
+    private Activity activity;
+    private int newColor;
+
+    public StatusManagerAdapter(Activity activity) {
+        statusList = StatusManager.toList();
+        this.activity = activity;
+    }
+
+    @NonNull
+    @Override
+    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        int resId = R.layout.entry_status;
+        View view = LayoutInflater.from(activity).inflate(resId, parent, false);
+        return new ViewHolder(view);
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+        if (holder.getBindingAdapterPosition() == statusList.size()) {
+            holder.name.setText(R.string.add);
+            holder.color.setVisibility(View.INVISIBLE);
+            holder.color.setBackgroundColor(Color.TRANSPARENT);
+            holder.cancel.setImageResource(R.drawable.ic_add);
+            Global.setTint(holder.cancel.getDrawable());
+            holder.cancel.setOnClickListener(null);
+            holder.master.setOnClickListener(v -> updateStatus(null));
+            return;
+        }
+        Status status = statusList.get(holder.getBindingAdapterPosition());
+        holder.name.setText(status.name);
+        holder.color.setVisibility(View.VISIBLE);
+        holder.color.setBackgroundColor(status.opaqueColor());
+
+        holder.cancel.setImageResource(R.drawable.ic_close);
+        holder.master.setOnClickListener(v -> updateStatus(status));
+        holder.cancel.setOnClickListener(v -> {
+            StatusManager.remove(status);
+            notifyItemRemoved(statusList.indexOf(status));
+            statusList.remove(status);
+        });
+    }
+
+    @Override
+    public int getItemCount() {
+        return statusList.size() + 1;
+    }
+
+    @Override
+    public int getItemViewType(int position) {
+        return position == statusList.size() ? 1 : 0;
+    }
+
+    private void updateStatus(@Nullable Status status) {
+        MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(activity);
+        LinearLayout layout = (LinearLayout) View.inflate(activity, R.layout.dialog_add_status, null);
+        EditText name = layout.findViewById(R.id.name);
+        Button btnColor = layout.findViewById(R.id.color);
+        int color = status == null ? Utility.RANDOM.nextInt() | 0xff000000 : status.opaqueColor();
+        newColor = color;
+        btnColor.setBackgroundColor(color);
+        name.setText(status == null ? "" : status.name);
+        btnColor.setOnClickListener(v -> new AmbilWarnaDialog(activity, color, false, new AmbilWarnaDialog.OnAmbilWarnaListener() {
+            @Override
+            public void onCancel(AmbilWarnaDialog dialog) {
+            }
+
+            @Override
+            public void onOk(AmbilWarnaDialog dialog, int color) {
+                if (color == Color.WHITE || color == Color.BLACK) {
+                    Toast.makeText(activity, R.string.invalid_color_selected, Toast.LENGTH_SHORT).show();
+                    return;
+                }
+                newColor = color;
+                btnColor.setBackgroundColor(color);
+            }
+        }).show());
+        builder.setView(layout);
+        builder.setTitle(status == null ? R.string.create_new_status : R.string.update_status);
+        builder.setPositiveButton(R.string.ok, (dialog, which) -> {
+            String newName = name.getText().toString();
+            if (newName.length() < 2) {
+                Toast.makeText(activity, R.string.name_too_short, Toast.LENGTH_SHORT).show();
+                return;
+            }
+            if (StatusManager.getByName(newName) != null && !newName.equals(status.name)) {
+                Toast.makeText(activity, R.string.duplicated_name, Toast.LENGTH_SHORT).show();
+                return;
+            }
+            Status newStatus = StatusManager.updateStatus(status, name.getText().toString(), newColor);
+            if (status == null) {
+                statusList.add(newStatus);
+                Collections.sort(statusList, (o1, o2) -> o1.name.compareToIgnoreCase(o2.name));
+                int index = statusList.indexOf(newStatus);
+                notifyItemInserted(index);
+            } else {
+                int oldIndex = statusList.indexOf(status);
+                statusList.set(oldIndex, newStatus);
+                Collections.sort(statusList, (o1, o2) -> o1.name.compareToIgnoreCase(o2.name));
+                int newIndex = statusList.indexOf(newStatus);
+                notifyItemMoved(oldIndex, newIndex);
+                notifyItemChanged(newIndex);
+            }
+
+        });
+        builder.setNegativeButton(R.string.cancel, null);
+        builder.show();
+    }
+
+
+    public static class ViewHolder extends RecyclerView.ViewHolder {
+        LinearLayout master;
+        Button color;
+        ImageButton cancel;
+        TextView name;
+
+        public ViewHolder(@NonNull View itemView) {
+            super(itemView);
+            name = itemView.findViewById(R.id.name);
+            cancel = itemView.findViewById(R.id.cancelButton);
+            color = itemView.findViewById(R.id.color);
+            master = itemView.findViewById(R.id.master_layout);
+        }
+    }
+}

+ 123 - 0
app/src/main/java/com/dar/nbook/adapters/StatusViewerAdapter.java

@@ -0,0 +1,123 @@
+package com.dar.nbook.adapters;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.database.Cursor;
+import android.os.Build;
+import android.text.Layout;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.dar.nbook.GalleryActivity;
+import com.dar.nbook.R;
+import com.dar.nbook.api.components.Gallery;
+import com.dar.nbook.async.database.Queries;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.utility.ImageDownloadUtility;
+import com.dar.nbook.utility.LogUtility;
+
+import java.io.IOException;
+import java.util.Locale;
+
+public class StatusViewerAdapter extends RecyclerView.Adapter<GenericAdapter.ViewHolder> {
+    private final String statusName;
+    private final Activity context;
+    @NonNull
+    private String query = "";
+    private boolean sortByTitle = false;
+    @Nullable
+    private Cursor galleries = null;
+
+    public StatusViewerAdapter(Activity context, String statusName) {
+        this.statusName = statusName;
+        this.context = context;
+        reloadGalleries();
+    }
+
+    @NonNull
+    @Override
+    public GenericAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        View view = LayoutInflater.from(context).inflate(R.layout.entry_layout, parent, false);
+        return new GenericAdapter.ViewHolder(view);
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull GenericAdapter.ViewHolder holder, int position) {
+        Gallery ent = positionToGallery(holder.getBindingAdapterPosition());
+        if (ent == null) return;
+        ImageDownloadUtility.loadImage(context, ent.getThumbnail(), holder.imgView);
+        holder.pages.setText(String.format(Locale.US, "%d", ent.getPageCount()));
+        holder.title.setText(ent.getTitle());
+        holder.flag.setText(Global.getLanguageFlag(ent.getLanguage()));
+        holder.title.setOnClickListener(v -> {
+            Layout layout = holder.title.getLayout();
+            if (Build.VERSION.SDK_INT > Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1) {
+                if (layout.getEllipsisCount(layout.getLineCount() - 1) > 0)
+                    holder.title.setMaxLines(7);
+                else if (holder.title.getMaxLines() == 7) holder.title.setMaxLines(3);
+                else holder.layout.performClick();
+            } else holder.layout.performClick();
+        });
+        holder.layout.setOnClickListener(v -> {
+            //Global.setLoadedGallery(ent);
+            Intent intent = new Intent(context, GalleryActivity.class);
+            LogUtility.d(ent + "");
+            intent.putExtra(context.getPackageName() + ".GALLERY", ent);
+            context.startActivity(intent);
+        });
+        holder.layout.setOnLongClickListener(v -> {
+            holder.title.animate().alpha(holder.title.getAlpha() == 0f ? 1f : 0f).setDuration(100).start();
+            holder.flag.animate().alpha(holder.flag.getAlpha() == 0f ? 1f : 0f).setDuration(100).start();
+            holder.pages.animate().alpha(holder.pages.getAlpha() == 0f ? 1f : 0f).setDuration(100).start();
+            return true;
+        });
+    }
+
+    @Nullable
+    private Gallery positionToGallery(int position) {
+        try {
+            if (galleries != null && galleries.moveToPosition(position)) {
+                return Queries.GalleryTable.cursorToGallery(galleries);
+            }
+        } catch (IOException ignore) {
+        }
+        return null;
+    }
+
+    @Override
+    public int getItemCount() {
+        return galleries != null ? galleries.getCount() : 0;
+    }
+
+    public void setGalleries(@Nullable Cursor galleries) {
+        if (this.galleries != null) this.galleries.close();
+        this.galleries = galleries;
+        context.runOnUiThread(this::notifyDataSetChanged);
+    }
+
+    public void reloadGalleries() {
+        setGalleries(Queries.StatusMangaTable.getGalleryOfStatus(statusName, query, sortByTitle));
+    }
+
+    public void setQuery(@Nullable String newQuery) {
+        query = newQuery == null ? "" : newQuery;
+        reloadGalleries();
+    }
+
+    public void updateSort(boolean byTitle) {
+        sortByTitle = byTitle;
+        reloadGalleries();
+    }
+
+    public void update(String newQuery, boolean byTitle) {
+        if (query.equals(newQuery) && byTitle == sortByTitle) return;
+        query = newQuery == null ? "" : newQuery;
+        sortByTitle = byTitle;
+        reloadGalleries();
+    }
+}

+ 237 - 0
app/src/main/java/com/dar/nbook/adapters/TagsAdapter.java

@@ -0,0 +1,237 @@
+package com.dar.nbook.adapters;
+
+import android.database.Cursor;
+import android.util.JsonWriter;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Filter;
+import android.widget.Filterable;
+import android.widget.ImageView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.constraintlayout.widget.ConstraintLayout;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.dar.nbook.R;
+import com.dar.nbook.TagFilterActivity;
+import com.dar.nbook.api.components.Tag;
+import com.dar.nbook.api.enums.TagStatus;
+import com.dar.nbook.api.enums.TagType;
+import com.dar.nbook.async.database.Queries;
+import com.dar.nbook.settings.AuthRequest;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.settings.Login;
+import com.dar.nbook.settings.TagV2;
+import com.dar.nbook.utility.ImageDownloadUtility;
+import com.dar.nbook.utility.LogUtility;
+import com.dar.nbook.utility.Utility;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import java.io.IOException;
+import java.io.StringWriter;
+import java.util.Locale;
+
+import okhttp3.Call;
+import okhttp3.Callback;
+import okhttp3.MediaType;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+
+public class TagsAdapter extends RecyclerView.Adapter<TagsAdapter.ViewHolder> implements Filterable {
+    private final TagFilterActivity context;
+    private final boolean logged = Login.isLogged();
+    private final TagType type;
+    private final TagMode tagMode;
+    private String lastQuery = null;
+    private boolean wasSortedByName;
+    private Cursor cursor = null;
+    private boolean force = false;
+
+    public TagsAdapter(TagFilterActivity cont, String query, boolean online) {
+        this.context = cont;
+        this.type = null;
+        this.tagMode = online ? TagMode.ONLINE : TagMode.OFFLINE;
+        getFilter().filter(query);
+    }
+
+    public TagsAdapter(TagFilterActivity cont, String query, TagType type) {
+        this.context = cont;
+        this.type = type;
+        this.tagMode = TagMode.TYPE;
+        getFilter().filter(query);
+    }
+
+    private static void writeTag(JsonWriter jw, Tag tag) throws IOException {
+        jw.beginObject();
+        jw.name("id").value(tag.getId());
+        jw.name("name").value(tag.getName());
+        jw.name("type").value(tag.getTypeSingleName());
+        jw.endObject();
+    }
+
+    @Override
+    public Filter getFilter() {
+        return new Filter() {
+            @Override
+            protected FilterResults performFiltering(CharSequence constraint) {
+                FilterResults results = new FilterResults();
+                if (constraint == null) constraint = "";
+                force = false;
+                wasSortedByName = TagV2.isSortedByName();
+
+                lastQuery = constraint.toString();
+                Cursor tags = Queries.TagTable.getFilterCursor(lastQuery, type, tagMode == TagMode.ONLINE, TagV2.isSortedByName());
+                results.count = tags.getCount();
+                results.values = tags;
+
+                LogUtility.d(results.count + "," + results.values);
+                return results;
+            }
+
+            @Override
+            protected void publishResults(CharSequence constraint, FilterResults results) {
+                if (results.count == -1) return;
+                Cursor newCursor = (Cursor) results.values;
+                int oldCount = getItemCount(), newCount = results.count;
+                if (cursor != null) cursor.close();
+                cursor = newCursor;
+                if (newCount > oldCount) notifyItemRangeInserted(oldCount, newCount - oldCount);
+                else notifyItemRangeRemoved(newCount, oldCount - newCount);
+                notifyItemRangeChanged(0, Math.min(newCount, oldCount));
+            }
+        };
+    }
+
+    @NonNull
+    @Override
+    public TagsAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        return new TagsAdapter.ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.entry_tag_layout, parent, false));
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull final TagsAdapter.ViewHolder holder, int position) {
+        cursor.moveToPosition(position);
+        final Tag ent = Queries.TagTable.cursorToTag(cursor);
+        holder.title.setText(ent.getName());
+        holder.count.setText(String.format(Locale.US, "%d", ent.getCount()));
+        holder.master.setOnClickListener(v -> {
+            switch (tagMode) {
+                case OFFLINE:
+                case TYPE:
+                    if (TagV2.maxTagReached() && ent.getStatus() == TagStatus.DEFAULT) {
+                        context.runOnUiThread(() -> Toast.makeText(context, context.getString(R.string.tags_max_reached, TagV2.MAXTAGS), Toast.LENGTH_LONG).show());
+                    } else {
+                        TagV2.updateStatus(ent);
+                        updateLogo(holder.imgView, ent.getStatus());
+                    }
+                    break;
+                case ONLINE:
+                    try {
+                        onlineTagUpdate(ent, !Login.isOnlineTags(ent), holder.imgView);
+                    } catch (IOException e) {
+                        e.printStackTrace();
+                    }
+                    break;
+            }
+
+        });
+        if (tagMode != TagMode.ONLINE && logged) holder.master.setOnLongClickListener(view -> {
+            if (!Login.isOnlineTags(ent)) showBlacklistDialog(ent, holder.imgView);
+            else
+                Toast.makeText(context, R.string.tag_already_in_blacklist, Toast.LENGTH_SHORT).show();
+            return true;
+        });
+        updateLogo(holder.imgView, tagMode == TagMode.ONLINE ? TagStatus.AVOIDED : ent.getStatus());
+    }
+
+    @Override
+    public int getItemCount() {
+        return cursor == null ? 0 : cursor.getCount();
+    }
+
+    private void showBlacklistDialog(final Tag tag, final ImageView imgView) {
+        MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(context);
+        builder.setIcon(R.drawable.ic_star_border).setTitle(R.string.add_to_online_blacklist).setMessage(R.string.are_you_sure);
+        builder.setPositiveButton(R.string.yes, (dialogInterface, i) -> {
+            try {
+                onlineTagUpdate(tag, true, imgView);
+            } catch (IOException e) {
+                e.printStackTrace();
+            }
+        }).setNegativeButton(R.string.no, null).show();
+    }
+
+    private void onlineTagUpdate(final Tag tag, final boolean add, final ImageView imgView) throws IOException {
+        if (!Login.isLogged() || Login.getUser() == null) return;
+        StringWriter sw = new StringWriter();
+        JsonWriter jw = new JsonWriter(sw);
+        jw.beginObject().name("added").beginArray();
+        if (add) writeTag(jw, tag);
+        jw.endArray().name("removed").beginArray();
+        if (!add) writeTag(jw, tag);
+        jw.endArray().endObject();
+
+        final String url = String.format(Locale.US, Utility.getBaseUrl() + "users/%d/%s/blacklist", Login.getUser().getId(), Login.getUser().getCodename());
+        final RequestBody ss = RequestBody.create(MediaType.get("application/json"), sw.toString());
+        new AuthRequest(url, url, new Callback() {
+            @Override
+            public void onFailure(@NonNull Call call, @NonNull IOException e) {
+            }
+
+            @Override
+            public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
+                if (response.body().string().contains("ok")) {
+                    if (add) Login.addOnlineTag(tag);
+                    else Login.removeOnlineTag(tag);
+                    if (tagMode == TagMode.ONLINE)
+                        updateLogo(imgView, add ? TagStatus.AVOIDED : TagStatus.DEFAULT);
+                }
+            }
+        }).setMethod("POST", ss).start();
+    }
+
+    private void updateLogo(ImageView img, TagStatus s) {
+        context.runOnUiThread(() -> {
+            switch (s) {
+                case DEFAULT:
+                    img.setImageDrawable(null);
+                    break;//ImageDownloadUtility.loadImage(R.drawable.ic_void,img); break;
+                case ACCEPTED:
+                    ImageDownloadUtility.loadImage(R.drawable.ic_check, img);
+                    Global.setTint(img.getDrawable());
+                    break;
+                case AVOIDED:
+                    ImageDownloadUtility.loadImage(R.drawable.ic_close, img);
+                    Global.setTint(img.getDrawable());
+                    break;
+            }
+        });
+
+    }
+
+    public void addItem() {
+        force = true;
+        getFilter().filter(lastQuery);
+    }
+
+    private enum TagMode {ONLINE, OFFLINE, TYPE}
+
+    static class ViewHolder extends RecyclerView.ViewHolder {
+        final ImageView imgView;
+        final TextView title, count;
+        final ConstraintLayout master;
+
+        ViewHolder(View v) {
+            super(v);
+            imgView = v.findViewById(R.id.image);
+            title = v.findViewById(R.id.title);
+            count = v.findViewById(R.id.count);
+            master = v.findViewById(R.id.master_layout);
+        }
+    }
+
+
+}

+ 519 - 0
app/src/main/java/com/dar/nbook/api/InspectorV3.java

@@ -0,0 +1,519 @@
+package com.dar.nbook.api;
+
+import android.content.Context;
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.dar.nbook.api.components.Gallery;
+import com.dar.nbook.api.components.GenericGallery;
+import com.dar.nbook.api.components.Ranges;
+import com.dar.nbook.api.components.Tag;
+import com.dar.nbook.api.enums.ApiRequestType;
+import com.dar.nbook.api.enums.Language;
+import com.dar.nbook.api.enums.SortType;
+import com.dar.nbook.api.enums.SpecialTagIds;
+import com.dar.nbook.api.enums.TagStatus;
+import com.dar.nbook.api.enums.TagType;
+import com.dar.nbook.api.local.LocalGallery;
+import com.dar.nbook.async.database.Queries;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.utility.LogUtility;
+import com.dar.nbook.utility.Utility;
+
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+import org.jsoup.select.Elements;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.lang.ref.WeakReference;
+import java.net.HttpURLConnection;
+import java.net.URLEncoder;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import okhttp3.Request;
+import okhttp3.Response;
+
+public class InspectorV3 extends Thread implements Parcelable {
+    public static final Creator<InspectorV3> CREATOR = new Creator<InspectorV3>() {
+        @Override
+        public InspectorV3 createFromParcel(Parcel in) {
+            return new InspectorV3(in);
+        }
+
+        @Override
+        public InspectorV3[] newArray(int size) {
+            return new InspectorV3[size];
+        }
+    };
+    private SortType sortType;
+    private boolean custom, forceStart = false;
+    private int page, pageCount = -1, id;
+    private String query, url;
+    private ApiRequestType requestType;
+    private Set<Tag> tags;
+    private ArrayList<GenericGallery> galleries = null;
+    private Ranges ranges = null;
+    private InspectorResponse response;
+    private WeakReference<Context> context;
+    private Document htmlDocument;
+
+    protected InspectorV3(Parcel in) {
+        sortType = SortType.values()[in.readByte()];
+        custom = in.readByte() != 0;
+        page = in.readInt();
+        pageCount = in.readInt();
+        id = in.readInt();
+        query = in.readString();
+        url = in.readString();
+        requestType = ApiRequestType.values[in.readByte()];
+        ArrayList x = null;
+        switch (GenericGallery.Type.values()[in.readByte()]) {
+            case LOCAL:
+                x = in.createTypedArrayList(LocalGallery.CREATOR);
+                break;
+            case SIMPLE:
+                x = in.createTypedArrayList(SimpleGallery.CREATOR);
+                break;
+            case COMPLETE:
+                x = in.createTypedArrayList(Gallery.CREATOR);
+                break;
+        }
+        galleries = (ArrayList<GenericGallery>) x;
+        tags = new HashSet<>(in.createTypedArrayList(Tag.CREATOR));
+        ranges = in.readParcelable(Ranges.class.getClassLoader());
+    }
+
+    private InspectorV3(Context context, InspectorResponse response) {
+        initialize(context, response);
+    }
+
+    /**
+     * This method will not run, but a WebView inside MainActivity will do it in its place
+     */
+    public static InspectorV3 favoriteInspector(Context context, String query, int page, InspectorResponse response) {
+        InspectorV3 inspector = new InspectorV3(context, response);
+        inspector.page = page;
+        inspector.pageCount = 0;
+        inspector.query = query == null ? "" : query;
+        inspector.requestType = ApiRequestType.FAVORITE;
+        inspector.tags = new HashSet<>(1);
+        inspector.createUrl();
+        return inspector;
+    }
+
+    /**
+     * @param favorite true if random online favorite, false for general random manga
+     */
+    public static InspectorV3 randomInspector(Context context, InspectorResponse response, boolean favorite) {
+        InspectorV3 inspector = new InspectorV3(context, response);
+        inspector.requestType = favorite ? ApiRequestType.RANDOM_FAVORITE : ApiRequestType.RANDOM;
+        inspector.createUrl();
+        return inspector;
+    }
+
+    public static InspectorV3 galleryInspector(Context context, int id, InspectorResponse response) {
+        InspectorV3 inspector = new InspectorV3(context, response);
+        inspector.id = id;
+        inspector.requestType = ApiRequestType.BYSINGLE;
+        inspector.createUrl();
+        return inspector;
+    }
+
+    public static InspectorV3 basicInspector(Context context, int page, InspectorResponse response) {
+        return searchInspector(context, null, null, page, Global.getSortType(), null, response);
+    }
+
+    public static InspectorV3 tagInspector(Context context, Tag tag, int page, SortType sortType, InspectorResponse response) {
+        Collection<Tag> tags;
+        if (!Global.isOnlyTag()) {
+            tags = getDefaultTags();
+            tags.add(tag);
+        } else {
+            tags = Collections.singleton(tag);
+        }
+        return searchInspector(context, null, tags, page, sortType, null, response);
+    }
+
+    public static InspectorV3 searchInspector(Context context, String query, Collection<Tag> tags, int page, SortType sortType, @Nullable Ranges ranges, InspectorResponse response) {
+        InspectorV3 inspector = new InspectorV3(context, response);
+        inspector.custom = tags != null;
+        inspector.tags = inspector.custom ? new HashSet<>(tags) : getDefaultTags();
+        inspector.tags.addAll(getLanguageTags(Global.getOnlyLanguage()));
+        inspector.page = page;
+        inspector.pageCount = 0;
+        inspector.ranges = ranges;
+        inspector.query = query == null ? "" : query;
+        inspector.sortType = sortType;
+        if (inspector.query.isEmpty() && (ranges == null || ranges.isDefault())) {
+            switch (inspector.tags.size()) {
+                case 0:
+                    inspector.requestType = ApiRequestType.BYALL;
+                    inspector.tryByAllPopular();
+                    break;
+                case 1:
+                    inspector.requestType = ApiRequestType.BYTAG;
+                    //else by search for the negative tag
+                    if (inspector.getTag().getStatus() != TagStatus.AVOIDED)
+                        break;
+                default:
+                    inspector.requestType = ApiRequestType.BYSEARCH;
+                    break;
+            }
+        } else inspector.requestType = ApiRequestType.BYSEARCH;
+        inspector.createUrl();
+        return inspector;
+    }
+
+    @NonNull
+    private static HashSet<Tag> getDefaultTags() {
+        HashSet<Tag> tags = new HashSet<>(Queries.TagTable.getAllStatus(TagStatus.ACCEPTED));
+        tags.addAll(getLanguageTags(Global.getOnlyLanguage()));
+        if (Global.removeAvoidedGalleries())
+            tags.addAll(Queries.TagTable.getAllStatus(TagStatus.AVOIDED));
+        tags.addAll(Queries.TagTable.getAllOnlineBlacklisted());
+        return tags;
+    }
+
+    private static Set<Tag> getLanguageTags(Language onlyLanguage) {
+        Set<Tag> tags = new HashSet<>();
+        if (onlyLanguage == null) return tags;
+        switch (onlyLanguage) {
+            case ENGLISH:
+                tags.add(Queries.TagTable.getTagById(SpecialTagIds.LANGUAGE_ENGLISH));
+                break;
+            case JAPANESE:
+                tags.add(Queries.TagTable.getTagById(SpecialTagIds.LANGUAGE_JAPANESE));
+                break;
+            case CHINESE:
+                tags.add(Queries.TagTable.getTagById(SpecialTagIds.LANGUAGE_CHINESE));
+                break;
+        }
+        return tags;
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(Parcel dest, int flags) {
+        if (sortType != null)
+            dest.writeByte((byte) (sortType.ordinal()));
+        else dest.writeByte((byte) SortType.RECENT_ALL_TIME.ordinal());
+        dest.writeByte((byte) (custom ? 1 : 0));
+        dest.writeInt(page);
+        dest.writeInt(pageCount);
+        dest.writeInt(id);
+        dest.writeString(query);
+        dest.writeString(url);
+        dest.writeByte(requestType.ordinal());
+        if (galleries == null || galleries.size() == 0)
+            dest.writeByte((byte) GenericGallery.Type.SIMPLE.ordinal());
+        else dest.writeByte((byte) galleries.get(0).getType().ordinal());
+        dest.writeTypedList(galleries);
+        dest.writeTypedList(new ArrayList<>(tags));
+        dest.writeParcelable(ranges, flags);
+    }
+
+    public String getSearchTitle() {
+        //triggered only when in searchMode
+        if (query.length() > 0) return query;
+        return url.replace(Utility.getBaseUrl() + "search/?q=", "").replace('+', ' ');
+    }
+
+    public void initialize(Context context, InspectorResponse response) {
+        this.response = response;
+        this.context = new WeakReference<>(context);
+    }
+
+    public InspectorResponse getResponse() {
+        return response;
+    }
+
+    public InspectorV3 cloneInspector(Context context, InspectorResponse response) {
+        InspectorV3 inspectorV3 = new InspectorV3(context, response);
+        inspectorV3.query = query;
+        inspectorV3.url = url;
+        inspectorV3.tags = tags;
+        inspectorV3.requestType = requestType;
+        inspectorV3.sortType = sortType;
+        inspectorV3.pageCount = pageCount;
+        inspectorV3.page = page;
+        inspectorV3.id = id;
+        inspectorV3.custom = custom;
+        inspectorV3.ranges = ranges;
+        return inspectorV3;
+    }
+
+    private void tryByAllPopular() {
+        if (sortType != SortType.RECENT_ALL_TIME) {
+            requestType = ApiRequestType.BYSEARCH;
+            query = "-nclientv2";
+        }
+    }
+
+    private void createUrl() {
+        String query;
+        try {
+            query = this.query == null ? null : URLEncoder.encode(this.query, "UTF-8");
+        } catch (UnsupportedEncodingException ignore) {
+            query = this.query;
+        }
+        StringBuilder builder = new StringBuilder(Utility.getBaseUrl());
+        if (requestType == ApiRequestType.BYALL) builder.append("?page=").append(page);
+        else if (requestType == ApiRequestType.RANDOM) builder.append("random/");
+        else if (requestType == ApiRequestType.RANDOM_FAVORITE) builder.append("favorites/random");
+        else if (requestType == ApiRequestType.BYSINGLE) builder.append("g/").append(id);
+        else if (requestType == ApiRequestType.FAVORITE) {
+            builder.append("favorites/");
+            if (query != null && query.length() > 0)
+                builder.append("?q=").append(query).append('&');
+            else builder.append('?');
+            builder.append("page=").append(page);
+        } else if (requestType == ApiRequestType.BYSEARCH || requestType == ApiRequestType.BYTAG) {
+            builder.append("search/?q=").append(query);
+            for (Tag tt : tags) {
+                if (builder.toString().contains(tt.toQueryTag(TagStatus.ACCEPTED))) continue;
+                builder.append('+').append(URLEncoder.encode(tt.toQueryTag()));
+            }
+            if (ranges != null)
+                builder.append('+').append(ranges.toQuery());
+            builder.append("&page=").append(page);
+            if (sortType.getUrlAddition() != null) {
+                builder.append("&sort=").append(sortType.getUrlAddition());
+            }
+        }
+        url = builder.toString().replace(' ', '+');
+        LogUtility.d("WWW: " + getBookmarkURL());
+    }
+
+    public void forceStart() {
+        forceStart = true;
+        start();
+    }
+
+    private String getBookmarkURL() {
+        if (page < 2) return url;
+        else return url.substring(0, url.lastIndexOf('=') + 1);
+    }
+
+    public boolean createDocument() throws IOException {
+        if (htmlDocument != null) return true;
+        Response response = Global.getClient(context.get()).newCall(new Request.Builder().url(url).build()).execute();
+        setHtmlDocument(Jsoup.parse(response.body().byteStream(), "UTF-8", Utility.getBaseUrl()));
+        response.close();
+        return response.code() == HttpURLConnection.HTTP_OK;
+    }
+
+    public void parseDocument() throws IOException, InvalidResponseException {
+        if (requestType.isSingle()) doSingle(htmlDocument.body());
+        else doSearch(htmlDocument.body());
+        htmlDocument = null;
+    }
+
+    public void setHtmlDocument(Document htmlDocument) {
+        this.htmlDocument = htmlDocument;
+    }
+
+    public boolean canParseDocument() {
+        return this.htmlDocument != null;
+    }
+
+    @Override
+    public synchronized void start() {
+        if (getState() != State.NEW) return;
+        if (forceStart || response.shouldStart(this))
+            super.start();
+    }
+
+    @Override
+    public void run() {
+        LogUtility.d("Starting download: " + url);
+        if (response != null) response.onStart();
+        try {
+            createDocument();
+            parseDocument();
+            if (response != null) {
+                response.onSuccess(galleries);
+            }
+        } catch (Exception e) {
+            if (response != null) response.onFailure(e);
+        }
+        if (response != null) response.onEnd();
+        LogUtility.d("Finished download: " + url);
+    }
+
+    private void filterDocumentTags() {
+        if (galleries == null || tags == null) return;
+        ArrayList<SimpleGallery> galleryTag = new ArrayList<>(galleries.size());
+        for (GenericGallery gal : galleries) {
+            assert gal instanceof SimpleGallery;
+            SimpleGallery gallery = (SimpleGallery) gal;
+            if (gallery.hasTags(tags)) {
+                galleryTag.add(gallery);
+            }
+        }
+        galleries.clear();
+        galleries.addAll(galleryTag);
+    }
+
+    private void doSingle(Element document) throws IOException, InvalidResponseException {
+        galleries = new ArrayList<>(1);
+        Elements scripts = document.getElementsByTag("script");
+        if (scripts.isEmpty())
+            throw new InvalidResponseException();
+        String json = trimScriptTag(scripts.last().html());
+        if (json == null)
+            throw new InvalidResponseException();
+        Element relContainer = document.getElementById("related-container");
+        Elements rel;
+        if (relContainer != null)
+            rel = relContainer.getElementsByClass("gallery");
+        else
+            rel = new Elements();
+        boolean isFavorite;
+        try {
+            isFavorite = document.getElementById("favorite").getElementsByTag("span").get(0).text().equals("Unfavorite");
+        } catch (Exception e) {
+            isFavorite = false;
+        }
+        LogUtility.d("is favorite? " + isFavorite);
+        galleries.add(new Gallery(context.get(), json, rel, isFavorite));
+    }
+
+    @Nullable
+    private String trimScriptTag(String scriptHtml) {
+        int s = scriptHtml.indexOf("parse");
+        if (s < 0) return null;
+        s += 7;
+        scriptHtml = scriptHtml.substring(s, scriptHtml.lastIndexOf(");") - 1);
+        scriptHtml = Utility.unescapeUnicodeString(scriptHtml);
+        if (scriptHtml.isEmpty()) return null;
+        return scriptHtml;
+    }
+
+    private void doSearch(Element document) throws InvalidResponseException {
+        Elements gal = document.getElementsByClass("gallery");
+        galleries = new ArrayList<>(gal.size());
+        for (Element e : gal) galleries.add(new SimpleGallery(context.get(), e));
+        gal = document.getElementsByClass("last");
+        pageCount = gal.size() == 0 ? Math.max(1, page) : findTotal(gal.last());
+        if (document.getElementById("content") == null)
+            throw new InvalidResponseException();
+        if (Global.isExactTagMatch())
+            filterDocumentTags();
+    }
+
+    private int findTotal(Element e) {
+        String temp = e.attr("href");
+
+        try {
+            return Integer.parseInt(Uri.parse(temp).getQueryParameter("page"));
+        } catch (Exception ignore) {
+            return 1;
+        }
+    }
+
+    public void setSortType(SortType sortType) {
+        this.sortType = sortType;
+        createUrl();
+    }
+
+    public int getPage() {
+        return page;
+    }
+
+    public void setPage(int page) {
+        this.page = page;
+        createUrl();
+    }
+
+    public List<GenericGallery> getGalleries() {
+        return galleries;
+    }
+
+    public String getUrl() {
+        return url;
+    }
+
+    public ApiRequestType getRequestType() {
+        return requestType;
+    }
+
+    public int getPageCount() {
+        return pageCount;
+    }
+
+    public boolean isCustom() {
+        return custom;
+    }
+
+    public String getQuery() {
+        return query;
+    }
+
+    public Tag getTag() {
+        Tag t = null;
+        if (tags == null) return null;
+        for (Tag tt : tags) {
+            if (tt.getType() != TagType.LANGUAGE)
+                return tt;
+            t = tt;
+        }
+        return t;
+    }
+
+    public static class InvalidResponseException extends Exception {
+        public InvalidResponseException() {
+            super();
+        }
+    }
+
+    public interface InspectorResponse {
+        boolean shouldStart(InspectorV3 inspector);
+
+        void onSuccess(List<GenericGallery> galleries);
+
+        void onFailure(Exception e);
+
+        void onStart();
+
+        void onEnd();
+    }
+
+    public static abstract class DefaultInspectorResponse implements InspectorResponse {
+        @Override
+        public boolean shouldStart(InspectorV3 inspector) {
+            return true;
+        }
+
+        @Override
+        public void onStart() {
+        }
+
+        @Override
+        public void onEnd() {
+        }
+
+        @Override
+        public void onSuccess(List<GenericGallery> galleries) {
+        }
+
+        @Override
+        public void onFailure(Exception e) {
+            LogUtility.e(e.getLocalizedMessage(), e);
+        }
+    }
+}

+ 63 - 0
app/src/main/java/com/dar/nbook/api/RandomLoader.java

@@ -0,0 +1,63 @@
+package com.dar.nbook.api;
+
+import com.dar.nbook.RandomActivity;
+import com.dar.nbook.api.components.Gallery;
+import com.dar.nbook.api.components.GenericGallery;
+import com.dar.nbook.utility.ImageDownloadUtility;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class RandomLoader {
+    private static final int MAXLOADED = 5;
+    private final List<Gallery> galleries;
+    private final RandomActivity activity;
+    private boolean galleryHasBeenRequested;
+
+    private final InspectorV3.InspectorResponse response = new InspectorV3.DefaultInspectorResponse() {
+        @Override
+        public void onFailure(Exception e) {
+            loadRandomGallery();
+        }
+
+        @Override
+        public void onSuccess(List<GenericGallery> galleryList) {
+            if (galleryList.size() == 0 || !galleryList.get(0).isValid()) {
+                loadRandomGallery();
+                return;
+            }
+            Gallery gallery = (Gallery) galleryList.get(0);
+            galleries.add(gallery);
+            ImageDownloadUtility.preloadImage(activity, gallery.getCover());
+            if (galleryHasBeenRequested)
+                requestGallery();//requestGallery will call loadRandomGallery
+            else if (galleries.size() < MAXLOADED) loadRandomGallery();
+        }
+    };
+
+    public RandomLoader(RandomActivity activity) {
+        this.activity = activity;
+        galleries = new ArrayList<>(MAXLOADED);
+        galleryHasBeenRequested = RandomActivity.loadedGallery == null;
+        loadRandomGallery();
+    }
+
+    private void loadRandomGallery() {
+        if (galleries.size() >= MAXLOADED) return;
+        InspectorV3.randomInspector(activity, response, false).start();
+    }
+
+    public void requestGallery() {
+        galleryHasBeenRequested = true;
+        for (int i = 0; i < galleries.size(); i++) {
+            if (galleries.get(i) == null)
+                galleries.remove(i--);
+        }
+        if (galleries.size() > 0) {
+            Gallery gallery = galleries.remove(0);
+            activity.runOnUiThread(() -> activity.loadGallery(gallery));
+            galleryHasBeenRequested = false;
+        }
+        loadRandomGallery();
+    }
+}

+ 212 - 0
app/src/main/java/com/dar/nbook/api/SimpleGallery.java

@@ -0,0 +1,212 @@
+package com.dar.nbook.api;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Parcel;
+
+import androidx.annotation.NonNull;
+
+import com.dar.nbook.api.components.Gallery;
+import com.dar.nbook.api.components.GalleryData;
+import com.dar.nbook.api.components.GenericGallery;
+import com.dar.nbook.api.components.Page;
+import com.dar.nbook.api.components.Tag;
+import com.dar.nbook.api.components.TagList;
+import com.dar.nbook.api.enums.ImageExt;
+import com.dar.nbook.api.enums.Language;
+import com.dar.nbook.api.enums.TagStatus;
+import com.dar.nbook.async.database.Queries;
+import com.dar.nbook.components.classes.Size;
+import com.dar.nbook.files.GalleryFolder;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.utility.LogUtility;
+import com.dar.nbook.utility.Utility;
+
+import org.jsoup.nodes.Element;
+
+import java.util.Collection;
+import java.util.Locale;
+
+public class SimpleGallery extends GenericGallery {
+    public static final Creator<SimpleGallery> CREATOR = new Creator<SimpleGallery>() {
+        @Override
+        public SimpleGallery createFromParcel(Parcel in) {
+            return new SimpleGallery(in);
+        }
+
+        @Override
+        public SimpleGallery[] newArray(int size) {
+            return new SimpleGallery[size];
+        }
+    };
+    private final String title;
+    private final ImageExt thumbnail;
+    private final int id, mediaId;
+    private Language language = Language.UNKNOWN;
+    private TagList tags;
+
+    public SimpleGallery(Parcel in) {
+        title = in.readString();
+        id = in.readInt();
+        mediaId = in.readInt();
+        thumbnail = ImageExt.values()[in.readByte()];
+        language = Language.values()[in.readByte()];
+    }
+
+    public boolean hasTag(Tag tag) {
+        return tags.hasTag(tag);
+    }
+
+    public boolean hasTags(Collection<Tag> tags) {
+        return this.tags.hasTags(tags);
+    }
+
+    public SimpleGallery(Cursor c) {
+        title = c.getString(c.getColumnIndex(Queries.HistoryTable.TITLE));
+        id = c.getInt(c.getColumnIndex(Queries.HistoryTable.ID));
+        mediaId = c.getInt(c.getColumnIndex(Queries.HistoryTable.MEDIAID));
+        thumbnail = ImageExt.values()[c.getInt(c.getColumnIndex(Queries.HistoryTable.THUMB))];
+    }
+
+    public SimpleGallery(Context context, Element e) {
+        String temp;
+        String tags = e.attr("data-tags").replace(' ', ',');
+        this.tags = Queries.TagTable.getTagsFromListOfInt(tags);
+        language = Gallery.loadLanguage(this.tags);
+        Element a = e.getElementsByTag("a").first();
+        temp = a.attr("href");
+        id = Integer.parseInt(temp.substring(3, temp.length() - 1));
+        a = e.getElementsByTag("img").first();
+        temp = a.hasAttr("data-src") ? a.attr("data-src") : a.attr("src");
+        mediaId = Integer.parseInt(temp.substring(temp.indexOf("galleries") + 10, temp.lastIndexOf('/')));
+        thumbnail = Page.charToExt(temp.charAt(temp.length() - 3));
+        title = e.getElementsByTag("div").first().text();
+        if (context != null && id > Global.getMaxId()) Global.updateMaxId(context, id);
+    }
+
+    public SimpleGallery(Gallery gallery) {
+        title = gallery.getTitle();
+        mediaId = gallery.getMediaId();
+        id = gallery.getId();
+        thumbnail = gallery.getThumb();
+    }
+
+    private static String extToString(ImageExt ext) {
+        switch (ext) {
+            case GIF:
+                return "gif";
+            case PNG:
+                return "png";
+            case JPG:
+                return "jpg";
+        }
+        return null;
+    }
+
+    public Language getLanguage() {
+        return language;
+    }
+
+    public boolean hasIgnoredTags(String s) {
+        if (tags == null) return false;
+        for (Tag t : tags.getAllTagsList())
+            if (s.contains(t.toQueryTag(TagStatus.AVOIDED))) {
+                LogUtility.d("Found: " + s + ",," + t.toQueryTag());
+                return true;
+            }
+        return false;
+    }
+
+    @Override
+    public int getId() {
+        return id;
+    }
+
+    @Override
+    public Type getType() {
+        return Type.SIMPLE;
+    }
+
+    @Override
+    public int getPageCount() {
+        return 0;
+    }
+
+    @Override
+    public boolean isValid() {
+        return id > 0;
+    }
+
+    @Override
+    @NonNull
+    public String getTitle() {
+        return title;
+    }
+
+    @Override
+    public Size getMaxSize() {
+        return null;
+    }
+
+    @Override
+    public Size getMinSize() {
+        return null;
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(Parcel dest, int flags) {
+        dest.writeString(title);
+        dest.writeInt(id);
+        dest.writeInt(mediaId);
+        dest.writeByte((byte) thumbnail.ordinal());
+        dest.writeByte((byte) language.ordinal());
+        //TAGS AREN'T WRITTEN
+    }
+
+    public Uri getThumbnail() {
+        if (thumbnail == ImageExt.GIF) {
+            return Uri.parse(String.format(Locale.US, "https://i." + Utility.getHost() + "/galleries/%d/1.gif", mediaId));
+        }
+        return Uri.parse(String.format(Locale.US, "https://t." + Utility.getHost() + "/galleries/%d/thumb.%s", mediaId, extToString(thumbnail)));
+    }
+
+    public int getMediaId() {
+        return mediaId;
+    }
+
+    public ImageExt getThumb() {
+        return thumbnail;
+    }
+
+    @Override
+    public GalleryFolder getGalleryFolder() {
+        return null;
+    }
+
+    @Override
+    public String toString() {
+        return "SimpleGallery{" +
+            "language=" + language +
+            ", title='" + title + '\'' +
+            ", thumbnail=" + thumbnail +
+            ", id=" + id +
+            ", mediaId=" + mediaId +
+            '}';
+    }
+
+    @Override
+    public boolean hasGalleryData() {
+        return false;
+    }
+
+    @Override
+    public GalleryData getGalleryData() {
+        return null;
+    }
+}

+ 100 - 0
app/src/main/java/com/dar/nbook/api/comments/Comment.java

@@ -0,0 +1,100 @@
+package com.dar.nbook.api.comments;
+
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.util.JsonReader;
+import android.util.JsonToken;
+
+import java.io.IOException;
+import java.util.Date;
+
+public class Comment implements Parcelable {
+    public static final Creator<Comment> CREATOR = new Creator<Comment>() {
+        @Override
+        public Comment createFromParcel(Parcel in) {
+            return new Comment(in);
+        }
+
+        @Override
+        public Comment[] newArray(int size) {
+            return new Comment[size];
+        }
+    };
+    private int id;
+    private User poster;
+    private Date postDate;
+    private String body;
+
+    public Comment(JsonReader reader) throws IOException {
+        reader.beginObject();
+        while (reader.peek() != JsonToken.END_OBJECT) {
+            switch (reader.nextName()) {
+                case "id":
+                    id = reader.nextInt();
+                    break;
+                case "post_date":
+                    postDate = new Date(reader.nextLong() * 1000);
+                    break;
+                case "body":
+                    body = reader.nextString();
+                    break;
+                case "poster":
+                    poster = new User(reader);
+                    break;
+                default:
+                    reader.skipValue();
+                    break;
+            }
+        }
+        reader.endObject();
+    }
+
+    protected Comment(Parcel in) {
+        id = in.readInt();
+        poster = in.readParcelable(User.class.getClassLoader());
+        body = in.readString();
+        postDate = new Date(in.readLong());
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(Parcel dest, int flags) {
+        dest.writeInt(id);
+        dest.writeParcelable(poster, flags);
+        dest.writeString(body);
+        dest.writeLong(postDate.getTime());
+    }
+
+    public int getId() {
+        return id;
+    }
+
+    public User getPoster() {
+        return poster;
+    }
+
+    public Date getPostDate() {
+        return postDate;
+    }
+
+    public String getComment() {
+        return body;
+    }
+
+    public int getPosterId() {
+        return poster.getId();
+    }
+
+    public String getUsername() {
+        return poster.getUsername();
+    }
+
+    public Uri getAvatarUrl() {
+        return poster.getAvatarUrl();
+    }
+}

+ 68 - 0
app/src/main/java/com/dar/nbook/api/comments/CommentsFetcher.java

@@ -0,0 +1,68 @@
+package com.dar.nbook.api.comments;
+
+import android.util.JsonReader;
+import android.util.JsonToken;
+
+import com.dar.nbook.CommentActivity;
+import com.dar.nbook.adapters.CommentAdapter;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.utility.Utility;
+
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+import okhttp3.Request;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+
+public class CommentsFetcher extends Thread {
+    private static final String COMMENT_API_URL = Utility.getBaseUrl() + "api/gallery/%d/comments";
+    private final int id;
+    private final CommentActivity commentActivity;
+    private final List<Comment> comments = new ArrayList<>();
+
+    public CommentsFetcher(CommentActivity commentActivity, int id) {
+        this.id = id;
+        this.commentActivity = commentActivity;
+    }
+
+    @Override
+    public void run() {
+        populateComments();
+        postResult();
+    }
+
+    private void postResult() {
+        CommentAdapter commentAdapter = new CommentAdapter(commentActivity, comments, id);
+        commentActivity.setAdapter(commentAdapter);
+        commentActivity.runOnUiThread(() -> {
+            commentActivity.getRecycler().setAdapter(commentAdapter);
+            commentActivity.getRefresher().setRefreshing(false);
+        });
+    }
+
+    private void populateComments() {
+        String url = String.format(Locale.US, COMMENT_API_URL, id);
+        try {
+            Response response = Global.getClient().newCall(new Request.Builder().url(url).build()).execute();
+            ResponseBody body = response.body();
+            if (body == null) {
+                response.close();
+                return;
+            }
+            JsonReader reader = new JsonReader(new InputStreamReader(body.byteStream()));
+            if(reader.peek() == JsonToken.BEGIN_ARRAY) {
+                reader.beginArray();
+                while (reader.hasNext())
+                    comments.add(new Comment(reader));
+            }
+            reader.close();
+            response.close();
+        } catch (IOException e) {
+            e.printStackTrace();
+        }
+    }
+}

+ 79 - 0
app/src/main/java/com/dar/nbook/api/comments/User.java

@@ -0,0 +1,79 @@
+package com.dar.nbook.api.comments;
+
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.util.JsonReader;
+import android.util.JsonToken;
+
+import com.dar.nbook.utility.Utility;
+
+import java.io.IOException;
+import java.util.Locale;
+
+public class User implements Parcelable {
+    public static final Creator<User> CREATOR = new Creator<User>() {
+        @Override
+        public User createFromParcel(Parcel in) {
+            return new User(in);
+        }
+
+        @Override
+        public User[] newArray(int size) {
+            return new User[size];
+        }
+    };
+    private int id;
+    private String username, avatarUrl;
+
+    public User(JsonReader reader) throws IOException {
+        reader.beginObject();
+        while (reader.peek() != JsonToken.END_OBJECT) {
+            switch (reader.nextName()) {
+                case "id":
+                    id = reader.nextInt();
+                    break;
+                case "post_date":
+                    username = reader.nextString();
+                    break;
+                case "avatar_url":
+                    avatarUrl = reader.nextString();
+                    break;
+                default:
+                    reader.skipValue();
+                    break;
+            }
+        }
+        reader.endObject();
+    }
+
+    protected User(Parcel in) {
+        id = in.readInt();
+        username = in.readString();
+        avatarUrl = in.readString();
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(Parcel dest, int flags) {
+        dest.writeInt(id);
+        dest.writeString(username);
+        dest.writeString(avatarUrl);
+    }
+
+    public int getId() {
+        return id;
+    }
+
+    public Uri getAvatarUrl() {
+        return Uri.parse(String.format(Locale.US, "https://i.%s/%s", Utility.getHost(), avatarUrl));
+    }
+
+    public String getUsername() {
+        return username;
+    }
+}

+ 374 - 0
app/src/main/java/com/dar/nbook/api/components/Gallery.java

@@ -0,0 +1,374 @@
+package com.dar.nbook.api.components;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Parcel;
+import android.util.JsonReader;
+import android.util.JsonWriter;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.dar.nbook.api.SimpleGallery;
+import com.dar.nbook.api.enums.ImageExt;
+import com.dar.nbook.api.enums.Language;
+import com.dar.nbook.api.enums.SpecialTagIds;
+import com.dar.nbook.api.enums.TagStatus;
+import com.dar.nbook.api.enums.TagType;
+import com.dar.nbook.api.enums.TitleType;
+import com.dar.nbook.async.database.Queries;
+import com.dar.nbook.components.classes.Size;
+import com.dar.nbook.files.GalleryFolder;
+import com.dar.nbook.files.PageFile;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.utility.LogUtility;
+import com.dar.nbook.utility.Utility;
+
+import org.jsoup.nodes.Element;
+import org.jsoup.select.Elements;
+
+import java.io.IOException;
+import java.io.StringReader;
+import java.io.Writer;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+
+public class Gallery extends GenericGallery {
+    public static final Creator<Gallery> CREATOR = new Creator<Gallery>() {
+        @Override
+        public Gallery createFromParcel(Parcel in) {
+            LogUtility.d("Reading to parcel");
+            return new Gallery(in);
+        }
+
+        @Override
+        public Gallery[] newArray(int size) {
+            return new Gallery[size];
+        }
+    };
+    @NonNull
+    private final GalleryData galleryData;
+    @Nullable
+    private final GalleryFolder folder;
+    private final boolean onlineFavorite;
+    private List<SimpleGallery> related = new ArrayList<>();
+    private Language language = Language.UNKNOWN;
+    private Size maxSize = new Size(0, 0), minSize = new Size(Integer.MAX_VALUE, Integer.MAX_VALUE);
+
+    public Gallery(Context context, String json, Elements related, boolean isFavorite) throws IOException {
+        LogUtility.d("Found JSON: " + json);
+        JsonReader reader = new JsonReader(new StringReader(json));
+        this.related = new ArrayList<>(related.size());
+        for (Element e : related) this.related.add(new SimpleGallery(context, e));
+        galleryData = new GalleryData(reader);
+        folder = GalleryFolder.fromId(context, galleryData.getId());
+        calculateSizes(galleryData);
+        language = loadLanguage(getTags());
+        onlineFavorite = isFavorite;
+    }
+
+    public Gallery(Cursor cursor, TagList tags) throws IOException {
+        maxSize.setWidth(cursor.getInt(Queries.getColumnFromName(cursor, Queries.GalleryTable.MAX_WIDTH)));
+        maxSize.setHeight(cursor.getInt(Queries.getColumnFromName(cursor, Queries.GalleryTable.MAX_HEIGHT)));
+        minSize.setWidth(cursor.getInt(Queries.getColumnFromName(cursor, Queries.GalleryTable.MIN_WIDTH)));
+        minSize.setHeight(cursor.getInt(Queries.getColumnFromName(cursor, Queries.GalleryTable.MIN_HEIGHT)));
+        galleryData = new GalleryData(cursor, tags);
+        folder = GalleryFolder.fromId(null, galleryData.getId());
+        this.language = loadLanguage(tags);
+        onlineFavorite = false;
+        LogUtility.d(toString());
+    }
+
+    private Gallery() {
+        onlineFavorite = false;
+        galleryData = GalleryData.fakeData();
+        folder = null;
+    }
+
+    public Gallery(Parcel in) {
+        maxSize = in.readParcelable(Size.class.getClassLoader());
+        minSize = in.readParcelable(Size.class.getClassLoader());
+        galleryData = in.readParcelable(GalleryData.class.getClassLoader());
+        folder = in.readParcelable(GalleryFolder.class.getClassLoader());
+        in.readTypedList(related, SimpleGallery.CREATOR);
+        onlineFavorite = in.readByte() == 1;
+        language = loadLanguage(getTags());
+    }
+
+    public static String getPathTitle(@Nullable String title, @NonNull String defaultValue) {
+        if (title == null) return defaultValue;
+        String pathTitle = title.replace('/', ' ').replaceAll("[/|\\\\*\"'?:<>]", " ");
+        while (pathTitle.contains("  "))
+            pathTitle = pathTitle.replace("  ", " ");
+        return pathTitle.trim();
+    }
+
+    public static String getPathTitle(@Nullable String title) {
+        return getPathTitle(title, "");
+    }
+
+    public static Language loadLanguage(TagList tags) {
+        for (Tag tag : tags.retrieveForType(TagType.LANGUAGE)) {
+            switch (tag.getId()) {
+                case SpecialTagIds.LANGUAGE_JAPANESE:
+                    return Language.JAPANESE;
+                case SpecialTagIds.LANGUAGE_ENGLISH:
+                    return Language.ENGLISH;
+                case SpecialTagIds.LANGUAGE_CHINESE:
+                    return Language.CHINESE;
+            }
+        }
+        return Language.UNKNOWN;
+    }
+
+    public static Gallery emptyGallery() {
+        return new Gallery();
+    }
+
+    private void calculateSizes(GalleryData galleryData) {
+        Size actualSize;
+        for (Page page : galleryData.getPages()) {
+            actualSize = page.getSize();
+            if (actualSize.getWidth() > maxSize.getWidth()) maxSize.setWidth(actualSize.getWidth());
+            if (actualSize.getHeight() > maxSize.getHeight())
+                maxSize.setHeight(actualSize.getHeight());
+            if (actualSize.getWidth() < minSize.getWidth()) minSize.setWidth(actualSize.getWidth());
+            if (actualSize.getHeight() < minSize.getHeight())
+                minSize.setHeight(actualSize.getHeight());
+        }
+    }
+
+    public boolean isOnlineFavorite() {
+        return onlineFavorite;
+    }
+
+    @NonNull
+    public String getPathTitle() {
+        return getPathTitle(getTitle());
+    }
+
+    public Uri getCover() {
+        if (Global.getDownloadPolicy() == Global.DataUsageType.THUMBNAIL) return getThumbnail();
+        if (galleryData.getCover().getImageExt() == ImageExt.GIF) return getHighPage(0);
+        return Uri.parse(String.format(Locale.US, "https://t." + Utility.getHost() + "/galleries/%d/cover.%s", getMediaId(), galleryData.getCover().extToString()));
+    }
+
+    public ImageExt getThumb() {
+        return galleryData.getThumbnail().getImageExt();
+    }
+
+    public Uri getThumbnail() {
+        if (galleryData.getCover().getImageExt() == ImageExt.GIF) return getHighPage(0);
+        return Uri.parse(String.format(Locale.US, "https://t." + Utility.getHost() + "/galleries/%d/thumb.%s", getMediaId(), galleryData.getThumbnail().extToString()));
+    }
+
+    private @Nullable
+    Uri getFileUri(int page) {
+        if (folder == null) return null;
+        PageFile f = folder.getPage(page + 1);
+        if (f == null) return null;
+        return f.toUri();
+    }
+
+    public Uri getPageUrl(int page) {
+        if (Global.getDownloadPolicy() == Global.DataUsageType.THUMBNAIL) return getLowPage(page);
+        Uri uri = getFileUri(page);
+        if (uri != null) return uri;
+        return getHighPage(page);
+    }
+
+    public Uri getHighPage(int page) {
+        return Uri.parse(String.format(Locale.US, "https://i." + Utility.getHost() + "/galleries/%d/%d.%s", getMediaId(), page + 1, getPageExtension(page)));
+    }
+
+    public Uri getLowPage(int page) {
+        Uri uri = getFileUri(page);
+        if (uri != null) return uri;
+        return Uri.parse(String.format(Locale.US, "https://t." + Utility.getHost() + "/galleries/%d/%dt.%s", getMediaId(), page + 1, getPageExtension(page)));
+    }
+
+    public String getPageExtension(int page) {
+        return getPage(page).extToString();
+    }
+
+    private Page getPage(int index) {
+        return galleryData.getPage(index);
+    }
+
+    public SimpleGallery toSimpleGallery() {
+        return new SimpleGallery(this);
+    }
+
+    public boolean isRelatedLoaded() {
+        return related != null;
+    }
+
+    public List<SimpleGallery> getRelated() {
+        return related;
+    }
+
+    @Override
+    public boolean isValid() {
+        return galleryData.isValid();
+    }
+
+    @Override
+    public Size getMaxSize() {
+        return maxSize;
+    }
+
+    @Override
+    public Size getMinSize() {
+        return minSize;
+    }
+
+    @Override
+    public GalleryFolder getGalleryFolder() {
+        return folder;
+    }
+
+    @NonNull
+    @Override
+    public String getTitle() {
+        String x = getTitle(Global.getTitleType());
+        if (x.length() > 2) return x;
+        if ((x = getTitle(TitleType.PRETTY)).length() > 2) return x;
+        if ((x = getTitle(TitleType.ENGLISH)).length() > 2) return x;
+        if ((x = getTitle(TitleType.JAPANESE)).length() > 2) return x;
+        return "Unnamed";
+    }
+
+    public String getTitle(TitleType x) {
+        return galleryData.getTitle(x);
+    }
+
+    public Language getLanguage() {
+        return language;
+    }
+
+    public Date getUploadDate() {
+        return galleryData.getUploadDate();
+    }
+
+    public int getFavoriteCount() {
+        return galleryData.getFavoriteCount();
+    }
+
+    @Override
+    public int getId() {
+        return galleryData.getId();
+    }
+
+    public TagList getTags() {
+        return galleryData.getTags();
+    }
+
+    @Override
+    public int getPageCount() {
+        return galleryData.getPageCount();
+    }
+
+    @Override
+    public Type getType() {
+        return Type.COMPLETE;
+    }
+
+    public int getMediaId() {
+        return galleryData.getMediaId();
+    }
+
+    public boolean hasIgnoredTags(Set<Tag> s) {
+        for (Tag t : getTags().getAllTagsSet())
+            if (s.contains(t)) {
+                LogUtility.d("Found: " + s + ",," + t.toQueryTag());
+                return true;
+            }
+        return false;
+    }
+
+    public boolean hasIgnoredTags() {
+        Set<Tag> tags = new HashSet<>(Queries.TagTable.getAllStatus(TagStatus.AVOIDED));
+        if (Global.removeAvoidedGalleries())
+            tags.addAll(Queries.TagTable.getAllOnlineBlacklisted());
+        return hasIgnoredTags(tags);
+    }
+
+    @Override
+    public boolean hasGalleryData() {
+        return true;
+    }
+
+    @NonNull
+    @Override
+    public GalleryData getGalleryData() {
+        return galleryData;
+    }
+
+    public void jsonWrite(Writer ww) throws IOException {
+        //images aren't saved
+        JsonWriter writer = new JsonWriter(ww);
+        writer.beginObject();
+        writer.name("id").value(getId());
+        writer.name("media_id").value(getMediaId());
+        writer.name("upload_date").value(getUploadDate().getTime() / 1000);
+        writer.name("num_favorites").value(getFavoriteCount());
+        toJsonTitle(writer);
+        toJsonTags(writer);
+
+        writer.endObject();
+        writer.flush();
+    }
+
+    private void toJsonTags(JsonWriter writer) throws IOException {
+        writer.name("tags");
+        writer.beginArray();
+        for (Tag t : getTags().getAllTagsSet())
+            t.writeJson(writer);
+        writer.endArray();
+    }
+
+    private void toJsonTitle(JsonWriter writer) throws IOException {
+        String title;
+        writer.name("title");
+        writer.beginObject();
+        if ((title = getTitle(TitleType.JAPANESE)) != null)
+            writer.name("japanese").value(title);
+        if ((title = getTitle(TitleType.PRETTY)) != null)
+            writer.name("pretty").value(title);
+        if ((title = getTitle(TitleType.ENGLISH)) != null)
+            writer.name("english").value(title);
+        writer.endObject();
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(Parcel dest, int flags) {
+        dest.writeParcelable(maxSize, flags);
+        dest.writeParcelable(minSize, flags);
+        dest.writeParcelable(galleryData, flags);
+        dest.writeParcelable(folder, flags);
+        dest.writeTypedList(related);
+        dest.writeByte((byte) (onlineFavorite ? 1 : 0));
+    }
+
+    @NonNull
+    @Override
+    public String toString() {
+        return "Gallery{" +
+            "galleryData=" + galleryData +
+            ", language=" + language +
+            ", maxSize=" + maxSize +
+            ", minSize=" + minSize +
+            ", onlineFavorite=" + onlineFavorite +
+            '}';
+    }
+}

+ 365 - 0
app/src/main/java/com/dar/nbook/api/components/GalleryData.java

@@ -0,0 +1,365 @@
+package com.dar.nbook.api.components;
+
+import android.database.Cursor;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.util.JsonReader;
+import android.util.JsonToken;
+
+import androidx.annotation.NonNull;
+
+import com.dar.nbook.api.enums.ImageExt;
+import com.dar.nbook.api.enums.ImageType;
+import com.dar.nbook.api.enums.SpecialTagIds;
+import com.dar.nbook.api.enums.TitleType;
+import com.dar.nbook.async.database.Queries;
+import com.dar.nbook.utility.Utility;
+
+import java.io.IOException;
+import java.io.StringReader;
+import java.io.StringWriter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.Objects;
+
+public class GalleryData implements Parcelable {
+    public static final Creator<GalleryData> CREATOR = new Creator<GalleryData>() {
+        @Override
+        public GalleryData createFromParcel(Parcel in) {
+            return new GalleryData(in);
+        }
+
+        @Override
+        public GalleryData[] newArray(int size) {
+            return new GalleryData[size];
+        }
+    };
+    @NonNull
+    private Date uploadDate = new Date(0);
+    private int favoriteCount, id, pageCount, mediaId;
+    @NonNull
+    private String[] titles = new String[]{"", "", ""};
+    @NonNull
+    private TagList tags = new TagList();
+    @NonNull
+    private Page cover = new Page(), thumbnail = new Page();
+    @NonNull
+    private ArrayList<Page> pages = new ArrayList<>();
+    private boolean valid = true;
+
+    private GalleryData() {
+    }
+
+    public GalleryData(JsonReader jr) throws IOException {
+        parseJSON(jr);
+    }
+
+    public GalleryData(Cursor cursor, @NonNull TagList tagList) throws IOException {
+        id = cursor.getInt(Queries.getColumnFromName(cursor, Queries.GalleryTable.IDGALLERY));
+        mediaId = cursor.getInt(Queries.getColumnFromName(cursor, Queries.GalleryTable.MEDIAID));
+        favoriteCount = cursor.getInt(Queries.getColumnFromName(cursor, Queries.GalleryTable.FAVORITE_COUNT));
+
+        titles[TitleType.JAPANESE.ordinal()] = cursor.getString(Queries.getColumnFromName(cursor, Queries.GalleryTable.TITLE_JP));
+        titles[TitleType.PRETTY.ordinal()] = cursor.getString(Queries.getColumnFromName(cursor, Queries.GalleryTable.TITLE_PRETTY));
+        titles[TitleType.ENGLISH.ordinal()] = cursor.getString(Queries.getColumnFromName(cursor, Queries.GalleryTable.TITLE_ENG));
+
+        uploadDate = new Date(cursor.getLong(Queries.getColumnFromName(cursor, Queries.GalleryTable.UPLOAD)));
+        readPagePath(cursor.getString(Queries.getColumnFromName(cursor, Queries.GalleryTable.PAGES)));
+        pageCount = pages.size();
+        this.tags = tagList;
+    }
+
+    protected GalleryData(Parcel in) {
+        uploadDate = new Date(in.readLong());
+        favoriteCount = in.readInt();
+        id = in.readInt();
+        pageCount = in.readInt();
+        mediaId = in.readInt();
+        titles = Objects.requireNonNull(in.createStringArray());
+        tags = Objects.requireNonNull(in.readParcelable(TagList.class.getClassLoader()));
+        cover = Objects.requireNonNull(in.readParcelable(Page.class.getClassLoader()));
+        thumbnail = Objects.requireNonNull(in.readParcelable(Page.class.getClassLoader()));
+        pages = Objects.requireNonNull(in.createTypedArrayList(Page.CREATOR));
+        valid = in.readByte() != 0;
+    }
+
+    public static GalleryData fakeData() {
+        GalleryData galleryData = new GalleryData();
+        galleryData.id = SpecialTagIds.INVALID_ID;
+        galleryData.favoriteCount = -1;
+        galleryData.pageCount = -1;
+        galleryData.mediaId = SpecialTagIds.INVALID_ID;
+        galleryData.pages.trimToSize();
+        galleryData.valid = false;
+        return galleryData;
+    }
+
+    private void parseJSON(JsonReader jr) throws IOException {
+        jr.beginObject();
+        while (jr.peek() != JsonToken.END_OBJECT) {
+            switch (jr.nextName()) {
+                case "upload_date":
+                    uploadDate = new Date(jr.nextLong() * 1000);
+                    break;
+                case "num_favorites":
+                    favoriteCount = jr.nextInt();
+                    break;
+                case "num_pages":
+                    pageCount = jr.nextInt();
+                    break;
+                case "media_id":
+                    mediaId = jr.nextInt();
+                    break;
+                case "id":
+                    id = jr.nextInt();
+                    break;
+                case "images":
+                    readImages(jr);
+                    break;
+                case "title":
+                    readTitles(jr);
+                    break;
+                case "tags":
+                    readTags(jr);
+                    break;
+                case "error":
+                    jr.skipValue();
+                    valid = false;
+                    break;
+                default:
+                    jr.skipValue();
+                    break;
+            }
+        }
+        jr.endObject();
+    }
+
+    private void setTitle(TitleType type, String title) {
+        titles[type.ordinal()] = Utility.unescapeUnicodeString(title);
+    }
+
+    private void readTitles(JsonReader jr) throws IOException {
+        jr.beginObject();
+        while (jr.peek() != JsonToken.END_OBJECT) {
+            switch (jr.nextName()) {
+                case "japanese":
+                    setTitle(TitleType.JAPANESE, jr.peek() != JsonToken.NULL ? jr.nextString() : "");
+                    break;
+                case "english":
+                    setTitle(TitleType.ENGLISH, jr.peek() != JsonToken.NULL ? jr.nextString() : "");
+                    break;
+                case "pretty":
+                    setTitle(TitleType.PRETTY, jr.peek() != JsonToken.NULL ? jr.nextString() : "");
+                    break;
+                default:
+                    jr.skipValue();
+                    break;
+            }
+            if (jr.peek() == JsonToken.NULL) jr.skipValue();
+        }
+        jr.endObject();
+    }
+
+    private void readTags(JsonReader jr) throws IOException {
+        jr.beginArray();
+        while (jr.hasNext()) {
+            Tag createdTag = new Tag(jr);
+            Queries.TagTable.insert(createdTag);
+            tags.addTag(createdTag);
+        }
+        jr.endArray();
+        tags.sort((o1, o2) -> o2.getCount() - o1.getCount());
+    }
+
+    private void readImages(JsonReader jr) throws IOException {
+        int actualPage = 0;
+        jr.beginObject();
+        while (jr.peek() != JsonToken.END_OBJECT) {
+            switch (jr.nextName()) {
+                case "cover":
+                    cover = new Page(ImageType.COVER, jr);
+                    break;
+                case "thumbnail":
+                    thumbnail = new Page(ImageType.THUMBNAIL, jr);
+                    break;
+                case "pages":
+                    jr.beginArray();
+                    while (jr.hasNext())
+                        pages.add(new Page(ImageType.PAGE, jr, actualPage++));
+                    jr.endArray();
+                    break;
+                default:
+                    jr.skipValue();
+                    break;
+            }
+        }
+        jr.endObject();
+        pages.trimToSize();
+    }
+
+    public Date getUploadDate() {
+        return uploadDate;
+    }
+
+    public int getFavoriteCount() {
+        return favoriteCount;
+    }
+
+    public int getId() {
+        return id;
+    }
+
+    public void setId(int id) {
+        this.id = id;
+    }
+
+    public int getPageCount() {
+        return pageCount;
+    }
+
+    public void setPageCount(int pageCount) {
+        this.pageCount = pageCount;
+    }
+
+    public int getMediaId() {
+        return mediaId;
+    }
+
+    public String getTitle(int i) {
+        return titles[i];
+    }
+
+    public String getTitle(TitleType type) {
+        return titles[type.ordinal()];
+    }
+
+    public TagList getTags() {
+        return tags;
+    }
+
+    public Page getCover() {
+        return cover;
+    }
+
+    public Page getThumbnail() {
+        return thumbnail;
+    }
+
+    public Page getPage(int index) {
+        return pages.get(index);
+    }
+
+    public ArrayList<Page> getPages() {
+        return pages;
+    }
+
+    public boolean isValid() {
+        return valid;
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(Parcel dest, int flags) {
+        dest.writeLong(uploadDate.getTime());
+        dest.writeInt(favoriteCount);
+        dest.writeInt(id);
+        dest.writeInt(pageCount);
+        dest.writeInt(mediaId);
+        dest.writeStringArray(titles);
+        dest.writeParcelable(tags, flags);
+        dest.writeParcelable(cover, flags);
+        dest.writeParcelable(thumbnail, flags);
+        dest.writeTypedList(pages);
+        dest.writeByte((byte) (valid ? 1 : 0));
+    }
+
+    private void writeInterval(StringWriter writer, int intervalLen, ImageExt referencePage) {
+        writer.write(Integer.toString(intervalLen));
+        writer.write(Page.extToChar(referencePage));
+    }
+
+    public String createPagePath() {
+        StringWriter writer = new StringWriter();
+        writer.write(Integer.toString(pages.size()));
+        writer.write(cover.getImageExtChar());
+        writer.write(thumbnail.getImageExtChar());
+        if (pages.size() == 0) return writer.toString();
+        ImageExt referencePage = pages.get(0).getImageExt(), actualPage;
+        int intervalLen = 1;
+        for (int i = 1; i < pages.size(); i++) {
+            actualPage = pages.get(i).getImageExt();
+            if (actualPage != referencePage) {
+                writeInterval(writer, intervalLen, referencePage);
+                referencePage = actualPage;
+                intervalLen = 1;
+            } else intervalLen++;
+        }
+        writeInterval(writer, intervalLen, referencePage);
+        return writer.toString();
+    }
+
+    private void readPagePath(String path) throws IOException {
+        System.out.println(path);
+        StringReader reader = new StringReader(path + "e");//flag for the end
+        int absolutePage = 0;
+        int actualChar;
+        int pageOfType = 0;
+        boolean specialImages = true;//compability variable
+        while ((actualChar = reader.read()) != 'e') {
+            switch (actualChar) {
+                case 'p':
+                case 'j':
+                case 'g':
+                    if (specialImages) {
+                        cover = new Page(ImageType.COVER, Page.charToExt(actualChar));
+                        thumbnail = new Page(ImageType.THUMBNAIL, Page.charToExt(actualChar));
+                        specialImages = false;
+                    } else {
+                        for (int j = 0; j < pageOfType; j++) {//add pageOfType time a page of actualChar
+                            pages.add(new Page(ImageType.PAGE, Page.charToExt(actualChar), absolutePage++));
+                        }
+                    }
+                    pageOfType = 0;//reset digits
+                    break;
+                case '0':
+                case '1':
+                case '2':
+                case '3':
+                case '4':
+                case '5':
+                case '6':
+                case '7':
+                case '8':
+                case '9':
+                    pageOfType *= 10;
+                    pageOfType += actualChar - '0';
+                    break;
+                default:
+                    break;
+            }
+        }
+    }
+
+    @NonNull
+    @Override
+    public String toString() {
+        return "GalleryData{" +
+            "uploadDate=" + uploadDate +
+            ", favoriteCount=" + favoriteCount +
+            ", id=" + id +
+            ", pageCount=" + pageCount +
+            ", mediaId=" + mediaId +
+            ", titles=" + Arrays.toString(titles) +
+            ", tags=" + tags +
+            ", cover=" + cover +
+            ", thumbnail=" + thumbnail +
+            ", pages=" + pages +
+            ", valid=" + valid +
+            '}';
+    }
+}

+ 46 - 0
app/src/main/java/com/dar/nbook/api/components/GenericGallery.java

@@ -0,0 +1,46 @@
+package com.dar.nbook.api.components;
+
+import android.os.Parcelable;
+
+import androidx.annotation.NonNull;
+
+import com.dar.nbook.components.classes.Size;
+import com.dar.nbook.files.GalleryFolder;
+import com.dar.nbook.utility.Utility;
+
+import java.util.Locale;
+
+public abstract class GenericGallery implements Parcelable {
+
+    public abstract int getId();
+
+    public abstract Type getType();
+
+    public abstract int getPageCount();
+
+    public abstract boolean isValid();
+
+    @NonNull
+    public abstract String getTitle();
+
+    public abstract Size getMaxSize();
+
+    public abstract Size getMinSize();
+
+    public abstract GalleryFolder getGalleryFolder();
+
+    public String sharePageUrl(int i) {
+        return String.format(Locale.US, "https://" + Utility.getHost() + "/g/%d/%d/", getId(), i + 1);
+    }
+
+    public boolean isLocal() {
+        return getType() == Type.LOCAL;
+    }
+
+    public abstract boolean hasGalleryData();
+
+    public abstract GalleryData getGalleryData();
+
+    public enum Type {COMPLETE, LOCAL, SIMPLE}
+
+}

+ 167 - 0
app/src/main/java/com/dar/nbook/api/components/Page.java

@@ -0,0 +1,167 @@
+package com.dar.nbook.api.components;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.util.JsonReader;
+import android.util.JsonToken;
+
+import com.dar.nbook.api.enums.ImageExt;
+import com.dar.nbook.api.enums.ImageType;
+import com.dar.nbook.components.classes.Size;
+
+import java.io.IOException;
+
+public class Page implements Parcelable {
+    public static final Creator<Page> CREATOR = new Creator<Page>() {
+        @Override
+        public Page createFromParcel(Parcel in) {
+            return new Page(in);
+        }
+
+        @Override
+        public Page[] newArray(int size) {
+            return new Page[size];
+        }
+    };
+    private final int page;
+    private final ImageType imageType;
+    private ImageExt imageExt;
+    private Size size = new Size(0, 0);
+
+    Page() {
+        this.imageType = ImageType.PAGE;
+        this.imageExt = ImageExt.JPG;
+        this.page = 0;
+    }
+
+    public Page(ImageType type, JsonReader reader) throws IOException {
+        this(type, reader, 0);
+    }
+
+    public Page(ImageType type, ImageExt ext) {
+        this(type, ext, 0);
+    }
+
+    public Page(ImageType type, ImageExt ext, int page) {
+        this.imageType = type;
+        this.imageExt = ext;
+        this.page = page;
+    }
+
+    public Page(ImageType type, JsonReader reader, int page) throws IOException {
+        this.imageType = type;
+        this.page = page;
+        reader.beginObject();
+        while (reader.peek() != JsonToken.END_OBJECT) {
+            switch (reader.nextName()) {
+                case "t":
+                    imageExt = stringToExt(reader.nextString());
+                    break;
+                case "w":
+                    size.setWidth(reader.nextInt());
+                    break;
+                case "h":
+                    size.setHeight(reader.nextInt());
+                    break;
+                default:
+                    reader.skipValue();
+                    break;
+            }
+        }
+        reader.endObject();
+    }
+
+    protected Page(Parcel in) {
+        page = in.readInt();
+        size = in.readParcelable(Size.class.getClassLoader());
+        imageExt = ImageExt.values()[in.readByte()];
+        imageType = ImageType.values()[in.readByte()];
+    }
+
+    private static ImageExt stringToExt(String ext) {
+        return charToExt(ext.charAt(0));
+    }
+
+    public static String extToString(ImageExt ext) {
+        switch (ext) {
+            case GIF:
+                return "gif";
+            case PNG:
+                return "png";
+            case JPG:
+                return "jpg";
+        }
+        return null;
+    }
+
+    public static char extToChar(ImageExt imageExt) {
+        switch (imageExt) {
+            case GIF:
+                return 'g';
+            case PNG:
+                return 'p';
+            case JPG:
+                return 'j';
+        }
+        return '\0';
+    }
+
+    public static ImageExt charToExt(int ext) {
+        switch (ext) {
+            case 'g':
+                return ImageExt.GIF;
+            case 'p':
+                return ImageExt.PNG;
+            case 'j':
+                return ImageExt.JPG;
+        }
+        return null;
+    }
+
+    public String extToString() {
+        return extToString(imageExt);
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(Parcel dest, int flags) {
+        dest.writeInt(page);
+        dest.writeParcelable(size, flags);
+        dest.writeByte((byte) (imageExt == null ? ImageExt.JPG.ordinal() : imageExt.ordinal()));
+        dest.writeByte((byte) (imageType == null ? ImageType.PAGE.ordinal() : imageType.ordinal()));
+    }
+
+    public int getPage() {
+        return page;
+    }
+
+    public ImageExt getImageExt() {
+        return imageExt;
+    }
+
+    public char getImageExtChar() {
+        return extToChar(imageExt);
+    }
+
+    public ImageType getImageType() {
+        return imageType;
+    }
+
+    public Size getSize() {
+        return size;
+    }
+
+    @Override
+    public String toString() {
+        return "Page{" +
+            "page=" + page +
+            ", imageExt=" + imageExt +
+            ", imageType=" + imageType +
+            ", size=" + size +
+            '}';
+    }
+}

+ 158 - 0
app/src/main/java/com/dar/nbook/api/components/Ranges.java

@@ -0,0 +1,158 @@
+package com.dar.nbook.api.components;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import androidx.annotation.StringRes;
+
+import com.dar.nbook.R;
+
+public class Ranges implements Parcelable {
+
+
+    public static final int UNDEFINED = -1;
+    public static final TimeUnit UNDEFINED_DATE = null;
+    public static final Creator<Ranges> CREATOR = new Creator<Ranges>() {
+        @Override
+        public Ranges createFromParcel(Parcel in) {
+            return new Ranges(in);
+        }
+
+        @Override
+        public Ranges[] newArray(int size) {
+            return new Ranges[size];
+        }
+    };
+    private int fromPage = UNDEFINED, toPage = UNDEFINED;
+    private int fromDate = UNDEFINED, toDate = UNDEFINED;
+    private TimeUnit fromDateUnit = UNDEFINED_DATE, toDateUnit = UNDEFINED_DATE;
+
+    public Ranges() {
+    }
+
+    protected Ranges(Parcel in) {
+        int date;
+        fromPage = in.readInt();
+        toPage = in.readInt();
+        fromDate = in.readInt();
+        toDate = in.readInt();
+        date = in.readInt();
+        fromDateUnit = date == -1 ? UNDEFINED_DATE : TimeUnit.values()[date];
+        date = in.readInt();
+        toDateUnit = date == -1 ? UNDEFINED_DATE : TimeUnit.values()[date];
+    }
+
+    public boolean isDefault() {
+        return fromDate == UNDEFINED && toDate == UNDEFINED
+            && toPage == UNDEFINED && fromPage == UNDEFINED;
+    }
+
+    public int getFromPage() {
+        return fromPage;
+    }
+
+    public void setFromPage(int fromPage) {
+        this.fromPage = fromPage;
+    }
+
+    public int getToPage() {
+        return toPage;
+    }
+
+    public void setToPage(int toPage) {
+        this.toPage = toPage;
+    }
+
+    public int getFromDate() {
+        return fromDate;
+    }
+
+    public void setFromDate(int fromDate) {
+        this.fromDate = fromDate;
+    }
+
+    public int getToDate() {
+        return toDate;
+    }
+
+    public void setToDate(int toDate) {
+        this.toDate = toDate;
+    }
+
+    public TimeUnit getFromDateUnit() {
+        return fromDateUnit;
+    }
+
+    public void setFromDateUnit(TimeUnit fromDateUnit) {
+        this.fromDateUnit = fromDateUnit;
+    }
+
+    public TimeUnit getToDateUnit() {
+        return toDateUnit;
+    }
+
+    public void setToDateUnit(TimeUnit toDateUnit) {
+        this.toDateUnit = toDateUnit;
+    }
+
+    public String toQuery() {
+        boolean pageCreated = false;
+        StringBuilder builder = new StringBuilder();
+        if (fromPage != UNDEFINED && toPage != UNDEFINED && fromPage == toPage) {
+            builder.append("pages:").append(fromPage).append(' ');
+        } else {
+            if (fromPage != UNDEFINED) builder.append("pages:>=").append(fromPage).append(' ');
+            if (toPage != UNDEFINED) builder.append("pages:<=").append(toPage).append(' ');
+        }
+
+        if (fromDate != UNDEFINED && toDate != UNDEFINED && fromDate == toDate) {
+            builder.append("uploaded:").append(fromDate).append(fromDateUnit.val);
+        } else {
+            if (fromDate != UNDEFINED)
+                builder.append("uploaded:>=").append(fromDate).append(fromDateUnit.val).append(' ');
+            if (toDate != UNDEFINED)
+                builder.append("uploaded:<=").append(toDate).append(toDateUnit.val);
+        }
+        return builder.toString().trim();
+    }
+
+    @Override
+    public void writeToParcel(Parcel dest, int flags) {
+        dest.writeInt(fromPage);
+        dest.writeInt(toPage);
+        dest.writeInt(fromDate);
+        dest.writeInt(toDate);
+        dest.writeInt(fromDateUnit == UNDEFINED_DATE ? -1 : fromDateUnit.ordinal());
+        dest.writeInt(toDateUnit == UNDEFINED_DATE ? -1 : toDateUnit.ordinal());
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    public enum TimeUnit {
+        HOUR(R.string.hours, 'h'),
+        DAY(R.string.days, 'd'),
+        WEEK(R.string.weeks, 'w'),
+        MONTH(R.string.months, 'm'),
+        YEAR(R.string.years, 'y');
+        @StringRes
+        int string;
+        char val;
+
+        TimeUnit(int string, char val) {
+            this.string = string;
+            this.val = val;
+        }
+
+        public int getString() {
+            return string;
+        }
+
+        public char getVal() {
+            return val;
+        }
+    }
+
+}

+ 197 - 0
app/src/main/java/com/dar/nbook/api/components/Tag.java

@@ -0,0 +1,197 @@
+package com.dar.nbook.api.components;
+
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.util.JsonReader;
+import android.util.JsonToken;
+import android.util.JsonWriter;
+
+import com.dar.nbook.api.enums.TagStatus;
+import com.dar.nbook.api.enums.TagType;
+import com.dar.nbook.utility.LogUtility;
+
+import java.io.IOException;
+import java.util.Locale;
+
+@SuppressWarnings("unused")
+public class Tag implements Parcelable {
+    public static final Creator<Tag> CREATOR = new Creator<Tag>() {
+        @Override
+        public Tag createFromParcel(Parcel in) {
+            return new Tag(in);
+        }
+
+        @Override
+        public Tag[] newArray(int size) {
+            return new Tag[size];
+        }
+    };
+    private String name;
+    private int count, id;
+    private TagType type;
+    private TagStatus status = TagStatus.DEFAULT;
+
+    public Tag(String text) {
+        this.count = Integer.parseInt(text.substring(0, text.indexOf(',')));
+        text = text.substring(text.indexOf(',') + 1);
+        this.id = Integer.parseInt(text.substring(0, text.indexOf(',')));
+        text = text.substring(text.indexOf(',') + 1);
+        this.type = TagType.values[Integer.parseInt(text.substring(0, text.indexOf(',')))];
+        this.name = text.substring(text.indexOf(',') + 1);
+    }
+
+    public Tag(String name, int count, int id, TagType type, TagStatus status) {
+        this.name = name;
+        this.count = count;
+        this.id = id;
+        this.type = type;
+        this.status = status;
+    }
+
+    public Tag(JsonReader jr) throws IOException {
+        jr.beginObject();
+        while (jr.peek() != JsonToken.END_OBJECT) {
+            switch (jr.nextName()) {
+                case "count":
+                    count = jr.nextInt();
+                    break;
+                case "type":
+                    type = TagType.typeByName(jr.nextString());
+                    break;
+                case "id":
+                    id = jr.nextInt();
+                    break;
+                case "name":
+                    name = jr.nextString();
+                    break;
+                case "url":
+                    LogUtility.d("Tag URL: " + jr.nextString());
+                    break;
+                default:
+                    jr.skipValue();
+                    break;
+            }
+        }
+        jr.endObject();
+    }
+
+    private Tag(Parcel in) {
+        name = in.readString();
+        count = in.readInt();
+        id = in.readInt();
+        type = in.readParcelable(TagType.class.getClassLoader());
+        status = TagStatus.values()[in.readByte()];
+    }
+
+    public String toQueryTag(TagStatus status) {
+        StringBuilder builder = new StringBuilder();
+        if (status == TagStatus.AVOIDED)
+            builder.append('-');
+        builder
+            .append(type.getSingle())
+            .append(':')
+            .append('"')
+            .append(name)
+            .append('"');
+        return builder.toString();
+    }
+
+    public String toQueryTag() {
+        return toQueryTag(status);
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public int getCount() {
+        return count;
+    }
+
+    public TagStatus getStatus() {
+        return status;
+    }
+
+    public void setStatus(TagStatus status) {
+        this.status = status;
+    }
+
+    public int getId() {
+        return id;
+    }
+
+    public TagStatus updateStatus() {
+        switch (status) {
+            case AVOIDED:
+                return status = TagStatus.DEFAULT;
+            case DEFAULT:
+                return status = TagStatus.ACCEPTED;
+            case ACCEPTED:
+                return status = TagStatus.AVOIDED;
+        }
+        return null;
+    }
+
+    void writeJson(JsonWriter writer) throws IOException {
+        writer.beginObject();
+        writer.name("count").value(count);
+        writer.name("type").value(getTypeSingleName());
+        writer.name("id").value(id);
+        writer.name("name").value(name);
+        writer.endObject();
+    }
+
+    public TagType getType() {
+        return type;
+    }
+
+    public String getTypeSingleName() {
+        return type.getSingle();
+    }
+
+
+    @Override
+    public String toString() {
+        return "Tag{" +
+            "name='" + name + '\'' +
+            ", count=" + count +
+            ", id=" + id +
+            ", type=" + type +
+            ", status=" + status +
+            '}';
+    }
+
+    public String toScrapedString() {
+        return String.format(Locale.US, "%d,%d,%d,%s", count, id, type.getId(), name);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+
+        Tag tag = (Tag) o;
+
+        return id == tag.id;
+    }
+
+    @Override
+    public int hashCode() {
+        return id;
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(Parcel parcel, int flags) {
+        parcel.writeString(name);
+        parcel.writeInt(count);
+        parcel.writeInt(id);
+        parcel.writeParcelable(type, flags);
+        parcel.writeByte((byte) status.ordinal());
+    }
+}

+ 125 - 0
app/src/main/java/com/dar/nbook/api/components/TagList.java

@@ -0,0 +1,125 @@
+package com.dar.nbook.api.components;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import androidx.annotation.NonNull;
+
+import com.dar.nbook.api.enums.TagType;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+public class TagList implements Parcelable {
+
+    public static final Creator<TagList> CREATOR = new Creator<TagList>() {
+        @Override
+        public TagList createFromParcel(Parcel in) {
+            return new TagList(in);
+        }
+
+        @Override
+        public TagList[] newArray(int size) {
+            return new TagList[size];
+        }
+    };
+    private final Tags[] tagList = new Tags[TagType.values.length];
+
+    protected TagList(Parcel in) {
+        this();
+        ArrayList<Tag> list = new ArrayList<>();
+        in.readTypedList(list, Tag.CREATOR);
+        addTags(list);
+    }
+
+    public TagList() {
+        for (TagType type : TagType.values) tagList[type.getId()] = new Tags();
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(Parcel dest, int flags) {
+        dest.writeTypedList(getAllTagsList());
+    }
+
+    public Set<Tag> getAllTagsSet() {
+        HashSet<Tag> tags = new HashSet<>();
+        for (Tags t : tagList) tags.addAll(t);
+        return tags;
+    }
+
+    public List<Tag> getAllTagsList() {
+        List<Tag> tags = new ArrayList<>();
+        for (Tags t : tagList) tags.addAll(t);
+        return tags;
+    }
+
+    public int getCount(TagType type) {
+        return tagList[type.getId()].size();
+    }
+
+    public Tag getTag(TagType type, int index) {
+        return tagList[type.getId()].get(index);
+    }
+
+    public int getTotalCount() {
+        int total = 0;
+        for (Tags t : tagList) total += t.size();
+        return total;
+    }
+
+    public void addTag(Tag tag) {
+        tagList[tag.getType().getId()].add(tag);
+    }
+
+    public void addTags(Collection<? extends Tag> tags) {
+        for (Tag t : tags) addTag(t);
+    }
+
+    public List<Tag> retrieveForType(TagType type) {
+        return tagList[type.getId()];
+    }
+
+    public int getLenght() {
+        return tagList.length;
+    }
+
+    public void sort(Comparator<Tag> comparator) {
+        for (Tags t : tagList) Collections.sort(t, comparator);
+    }
+
+    public boolean hasTag(Tag tag) {
+        return tagList[tag.getType().getId()].contains(tag);
+    }
+
+    public boolean hasTags(Collection<Tag> tags) {
+        for (Tag tag : tags) {
+            if (!hasTag(tag)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    public static class Tags extends ArrayList<Tag> {
+        public Tags(int initialCapacity) {
+            super(initialCapacity);
+        }
+
+        public Tags() {
+        }
+
+        public Tags(@NonNull Collection<? extends Tag> c) {
+            super(c);
+        }
+    }
+}

+ 45 - 0
app/src/main/java/com/dar/nbook/api/enums/ApiRequestType.java

@@ -0,0 +1,45 @@
+package com.dar.nbook.api.enums;
+
+public class ApiRequestType {
+    public static final ApiRequestType BYALL = new ApiRequestType(0, false);
+    public static final ApiRequestType BYTAG = new ApiRequestType(1, false);
+    public static final ApiRequestType BYSEARCH = new ApiRequestType(2, false);
+    public static final ApiRequestType BYSINGLE = new ApiRequestType(3, true);
+    public static final ApiRequestType RELATED = new ApiRequestType(4, false);
+    public static final ApiRequestType FAVORITE = new ApiRequestType(5, false);
+    public static final ApiRequestType RANDOM = new ApiRequestType(6, true);
+    public static final ApiRequestType RANDOM_FAVORITE = new ApiRequestType(7, true);
+    public static final ApiRequestType[] values = {
+        BYALL, BYTAG, BYSEARCH, BYSINGLE, RELATED, FAVORITE, RANDOM, RANDOM_FAVORITE
+    };
+    private final byte id;
+    private final boolean single;
+
+    private ApiRequestType(int id, boolean single) {
+        this.id = (byte) id;
+        this.single = single;
+    }
+
+    public byte ordinal() {
+        return id;
+    }
+
+    public boolean isSingle() {
+        return single;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+
+        ApiRequestType that = (ApiRequestType) o;
+
+        return id == that.id;
+    }
+
+    @Override
+    public int hashCode() {
+        return id;
+    }
+}

+ 21 - 0
app/src/main/java/com/dar/nbook/api/enums/ImageExt.java

@@ -0,0 +1,21 @@
+package com.dar.nbook.api.enums;
+
+public enum ImageExt {
+    JPG("jpg"), PNG("png"), GIF("gif");
+
+    private final char firstLetter;
+    private final String name;
+
+    ImageExt(String name) {
+        this.name = name;
+        this.firstLetter = name.charAt(0);
+    }
+
+    public char getFirstLetter() {
+        return firstLetter;
+    }
+
+    public String getName() {
+        return name;
+    }
+}

+ 5 - 0
app/src/main/java/com/dar/nbook/api/enums/ImageType.java

@@ -0,0 +1,5 @@
+package com.dar.nbook.api.enums;
+
+public enum ImageType {
+    PAGE, COVER, THUMBNAIL
+}

+ 5 - 0
app/src/main/java/com/dar/nbook/api/enums/Language.java

@@ -0,0 +1,5 @@
+package com.dar.nbook.api.enums;
+
+public enum Language {
+    ENGLISH, CHINESE, JAPANESE, UNKNOWN, ALL
+}

+ 47 - 0
app/src/main/java/com/dar/nbook/api/enums/SortType.java

@@ -0,0 +1,47 @@
+package com.dar.nbook.api.enums;
+
+import androidx.annotation.Nullable;
+
+import com.dar.nbook.R;
+
+public enum SortType {
+    RECENT_ALL_TIME(R.string.sort_recent, null),
+    POPULAR_ALL_TIME(R.string.sort_popular_all_time, "popular"),
+    POPULAR_WEEKLY(R.string.sort_popular_week, "popular-week"),
+    POPULAR_DAILY(R.string.sort_popular_day, "popular-today"),
+    POPULAR_MONTH(R.string.sort_popoular_month, "popular-month");
+
+
+    private final int nameId;
+    @Nullable
+    private final String urlAddition;
+
+    SortType(int nameId, @Nullable String urlAddition) {
+        this.nameId = nameId;
+        this.urlAddition = urlAddition;
+    }
+
+
+    public static SortType findFromAddition(@Nullable String addition) {
+        if (addition == null)
+            return SortType.RECENT_ALL_TIME;
+
+        for (SortType t : SortType.values()) {
+            String url = t.getUrlAddition();
+            if (url != null && addition.contains(url)) {
+                return t;
+            }
+        }
+
+        return SortType.RECENT_ALL_TIME;
+    }
+
+    public int getNameId() {
+        return nameId;
+    }
+
+    @Nullable
+    public String getUrlAddition() {
+        return urlAddition;
+    }
+}

+ 8 - 0
app/src/main/java/com/dar/nbook/api/enums/SpecialTagIds.java

@@ -0,0 +1,8 @@
+package com.dar.nbook.api.enums;
+
+public class SpecialTagIds {
+    public static final short LANGUAGE_JAPANESE = 6346;
+    public static final short LANGUAGE_ENGLISH = 12227;
+    public static final short LANGUAGE_CHINESE = 29963;
+    public static final short INVALID_ID = -1;
+}

+ 5 - 0
app/src/main/java/com/dar/nbook/api/enums/TagStatus.java

@@ -0,0 +1,5 @@
+package com.dar.nbook.api.enums;
+
+public enum TagStatus {
+    DEFAULT, AVOIDED, ACCEPTED
+}

+ 84 - 0
app/src/main/java/com/dar/nbook/api/enums/TagType.java

@@ -0,0 +1,84 @@
+package com.dar.nbook.api.enums;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+public class TagType implements Parcelable {
+    public static final TagType UNKNOWN = new TagType(0, "", null);
+    public static final TagType PARODY = new TagType(1, "parody", "parodies");
+    public static final TagType CHARACTER = new TagType(2, "character", "characters");
+    public static final TagType TAG = new TagType(3, "tag", "tags");
+    public static final TagType ARTIST = new TagType(4, "artist", "artists");
+    public static final TagType GROUP = new TagType(5, "group", "groups");
+    public static final TagType LANGUAGE = new TagType(6, "language", null);
+    public static final TagType CATEGORY = new TagType(7, "category", null);
+    public static final TagType[] values = new TagType[]{UNKNOWN, PARODY, CHARACTER, TAG, ARTIST, GROUP, LANGUAGE, CATEGORY};
+    public static final Creator<TagType> CREATOR = new Creator<TagType>() {
+        @Override
+        public TagType createFromParcel(Parcel in) {
+            return new TagType(in);
+        }
+
+        @Override
+        public TagType[] newArray(int size) {
+            return new TagType[size];
+        }
+    };
+    private final byte id;
+    private final String single, plural;
+
+    private TagType(int id, String single, String plural) {
+        this.id = (byte) id;
+        this.single = single;
+        this.plural = plural;
+    }
+
+    protected TagType(Parcel in) {
+        id = in.readByte();
+        single = in.readString();
+        plural = in.readString();
+    }
+
+    public static TagType typeByName(String name) {
+        for (TagType t : values) if (t.getSingle().equals(name)) return t;
+        return UNKNOWN;
+    }
+
+    public byte getId() {
+        return id;
+    }
+
+    public String getSingle() {
+        return single;
+    }
+
+    public String getPlural() {
+        return plural;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        TagType type = (TagType) o;
+        return id == type.id;
+    }
+
+    @Override
+    public int hashCode() {
+        return id;
+    }
+
+    //start parcelable implementation
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(Parcel dest, int flags) {
+        dest.writeByte(id);
+        dest.writeString(single);
+        dest.writeString(plural);
+    }
+}

+ 5 - 0
app/src/main/java/com/dar/nbook/api/enums/TitleType.java

@@ -0,0 +1,5 @@
+package com.dar.nbook.api.enums;
+
+public enum TitleType {
+    JAPANESE, PRETTY, ENGLISH
+}

+ 57 - 0
app/src/main/java/com/dar/nbook/api/local/FakeInspector.java

@@ -0,0 +1,57 @@
+package com.dar.nbook.api.local;
+
+import com.dar.nbook.LocalActivity;
+import com.dar.nbook.adapters.LocalAdapter;
+import com.dar.nbook.components.ThreadAsyncTask;
+import com.dar.nbook.utility.LogUtility;
+
+import java.io.File;
+import java.util.ArrayList;
+
+public class FakeInspector extends ThreadAsyncTask<LocalActivity, LocalActivity, LocalActivity> {
+    private final ArrayList<LocalGallery> galleries;
+    private final ArrayList<String> invalidPaths;
+    private final File folder;
+
+    public FakeInspector(LocalActivity activity, File folder) {
+        super(activity);
+        this.folder = new File(folder, "Download");
+        galleries = new ArrayList<>();
+        invalidPaths = new ArrayList<>();
+    }
+
+
+    @Override
+    protected LocalActivity doInBackground(LocalActivity... voids) {
+        if (!this.folder.exists()) return voids[0];
+        publishProgress(voids[0]);
+        File parent = this.folder;
+        parent.mkdirs();
+        File[] files = parent.listFiles();
+        if (files == null) return voids[0];
+        for (File f : files) if (f.isDirectory()) createGallery(f);
+        for (String x : invalidPaths) LogUtility.d("Invalid path: " + x);
+        return voids[0];
+    }
+
+    @Override
+    protected void onProgressUpdate(LocalActivity... values) {
+        values[0].getRefresher().setRefreshing(true);
+    }
+
+    @Override
+    protected void onPostExecute(LocalActivity aVoid) {
+        aVoid.getRefresher().setRefreshing(false);
+        aVoid.setAdapter(new LocalAdapter(aVoid, galleries));
+    }
+
+    private void createGallery(final File file) {
+        LocalGallery lg = new LocalGallery(file, true);
+        if (lg.isValid()) {
+            galleries.add(lg);
+        } else {
+            LogUtility.e(lg);
+            invalidPaths.add(file.getAbsolutePath());
+        }
+    }
+}

+ 261 - 0
app/src/main/java/com/dar/nbook/api/local/LocalGallery.java

@@ -0,0 +1,261 @@
+package com.dar.nbook.api.local;
+
+import android.graphics.BitmapFactory;
+import android.os.Parcel;
+import android.util.JsonReader;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.dar.nbook.api.components.GalleryData;
+import com.dar.nbook.api.components.GenericGallery;
+import com.dar.nbook.api.enums.SpecialTagIds;
+import com.dar.nbook.components.classes.Size;
+import com.dar.nbook.files.GalleryFolder;
+import com.dar.nbook.files.PageFile;
+import com.dar.nbook.utility.LogUtility;
+
+import java.io.File;
+import java.io.FileReader;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class LocalGallery extends GenericGallery {
+    public static final Creator<LocalGallery> CREATOR = new Creator<LocalGallery>() {
+        @Override
+        public LocalGallery createFromParcel(Parcel in) {
+            return new LocalGallery(in);
+        }
+
+        @Override
+        public LocalGallery[] newArray(int size) {
+            return new LocalGallery[size];
+        }
+    };
+    private static final Pattern FILE_PATTERN = Pattern.compile("^(\\d{1,9})\\.(gif|png|jpg)$", Pattern.CASE_INSENSITIVE);
+    private static final Pattern DUP_PATTERN = Pattern.compile("^(.*)\\.DUP\\d+$");
+    private static final Pattern IDFILE_PATTERN = Pattern.compile("^\\.\\d{1,6}$");
+    private final GalleryFolder folder;
+    @NonNull
+    private final GalleryData galleryData;
+    private final String title, trueTitle;
+    private final boolean valid;
+    private boolean hasAdvancedData = true;
+    @NonNull
+    private Size maxSize = new Size(0, 0), minSize = new Size(Integer.MAX_VALUE, Integer.MAX_VALUE);
+
+    public LocalGallery(@NonNull File file, boolean jumpDataRetrieve) {
+        GalleryFolder folder1;
+        try {
+            folder1 = new GalleryFolder(file);
+        } catch (IllegalArgumentException ignore) {
+            folder1 = null;
+
+        }
+        folder = folder1;
+        trueTitle = file.getName();
+        title = createTitle(file);
+        if (jumpDataRetrieve) {
+            galleryData = GalleryData.fakeData();
+        } else {
+            galleryData = readGalleryData();
+            if (galleryData.getId() == SpecialTagIds.INVALID_ID)
+                galleryData.setId(getId());
+        }
+        //Start search pages
+        //Find page with max number
+        if (folder != null)
+            galleryData.setPageCount(folder.getMax());
+        valid = folder != null && folder.getPageCount() > 0;
+    }
+
+    public LocalGallery(@NonNull File file) {
+        this(file, false);
+    }
+
+    private LocalGallery(Parcel in) {
+        galleryData = Objects.requireNonNull(in.readParcelable(GalleryData.class.getClassLoader()));
+        maxSize = Objects.requireNonNull(in.readParcelable(Size.class.getClassLoader()));
+        minSize = Objects.requireNonNull(in.readParcelable(Size.class.getClassLoader()));
+        trueTitle = in.readString();
+        title = in.readString();
+        hasAdvancedData = in.readByte() == 1;
+        folder = in.readParcelable(GalleryFolder.class.getClassLoader());
+        valid = true;
+    }
+
+    private static int getPageFromFile(File f) {
+        String n = f.getName();
+        return Integer.parseInt(n.substring(0, n.indexOf('.')));
+    }
+
+    private static String createTitle(File file) {
+        String name = file.getName();
+        Matcher matcher = DUP_PATTERN.matcher(name);
+        if (!matcher.matches()) return name;
+        String title = matcher.group(1);
+        return title == null ? name : title;
+    }
+
+    /**
+     * @return null if not found or the file if found
+     */
+    public static File getPage(File dir, int page) {
+        if (dir == null || !dir.exists()) return null;
+        String pag = String.format(Locale.US, "%03d.", page);
+        File x;
+        x = new File(dir, pag + "jpg");
+        if (x.exists()) return x;
+        x = new File(dir, pag + "png");
+        if (x.exists()) return x;
+        x = new File(dir, pag + "gif");
+        if (x.exists()) return x;
+        return null;
+    }
+
+    @Override
+    public GalleryFolder getGalleryFolder() {
+        return folder;
+    }
+
+    @NonNull
+    private GalleryData readGalleryData() {
+        if (folder == null) return GalleryData.fakeData();
+        File nomedia = folder.getGalleryDataFile();
+        try (JsonReader reader = new JsonReader(new FileReader(nomedia))) {
+            return new GalleryData(reader);
+        } catch (Exception ignore) {
+        }
+        hasAdvancedData = false;
+        return GalleryData.fakeData();
+    }
+
+    public void calculateSizes() {
+        for (PageFile f : folder)
+            checkSize(f);
+    }
+
+    private void checkSize(File f) {
+        LogUtility.d("Decoding: " + f);
+        BitmapFactory.Options options = new BitmapFactory.Options();
+        options.inJustDecodeBounds = true;
+        BitmapFactory.decodeFile(f.getAbsolutePath(), options);
+        if (options.outWidth > maxSize.getWidth()) maxSize.setWidth(options.outWidth);
+        if (options.outWidth < minSize.getWidth()) minSize.setWidth(options.outWidth);
+        if (options.outHeight > maxSize.getHeight()) maxSize.setHeight(options.outHeight);
+        if (options.outHeight < minSize.getHeight()) minSize.setHeight(options.outHeight);
+    }
+
+    @NonNull
+    @Override
+    public Size getMaxSize() {
+        return maxSize;
+    }
+
+    @NonNull
+    @Override
+    public Size getMinSize() {
+        return minSize;
+    }
+
+    public String getTrueTitle() {
+        return trueTitle;
+    }
+
+    @Override
+    public boolean hasGalleryData() {
+        return hasAdvancedData;
+    }
+
+    @Override
+    @NonNull
+    public GalleryData getGalleryData() {
+        return galleryData;
+    }
+
+    @Override
+    public Type getType() {
+        return Type.LOCAL;
+    }
+
+    @Override
+    public boolean isValid() {
+        return valid;
+    }
+
+    @Override
+    public int getId() {
+        return folder == null ? SpecialTagIds.INVALID_ID : folder.getId();
+    }
+
+    @Override
+    public int getPageCount() {
+        return galleryData.getPageCount();
+    }
+
+    @Override
+    @NonNull
+    public String getTitle() {
+        return title;
+    }
+
+    public int getMin() {
+        return folder.getMin();
+    }
+
+    @NonNull
+    public File getDirectory() {
+        return folder.getFolder();
+    }
+
+    @Nullable
+    public File getPage(int index) {
+        return folder.getPage(index);
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(Parcel dest, int flags) {
+        dest.writeParcelable(galleryData, flags);
+        dest.writeParcelable(maxSize, flags);
+        dest.writeParcelable(minSize, flags);
+        dest.writeString(trueTitle);
+        dest.writeString(title);
+        dest.writeByte((byte) (hasAdvancedData ? 1 : 0));
+        dest.writeParcelable(folder, flags);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+
+        LocalGallery gallery = (LocalGallery) o;
+
+        return folder.equals(gallery.folder);
+    }
+
+    @Override
+    public int hashCode() {
+        return folder.hashCode();
+    }
+
+    @NonNull
+    @Override
+    public String toString() {
+        return "LocalGallery{" +
+            "galleryData=" + galleryData +
+            ", title='" + title + '\'' +
+            ", folder=" + folder +
+            ", valid=" + valid +
+            ", maxSize=" + maxSize +
+            ", minSize=" + minSize +
+            '}';
+    }
+}

+ 50 - 0
app/src/main/java/com/dar/nbook/api/local/LocalSortType.java

@@ -0,0 +1,50 @@
+package com.dar.nbook.api.local;
+
+import androidx.annotation.NonNull;
+
+public class LocalSortType {
+    public static final byte MASK_DESCENDING = (byte) (1 << 7);         //10000000
+    private static final byte MASK_TYPE = (byte) (MASK_DESCENDING - 1);  //01111111
+    @NonNull
+    public final Type type;
+    public final boolean descending;
+
+    public LocalSortType(@NonNull Type type, boolean ascending) {
+        this.type = type;
+        this.descending = ascending;
+    }
+
+    public LocalSortType(int hash) {
+        this.type = Type.values()[(hash & MASK_TYPE) % Type.values().length];
+        this.descending = (hash & MASK_DESCENDING) != 0;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+
+        LocalSortType that = (LocalSortType) o;
+
+        return this.type == that.type && this.descending == that.descending;
+    }
+
+    @Override
+    public int hashCode() {
+        int hash = type.ordinal();
+        if (descending) hash |= MASK_DESCENDING;
+        return hash;
+    }
+
+    @NonNull
+    @Override
+    public String toString() {
+        return "LocalSortType{" +
+            "type=" + type +
+            ", descending=" + descending +
+            ", hash=" + hashCode() +
+            '}';
+    }
+
+    public enum Type {TITLE, DATE, PAGE_COUNT, RANDOM}
+}

+ 44 - 0
app/src/main/java/com/dar/nbook/async/MetadataFetcher.java

@@ -0,0 +1,44 @@
+package com.dar.nbook.async;
+
+import android.content.Context;
+
+import com.dar.nbook.api.InspectorV3;
+import com.dar.nbook.api.components.Gallery;
+import com.dar.nbook.api.enums.SpecialTagIds;
+import com.dar.nbook.api.local.LocalGallery;
+import com.dar.nbook.settings.Global;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+
+public class MetadataFetcher implements Runnable {
+    private final Context context;
+
+    public MetadataFetcher(Context context) {
+        this.context = context;
+    }
+
+    @Override
+    public void run() {
+        File[] files = Global.DOWNLOADFOLDER.listFiles();
+        if (files == null) return;
+        for (File f : files) {
+            if (!f.isDirectory()) continue;
+            LocalGallery lg = new LocalGallery(f, false);
+            if (lg.getId() == SpecialTagIds.INVALID_ID || lg.hasGalleryData()) continue;
+            InspectorV3 inspector = InspectorV3.galleryInspector(context, lg.getId(), null);
+            inspector.run();//it is run, not start
+            if (inspector.getGalleries() == null || inspector.getGalleries().size() == 0)
+                continue;
+            Gallery g = (Gallery) inspector.getGalleries().get(0);
+            try {
+                FileWriter writer = new FileWriter(new File(lg.getDirectory(), ".nomedia"));
+                g.jsonWrite(writer);
+                writer.close();
+            } catch (IOException e) {
+                e.printStackTrace();
+            }
+        }
+    }
+}

+ 126 - 0
app/src/main/java/com/dar/nbook/async/ScrapeTags.java

@@ -0,0 +1,126 @@
+package com.dar.nbook.async;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.util.JsonReader;
+
+import androidx.annotation.Nullable;
+import androidx.core.app.JobIntentService;
+
+import com.dar.nbook.api.components.Tag;
+import com.dar.nbook.api.enums.TagStatus;
+import com.dar.nbook.api.enums.TagType;
+import com.dar.nbook.async.database.Queries;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.utility.LogUtility;
+
+import java.io.IOException;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+
+import okhttp3.Request;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+
+public class ScrapeTags extends JobIntentService {
+    private static final int DAYS_UNTIL_SCRAPE = 7;
+    private static final String DATA_FOLDER = "https://raw.githubusercontent.com/Dar9586/NClientV2/master/data/";
+    private static final String TAGS = DATA_FOLDER + "tags.json";
+    private static final String VERSION = DATA_FOLDER + "tagsVersion";
+
+    public ScrapeTags() {
+    }
+
+    public static void startWork(Context context) {
+        enqueueWork(context, ScrapeTags.class, 2000, new Intent());
+    }
+
+    private int getNewVersionCode() throws IOException {
+        Response x = Global.getClient(this).newCall(new Request.Builder().url(VERSION).build()).execute();
+        ResponseBody body = x.body();
+        if (body == null) {
+            x.close();
+            return -1;
+        }
+        try {
+            int k = Integer.parseInt(body.string().trim());
+            LogUtility.d("Found version: " + k);
+            x.close();
+            return k;
+        } catch (NumberFormatException e) {
+            LogUtility.e("Unable to convert", e);
+        }
+        return -1;
+    }
+
+    @Override
+    protected void onHandleWork(@Nullable Intent intent) {
+        SharedPreferences preferences = getApplicationContext().getSharedPreferences("Settings", 0);
+        Date nowTime = new Date();
+        Date lastTime = new Date(preferences.getLong("lastSync", nowTime.getTime()));
+        int lastVersion = preferences.getInt("lastTagsVersion", -1), newVersion = -1;
+        if (!enoughDayPassed(nowTime, lastTime)) return;
+
+        LogUtility.d("Scraping tags");
+        try {
+            newVersion = getNewVersionCode();
+            if (lastVersion > -1 && lastVersion >= newVersion) return;
+            List<Tag> tags = Queries.TagTable.getAllFiltered();
+            fetchTags();
+            for (Tag t : tags) Queries.TagTable.updateStatus(t.getId(), t.getStatus());
+        } catch (IOException e) {
+            e.printStackTrace();
+        }
+        LogUtility.d("End scraping");
+        preferences.edit()
+            .putLong("lastSync", nowTime.getTime())
+            .putInt("lastTagsVersion", newVersion)
+            .apply();
+    }
+
+    private void fetchTags() throws IOException {
+        Response x = Global.getClient(this).newCall(new Request.Builder().url(TAGS).build()).execute();
+        ResponseBody body = x.body();
+        if (body == null) {
+            x.close();
+            return;
+        }
+        JsonReader reader = new JsonReader(body.charStream());
+        reader.beginArray();
+        while (reader.hasNext()) {
+            Tag tag = readTag(reader);
+            Queries.TagTable.insertScrape(tag, true);
+        }
+        reader.close();
+        x.close();
+    }
+
+    private Tag readTag(JsonReader reader) throws IOException {
+        reader.beginArray();
+        int id = reader.nextInt();
+        String name = reader.nextString();
+        int count = reader.nextInt();
+        TagType type = TagType.values[reader.nextInt()];
+        reader.endArray();
+        return new Tag(name, count, id, type, TagStatus.DEFAULT);
+    }
+
+    private boolean enoughDayPassed(Date nowTime, Date lastTime) {
+        //first start or never completed
+        if (nowTime.getTime() == lastTime.getTime()) return true;
+        int daysBetween = 0;
+        Calendar now = Calendar.getInstance(), last = Calendar.getInstance();
+        now.setTime(nowTime);
+        last.setTime(lastTime);
+        while (last.before(now)) {
+            last.add(Calendar.DAY_OF_MONTH, 1);
+            daysBetween++;
+            if (daysBetween > DAYS_UNTIL_SCRAPE)
+                return true;
+        }
+        LogUtility.d("Passed " + daysBetween + " days since last scrape");
+        return false;
+    }
+}

+ 254 - 0
app/src/main/java/com/dar/nbook/async/VersionChecker.java

@@ -0,0 +1,254 @@
+package com.dar.nbook.async;
+
+import android.Manifest;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Build;
+import android.util.JsonReader;
+import android.util.JsonToken;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.content.FileProvider;
+
+import com.dar.nbook.BuildConfig;
+import com.dar.nbook.R;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.utility.LogUtility;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+import okhttp3.Call;
+import okhttp3.Callback;
+import okhttp3.Request;
+import okhttp3.Response;
+
+public class VersionChecker {
+    private static final String RELEASE_API_URL = "https://api.github.com/repos/Dar9586/NClientV2/releases";
+    private static final String LATEST_RELEASE_URL = "https://github.com/Dar9586/NClientV2/releases/latest";
+    private static final String RELEASE_TYPE = BuildConfig.DEBUG ? "Debug" : "Release";
+    private static String latest = null;
+    private final AppCompatActivity context;
+    private String downloadUrl;
+
+    public VersionChecker(AppCompatActivity context, final boolean silent) {
+        boolean withPrerelease = Global.isEnableBeta();
+        this.context = context;
+        if (latest != null && Global.hasStoragePermission(context)) {
+            downloadVersion(latest);
+            latest = null;
+            return;
+        }
+        String actualVersionName = Global.getVersionName(context);
+        LogUtility.d("ACTUAL VERSION: " + actualVersionName);
+        Global.getClient(context).newCall(new Request.Builder().url(RELEASE_API_URL).build()).enqueue(new Callback() {
+            @Override
+            public void onFailure(@NonNull Call call, @NonNull IOException e) {
+                context.runOnUiThread(() -> {
+                    LogUtility.e(e.getLocalizedMessage(), e);
+                    if (!silent)
+                        Toast.makeText(context, R.string.error_retrieving, Toast.LENGTH_SHORT).show();
+                });
+            }
+
+            @Override
+            public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
+                JsonReader jr = new JsonReader(response.body().charStream());
+                GitHubRelease release = parseVersionJson(jr, withPrerelease);
+                jr.close();
+                if (release == null) {
+                    release = new GitHubRelease();
+                    release.versionCode = actualVersionName;
+                }
+                downloadUrl = release.downloadUrl;
+                GitHubRelease finalRelease = release;
+                context.runOnUiThread(() -> {
+                    if (downloadUrl == null || extractVersion(actualVersionName) >= extractVersion(finalRelease.versionCode)) {
+                        if (!silent)
+                            Toast.makeText(context, R.string.no_updates_found, Toast.LENGTH_SHORT).show();
+                    } else {
+                        LogUtility.d("Executing false");
+                        createDialog(actualVersionName, finalRelease);
+                    }
+                });
+            }
+        });
+    }
+
+    private static int extractVersion(String version) {
+        int index = version.indexOf('-');
+        if (index >= 0) version = version.substring(0, index);
+        return Integer.parseInt(version.replace(".", ""));
+    }
+
+    private static GitHubRelease parseVersionJson(JsonReader jr, boolean withPrerelease) throws IOException {
+        try {
+            GitHubRelease release;
+            jr.beginArray();
+            while (jr.hasNext()) {
+                release = parseVersion(jr, withPrerelease);
+                if (release != null)
+                    return release;
+            }
+        } catch (IllegalStateException ignore) {
+        }
+        return null;
+    }
+
+    private static GitHubRelease parseVersion(JsonReader jr, boolean withPrerelease) throws IOException {
+        GitHubRelease release = new GitHubRelease();
+        boolean invalid = false;
+        jr.beginObject();
+
+        while (jr.peek() != JsonToken.END_OBJECT) {
+            switch (jr.nextName()) {
+                default:
+                    jr.skipValue();
+                    break;
+                case "tag_name":
+                    release.versionCode = jr.nextString();
+                    break;
+                case "body":
+                    release.body = jr.nextString();
+                    break;
+                case "prerelease":
+                    release.beta = jr.nextBoolean();
+                    if (release.beta && !withPrerelease)
+                        invalid = true;
+                    break;
+                case "assets":
+                    jr.beginArray();
+                    while (jr.hasNext()) {
+                        if (release.downloadUrl != null) {
+                            jr.skipValue();
+                            continue;
+                        }
+                        release.downloadUrl = getDownloadUrl(jr);
+                        if (!release.downloadUrl.contains(RELEASE_TYPE))
+                            release.downloadUrl = null;
+                    }
+                    jr.endArray();
+                    break;
+            }
+        }
+        jr.endObject();
+
+        return invalid ? null : release;
+    }
+
+    private static String getDownloadUrl(JsonReader jr) throws IOException {
+        String url = null;
+        jr.beginObject();
+        while (jr.peek() != JsonToken.END_OBJECT) {
+            if ("browser_download_url".equals(jr.nextName()))
+                url = jr.nextString();
+            else jr.skipValue();
+        }
+        jr.endObject();
+        return url;
+    }
+
+    private void createDialog(String versionName, GitHubRelease release) {
+        String finalBody = release.body;
+        String latestVersion = release.versionCode;
+        boolean beta = release.beta;
+        if (finalBody == null) return;
+        finalBody = finalBody
+            .replace("\r\n", "\n")//Remove ugly newline
+            .replace("NClientV2 " + latestVersion, "")//remove version header
+            .replaceAll("(\\s*\n\\s*)+", "\n")//remove multiple newline
+            .replaceAll("\\(.*\\)", "").trim();//remove things between ()
+        LogUtility.d("Evaluated: " + finalBody);
+        LogUtility.d("Creating dialog");
+        MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(context);
+        LogUtility.d("" + context);
+        builder.setTitle(beta ? R.string.new_beta_version_found : R.string.new_version_found);
+        builder.setIcon(R.drawable.ic_file);
+        builder.setMessage(context.getString(R.string.update_version_format, versionName, latestVersion, finalBody));
+        builder.setPositiveButton(R.string.install, (dialog, which) -> {
+            if (Global.hasStoragePermission(context)) downloadVersion(latestVersion);
+            else {
+                latest = latestVersion;
+                context.runOnUiThread(() -> context.requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE}, 2));
+            }
+        }).setNegativeButton(R.string.cancel, null)
+            .setNeutralButton(R.string.github, (dialog, which) -> {
+                Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(LATEST_RELEASE_URL));
+                context.startActivity(browserIntent);
+            });
+        if (!context.isFinishing()) builder.show();
+    }
+
+    private void downloadVersion(String latestVersion) {
+        final File f = new File(Global.UPDATEFOLDER, "NClientV2_" + latestVersion + ".apk");
+        if (f.exists()) {
+            if (context.getSharedPreferences("Settings", 0).getBoolean("downloaded", false)) {
+                installApp(f);
+                return;
+            }
+            f.delete();
+        }
+        if (downloadUrl == null) return;
+        LogUtility.d(f.getAbsolutePath());
+        Global.getClient(context).newCall(new Request.Builder().url(downloadUrl).build()).enqueue(new Callback() {
+            @Override
+            public void onFailure(@NonNull Call call, @NonNull IOException e) {
+                context.runOnUiThread(() -> Toast.makeText(context, R.string.download_update_failed, Toast.LENGTH_LONG).show());
+            }
+
+            @Override
+            public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
+                context.getSharedPreferences("Settings", 0).edit().putBoolean("downloaded", false).apply();
+                if (Global.UPDATEFOLDER == null) {
+                    Global.initStorage(context);
+                }
+                Global.UPDATEFOLDER.mkdirs();
+                f.createNewFile();
+                FileOutputStream stream = new FileOutputStream(f);
+                InputStream stream1 = response.body().byteStream();
+                int read;
+                byte[] bytes = new byte[1024];
+                while ((read = stream1.read(bytes)) != -1) {
+                    stream.write(bytes, 0, read);
+                }
+                stream1.close();
+                stream.flush();
+                stream.close();
+                context.getSharedPreferences("Settings", 0).edit().putBoolean("downloaded", true).apply();
+                installApp(f);
+            }
+        });
+    }
+
+    private void installApp(File f) {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+            try {
+                Uri apkUri = FileProvider.getUriForFile(context, context.getPackageName() + ".provider", f);
+                Intent intent = new Intent(Intent.ACTION_INSTALL_PACKAGE);
+                intent.setData(apkUri);
+                intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+                context.startActivity(intent);
+            } catch (IllegalArgumentException ignore) {
+                context.runOnUiThread(() -> Toast.makeText(context, context.getString(R.string.downloaded_update_at, f.getAbsolutePath()), Toast.LENGTH_SHORT).show());
+
+            }
+        } else {
+            Uri apkUri = Uri.fromFile(f);
+            Intent intent = new Intent(Intent.ACTION_VIEW);
+            intent.setDataAndType(apkUri, "application/vnd.android.package-archive");
+            intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+            context.startActivity(intent);
+        }
+    }
+
+
+    public static class GitHubRelease {
+        String versionCode, body, downloadUrl;
+        boolean beta;
+    }
+}

+ 154 - 0
app/src/main/java/com/dar/nbook/async/converters/CreatePDF.java

@@ -0,0 +1,154 @@
+package com.dar.nbook.async.converters;
+
+import android.annotation.TargetApi;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.pdf.PdfDocument;
+import android.net.Uri;
+import android.os.Build;
+
+import androidx.annotation.Nullable;
+import androidx.core.app.JobIntentService;
+import androidx.core.app.NotificationCompat;
+import androidx.core.content.FileProvider;
+
+import com.dar.nbook.R;
+import com.dar.nbook.api.local.LocalGallery;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.settings.NotificationSettings;
+import com.dar.nbook.utility.LogUtility;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.List;
+
+@TargetApi(Build.VERSION_CODES.KITKAT)
+public class CreatePDF extends JobIntentService {
+    private int notId;
+    private int totalPage;
+    private NotificationCompat.Builder notification;
+
+    public CreatePDF() {
+    }
+
+    public static void startWork(Context context, LocalGallery gallery) {
+        Intent i = new Intent();
+        i.putExtra(context.getPackageName() + ".GALLERY", gallery);
+        enqueueWork(context, CreatePDF.class, 444, i);
+    }
+
+    public static boolean hasPDFCapabilities() {
+        try {
+            Class.forName("android.graphics.pdf.PdfDocument");
+            return true;
+        } catch (ClassNotFoundException e) {
+            return false;
+        }
+    }
+
+    @Override
+    protected void onHandleWork(@Nullable Intent intent) {
+        if (!hasPDFCapabilities()) {
+            return;
+        }
+        notId = NotificationSettings.getNotificationId();
+        System.gc();
+        LocalGallery gallery = intent.getParcelableExtra(getPackageName() + ".GALLERY");
+        if (gallery == null) return;
+        totalPage = gallery.getPageCount();
+        preExecute(gallery.getDirectory());
+        PdfDocument document = new PdfDocument();
+        File page;
+        for (int a = 1; a <= gallery.getPageCount(); a++) {
+            page = gallery.getPage(a);
+            if (page == null) continue;
+            Bitmap bitmap = BitmapFactory.decodeFile(page.getAbsolutePath());
+            if (bitmap != null) {
+                PdfDocument.PageInfo info = new PdfDocument.PageInfo.Builder(bitmap.getWidth(), bitmap.getHeight(), a).create();
+                PdfDocument.Page p = document.startPage(info);
+                p.getCanvas().drawBitmap(bitmap, 0f, 0f, null);
+                document.finishPage(p);
+                bitmap.recycle();
+            }
+            notification.setProgress(totalPage - 1, a + 1, false);
+            NotificationSettings.notify(getString(R.string.channel2_name), notId, notification.build());
+
+        }
+        notification.setContentText(getString(R.string.writing_pdf));
+        notification.setProgress(totalPage, 0, true);
+        NotificationSettings.notify(getString(R.string.channel2_name), notId, notification.build());
+        try {
+
+            File finalPath = Global.PDFFOLDER;
+            finalPath.mkdirs();
+            finalPath = new File(finalPath, gallery.getTitle() + ".pdf");
+            finalPath.createNewFile();
+            LogUtility.d("Generating PDF at: " + finalPath);
+            FileOutputStream out = new FileOutputStream(finalPath);
+            document.writeTo(out);
+            out.close();
+            document.close();
+            notification.setProgress(0, 0, false);
+            notification.setContentTitle(getString(R.string.created_pdf));
+            notification.setContentText(gallery.getTitle());
+            createIntentOpen(finalPath);
+            NotificationSettings.notify(getString(R.string.channel2_name), notId, notification.build());
+            LogUtility.d(finalPath.getAbsolutePath());
+        } catch (IOException e) {
+            notification.setContentTitle(getString(R.string.error_pdf));
+            notification.setContentText(getString(R.string.failed));
+            notification.setProgress(0, 0, false);
+            NotificationSettings.notify(getString(R.string.channel2_name), notId, notification.build());
+            throw new RuntimeException("Error generating file", e);
+        } finally {
+            document.close();
+        }
+
+
+    }
+
+    private void createIntentOpen(File finalPath) {
+        try {
+            Intent i = new Intent(Intent.ACTION_VIEW);
+            Uri apkURI = FileProvider.getUriForFile(
+                getApplicationContext(), getPackageName() + ".provider", finalPath);
+            i.setDataAndType(apkURI, "application/pdf");
+            i.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+            i.setFlags(Intent.FLAG_ACTIVITY_NO_HISTORY);
+            List<ResolveInfo> resInfoList = getApplicationContext().getPackageManager().queryIntentActivities(i, PackageManager.MATCH_DEFAULT_ONLY);
+            for (ResolveInfo resolveInfo : resInfoList) {
+                String packageName = resolveInfo.activityInfo.packageName;
+                getApplicationContext().grantUriPermission(packageName, apkURI, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION);
+            }
+
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+                notification.setContentIntent(PendingIntent.getActivity(getApplicationContext(), 0, i, PendingIntent.FLAG_MUTABLE));
+            } else {
+                notification.setContentIntent(PendingIntent.getActivity(getApplicationContext(), 0, i, 0));
+            }
+            LogUtility.d(apkURI.toString());
+        } catch (IllegalArgumentException ignore) {//sometimes the uri isn't available
+
+        }
+    }
+
+    private void preExecute(File file) {
+        notification = new NotificationCompat.Builder(getApplicationContext(), Global.CHANNEL_ID2);
+        notification.setSmallIcon(R.drawable.ic_pdf)
+            .setOnlyAlertOnce(true)
+            .setStyle(new NotificationCompat.BigTextStyle().bigText(file.getName()))
+            .setContentTitle(getString(R.string.channel2_title))
+            .setContentText(getString(R.string.parsing_pages))
+            .setProgress(totalPage - 1, 0, false)
+            .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+            .setCategory(NotificationCompat.CATEGORY_STATUS);
+        NotificationSettings.notify(getString(R.string.channel2_name), notId, notification.build());
+    }
+
+}

+ 136 - 0
app/src/main/java/com/dar/nbook/async/converters/CreateZIP.java

@@ -0,0 +1,136 @@
+package com.dar.nbook.async.converters;
+
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.net.Uri;
+import android.os.Build;
+
+import androidx.annotation.Nullable;
+import androidx.core.app.JobIntentService;
+import androidx.core.app.NotificationCompat;
+import androidx.core.content.FileProvider;
+
+import com.dar.nbook.R;
+import com.dar.nbook.api.local.LocalGallery;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.settings.NotificationSettings;
+import com.dar.nbook.utility.LogUtility;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.List;
+import java.util.zip.Deflater;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+public class CreateZIP extends JobIntentService {
+    // TODO: 11/04/20 REFACTOR CREATE ZIP AND PDF
+
+    private final byte[] buffer = new byte[1024];
+    private int notId;
+    private NotificationCompat.Builder notification;
+
+    public CreateZIP() {
+    }
+
+    public static void startWork(Context context, LocalGallery gallery) {
+        Intent i = new Intent();
+        i.putExtra(context.getPackageName() + ".GALLERY", gallery);
+        enqueueWork(context, CreateZIP.class, 555, i);
+    }
+
+    @Override
+    protected void onHandleWork(@Nullable Intent intent) {
+        System.gc();
+        LocalGallery gallery = intent.getParcelableExtra(getPackageName() + ".GALLERY");
+        if (gallery == null) return;
+        preExecute(gallery.getDirectory());
+        try {
+            File file = new File(Global.ZIPFOLDER, gallery.getTitle() + ".zip");
+            FileOutputStream o = new FileOutputStream(file);
+            ZipOutputStream out = new ZipOutputStream(o);
+            out.setLevel(Deflater.BEST_COMPRESSION);
+            FileInputStream in;
+            File actual;
+            int read;
+            for (int i = 1; i <= gallery.getPageCount(); i++) {
+                actual = gallery.getPage(i);
+                if (actual == null) continue;
+                ZipEntry entry = new ZipEntry(actual.getName());
+                in = new FileInputStream(actual);
+                out.putNextEntry(entry);
+                while ((read = in.read(buffer)) != -1) {
+                    out.write(buffer, 0, read);
+                }
+                in.close();
+                out.closeEntry();
+                notification.setProgress(gallery.getPageCount(), i, false);
+                NotificationSettings.notify(getString(R.string.channel3_name), notId, notification.build());
+            }
+            out.flush();
+            out.close();
+            postExecute(true, gallery, null, file);
+        } catch (IOException e) {
+            LogUtility.e(e.getLocalizedMessage(), e);
+            postExecute(false, gallery, e.getLocalizedMessage(), null);
+        }
+
+    }
+
+    private void postExecute(boolean success, LocalGallery gallery, String localizedMessage, File file) {
+        notification.setProgress(0, 0, false)
+            .setContentTitle(success ? getString(R.string.created_zip) : getString(R.string.failed_zip));
+        if (!success) {
+            notification.setStyle(new NotificationCompat.BigTextStyle()
+                .bigText(gallery.getTitle())
+                .setSummaryText(localizedMessage));
+        } else {
+            createIntentOpen(file);
+        }
+        NotificationSettings.notify(getString(R.string.channel3_name), notId, notification.build());
+
+    }
+
+    private void createIntentOpen(File finalPath) {
+        try {
+            Intent i = new Intent(Intent.ACTION_VIEW);
+            Uri apkURI = FileProvider.getUriForFile(
+                getApplicationContext(), getPackageName() + ".provider", finalPath);
+            i.setDataAndType(apkURI, "application/zip");
+            i.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+            i.setFlags(Intent.FLAG_ACTIVITY_NO_HISTORY);
+            List<ResolveInfo> resInfoList = getApplicationContext().getPackageManager().queryIntentActivities(i, PackageManager.MATCH_DEFAULT_ONLY);
+            for (ResolveInfo resolveInfo : resInfoList) {
+                String packageName = resolveInfo.activityInfo.packageName;
+                getApplicationContext().grantUriPermission(packageName, apkURI, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION);
+            }
+
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+                notification.setContentIntent(PendingIntent.getActivity(getApplicationContext(), 0, i, PendingIntent.FLAG_MUTABLE));
+            } else {
+                notification.setContentIntent(PendingIntent.getActivity(getApplicationContext(), 0, i, 0));
+            }
+            LogUtility.d(apkURI.toString());
+        } catch (IllegalArgumentException ignore) {//sometimes the uri isn't available
+
+        }
+    }
+
+    private void preExecute(File file) {
+        notId = NotificationSettings.getNotificationId();
+        notification = new NotificationCompat.Builder(getApplicationContext(), Global.CHANNEL_ID3);
+        notification.setSmallIcon(R.drawable.ic_archive)
+            .setOnlyAlertOnce(true)
+            .setContentText(file.getName())
+            .setContentTitle(getString(R.string.channel3_title))
+            .setProgress(1, 0, false)
+            .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+            .setCategory(NotificationCompat.CATEGORY_STATUS);
+        NotificationSettings.notify(getString(R.string.channel3_name), notId, notification.build());
+    }
+}

+ 182 - 0
app/src/main/java/com/dar/nbook/async/database/DatabaseHelper.java

@@ -0,0 +1,182 @@
+package com.dar.nbook.async.database;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.graphics.Color;
+
+import com.dar.nbook.R;
+import com.dar.nbook.api.components.Gallery;
+import com.dar.nbook.api.components.Tag;
+import com.dar.nbook.api.enums.SpecialTagIds;
+import com.dar.nbook.api.enums.TagStatus;
+import com.dar.nbook.api.enums.TagType;
+import com.dar.nbook.components.status.StatusManager;
+import com.dar.nbook.settings.Database;
+import com.dar.nbook.utility.LogUtility;
+
+import java.io.IOException;
+import java.util.Date;
+import java.util.List;
+
+@SuppressWarnings("deprecation")
+public class DatabaseHelper extends SQLiteOpenHelper {
+    static final String DATABASE_NAME = "Entries.db";
+    private static final int DATABASE_VERSION = 13;
+    private final Context context;
+
+    public DatabaseHelper(Context context1) {
+        super(context1, DATABASE_NAME, null, DATABASE_VERSION);
+        this.context = context1;
+    }
+
+    @Override
+    public void onCreate(SQLiteDatabase db) {
+        createAllTables(db);
+        Database.setDatabase(db);
+        insertLanguageTags();
+        insertCategoryTags();
+        insertDefaultStatus();
+        //Queries.DebugDatabase.dumpDatabase(db);
+    }
+
+    private void createAllTables(SQLiteDatabase db) {
+        db.execSQL(Queries.GalleryTable.CREATE_TABLE);
+        db.execSQL(Queries.TagTable.CREATE_TABLE);
+        db.execSQL(Queries.GalleryBridgeTable.CREATE_TABLE);
+        db.execSQL(Queries.BookmarkTable.CREATE_TABLE);
+        db.execSQL(Queries.DownloadTable.CREATE_TABLE);
+        db.execSQL(Queries.HistoryTable.CREATE_TABLE);
+        db.execSQL(Queries.FavoriteTable.CREATE_TABLE);
+        db.execSQL(Queries.ResumeTable.CREATE_TABLE);
+        db.execSQL(Queries.StatusTable.CREATE_TABLE);
+        db.execSQL(Queries.StatusMangaTable.CREATE_TABLE);
+
+    }
+    // TODO: 28/10/20 Add search history to DB instead of shared
+
+    private void insertCategoryTags() {
+        Tag[] types = {
+            new Tag("doujinshi", 0, 33172, TagType.CATEGORY, TagStatus.DEFAULT),
+            new Tag("manga", 0, 33173, TagType.CATEGORY, TagStatus.DEFAULT),
+            new Tag("misc", 0, 97152, TagType.CATEGORY, TagStatus.DEFAULT),
+            new Tag("western", 0, 34125, TagType.CATEGORY, TagStatus.DEFAULT),
+            new Tag("non-h", 0, 34065, TagType.CATEGORY, TagStatus.DEFAULT),
+            new Tag("artistcg", 0, 36320, TagType.CATEGORY, TagStatus.DEFAULT),
+        };
+        for (Tag t : types) Queries.TagTable.insert(t);
+    }
+
+    private void insertLanguageTags() {
+        Tag[] languages = {
+            new Tag("english", 0, SpecialTagIds.LANGUAGE_ENGLISH, TagType.LANGUAGE, TagStatus.DEFAULT),
+            new Tag("japanese", 0, SpecialTagIds.LANGUAGE_JAPANESE, TagType.LANGUAGE, TagStatus.DEFAULT),
+            new Tag("chinese", 0, SpecialTagIds.LANGUAGE_CHINESE, TagType.LANGUAGE, TagStatus.DEFAULT),
+        };
+        for (Tag t : languages) Queries.TagTable.insert(t);
+    }
+
+    @Override
+    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+        Database.setDatabase(db);
+        if (oldVersion == 2) insertLanguageTags();
+        if (oldVersion <= 3) insertCategoryTags();
+        if (oldVersion <= 4) db.execSQL(Queries.BookmarkTable.CREATE_TABLE);
+        if (oldVersion <= 5) updateGalleryWithSizes(db);
+        if (oldVersion <= 6) db.execSQL(Queries.DownloadTable.CREATE_TABLE);
+        if (oldVersion <= 7) db.execSQL(Queries.HistoryTable.CREATE_TABLE);
+        if (oldVersion <= 8) insertFavorite(db);
+        if (oldVersion <= 9) addRangeColumn(db);
+        if (oldVersion <= 10) db.execSQL(Queries.ResumeTable.CREATE_TABLE);
+        if (oldVersion <= 11) updateFavoriteTable(db);
+        if (oldVersion <= 12) addStatusTables(db);
+
+    }
+
+    private void addStatusTables(SQLiteDatabase db) {
+        db.execSQL(Queries.StatusTable.CREATE_TABLE);
+        db.execSQL(Queries.StatusMangaTable.CREATE_TABLE);
+        insertDefaultStatus();
+    }
+
+    private void insertDefaultStatus() {
+        StatusManager.add(context.getString(R.string.default_status_1), Color.BLUE);
+        StatusManager.add(context.getString(R.string.default_status_2), Color.GREEN);
+        StatusManager.add(context.getString(R.string.default_status_3), Color.YELLOW);
+        StatusManager.add(context.getString(R.string.default_status_4), Color.RED);
+        StatusManager.add(context.getString(R.string.default_status_5), Color.GRAY);
+        StatusManager.add(StatusManager.DEFAULT_STATUS, Color.BLACK);
+    }
+
+    private void updateFavoriteTable(SQLiteDatabase db) {
+        db.execSQL("ALTER TABLE Favorite ADD COLUMN `time` INT NOT NULL DEFAULT " + new Date().getTime());
+    }
+
+    private void addRangeColumn(SQLiteDatabase db) {
+        db.execSQL("ALTER TABLE Downloads ADD COLUMN `range_start` INT NOT NULL DEFAULT -1");
+        db.execSQL("ALTER TABLE Downloads ADD COLUMN `range_end`   INT NOT NULL DEFAULT -1");
+    }
+
+    /**
+     * Add all item which are favorite into the favorite table
+     */
+    private int[] getAllFavoriteIndex() {
+        Cursor c = Queries.GalleryTable.getAllFavoriteCursorDeprecated("%", false);
+        int[] favorites = new int[c.getCount()];
+        int i = 0;
+        if (c.moveToFirst()) {
+            do {
+                favorites[i++] = c.getInt(c.getColumnIndex(Queries.GalleryTable.IDGALLERY));
+            } while (c.moveToNext());
+        }
+        c.close();
+        return favorites;
+    }
+
+    /**
+     * Create favorite table
+     * Get all id of favorite gallery
+     * save all galleries
+     * delete and recreate table without favorite column
+     * insert all galleries again
+     * populate favorite
+     */
+    private void insertFavorite(SQLiteDatabase db) {
+        Database.setDatabase(db);
+        db.execSQL(Queries.FavoriteTable.CREATE_TABLE);
+        try {
+            int[] favorites = getAllFavoriteIndex();
+            List<Gallery> allGalleries = Queries.GalleryTable.getAllGalleries();
+            db.execSQL(Queries.GalleryTable.DROP_TABLE);
+            db.execSQL(Queries.GalleryTable.CREATE_TABLE);
+            for (Gallery g : allGalleries) Queries.GalleryTable.insert(g);
+            for (int i : favorites) Queries.FavoriteTable.insert(i);
+        } catch (IOException e) {
+            e.printStackTrace();
+        }
+    }
+
+    /**
+     * Add the columns which contains the sizes of the images
+     */
+    private void updateGalleryWithSizes(SQLiteDatabase db) {
+        db.execSQL("ALTER TABLE Gallery ADD COLUMN `maxW` INT NOT NULL DEFAULT 0");
+        db.execSQL("ALTER TABLE Gallery ADD COLUMN `maxH` INT NOT NULL DEFAULT 0");
+        db.execSQL("ALTER TABLE Gallery ADD COLUMN `minW` INT NOT NULL DEFAULT 0");
+        db.execSQL("ALTER TABLE Gallery ADD COLUMN `minH` INT NOT NULL DEFAULT 0");
+    }
+
+    @Override
+    public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+        LogUtility.d("Downgrading database from " + oldVersion + " to " + newVersion);
+        onCreate(db);
+    }
+
+    @Override
+    public void onOpen(SQLiteDatabase db) {
+        super.onOpen(db);
+        Database.setDatabase(db);
+        Queries.GalleryTable.clearGalleries();
+    }
+}

+ 1042 - 0
app/src/main/java/com/dar/nbook/async/database/Queries.java

@@ -0,0 +1,1042 @@
+package com.dar.nbook.async.database;
+
+import android.annotation.SuppressLint;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.DatabaseUtils;
+import android.database.sqlite.SQLiteDatabase;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.dar.nbook.api.InspectorV3;
+import com.dar.nbook.api.SimpleGallery;
+import com.dar.nbook.api.components.Gallery;
+import com.dar.nbook.api.components.GalleryData;
+import com.dar.nbook.api.components.GenericGallery;
+import com.dar.nbook.api.components.Tag;
+import com.dar.nbook.api.components.TagList;
+import com.dar.nbook.api.enums.ApiRequestType;
+import com.dar.nbook.api.enums.TagStatus;
+import com.dar.nbook.api.enums.TagType;
+import com.dar.nbook.api.enums.TitleType;
+import com.dar.nbook.async.downloader.GalleryDownloaderManager;
+import com.dar.nbook.async.downloader.GalleryDownloaderV2;
+import com.dar.nbook.components.classes.Bookmark;
+import com.dar.nbook.components.status.Status;
+import com.dar.nbook.components.status.StatusManager;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.settings.TagV2;
+import com.dar.nbook.utility.LogUtility;
+
+import java.io.FileWriter;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+
+@SuppressLint("Range")
+public class Queries {
+
+    static SQLiteDatabase db;
+
+    public static void setDb(SQLiteDatabase database) {
+        db = database;
+    }
+
+    public static int getColumnFromName(Cursor cursor, String name) {
+        return cursor.getColumnIndex(name);
+    }
+
+    public static class DebugDatabase {
+        private static void dumpTable(String name, FileWriter sb) throws IOException {
+
+            String query = "SELECT * FROM " + name;
+            Cursor c = db.rawQuery(query, null);
+            sb.write("DUMPING: ");
+            sb.write(name);
+            sb.write(" count: ");
+            sb.write("" + c.getCount());
+            sb.write(": ");
+            if (c.moveToFirst()) {
+                do {
+                    sb.write(DatabaseUtils.dumpCurrentRowToString(c));
+                } while (c.moveToNext());
+            }
+            c.close();
+            sb.append("END DUMPING\n");
+        }
+    }
+
+    /**
+     * Table with information about the galleries
+     */
+    public static class GalleryTable {
+        public static final String TABLE_NAME = "Gallery";
+        public static final String DROP_TABLE = "DROP TABLE IF EXISTS " + TABLE_NAME;
+        public static final String IDGALLERY = "idGallery";
+        public static final String TITLE_ENG = "title_eng";
+        public static final String TITLE_JP = "title_jp";
+        public static final String TITLE_PRETTY = "title_pretty";
+        public static final String FAVORITE_COUNT = "favorite_count";
+        public static final String MEDIAID = "mediaId";
+        public static final String FAVORITE = "favorite";
+        public static final String PAGES = "pages";
+        public static final String UPLOAD = "upload";
+        public static final String MAX_WIDTH = "maxW";
+        public static final String MAX_HEIGHT = "maxH";
+        public static final String MIN_WIDTH = "minW";
+        public static final String MIN_HEIGHT = "minH";
+        static final String CREATE_TABLE = "CREATE TABLE IF NOT EXISTS `Gallery` ( " +
+            "`idGallery`      INT               NOT NULL PRIMARY KEY , " +
+            "`title_eng`      TINYTEXT          NOT NULL, " +
+            "`title_jp`       TINYTEXT          NOT NULL, " +
+            "`title_pretty`   TINYTEXT          NOT NULL, " +
+            "`favorite_count` INT               NOT NULL, " +
+            "`mediaId`        INT               NOT NULL, " +
+            "`pages`          TEXT              NOT NULL," +
+            "`upload`         UNSIGNED BIG INT  NOT NULL," +//Date
+            "`maxW`           INT               NOT NULL," +
+            "`maxH`           INT               NOT NULL," +
+            "`minW`           INT               NOT NULL," +
+            "`minH`           INT               NOT NULL" +
+            ");";
+
+        static void clearGalleries() {
+            db.delete(GalleryTable.TABLE_NAME, String.format(Locale.US,
+                "%s NOT IN (SELECT %s FROM %s) AND " +
+                    "%s NOT IN (SELECT %s FROM %s) AND " +
+                    "%s NOT IN (SELECT %s FROM %s)",
+                GalleryTable.IDGALLERY, DownloadTable.ID_GALLERY, DownloadTable.TABLE_NAME,
+                GalleryTable.IDGALLERY, FavoriteTable.ID_GALLERY, FavoriteTable.TABLE_NAME,
+                GalleryTable.IDGALLERY, StatusMangaTable.GALLERY, StatusMangaTable.TABLE_NAME)
+                , null);
+            db.delete(GalleryBridgeTable.TABLE_NAME, String.format(Locale.US,
+                "%s NOT IN (SELECT %s FROM %s)",
+                GalleryBridgeTable.ID_GALLERY, GalleryTable.IDGALLERY, GalleryTable.TABLE_NAME)
+                , null);
+            db.delete(FavoriteTable.TABLE_NAME, String.format(Locale.US,
+                "%s NOT IN (SELECT %s FROM %s)",
+                FavoriteTable.ID_GALLERY, GalleryTable.IDGALLERY, GalleryTable.TABLE_NAME)
+                , null);
+            db.delete(DownloadTable.TABLE_NAME, String.format(Locale.US,
+                "%s NOT IN (SELECT %s FROM %s)",
+                DownloadTable.ID_GALLERY, GalleryTable.IDGALLERY, GalleryTable.TABLE_NAME)
+                , null);
+        }
+
+        /**
+         * Retrieve gallery using the id
+         *
+         * @param id id of the gallery to retrieve
+         */
+        public static Gallery galleryFromId(int id) throws IOException {
+            Cursor cursor = db.query(true, TABLE_NAME, null, IDGALLERY + "=?", new String[]{"" + id}, null, null, null, null);
+            Gallery g = null;
+            if (cursor.moveToFirst()) {
+                g = cursorToGallery(cursor);
+            }
+            cursor.close();
+            return g;
+        }
+
+        @Deprecated
+        @NonNull
+        public static Cursor getAllFavoriteCursorDeprecated(CharSequence query, boolean online) {
+            LogUtility.i("FILTER IN: " + query + ";;" + online);
+            Cursor cursor;//=db.rawQuery(sql,new String[]{url,url,url,""+(online?2:1)});
+            String sql = "SELECT * FROM " + TABLE_NAME + " WHERE (" +
+                FAVORITE + " =? OR " + FAVORITE + "=3)";
+            if (query != null && query.length() > 0) {
+                sql += " AND (" + TITLE_ENG + " LIKE ? OR " +
+                    TITLE_JP + " LIKE ? OR " +
+                    TITLE_PRETTY + " LIKE ? )";
+                String q = '%' + query.toString() + '%';
+                cursor = db.rawQuery(sql, new String[]{"" + (online ? 2 : 1), q, q, q});
+            } else cursor = db.rawQuery(sql, new String[]{"" + (online ? 2 : 1)});
+            LogUtility.d(sql);
+            LogUtility.d("AFTER FILTERING: " + cursor.getCount());
+            LogUtility.i("END FILTER IN: " + query + ";;" + online);
+            return cursor;
+        }
+
+        /**
+         * Retrieve all galleries inside the DB
+         */
+        public static List<Gallery> getAllGalleries() throws IOException {
+            String query = "SELECT * FROM " + TABLE_NAME;
+            Cursor cursor = db.rawQuery(query, null);
+            List<Gallery> galleries = new ArrayList<>(cursor.getCount());
+            if (cursor.moveToFirst()) {
+                do {
+                    galleries.add(cursorToGallery(cursor));
+                } while (cursor.moveToNext());
+            }
+            cursor.close();
+            return galleries;
+        }
+
+        public static void insert(GenericGallery gallery) {
+            ContentValues values = new ContentValues(12);
+            GalleryData data = gallery.getGalleryData();
+            values.put(IDGALLERY, gallery.getId());
+            values.put(TITLE_ENG, data.getTitle(TitleType.ENGLISH));
+            values.put(TITLE_JP, data.getTitle(TitleType.JAPANESE));
+            values.put(TITLE_PRETTY, data.getTitle(TitleType.PRETTY));
+            values.put(FAVORITE_COUNT, data.getFavoriteCount());
+            values.put(MEDIAID, data.getMediaId());
+            values.put(PAGES, data.createPagePath());
+            values.put(UPLOAD, data.getUploadDate().getTime());
+            values.put(MAX_WIDTH, gallery.getMaxSize().getWidth());
+            values.put(MAX_HEIGHT, gallery.getMaxSize().getHeight());
+            values.put(MIN_WIDTH, gallery.getMinSize().getWidth());
+            values.put(MIN_HEIGHT, gallery.getMinSize().getHeight());
+            //Insert gallery
+            db.insertWithOnConflict(TABLE_NAME, null, values, gallery instanceof Gallery ? SQLiteDatabase.CONFLICT_REPLACE : SQLiteDatabase.CONFLICT_IGNORE);
+            TagTable.insertTagsForGallery(data);
+        }
+
+        /**
+         * Convert a cursor pointing to galleries to a list of galleries, cursor not closed
+         *
+         * @param cursor Cursor to scroll
+         * @return ArrayList of galleries
+         */
+        static List<Gallery> cursorToList(Cursor cursor) throws IOException {
+            List<Gallery> galleries = new ArrayList<>(cursor.getCount());
+            if (cursor.moveToFirst()) {
+                do {
+                    galleries.add(GalleryTable.cursorToGallery(cursor));
+                } while (cursor.moveToNext());
+            }
+            return galleries;
+        }
+
+
+        public static void delete(int id) {
+            db.delete(TABLE_NAME, IDGALLERY + "=?", new String[]{"" + id});
+            GalleryBridgeTable.deleteGallery(id);
+        }
+
+
+        /**
+         * Convert a row of a cursor to a {@link Gallery}
+         */
+        public static Gallery cursorToGallery(Cursor cursor) throws IOException {
+            return new Gallery(cursor, GalleryBridgeTable.getTagsForGallery(cursor.getInt(getColumnFromName(cursor, IDGALLERY))));
+        }
+
+        /**
+         * Insert max and min size of a certain {@link Gallery}
+         */
+        public static void updateSizes(@Nullable Gallery gallery) {
+            if (gallery == null) return;
+            ContentValues values = new ContentValues(4);
+            values.put(MAX_WIDTH, gallery.getMaxSize().getWidth());
+            values.put(MAX_HEIGHT, gallery.getMaxSize().getHeight());
+            values.put(MIN_WIDTH, gallery.getMinSize().getWidth());
+            values.put(MIN_HEIGHT, gallery.getMinSize().getHeight());
+            db.updateWithOnConflict("Gallery", values, IDGALLERY + "=?", new String[]{"" + gallery.getId()}, SQLiteDatabase.CONFLICT_IGNORE);
+        }
+
+
+    }
+
+    public static class TagTable {
+        public static final String TABLE_NAME = "Tags";
+        public static final String DROP_TABLE = "DROP TABLE IF EXISTS " + TABLE_NAME;
+        static final String CREATE_TABLE = "CREATE TABLE IF NOT EXISTS `Tags` (" +
+            " `idTag` INT  NOT NULL PRIMARY KEY," +
+            " `name` TEXT NOT NULL , " +
+            "`type` TINYINT(1) NOT NULL , " +
+            "`count` INT NOT NULL," +
+            "`status` TINYINT(1) NOT NULL," +
+            "`online` TINYINT(1) NOT NULL DEFAULT 0);";
+
+        static final String IDTAG = "idTag";
+        static final String NAME = "name";
+        static final String TYPE = "type";
+        static final String COUNT = "count";
+        static final String STATUS = "status";
+        static final String ONLINE = "online";
+
+        /**
+         * Convert a {@link Cursor} row to a {@link Tag}
+         */
+        public static Tag cursorToTag(Cursor cursor) {
+            return new Tag(
+                cursor.getString(cursor.getColumnIndex(NAME)),
+                cursor.getInt(cursor.getColumnIndex(COUNT)),
+                cursor.getInt(cursor.getColumnIndex(IDTAG)),
+                TagType.values[cursor.getInt(cursor.getColumnIndex(TYPE))],
+                TagStatus.values()[cursor.getInt(cursor.getColumnIndex(STATUS))]
+            );
+        }
+
+        /**
+         * Fetch all rows inside a {@link Cursor} and convert them into a {@link Tag}
+         * The {@link Cursor} passed as parameter is closed
+         */
+        private static List<Tag> getTagsFromCursor(Cursor cursor) {
+            List<Tag> tags = new ArrayList<>(cursor.getCount());
+            int i = 0;
+            if (cursor.moveToFirst()) {
+                do {
+                    tags.add(cursorToTag(cursor));
+                } while (cursor.moveToNext());
+            }
+            cursor.close();
+            return tags;
+        }
+
+        /**
+         * Return a cursor which points to a list of {@link Tag} which have certain properties
+         *
+         * @param query      Retrieve only tags which contains a certain string
+         * @param type       If not null only tags which are of a specific {@link TagType}
+         * @param online     Retrieve only tags which have been blacklisted from the main site
+         * @param sortByName sort by name or by count
+         */
+        public static Cursor getFilterCursor(@NonNull String query, TagType type, boolean online, boolean sortByName) {
+            //create query
+            StringBuilder sql = new StringBuilder("SELECT * FROM ").append(TABLE_NAME);
+            sql.append(" WHERE ");
+            sql.append(COUNT).append(">=? ");                                        //min tag count
+            if (query.length() > 0)
+                sql.append("AND ").append(NAME).append(" LIKE ?");  //query if is used
+            if (type != null)
+                sql.append("AND ").append(TYPE).append("=? ");            //type if is used
+            if (online)
+                sql.append("AND ").append(ONLINE).append("=1 ");              //retrieve only online tags
+            if (!online && type == null)
+                sql.append("AND ").append(STATUS).append("!=0 ");//retrieve only used tags
+
+            sql.append("ORDER BY ");                                                 //sort first by name if provided, the for count
+            if (!sortByName) sql.append(COUNT).append(" DESC,");
+            sql.append(NAME).append(" ASC");
+
+            //create parameter list
+            ArrayList<String> list = new ArrayList<>();
+            list.add("" + TagV2.getMinCount());               //minium tags (always provided)
+            if (query.length() > 0) list.add('%' + query + '%');    //query
+            if (type != null) list.add("" + type.getId());      //type of the tag
+            LogUtility.d("FILTER URL: " + sql + ", ARGS: " + list);
+            return db.rawQuery(sql.toString(), list.toArray(new String[0]));
+        }
+
+        /**
+         * Returns a List of all tags of a specific type and which have a min count
+         *
+         * @param type The type to fetch
+         */
+        public static List<Tag> getAllTagOfType(TagType type) {
+            String query = "SELECT * FROM " + TABLE_NAME + " WHERE " + TYPE + " = ? AND " + COUNT + " >= ?";
+            return getTagsFromCursor(db.rawQuery(query, new String[]{"" + type.getId(), "" + TagV2.getMinCount()}));
+        }
+
+        /**
+         * Returns a List of all tags of a specific type
+         *
+         * @param type The type to fetch
+         */
+        public static List<Tag> getTrueAllType(TagType type) {
+            String query = "SELECT * FROM " + TABLE_NAME + " WHERE " + TYPE + " = ?";
+            return getTagsFromCursor(db.rawQuery(query, new String[]{"" + type.getId()}));
+        }
+
+        /**
+         * Returns a List of all tags of a specific status
+         *
+         * @param status The status to fetch
+         */
+        public static List<Tag> getAllStatus(TagStatus status) {
+            String query = "SELECT * FROM " + TABLE_NAME + " WHERE " + STATUS + " = ?";
+            return getTagsFromCursor(db.rawQuery(query, new String[]{"" + status.ordinal()}));
+        }
+
+        /**
+         * Returns a List of all tags which are AVOIDED or ACCEPTED
+         */
+        public static List<Tag> getAllFiltered() {
+            String query = "SELECT * FROM " + TABLE_NAME + " WHERE " + STATUS + " != ?";
+            return getTagsFromCursor(db.rawQuery(query, new String[]{"" + TagStatus.DEFAULT.ordinal()}));
+        }
+
+        /**
+         * Returns a List of all tags which are AVOIDED or ACCEPTED of a specific type
+         */
+        public static List<Tag> getAllFilteredByType(TagType type) {
+            String query = "SELECT * FROM " + TABLE_NAME + " WHERE " + STATUS + " != ?";
+            return getTagsFromCursor(db.rawQuery(query, new String[]{"" + TagStatus.DEFAULT.ordinal()}));
+        }
+
+        /**
+         * Returns a List of all tags which have been blacklisted from the site
+         */
+        public static List<Tag> getAllOnlineBlacklisted() {
+            String query = "SELECT * FROM " + TABLE_NAME + " WHERE " + ONLINE + " = 1";
+            List<Tag> t = getTagsFromCursor(db.rawQuery(query, null));
+            for (Tag t1 : t) t1.setStatus(TagStatus.AVOIDED);
+            return t;
+        }
+
+        /**
+         * Returns true if the tag has been blacklisted form the main site
+         */
+        public static boolean isBlackListed(Tag tag) {
+            String query = "SELECT " + IDTAG + " FROM " + TABLE_NAME + " WHERE " + IDTAG + "=? AND " + ONLINE + "=1";
+            Cursor c = db.rawQuery(query, new String[]{"" + tag.getId()});
+            boolean x = c.moveToFirst();
+            c.close();
+            return x;
+        }
+
+        /**
+         * Returns the tag which has a specific if, null if it does not exists
+         */
+        @Nullable
+        public static Tag getTagById(int id) {
+            String query = "SELECT * FROM " + TABLE_NAME + " WHERE " + IDTAG + " = ?";
+            Cursor c = db.rawQuery(query, new String[]{"" + id});
+            Tag t = null;
+            if (c.moveToFirst()) t = cursorToTag(c);
+            c.close();
+            return t;
+        }
+
+        public static int updateStatus(int id, TagStatus status) {
+            ContentValues values = new ContentValues(1);
+            values.put(STATUS, status.ordinal());
+            return db.updateWithOnConflict(TABLE_NAME, values, IDTAG + "=?", new String[]{"" + id}, SQLiteDatabase.CONFLICT_IGNORE);
+        }
+
+        /**
+         * Update status and count of a specific tag
+         */
+        public static int updateTag(Tag tag) {
+            insert(tag);
+            ContentValues values = new ContentValues(2);
+            values.put(STATUS, tag.getStatus().ordinal());
+            values.put(COUNT, tag.getCount());
+            return db.updateWithOnConflict(TABLE_NAME, values, IDTAG + "=?", new String[]{"" + tag.getId()}, SQLiteDatabase.CONFLICT_IGNORE);
+        }
+
+        public static void insert(Tag tag, boolean replace) {
+            ContentValues values = new ContentValues(5);
+            values.put(IDTAG, tag.getId());
+            values.put(NAME, tag.getName());
+            values.put(TYPE, tag.getType().getId());
+            values.put(COUNT, tag.getCount());
+            values.put(STATUS, tag.getStatus().ordinal());
+
+            db.insertWithOnConflict(TABLE_NAME, null, values, replace ? SQLiteDatabase.CONFLICT_REPLACE : SQLiteDatabase.CONFLICT_IGNORE);
+        }
+
+        public static void insert(Tag tag) {
+            insert(tag, false);
+        }
+
+        public static void updateBlacklistedTag(Tag tag, boolean online) {
+            ContentValues values = new ContentValues(1);
+            values.put(ONLINE, online ? 1 : 0);
+            db.updateWithOnConflict(TABLE_NAME, values, IDTAG + "=?", new String[]{"" + tag.getId()}, SQLiteDatabase.CONFLICT_IGNORE);
+        }
+
+        public static void removeAllBlacklisted() {
+            ContentValues values = new ContentValues(1);
+            values.put(ONLINE, 0);
+            db.updateWithOnConflict(TABLE_NAME, values, null, null, SQLiteDatabase.CONFLICT_IGNORE);
+        }
+
+        public static void resetAllStatus() {
+            ContentValues values = new ContentValues(1);
+            values.put(STATUS, TagStatus.DEFAULT.ordinal());
+            db.updateWithOnConflict(TABLE_NAME, values, null, null, SQLiteDatabase.CONFLICT_IGNORE);
+        }
+
+        /**
+         * Get the first <code>count</code> tags of <code>type</code>, ordered by tag count
+         */
+        public static List<Tag> getTopTags(TagType type, int count) {
+            String query = "SELECT * FROM " + TABLE_NAME + " WHERE " + TYPE + "=? ORDER BY " + COUNT + " DESC LIMIT ?;";
+            Cursor cursor = db.rawQuery(query, new String[]{"" + type.getId(), "" + count});
+            return TagTable.getTagsFromCursor(cursor);
+        }
+
+        /**
+         * Retrieve the status of a tag from the DB and set it
+         *
+         * @return the status if the tag exists, null otherwise
+         */
+        @Nullable
+        public static TagStatus getStatus(Tag tag) {
+            String query = "SELECT " + STATUS + " FROM " + TABLE_NAME +
+                " WHERE " + IDTAG + " =?";
+            Cursor c = db.rawQuery(query, new String[]{"" + tag.getId()});
+            TagStatus status = null;
+            if (c.moveToFirst()) {
+                status = TagTable.cursorToTag(c).getStatus();
+                tag.setStatus(status);
+            }
+            c.close();
+            return status;
+        }
+
+        public static Tag getTagFromTagName(String name) {
+            Tag tag = null;
+            Cursor cursor = db.query(TABLE_NAME, null, NAME + "=?", new String[]{name}, null, null, null);
+            if (cursor.moveToFirst()) tag = cursorToTag(cursor);
+            cursor.close();
+            return tag;
+        }
+
+        /**
+         * @param tagString a comma-separated list of integers (maybe vulnerable)
+         * @return the tags with id contained inside the list
+         */
+        public static TagList getTagsFromListOfInt(String tagString) {
+            TagList tags = new TagList();
+            String query = "SELECT * FROM " + TABLE_NAME + " WHERE " + IDTAG + " IN (" + tagString + ")";
+            Cursor cursor = db.rawQuery(query, null);
+            if (cursor.moveToFirst()) {
+                do {
+                    tags.addTag(cursorToTag(cursor));
+                } while (cursor.moveToNext());
+            }
+            return tags;
+        }
+
+        /**
+         * Return a list of tags which contain name and are of a certain type
+         */
+        public static List<Tag> search(String name, TagType type) {
+            String query = "SELECT * FROM " + TABLE_NAME + " WHERE " + NAME + " LIKE ? AND " + TYPE + "=?";
+            LogUtility.d(query);
+            Cursor c = db.rawQuery(query, new String[]{'%' + name + '%', "" + type.getId()});
+            return getTagsFromCursor(c);
+        }
+
+        /**
+         * Search a tag by name and type
+         *
+         * @return The Tag if found, null otehrwise
+         */
+        public static Tag searchTag(String name, TagType type) {
+            Tag tag = null;
+            String query = "SELECT * FROM " + TABLE_NAME + " WHERE " + NAME + " = ? AND " + TYPE + "=?";
+            LogUtility.d(query);
+            Cursor c = db.rawQuery(query, new String[]{name, "" + type.getId()});
+            if (c.moveToFirst()) tag = cursorToTag(c);
+            c.close();
+            return tag;
+        }
+
+        /**
+         * Insert all tags owned by a gallery and link it using {@link GalleryBridgeTable}
+         */
+        public static void insertTagsForGallery(GalleryData gallery) {
+            TagList tags = gallery.getTags();
+            int len;
+            Tag tag;
+            for (TagType t : TagType.values) {
+                len = tags.getCount(t);
+                for (int i = 0; i < len; i++) {
+                    tag = tags.getTag(t, i);
+                    TagTable.insert(tag);//Insert tag
+                    GalleryBridgeTable.insert(gallery.getId(), tag.getId());//Insert link
+                }
+            }
+        }
+
+        /*To avoid conflict between the import process and the ScrapeTags*/
+        public static void insertScrape(Tag tag, boolean b) {
+            if (db.isOpen()) insert(tag, b);
+        }
+    }
+
+    public static class DownloadTable {
+        public static final String ID_GALLERY = "id_gallery";
+        public static final String RANGE_START = "range_start";
+        public static final String RANGE_END = "range_end";
+        public static final String TABLE_NAME = "Downloads";
+        public static final String DROP_TABLE = "DROP TABLE IF EXISTS " + TABLE_NAME;
+        static final String CREATE_TABLE = "CREATE TABLE IF NOT EXISTS `Downloads` (" +
+            "`id_gallery`  INT NOT NULL PRIMARY KEY , " +
+            "`range_start` INT NOT NULL," +
+            "`range_end`   INT NOT NULL," +
+            "FOREIGN KEY(`id_gallery`) REFERENCES `Gallery`(`idGallery`) ON UPDATE CASCADE ON DELETE CASCADE" +
+            "); ";
+
+        public static void addGallery(GalleryDownloaderV2 downloader) {
+            Gallery gallery = downloader.getGallery();
+            Queries.GalleryTable.insert(gallery);
+            ContentValues values = new ContentValues(3);
+            values.put(ID_GALLERY, gallery.getId());
+            values.put(RANGE_START, downloader.getStart());
+            values.put(RANGE_END, downloader.getEnd());
+            db.insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_IGNORE);
+        }
+
+        public static void removeGallery(int id) {
+            boolean favorite = Queries.FavoriteTable.isFavorite(id);
+            if (!favorite) Queries.GalleryTable.delete(id);
+            db.delete(TABLE_NAME, ID_GALLERY + "=?", new String[]{"" + id});
+        }
+
+        public static List<GalleryDownloaderManager> getAllDownloads(Context context) throws IOException {
+            String q = "SELECT * FROM %s INNER JOIN %s ON %s=%s";
+            String query = String.format(Locale.US, q, GalleryTable.TABLE_NAME, DownloadTable.TABLE_NAME, GalleryTable.IDGALLERY, DownloadTable.ID_GALLERY);
+            Cursor c = db.rawQuery(query, null);
+            List<GalleryDownloaderManager> managers = new ArrayList<>();
+
+            Gallery x;
+            GalleryDownloaderManager m;
+            if (c.moveToFirst()) {
+                do {
+                    x = GalleryTable.cursorToGallery(c);
+                    m = new GalleryDownloaderManager(context, x, c.getInt(c.getColumnIndex(RANGE_START)), c.getInt(c.getColumnIndex(RANGE_END)));
+                    managers.add(m);
+                } while (c.moveToNext());
+            }
+            c.close();
+            return managers;
+        }
+    }
+
+    public static class HistoryTable {
+        public static final String ID = "id";
+        public static final String MEDIAID = "mediaId";
+        public static final String TITLE = "title";
+        public static final String THUMB = "thumbType";
+        public static final String TIME = "time";
+        public static final String TABLE_NAME = "History";
+        public static final String DROP_TABLE = "DROP TABLE IF EXISTS " + TABLE_NAME;
+        static final String CREATE_TABLE = "CREATE TABLE IF NOT EXISTS `History`(" +
+            "`id` INT NOT NULL PRIMARY KEY," +
+            "`mediaId` INT NOT NULL," +
+            "`title` TEXT NOT NULL," +
+            "`thumbType` TINYINT(1) NOT NULL," +
+            "`time` INT NOT NULL" +
+            ");";
+
+        public static void addGallery(SimpleGallery gallery) {
+            if (gallery.getId() <= 0) return;
+            ContentValues values = new ContentValues(5);
+            values.put(ID, gallery.getId());
+            values.put(MEDIAID, gallery.getMediaId());
+            values.put(TITLE, gallery.getTitle());
+            values.put(THUMB, gallery.getThumb().ordinal());
+            values.put(TIME, new Date().getTime());
+            db.insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_REPLACE);
+            cleanHistory();
+        }
+
+        public static List<SimpleGallery> getHistory() {
+            ArrayList<SimpleGallery> galleries = new ArrayList<>();
+            Cursor c = db.query(TABLE_NAME, null, null, null, null, null, TIME + " DESC", "" + Global.getMaxHistory());
+            if (c.moveToFirst()) {
+                do {
+                    galleries.add(new SimpleGallery(c));
+                } while (c.moveToNext());
+            }
+            galleries.trimToSize();
+            return galleries;
+        }
+
+        public static void emptyHistory() {
+            db.delete(TABLE_NAME, null, null);
+        }
+
+        private static void cleanHistory() {
+            while (db.delete(TABLE_NAME, "(SELECT COUNT(*) FROM " + TABLE_NAME + ")>? AND " + TIME + "=(SELECT MIN(" + TIME + ") FROM " + TABLE_NAME + ")", new String[]{"" + Global.getMaxHistory()}) == 1)
+                ;
+        }
+    }
+
+    public static class BookmarkTable {
+        public static final String TABLE_NAME = "Bookmark";
+        public static final String DROP_TABLE = "DROP TABLE IF EXISTS " + TABLE_NAME;
+        static final String URL = "url";
+        static final String PAGE = "page";
+        static final String TYPE = "type";
+        static final String TAG_ID = "tagId";
+        static final String CREATE_TABLE = "CREATE TABLE IF NOT EXISTS `Bookmark`(" +
+            "`url` TEXT NOT NULL UNIQUE," +
+            "`page` INT NOT NULL," +
+            "`type` INT NOT NULL," +
+            "`tagId` INT NOT NULL" +
+            ");";
+
+        public static void deleteBookmark(String url) {
+            LogUtility.d("Deleted: " + db.delete(TABLE_NAME, URL + "=?", new String[]{url}));
+        }
+
+        public static void addBookmark(InspectorV3 inspector) {
+            Tag tag = inspector.getTag();
+            ContentValues values = new ContentValues(4);
+            values.put(URL, inspector.getUrl());
+            values.put(PAGE, inspector.getPage());
+            values.put(TYPE, inspector.getRequestType().ordinal());
+            values.put(TAG_ID, tag == null ? 0 : tag.getId());
+            LogUtility.d("ADDED: " + inspector.getUrl());
+            db.insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_IGNORE);
+        }
+
+        public static List<Bookmark> getBookmarks() {
+            String query = "SELECT * FROM " + TABLE_NAME;
+            Cursor cursor = db.rawQuery(query, null);
+            List<Bookmark> bookmarks = new ArrayList<>(cursor.getCount());
+            Bookmark b;
+            LogUtility.d("This url has " + cursor.getCount());
+            if (cursor.moveToFirst()) {
+                do {
+                    b = new Bookmark(
+                        cursor.getString(cursor.getColumnIndex(URL)),
+                        cursor.getInt(cursor.getColumnIndex(PAGE)),
+                        ApiRequestType.values[cursor.getInt(cursor.getColumnIndex(TYPE))],
+                        cursor.getInt(cursor.getColumnIndex(TAG_ID))
+                    );
+                    bookmarks.add(b);
+                } while (cursor.moveToNext());
+            }
+            cursor.close();
+            return bookmarks;
+        }
+    }
+
+    public static class GalleryBridgeTable {
+        public static final String TABLE_NAME = "GalleryTags";
+        public static final String DROP_TABLE = "DROP TABLE IF EXISTS " + TABLE_NAME;
+        static final String CREATE_TABLE = "CREATE TABLE IF NOT EXISTS `GalleryTags` (" +
+            "`id_gallery` INT NOT NULL , " +
+            "`id_tag` INT NOT NULL ," +
+            "PRIMARY KEY (`id_gallery`, `id_tag`), " +
+            "FOREIGN KEY(`id_gallery`) REFERENCES `Gallery`(`idGallery`) ON UPDATE CASCADE ON DELETE CASCADE , " +
+            "FOREIGN KEY(`id_tag`) REFERENCES `Tags`(`idTag`) ON UPDATE CASCADE ON DELETE RESTRICT );";
+
+        static final String ID_GALLERY = "id_gallery";
+        static final String ID_TAG = "id_tag";
+
+        static void insert(int galleryId, int tagId) {
+            ContentValues values = new ContentValues(2);
+            values.put(ID_GALLERY, galleryId);
+            values.put(ID_TAG, tagId);
+            db.insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_IGNORE);
+        }
+
+        public static void deleteGallery(int id) {
+            db.delete(TABLE_NAME, ID_GALLERY + "=?", new String[]{"" + id});
+        }
+
+        static Cursor getTagCursorForGallery(int id) {
+            String query = String.format(Locale.US, "SELECT * FROM %s WHERE %s IN (SELECT %s FROM %s WHERE %s=%d)",
+                TagTable.TABLE_NAME,
+                TagTable.IDTAG,
+                GalleryBridgeTable.ID_TAG,
+                GalleryBridgeTable.TABLE_NAME,
+                GalleryBridgeTable.ID_GALLERY,
+                id
+            );
+            return db.rawQuery(query, null);
+        }
+
+        public static TagList getTagsForGallery(int id) {
+            Cursor c = getTagCursorForGallery(id);
+            TagList tagList = new TagList();
+            List<Tag> tags = TagTable.getTagsFromCursor(c);
+            tagList.addTags(tags);
+            return tagList;
+        }
+    }
+
+    public static class FavoriteTable {
+        public static final String TABLE_NAME = "Favorite";
+        public static final String DROP_TABLE = "DROP TABLE IF EXISTS " + TABLE_NAME;
+        static final String CREATE_TABLE = "CREATE TABLE IF NOT EXISTS `Favorite` (" +
+            "`id_gallery` INT NOT NULL PRIMARY KEY , " +
+            "`time` INT NOT NULL," +
+            "FOREIGN KEY(`id_gallery`) REFERENCES `Gallery`(`idGallery`) ON UPDATE CASCADE ON DELETE CASCADE);";
+
+        static final String ID_GALLERY = "id_gallery";
+        static final String TIME = "time";
+
+        private static final String TITLE_CLAUSE = String.format(Locale.US, "%s LIKE ? OR %s LIKE ? OR %s LIKE ?",
+            GalleryTable.TITLE_ENG,
+            GalleryTable.TITLE_JP,
+            GalleryTable.TITLE_PRETTY
+        );
+
+        private static final String FAVORITE_JOIN_GALLERY = String.format(Locale.US, "%s INNER JOIN %s ON %s=%s",
+            FavoriteTable.TABLE_NAME,
+            GalleryTable.TABLE_NAME,
+            FavoriteTable.ID_GALLERY,
+            GalleryTable.IDGALLERY
+        );
+
+        public static void addFavorite(Gallery gallery) {
+            GalleryTable.insert(gallery);
+            FavoriteTable.insert(gallery.getId());
+        }
+
+
+        static String titleTypeToColumn(TitleType type) {
+            switch (type) {
+                case PRETTY:
+                    return GalleryTable.TITLE_PRETTY;
+                case ENGLISH:
+                    return GalleryTable.TITLE_ENG;
+                case JAPANESE:
+                    return GalleryTable.TITLE_JP;
+            }
+            return "";
+        }
+
+        /**
+         * Get all favorites galleries which title contains <code>query</code>
+         *
+         * @param orderByTitle true if order by title, false order by latest
+         * @return cursor which points to the galleries
+         */
+        public static Cursor getAllFavoriteGalleriesCursor(CharSequence query, boolean orderByTitle, int limit, int offset) {
+            String order = orderByTitle ? titleTypeToColumn(Global.getTitleType()) : FavoriteTable.TIME + " DESC";
+            String param = "%" + query + "%";
+            String limitString = String.format(Locale.US, " %d, %d ", offset, limit);
+            return db.query(FAVORITE_JOIN_GALLERY, null, TITLE_CLAUSE, new String[]{param, param, param}, null, null, order, limitString);
+        }
+
+        /**
+         * Get all favorites galleries
+         *
+         * @return cursor which points to the galleries
+         */
+        public static Cursor getAllFavoriteGalleriesCursor() {
+            String query = String.format(Locale.US, "SELECT * FROM %s WHERE %s IN (SELECT %s FROM %s)",
+                GalleryTable.TABLE_NAME,
+                GalleryTable.IDGALLERY,
+                FavoriteTable.ID_GALLERY,
+                FavoriteTable.TABLE_NAME
+            );
+            return db.rawQuery(query, null);
+        }
+
+        /**
+         * Retrieve all favorite galleries
+         */
+        static List<Gallery> getAllFavoriteGalleries() throws IOException {
+            Cursor c = getAllFavoriteGalleriesCursor();
+            List<Gallery> galleries = GalleryTable.cursorToList(c);
+            c.close();
+            return galleries;
+        }
+
+        static void insert(int galleryId) {
+            ContentValues values = new ContentValues(2);
+            values.put(ID_GALLERY, galleryId);
+            values.put(TIME, new Date().getTime());
+            db.insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_IGNORE);
+        }
+
+        public static void removeFavorite(int id) {
+            db.delete(TABLE_NAME, ID_GALLERY + "=?", new String[]{"" + id});
+        }
+
+        public static int countFavorite(@Nullable String text) {
+            if (text == null || text.trim().isEmpty()) return countFavorite();
+            int totalFavorite = 0;
+            String param = "%" + text + "%";
+            Cursor c = db.query(FAVORITE_JOIN_GALLERY, new String[]{"COUNT(*)"}, TITLE_CLAUSE, new String[]{param, param, param}, null, null, null);
+            if (c.moveToFirst()) {
+                totalFavorite = c.getInt(0);
+            }
+            c.close();
+            return totalFavorite;
+        }
+
+        public static int countFavorite() {
+            int totalFavorite = 0;
+            String query = "SELECT COUNT(*) FROM " + TABLE_NAME;
+            Cursor c = db.rawQuery(query, null);
+            if (c.moveToFirst()) {
+                totalFavorite = c.getInt(0);
+            }
+            c.close();
+            return totalFavorite;
+        }
+
+        public static boolean isFavorite(int id) {
+            String query = "SELECT * FROM " + TABLE_NAME + " WHERE " + ID_GALLERY + "=?";
+            Cursor c = db.rawQuery(query, new String[]{"" + id});
+            boolean b = c.moveToFirst();
+            c.close();
+            return b;
+        }
+
+        public static void removeAllFavorite() {
+            db.delete(TABLE_NAME, null, null);
+        }
+    }
+
+    public static class ResumeTable {
+        public static final String TABLE_NAME = "Resume";
+        public static final String DROP_TABLE = "DROP TABLE IF EXISTS " + TABLE_NAME;
+        static final String CREATE_TABLE = "CREATE TABLE IF NOT EXISTS `Resume` (" +
+            "`id_gallery` INT NOT NULL PRIMARY KEY , " +
+            "`page` INT NOT NULL" +
+            ");";
+        static final String ID_GALLERY = "id_gallery";
+        static final String PAGE = "page";
+
+        public static void insert(int id, int page) {
+            if (id < 0) return;
+            ContentValues values = new ContentValues(2);
+            values.put(ID_GALLERY, id);
+            values.put(PAGE, page);
+            db.insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_REPLACE);
+            LogUtility.d("Added bookmark to page " + page + " of id " + id);
+        }
+
+        public static int pageFromId(int id) {
+            if (id < 0) return -1;
+            int val = -1;
+            Cursor c = db.query(TABLE_NAME, new String[]{PAGE}, ID_GALLERY + "= ?", new String[]{"" + id}, null, null, null);
+            if (c.moveToFirst())
+                val = c.getInt(c.getColumnIndex(PAGE));
+            c.close();
+            return val;
+        }
+
+        public static void remove(int id) {
+            db.delete(TABLE_NAME, ID_GALLERY + "= ?", new String[]{"" + id});
+        }
+
+    }
+
+    public static class StatusMangaTable {
+        public static final String TABLE_NAME = "StatusManga";
+        public static final String DROP_TABLE = "DROP TABLE IF EXISTS " + TABLE_NAME;
+        static final String CREATE_TABLE = "CREATE TABLE IF NOT EXISTS `StatusManga` (" +
+            "`gallery` INT NOT NULL PRIMARY KEY, " +
+            "`name` TINYTEXT NOT NULL, " +
+            "`time` INT NOT NULL," +
+            "FOREIGN KEY(`gallery`) REFERENCES `" + GalleryTable.TABLE_NAME + "`(`" + GalleryTable.IDGALLERY + "`) ON UPDATE CASCADE ON DELETE CASCADE," +
+            "FOREIGN KEY(`name`) REFERENCES `" + StatusTable.TABLE_NAME + "`(`" + StatusTable.NAME + "`) ON UPDATE CASCADE ON DELETE CASCADE" +
+            ");";
+        static final String NAME = "name";
+        static final String GALLERY = "gallery";
+        static final String TIME = "time";
+
+        public static void insert(GenericGallery gallery, Status status) {
+            ContentValues values = new ContentValues(3);
+            GalleryTable.insert(gallery);
+            StatusTable.insert(status);
+            values.put(NAME, status.name);
+            values.put(GALLERY, gallery.getId());
+            values.put(TIME, new Date().getTime());
+            db.insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_REPLACE);
+        }
+
+        public static void remove(int id) {
+            db.delete(TABLE_NAME, GALLERY + "=?", new String[]{"" + id});
+        }
+
+        @NonNull
+        public static Status getStatus(int id) {
+            Cursor cursor = db.query(TABLE_NAME, new String[]{NAME}, GALLERY + "=?", new String[]{"" + id}, null, null, null);
+            Status status;
+            if (cursor.moveToFirst())
+                status = StatusManager.getByName(cursor.getString(cursor.getColumnIndex(NAME)));
+            else
+                status = StatusManager.getByName(StatusManager.DEFAULT_STATUS);
+            cursor.close();
+            return status;
+        }
+
+        public static void insert(GenericGallery gallery, String s) {
+            insert(gallery, StatusManager.getByName(s));
+        }
+
+        public static void update(Status oldStatus, Status newStatus) {
+            ContentValues values = new ContentValues(1);
+            values.put(NAME, newStatus.name);
+            values.put(TIME, new Date().getTime());
+            db.update(TABLE_NAME, values, NAME + "=?", new String[]{oldStatus.name});
+        }
+
+        public static Cursor getGalleryOfStatus(String name, String filter, boolean sortByTitle) {
+            String query = String.format("SELECT * FROM %s INNER JOIN %s ON %s=%s WHERE %s=? AND (%s LIKE ? OR %s LIKE ? OR %s LIKE ?) ORDER BY %s",
+                GalleryTable.TABLE_NAME, StatusMangaTable.TABLE_NAME,
+                GalleryTable.IDGALLERY, StatusMangaTable.GALLERY,
+                StatusMangaTable.NAME,
+                GalleryTable.TITLE_ENG, GalleryTable.TITLE_JP, GalleryTable.TITLE_PRETTY,
+                sortByTitle ? FavoriteTable.titleTypeToColumn(Global.getTitleType()) : TIME + " DESC"
+            );
+            String likeFilter = '%' + filter + '%';
+            LogUtility.d(query);
+            return db.rawQuery(query, new String[]{name, likeFilter, likeFilter, likeFilter});
+        }
+
+        public static int getCountPerStatus(String name) {
+            String query = String.format("SELECT COUNT(*) FROM %s WHERE %s = ?",
+                StatusMangaTable.TABLE_NAME,
+                StatusMangaTable.NAME);
+            LogUtility.d(query);
+            int value = -1;
+            Cursor cursor = db.rawQuery(query, new String[]{name});
+            if (cursor.moveToFirst()) {
+                value = cursor.getInt(0);
+            }
+            cursor.close();
+            return value;
+        }
+
+        public static void removeStatus(String name) {
+            db.delete(TABLE_NAME, NAME + "=?", new String[]{name});
+        }
+    }
+
+    public static class StatusTable {
+        public static final String TABLE_NAME = "Status";
+        public static final String DROP_TABLE = "DROP TABLE IF EXISTS " + TABLE_NAME;
+        static final String CREATE_TABLE = "CREATE TABLE IF NOT EXISTS `Status` (" +
+            "`name` TINYTEXT NOT NULL PRIMARY KEY, " +
+            "`color` INT NOT NULL " +
+            ");";
+        static final String NAME = "name";
+        static final String COLOR = "color";
+
+        public static void insert(Status status) {
+            ContentValues values = new ContentValues(2);
+            values.put(NAME, status.name);
+            values.put(COLOR, status.color);
+            db.insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_IGNORE);
+        }
+
+        public static void remove(String name) {
+            db.delete(TABLE_NAME, NAME + "= ?", new String[]{name});
+            StatusMangaTable.removeStatus(name);
+        }
+
+        public static void initStatuses() {
+            Cursor cursor = db.rawQuery("SELECT * FROM " + TABLE_NAME, null);
+            if (cursor.moveToFirst()) {
+                do {
+                    StatusManager.add(
+                        cursor.getString(cursor.getColumnIndex(NAME)),
+                        cursor.getInt(cursor.getColumnIndex(COLOR))
+                    );
+                } while (cursor.moveToNext());
+            }
+            cursor.close();
+        }
+
+        public static void update(Status oldStatus, Status newStatus) {
+            ContentValues values = new ContentValues(2);
+            values.put(NAME, newStatus.name);
+            values.put(COLOR, newStatus.color);
+            db.update(TABLE_NAME, values, NAME + "=?", new String[]{oldStatus.name});
+            StatusMangaTable.update(oldStatus, newStatus);
+        }
+    }
+}

+ 152 - 0
app/src/main/java/com/dar/nbook/async/database/export/Exporter.java

@@ -0,0 +1,152 @@
+package com.dar.nbook.async.database.export;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+import android.text.format.DateFormat;
+import android.util.JsonWriter;
+
+import com.dar.nbook.SettingsActivity;
+import com.dar.nbook.async.database.Queries;
+import com.dar.nbook.settings.Database;
+import com.dar.nbook.utility.LogUtility;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.util.Date;
+import java.util.Map;
+import java.util.Set;
+import java.util.zip.Deflater;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+public class Exporter {
+    static final String DB_ZIP_FILE = "Database.json";
+    private static final String[] SHARED_FILES = new String[]{
+        "Settings",
+        "ScrapedTags",
+    };
+    private static final String[] SCHEMAS = new String[]{
+        Queries.GalleryTable.TABLE_NAME,
+        Queries.TagTable.TABLE_NAME,
+        Queries.GalleryBridgeTable.TABLE_NAME,
+        Queries.BookmarkTable.TABLE_NAME,
+        Queries.DownloadTable.TABLE_NAME,
+        Queries.HistoryTable.TABLE_NAME,
+        Queries.FavoriteTable.TABLE_NAME,
+        Queries.ResumeTable.TABLE_NAME,
+        Queries.StatusTable.TABLE_NAME,
+        Queries.StatusMangaTable.TABLE_NAME,
+    };
+
+    private static void dumpDB(OutputStream stream) throws IOException {
+        SQLiteDatabase db = Database.getDatabase();
+        JsonWriter writer = new JsonWriter(new OutputStreamWriter(stream));
+        writer.beginObject();
+        for (String s : SCHEMAS) {
+            Cursor cur = db.query(s, null, null, null, null, null, null);
+            writer.name(s).beginArray();
+            if (cur.moveToFirst()) {
+                do {
+                    writer.beginObject();
+                    for (int i = 0; i < cur.getColumnCount(); i++) {
+                        writer.name(cur.getColumnName(i));
+                        if (cur.isNull(i)) {
+                            writer.nullValue();
+                        } else {
+                            switch (cur.getType(i)) {
+                                case Cursor.FIELD_TYPE_INTEGER:
+                                    writer.value(cur.getLong(i));
+                                    break;
+                                case Cursor.FIELD_TYPE_FLOAT:
+                                    writer.value(cur.getDouble(i));
+                                    break;
+                                case Cursor.FIELD_TYPE_STRING:
+                                    writer.value(cur.getString(i));
+                                    break;
+                                case Cursor.FIELD_TYPE_BLOB:
+                                case Cursor.FIELD_TYPE_NULL:
+                                    break;
+                            }
+                        }
+                    }
+                    writer.endObject();
+                } while (cur.moveToNext());
+            }
+            writer.endArray();
+            cur.close();
+        }
+        writer.endObject();
+        writer.flush();
+    }
+
+    public static String defaultExportName(SettingsActivity context) {
+        Date actualTime = new Date();
+        String date = DateFormat.getDateFormat(context).format(actualTime).replaceAll("[^0-9]*", "");
+        String time = DateFormat.getTimeFormat(context).format(actualTime).replaceAll("[^0-9]*", "");
+        return String.format("Backup_%s_%s.zip", date, time);
+    }
+
+    public static void exportData(SettingsActivity context, Uri selectedFile) throws IOException {
+
+        OutputStream outputStream = context.getContentResolver().openOutputStream(selectedFile);
+        ZipOutputStream zip = new ZipOutputStream(outputStream);
+        zip.setLevel(Deflater.BEST_COMPRESSION);
+
+        zip.putNextEntry(new ZipEntry(DB_ZIP_FILE));
+        dumpDB(zip);
+        zip.closeEntry();
+
+        for (String shared : SHARED_FILES) {
+            zip.putNextEntry(new ZipEntry(shared + ".json"));
+            exportSharedPreferences(context, shared, zip);
+            zip.closeEntry();
+        }
+
+        zip.close();
+
+    }
+
+    private static void exportSharedPreferences(Context context, String sharedName, OutputStream stream) throws IOException {
+        JsonWriter writer = new JsonWriter(new OutputStreamWriter(stream));
+        SharedPreferences pref = context.getSharedPreferences(sharedName, 0);
+        Map<String, ?> map = pref.getAll();
+        writer.beginObject();
+        for (Map.Entry<String, ?> o : map.entrySet()) {
+            Object val = o.getValue();
+            writer.name(o.getKey());
+            if (val instanceof String) {
+                writer.beginObject().name(SharedType.STRING.name()).value((String) val).endObject();
+            } else if (val instanceof Boolean) {
+                writer.beginObject().name(SharedType.BOOLEAN.name()).value((Boolean) val).endObject();
+            } else if (val instanceof Integer) {
+                writer.beginObject().name(SharedType.INT.name()).value((Integer) val).endObject();
+            } else if (val instanceof Float) {
+                writer.beginObject().name(SharedType.FLOAT.name()).value((Float) val).endObject();
+            } else if (val instanceof Set) {
+                writer.beginObject().name(SharedType.STRING_SET.name());
+                writer.beginArray();
+                for (String s : (Set<String>) val) {
+                    writer.value(s);
+                }
+                writer.endArray();
+                writer.endObject();
+            } else if (val instanceof Long) {
+                writer.beginObject().name(SharedType.LONG.name()).value((Long) val).endObject();
+            } else {
+                LogUtility.e("Missing export class: " + val.getClass().getName());
+            }
+        }
+        writer.endObject();
+        writer.flush();
+    }
+
+    enum SharedType {
+        FLOAT, INT, LONG, STRING_SET, STRING, BOOLEAN
+    }
+
+
+}

+ 120 - 0
app/src/main/java/com/dar/nbook/async/database/export/Importer.java

@@ -0,0 +1,120 @@
+package com.dar.nbook.async.database.export;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+import android.util.JsonReader;
+
+import androidx.annotation.NonNull;
+
+import com.dar.nbook.settings.Database;
+import com.dar.nbook.utility.LogUtility;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+class Importer {
+    private static void importSharedPreferences(Context context, String sharedName, InputStream stream) throws IOException {
+        JsonReader reader = new JsonReader(new InputStreamReader(stream));
+        SharedPreferences.Editor editor = context.getSharedPreferences(sharedName, 0).edit();
+        reader.beginObject();
+        while (reader.hasNext()) {
+            String name = reader.nextName();
+            reader.beginObject();
+            Exporter.SharedType type = Exporter.SharedType.valueOf(reader.nextName());
+            switch (type) {
+                case STRING:
+                    editor.putString(name, reader.nextString());
+                    break;
+                case INT:
+                    editor.putInt(name, reader.nextInt());
+                    break;
+                case FLOAT:
+                    editor.putFloat(name, (float) reader.nextDouble());
+                    break;
+                case LONG:
+                    editor.putLong(name, reader.nextLong());
+                    break;
+                case BOOLEAN:
+                    editor.putBoolean(name, reader.nextBoolean());
+                    break;
+                case STRING_SET:
+                    Set<String> strings = new HashSet<>();
+                    reader.beginArray();
+                    while (reader.hasNext())
+                        strings.add(reader.nextString());
+                    reader.endArray();
+                    editor.putStringSet(name, strings);
+                    break;
+            }
+            reader.endObject();
+        }
+        editor.commit();
+    }
+
+    private static void importDB(InputStream stream) throws IOException {
+        SQLiteDatabase db = Database.getDatabase();
+        db.beginTransaction();
+        JsonReader reader = new JsonReader(new InputStreamReader(stream));
+        reader.beginObject();
+        while (reader.hasNext()) {
+            String tableName = reader.nextName();
+            db.delete(tableName, null, null);
+            reader.beginArray();
+            while (reader.hasNext()) {
+                reader.beginObject();
+                ContentValues values = new ContentValues();
+                while (reader.hasNext()) {
+                    String fieldName = reader.nextName();
+                    switch (reader.peek()) {
+                        case NULL:
+                            values.putNull(fieldName);
+                            reader.nextNull();
+                            break;
+                        case NUMBER:
+                            //there are no doubles in the DB
+                            values.put(fieldName, reader.nextLong());
+                            break;
+                        case STRING:
+                            values.put(fieldName, reader.nextString());
+                            break;
+                    }
+                }
+                db.insertWithOnConflict(tableName, null, values, SQLiteDatabase.CONFLICT_REPLACE);
+                reader.endObject();
+            }
+            reader.endArray();
+
+
+        }
+
+        reader.endObject();
+        db.setTransactionSuccessful();
+        db.endTransaction();
+    }
+
+    public static void importData(@NonNull Context context, Uri selectedFile) throws IOException {
+        InputStream stream = context.getContentResolver().openInputStream(selectedFile);
+        ZipInputStream inputStream = new ZipInputStream(stream);
+        ZipEntry entry;
+        while ((entry = inputStream.getNextEntry()) != null) {
+            String name = entry.getName();
+            LogUtility.d("Importing: " + name);
+            if (Exporter.DB_ZIP_FILE.equals(name)) {
+                importDB(inputStream);
+            } else {
+                String shared = name.substring(0, name.length() - 5);
+                importSharedPreferences(context, shared, inputStream);
+            }
+            inputStream.closeEntry();
+        }
+        inputStream.close();
+    }
+}

+ 38 - 0
app/src/main/java/com/dar/nbook/async/database/export/Manager.java

@@ -0,0 +1,38 @@
+package com.dar.nbook.async.database.export;
+
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import com.dar.nbook.SettingsActivity;
+import com.dar.nbook.utility.LogUtility;
+
+import java.io.IOException;
+
+public class Manager extends Thread {
+    @NonNull
+    private final Uri file;
+    @NonNull
+    private final SettingsActivity context;
+    private final boolean export;
+    private final Runnable end;
+
+    public Manager(@NonNull Uri file, @NonNull SettingsActivity context, boolean export, Runnable end) {
+        this.file = file;
+        this.context = context;
+        this.export = export;
+        this.end = end;
+    }
+
+
+    @Override
+    public void run() {
+        try {
+            if (export) Exporter.exportData(context, file);
+            else Importer.importData(context, file);
+            context.runOnUiThread(end);
+        } catch (IOException e) {
+            LogUtility.e(e, e);
+        }
+    }
+}

+ 139 - 0
app/src/main/java/com/dar/nbook/async/downloader/DownloadGalleryV2.java

@@ -0,0 +1,139 @@
+package com.dar.nbook.async.downloader;
+
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.app.JobIntentService;
+
+import com.dar.nbook.api.SimpleGallery;
+import com.dar.nbook.api.components.Gallery;
+import com.dar.nbook.api.components.GenericGallery;
+import com.dar.nbook.async.database.Queries;
+import com.dar.nbook.utility.LogUtility;
+import com.dar.nbook.utility.Utility;
+
+import java.io.IOException;
+import java.util.List;
+
+public class DownloadGalleryV2 extends JobIntentService {
+    private static final Object lock = new Object();
+    private static final int JOB_DOWNLOAD_GALLERY_ID = 9999;
+
+    public static void downloadGallery(Context context, GenericGallery gallery) {
+        if (gallery.isValid() && gallery instanceof Gallery)
+            downloadGallery(context, (Gallery) gallery);
+        if (gallery.getId() > 0) {
+            if (gallery instanceof SimpleGallery) {
+                SimpleGallery simple = (SimpleGallery) gallery;
+                downloadGallery(context, gallery.getTitle(), simple.getThumbnail(), simple.getId());
+            } else downloadGallery(context, null, null, gallery.getId());
+        }
+    }
+
+    private static void downloadGallery(Context context, String title, Uri thumbnail, int id) {
+        if (id < 1) return;
+        DownloadQueue.add(new GalleryDownloaderManager(context, title, thumbnail, id));
+        startWork(context);
+    }
+
+    private static void downloadGallery(Context context, Gallery gallery) {
+        downloadGallery(context, gallery, 0, gallery.getPageCount() - 1);
+    }
+
+    private static void downloadGallery(Context context, Gallery gallery, int start, int end) {
+        DownloadQueue.add(new GalleryDownloaderManager(context, gallery, start, end));
+        startWork(context);
+    }
+
+    public static void loadDownloads(Context context) {
+        try {
+            List<GalleryDownloaderManager> g = Queries.DownloadTable.getAllDownloads(context);
+            for (GalleryDownloaderManager gg : g) {
+                gg.downloader().setStatus(GalleryDownloaderV2.Status.PAUSED);
+                DownloadQueue.add(gg);
+            }
+            new PageChecker().start();
+            startWork(context);
+        } catch (IOException e) {
+            LogUtility.e(e, e);
+        }
+    }
+
+    public static void downloadRange(Context context, Gallery gallery, int start, int end) {
+        downloadGallery(context, gallery, start, end);
+    }
+
+    public static void startWork(@Nullable Context context) {
+        if (context != null)
+            enqueueWork(context, DownloadGalleryV2.class, JOB_DOWNLOAD_GALLERY_ID, new Intent());
+        synchronized (lock) {
+            lock.notify();
+        }
+    }
+
+    @Override
+    public int onStartCommand(@Nullable Intent intent, int flags, int startId) {
+        int startCommand = super.onStartCommand(intent, flags, startId);
+        if (intent != null) {
+            int id = intent.getIntExtra(getPackageName() + ".ID", -1);
+            String mode = intent.getStringExtra(getPackageName() + ".MODE");
+            LogUtility.d("" + mode);
+            GalleryDownloaderManager manager = DownloadQueue.managerFromId(id);
+            if (manager != null) {
+                LogUtility.d("IntentAction: " + mode + " for id " + id);
+                assert mode != null;
+                switch (mode) {
+                    case "STOP":
+                        DownloadQueue.remove(id, true);
+                        break;
+                    case "PAUSE":
+                        manager.downloader().setStatus(GalleryDownloaderV2.Status.PAUSED);
+                        break;
+                    case "START":
+                        manager.downloader().setStatus(GalleryDownloaderV2.Status.NOT_STARTED);
+                        DownloadQueue.givePriority(manager.downloader());
+                        startWork(this);
+                        break;
+                }
+            }
+        }
+        return startCommand;
+    }
+
+    @Override
+    protected void onHandleWork(@NonNull Intent intent) {
+        for (; ; ) {
+            obtainData();
+            GalleryDownloaderManager entry = DownloadQueue.fetch();
+            if (entry == null) {
+                synchronized (lock) {
+                    try {
+                        lock.wait();
+                    } catch (InterruptedException e) {
+                        e.printStackTrace();
+                    }
+                }
+                continue;
+            }
+            LogUtility.d("Downloading: " + entry.downloader().getId());
+            if (entry.downloader().downloadGalleryData()) {
+                entry.downloader().download();
+            }
+            Utility.threadSleep(1000);
+        }
+    }
+
+    private void obtainData() {
+        GalleryDownloaderV2 downloader = DownloadQueue.fetchForData();
+        while (downloader != null) {
+            downloader.downloadGalleryData();
+            Utility.threadSleep(100);
+            downloader = DownloadQueue.fetchForData();
+        }
+    }
+
+
+}

+ 13 - 0
app/src/main/java/com/dar/nbook/async/downloader/DownloadObserver.java

@@ -0,0 +1,13 @@
+package com.dar.nbook.async.downloader;
+
+public interface DownloadObserver {
+    void triggerStartDownload(GalleryDownloaderV2 downloader);
+
+    void triggerUpdateProgress(GalleryDownloaderV2 downloader, int reach, int total);
+
+    void triggerEndDownload(GalleryDownloaderV2 downloader);
+
+    void triggerCancelDownload(GalleryDownloaderV2 downloader);
+
+    void triggerPauseDownload(GalleryDownloaderV2 downloader);
+}

+ 96 - 0
app/src/main/java/com/dar/nbook/async/downloader/DownloadQueue.java

@@ -0,0 +1,96 @@
+package com.dar.nbook.async.downloader;
+
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+public class DownloadQueue {
+    private static final List<GalleryDownloaderManager> downloadQueue = new CopyOnWriteArrayList<>();
+
+    public static void add(GalleryDownloaderManager x) {
+        for (GalleryDownloaderManager manager : downloadQueue)
+            if (x.downloader().getId() == manager.downloader().getId()) {
+                manager.downloader().setStatus(GalleryDownloaderV2.Status.NOT_STARTED);
+                givePriority(manager.downloader());
+                return;
+            }
+        downloadQueue.add(x);
+    }
+
+    public static GalleryDownloaderV2 fetchForData() {
+        for (GalleryDownloaderManager x : downloadQueue)
+            if (!x.downloader().hasData()) return x.downloader();
+        return null;
+    }
+
+    public static GalleryDownloaderManager fetch() {
+        for (GalleryDownloaderManager x : downloadQueue)
+            if (x.downloader().canBeFetched()) return x;
+        return null;
+    }
+
+    public static void clear() {
+        for (GalleryDownloaderManager x : downloadQueue)
+            x.downloader().setStatus(GalleryDownloaderV2.Status.CANCELED);
+        downloadQueue.clear();
+    }
+
+    public static CopyOnWriteArrayList<GalleryDownloaderV2> getDownloaders() {
+        CopyOnWriteArrayList<GalleryDownloaderV2> downloaders = new CopyOnWriteArrayList<>();
+        for (GalleryDownloaderManager manager : downloadQueue)
+            downloaders.add(manager.downloader());
+        return downloaders;
+    }
+
+    public static void addObserver(DownloadObserver observer) {
+        for (GalleryDownloaderManager manager : downloadQueue)
+            manager.downloader().addObserver(observer);
+    }
+
+    public static void removeObserver(DownloadObserver observer) {
+        for (GalleryDownloaderManager manager : downloadQueue)
+            manager.downloader().removeObserver(observer);
+    }
+
+    private static GalleryDownloaderManager findManagerFromDownloader(GalleryDownloaderV2 downloader) {
+        for (GalleryDownloaderManager manager : downloadQueue)
+            if (manager.downloader() == downloader)
+                return manager;
+        return null;
+    }
+
+    public static void remove(int id, boolean cancel) {
+        remove(findDownloaderFromId(id), cancel);
+    }
+
+    private static GalleryDownloaderV2 findDownloaderFromId(int id) {
+        for (GalleryDownloaderManager manager : downloadQueue)
+            if (manager.downloader().getId() == id) return manager.downloader();
+        return null;
+    }
+
+    public static void remove(GalleryDownloaderV2 downloader, boolean cancel) {
+        GalleryDownloaderManager manager = findManagerFromDownloader(downloader);
+        if (manager == null) return;
+        if (cancel)
+            downloader.setStatus(GalleryDownloaderV2.Status.CANCELED);
+        downloadQueue.remove(manager);
+    }
+
+    public static void givePriority(GalleryDownloaderV2 downloader) {
+        GalleryDownloaderManager manager = findManagerFromDownloader(downloader);
+        if (manager == null) return;
+        downloadQueue.remove(manager);
+        downloadQueue.add(0, manager);
+    }
+
+    public static GalleryDownloaderManager managerFromId(int id) {
+        for (GalleryDownloaderManager manager : downloadQueue)
+            if (manager.downloader().getId() == id) return manager;
+        return null;
+    }
+
+    public static boolean isEmpty() {
+        return downloadQueue.size() == 0;
+
+    }
+}

+ 183 - 0
app/src/main/java/com/dar/nbook/async/downloader/GalleryDownloaderManager.java

@@ -0,0 +1,183 @@
+package com.dar.nbook.async.downloader;
+
+import android.annotation.SuppressLint;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+
+import androidx.core.app.NotificationCompat;
+
+import com.dar.nbook.GalleryActivity;
+import com.dar.nbook.R;
+import com.dar.nbook.api.components.Gallery;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.settings.NotificationSettings;
+
+import java.util.ConcurrentModificationException;
+import java.util.Locale;
+
+public class GalleryDownloaderManager {
+    private final int notificationId = NotificationSettings.getNotificationId();
+    private final GalleryDownloaderV2 downloaderV2;
+    private final Context context;
+    private NotificationCompat.Builder notification;
+    private Gallery gallery;
+
+    private final DownloadObserver observer = new DownloadObserver() {
+        @Override
+        public void triggerStartDownload(GalleryDownloaderV2 downloader) {
+            gallery = downloader.getGallery();
+            prepareNotification();
+            addActionToNotification(false);
+            notificationUpdate();
+        }
+
+        @Override
+        public void triggerUpdateProgress(GalleryDownloaderV2 downloader, int reach, int total) {
+            setPercentage(reach, total);
+            notificationUpdate();
+        }
+
+        @Override
+        public void triggerEndDownload(GalleryDownloaderV2 downloader) {
+            endNotification();
+            addClickListener();
+            notificationUpdate();
+            DownloadQueue.remove(downloader, false);
+        }
+
+        @Override
+        public void triggerCancelDownload(GalleryDownloaderV2 downloader) {
+            cancelNotification();
+            Global.recursiveDelete(downloader.getFolder());
+        }
+
+        @Override
+        public void triggerPauseDownload(GalleryDownloaderV2 downloader) {
+            addActionToNotification(true);
+            notificationUpdate();
+        }
+    };
+
+    public GalleryDownloaderManager(Context context, Gallery gallery, int start, int end) {
+        this.context = context;
+        this.gallery = gallery;
+        this.downloaderV2 = new GalleryDownloaderV2(context, gallery, start, end);
+        this.downloaderV2.addObserver(observer);
+    }
+
+    public GalleryDownloaderManager(Context context, String title, Uri thumbnail, int id) {
+        this.context = context;
+        this.downloaderV2 = new GalleryDownloaderV2(context, title, thumbnail, id);
+        this.downloaderV2.addObserver(observer);
+    }
+
+    private void cancelNotification() {
+        NotificationSettings.cancel(context.getString(R.string.channel1_name), notificationId);
+    }
+
+    private void addClickListener() {
+        Intent notifyIntent = new Intent(context, GalleryActivity.class);
+        notifyIntent.putExtra(context.getPackageName() + ".GALLERY", downloaderV2.localGallery());
+        notifyIntent.putExtra(context.getPackageName() + ".ISLOCAL", true);
+        // Create the PendingIntent
+
+        PendingIntent notifyPendingIntent;
+        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
+            notifyPendingIntent = PendingIntent.getActivity(
+                context, 0, notifyIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE
+            );
+        } else {
+            notifyPendingIntent = PendingIntent.getActivity(
+                context, 0, notifyIntent, PendingIntent.FLAG_UPDATE_CURRENT
+            );
+        }
+        notification.setContentIntent(notifyPendingIntent);
+    }
+
+    public GalleryDownloaderV2 downloader() {
+        return downloaderV2;
+    }
+
+    private void endNotification() {
+        //notification=new NotificationCompat.Builder(context.getApplicationContext(), Global.CHANNEL_ID1);
+        //notification.setOnlyAlertOnce(true).setSmallIcon(R.drawable.ic_check).setAutoCancel(true);
+        clearNotificationAction();
+        hidePercentage();
+        if (downloaderV2.getStatus() != GalleryDownloaderV2.Status.CANCELED) {
+            notification.setSmallIcon(R.drawable.ic_check);
+            notification.setContentTitle(String.format(Locale.US, context.getString(R.string.completed_format), gallery.getTitle()));
+        } else {
+            notification.setSmallIcon(R.drawable.ic_close);
+            notification.setContentTitle(String.format(Locale.US, context.getString(R.string.cancelled_format), gallery.getTitle()));
+        }
+    }
+
+    private void hidePercentage() {
+        setPercentage(0, 0);
+    }
+
+    private void setPercentage(int reach, int total) {
+        notification.setProgress(total, reach, false);
+    }
+
+    private void prepareNotification() {
+        notification = new NotificationCompat.Builder(context.getApplicationContext(), Global.CHANNEL_ID1);
+        notification.setOnlyAlertOnce(true)
+
+            .setContentTitle(String.format(Locale.US, context.getString(R.string.downloading_format), gallery.getTitle()))
+            .setProgress(gallery.getPageCount(), 0, false)
+            .setSmallIcon(R.drawable.ic_file);
+        setPercentage(0, 1);
+    }
+
+    @SuppressLint("RestrictedApi")
+    private void clearNotificationAction() {
+        notification.mActions.clear();
+    }
+
+    private void addActionToNotification(boolean pauseMode) {
+        clearNotificationAction();
+        Intent startIntent = new Intent(context, DownloadGalleryV2.class);
+        Intent stopIntent = new Intent(context, DownloadGalleryV2.class);
+        Intent pauseIntent = new Intent(context, DownloadGalleryV2.class);
+
+        //stopIntent.setAction("STOP");
+        //startIntent.setAction("START");
+        //pauseIntent.setAction("PAUSE");
+
+        stopIntent.putExtra(context.getPackageName() + ".ID", downloaderV2.getId());
+        pauseIntent.putExtra(context.getPackageName() + ".ID", downloaderV2.getId());
+        startIntent.putExtra(context.getPackageName() + ".ID", downloaderV2.getId());
+
+        stopIntent.putExtra(context.getPackageName() + ".MODE", "STOP");
+        pauseIntent.putExtra(context.getPackageName() + ".MODE", "PAUSE");
+        startIntent.putExtra(context.getPackageName() + ".MODE", "START");
+        PendingIntent pStop;
+        PendingIntent pPause;
+        PendingIntent pStart;
+        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
+            pStop = PendingIntent.getService(context, 0, stopIntent, PendingIntent.FLAG_MUTABLE | PendingIntent.FLAG_CANCEL_CURRENT);
+            pPause = PendingIntent.getService(context, 1, pauseIntent, PendingIntent.FLAG_MUTABLE | PendingIntent.FLAG_CANCEL_CURRENT);
+            pStart = PendingIntent.getService(context, 2, startIntent, PendingIntent.FLAG_MUTABLE | PendingIntent.FLAG_CANCEL_CURRENT);
+        } else {
+            pStop = PendingIntent.getService(context, 0, stopIntent, PendingIntent.FLAG_CANCEL_CURRENT);
+            pPause = PendingIntent.getService(context, 1, pauseIntent, PendingIntent.FLAG_CANCEL_CURRENT);
+            pStart = PendingIntent.getService(context, 2, startIntent, PendingIntent.FLAG_CANCEL_CURRENT);
+        }
+        if (pauseMode)
+            notification.addAction(R.drawable.ic_play, context.getString(R.string.resume), pStart);
+        else notification.addAction(R.drawable.ic_pause, context.getString(R.string.pause), pPause);
+        notification.addAction(R.drawable.ic_close, context.getString(R.string.cancel), pStop);
+    }
+
+
+    private synchronized void notificationUpdate() {
+        try {
+            NotificationSettings.notify(context.getString(R.string.channel1_name), notificationId, notification.build());
+        } catch (NullPointerException | ConcurrentModificationException ignore) {
+        }
+    }
+
+}

+ 376 - 0
app/src/main/java/com/dar/nbook/async/downloader/GalleryDownloaderV2.java

@@ -0,0 +1,376 @@
+package com.dar.nbook.async.downloader;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.dar.nbook.R;
+import com.dar.nbook.api.InspectorV3;
+import com.dar.nbook.api.components.Gallery;
+import com.dar.nbook.api.local.LocalGallery;
+import com.dar.nbook.async.database.Queries;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.utility.LogUtility;
+import com.dar.nbook.utility.Utility;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.CopyOnWriteArraySet;
+import java.util.regex.Pattern;
+
+import okhttp3.Request;
+import okhttp3.Response;
+
+public class GalleryDownloaderV2 {
+    public static final String DUPLICATE_EXTENSION = ".DUP";
+    public static final Pattern ID_FILE = Pattern.compile("^\\.\\d{1,6}$");
+    private final Context context;
+    private final int id;
+    private final CopyOnWriteArraySet<DownloadObserver> observers = new CopyOnWriteArraySet<>();
+    private final List<PageContainer> urls = new ArrayList<>();
+    private Status status = Status.NOT_STARTED;
+    private String title;
+    private Uri thumbnail;
+    private int start = -1, end = -1;
+    private Gallery gallery;
+    private File folder;
+    private boolean initialized = false;
+
+    public GalleryDownloaderV2(Context context, @Nullable String title, @Nullable Uri thumbnail, int id) {
+        this.context = context;
+        this.id = id;
+        this.thumbnail = thumbnail;
+        this.title = Gallery.getPathTitle(title, context.getString(R.string.download_gallery));
+    }
+
+    public GalleryDownloaderV2(Context context, Gallery gallery, int start, int end) {
+        this(context, gallery.getTitle(), gallery.getCover(), gallery.getId());
+        this.start = start;
+        this.end = end;
+        setGallery(gallery);
+    }
+
+    private static File findFolder(File downloadfolder, String pathTitle, int id) {
+        File folder = new File(downloadfolder, pathTitle);
+        if (usableFolder(folder, id)) return folder;
+        int i = 1;
+        do {
+            folder = new File(downloadfolder, pathTitle + DUPLICATE_EXTENSION + (i++));
+        } while (!usableFolder(folder, id));
+        return folder;
+    }
+
+    private static boolean usableFolder(File file, int id) {
+        if (!file.exists()) return true;//folder not exists
+        if (new File(file, "." + id).exists()) return true;//same id
+        File[] files = file.listFiles((dir, name) -> ID_FILE.matcher(name).matches());
+        if (files != null && files.length > 0) return false;//has id but not equal
+        LocalGallery localGallery = new LocalGallery(file);//read id from metadata
+        return localGallery.getId() == id;
+    }
+
+    public boolean hasData() {
+        return gallery != null;
+    }
+
+    public void removeObserver(DownloadObserver observer) {
+        observers.remove(observer);
+    }
+
+    public File getFolder() {
+        return folder;
+    }
+
+    public Gallery getGallery() {
+        return gallery;
+    }
+
+    private void setGallery(Gallery gallery) {
+        this.gallery = gallery;
+        title = gallery.getPathTitle();
+        thumbnail = gallery.getThumbnail();
+        Queries.DownloadTable.addGallery(this);
+        if (start == -1) start = 0;
+        if (end == -1) end = gallery.getPageCount() - 1;
+    }
+
+    private int getTotalPage() {
+        return Math.max(1, end - start + 1);
+    }
+
+    public int getPercentage() {
+        if (gallery == null || urls.size() == 0) return 0;
+        return ((getTotalPage() - urls.size()) * 100) / getTotalPage();
+    }
+
+    private void onStart() {
+        setStatus(Status.DOWNLOADING);
+        for (DownloadObserver observer : observers) observer.triggerStartDownload(this);
+    }
+
+    private void onEnd() {
+        setStatus(Status.FINISHED);
+        for (DownloadObserver observer : observers) observer.triggerEndDownload(this);
+        LogUtility.d("Delete 75: " + id);
+        Queries.DownloadTable.removeGallery(id);
+    }
+
+    private void onUpdate() {
+        int total = getTotalPage();
+        int reach = total - urls.size();
+        for (DownloadObserver observer : observers)
+            observer.triggerUpdateProgress(this, reach, total);
+    }
+
+    private void onCancel() {
+        for (DownloadObserver observer : observers) observer.triggerCancelDownload(this);
+    }
+
+    private void onPause() {
+        for (DownloadObserver observer : observers) observer.triggerPauseDownload(this);
+    }
+
+    public LocalGallery localGallery() {
+        if (status != Status.FINISHED || folder == null) return null;
+        return new LocalGallery(folder);
+    }
+
+    public String getTitle() {
+        return title;
+    }
+
+    public void addObserver(DownloadObserver observer) {
+        if (observer == null) return;
+        observers.add(observer);
+    }
+
+    public int getId() {
+        return id;
+    }
+
+    public int getStart() {
+        return start;
+    }
+
+    public int getEnd() {
+        return end;
+    }
+
+    @NonNull
+    public String getPathTitle() {
+        return title;
+    }
+
+    @NonNull
+    public String getTruePathTitle() {
+        return title;
+    }
+
+    /**
+     * @return true if the download has been completed, false otherwise
+     */
+    public boolean downloadGalleryData() {
+        if (this.gallery != null) return true;
+        InspectorV3 inspector = InspectorV3.galleryInspector(context, id, null);
+        try {
+            if (!inspector.createDocument()) return false;
+            inspector.parseDocument();
+            if (inspector.getGalleries() == null || inspector.getGalleries().size() == 0)
+                return false;
+            Gallery g = (Gallery) inspector.getGalleries().get(0);
+            if (g.isValid())
+                setGallery(g);
+            return g.isValid();
+        } catch (Exception e) {
+            LogUtility.e("Error while downloading", e);
+            return false;
+        }
+    }
+
+    public Uri getThumbnail() {
+        return thumbnail;
+    }
+
+    public boolean canBeFetched() {
+        return status != Status.FINISHED && status != Status.PAUSED;
+    }
+
+    public Status getStatus() {
+        return status;
+    }
+
+    public void setStatus(Status status) {
+        if (this.status == status) return;
+        this.status = status;
+        if (status == Status.CANCELED) {
+            LogUtility.d("Delete 95: " + id);
+            onCancel();
+            Global.recursiveDelete(folder);
+            Queries.DownloadTable.removeGallery(id);
+        }
+    }
+
+    public void download() {
+        initDownload();
+        onStart();
+        while (!urls.isEmpty()) {
+            downloadPage(urls.get(0));
+            Utility.threadSleep(50);
+            if (status == Status.PAUSED) {
+                onPause();
+                return;
+            }
+            if (status == Status.CANCELED) {
+                onCancel();
+                return;
+            }
+        }
+        onEnd();
+    }
+
+    private void downloadPage(PageContainer page) {
+        if (savePage(page)) {
+            urls.remove(page);
+            onUpdate();
+        }
+    }
+
+    private boolean isCorrupted(File file) {
+        String path = file.getAbsolutePath();
+        if (path.endsWith(".jpg") || path.endsWith(".jpeg")) {
+            return Global.isJPEGCorrupted(path);
+        }
+        BitmapFactory.Options options = new BitmapFactory.Options();
+        options.inSampleSize = 256;
+        Bitmap bitmap = BitmapFactory.decodeFile(path, options);
+        boolean x = bitmap == null;
+        if (!x) bitmap.recycle();
+        bitmap = null;
+        return x;
+    }
+
+    private boolean savePage(PageContainer page) {
+        if (page == null) return true;
+        File filePath = new File(folder, page.getPageName());
+        LogUtility.d("Saving into: " + filePath + "," + page.url);
+        if (filePath.exists() && !isCorrupted(filePath)) return true;
+        try {
+            Response r = Global.getClient(context).newCall(new Request.Builder().url(page.url).build()).execute();
+            if (r.code() != 200) {
+                r.close();
+                return false;
+            }
+            assert r.body() != null;
+            long expectedSize = Integer.parseInt(r.header("Content-Length", "-1"));
+            long len = r.body().contentLength();
+            if (len < 0 || expectedSize != len) {
+                r.close();
+                return false;
+            }
+            long written = Utility.writeStreamToFile(r.body().byteStream(), filePath);
+            r.close();
+            if (written != len) {
+                filePath.delete();
+                return false;
+            }
+            return true;
+        } catch (IOException | NumberFormatException e) {
+            LogUtility.e(e, e);
+        }
+        return false;
+    }
+
+
+    public void initDownload() {
+        if (initialized) return;
+        initialized = true;
+        createFolder();
+        createPages();
+        checkPages();
+    }
+
+    private void checkPages() {
+        File filePath;
+        for (int i = 0; i < urls.size(); i++) {
+            if(urls.get(i)==null){
+                urls.remove(i--);
+                continue;
+            }
+            filePath = new File(folder, urls.get(i).getPageName());
+            if (filePath.exists() && !isCorrupted(filePath))
+                urls.remove(i--);
+        }
+    }
+
+    private void createPages() {
+        for (int i = start; i <= end && i < gallery.getPageCount(); i++)
+            urls.add(new PageContainer(i + 1, gallery.getHighPage(i).toString(), gallery.getPageExtension(i)));
+    }
+
+    private void createFolder() {
+        folder = findFolder(Global.DOWNLOADFOLDER, title, id);
+        folder.mkdirs();
+        try {
+            writeNoMedia();
+            createIdFile();
+        } catch (IOException e) {
+            e.printStackTrace();
+        }
+    }
+
+    private void createIdFile() throws IOException {
+        File idFile = new File(folder, "." + id);
+        idFile.createNewFile();
+    }
+
+    private void writeNoMedia() throws IOException {
+        File nomedia = new File(folder, ".nomedia");
+        LogUtility.d("NOMEDIA: " + nomedia + " for id " + id);
+        FileWriter writer = new FileWriter(nomedia);
+        gallery.jsonWrite(writer);
+        writer.close();
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+
+        GalleryDownloaderV2 that = (GalleryDownloaderV2) o;
+
+        if (id != that.id) return false;
+        return folder != null ? folder.equals(that.folder) : that.folder == null;
+    }
+
+    @Override
+    public int hashCode() {
+        int result = id;
+        result = 31 * result + (folder != null ? folder.hashCode() : 0);
+        return result;
+    }
+
+    public enum Status {NOT_STARTED, DOWNLOADING, PAUSED, FINISHED, CANCELED}
+
+    public static class PageContainer {
+        public final int page;
+        public final String url, ext;
+
+        public PageContainer(int page, String url, String ext) {
+            this.page = page;
+            this.url = url;
+            this.ext = ext;
+        }
+
+        public String getPageName() {
+            return String.format(Locale.US, "%03d.%s", page, ext);
+        }
+    }
+}

+ 9 - 0
app/src/main/java/com/dar/nbook/async/downloader/PageChecker.java

@@ -0,0 +1,9 @@
+package com.dar.nbook.async.downloader;
+
+public class PageChecker extends Thread {
+    @Override
+    public void run() {
+        for (GalleryDownloaderV2 g : DownloadQueue.getDownloaders())
+            if (g.hasData()) g.initDownload();
+    }
+}

+ 103 - 0
app/src/main/java/com/dar/nbook/components/CookieInterceptor.java

@@ -0,0 +1,103 @@
+package com.dar.nbook.components;
+
+import android.view.View;
+import android.webkit.CookieManager;
+
+import androidx.annotation.NonNull;
+
+import com.dar.nbook.components.activities.GeneralActivity;
+import com.dar.nbook.components.views.CFTokenView;
+import com.dar.nbook.utility.LogUtility;
+import com.dar.nbook.utility.Utility;
+
+import java.util.HashMap;
+
+public class CookieInterceptor {
+    private static volatile boolean intercepting = false;
+    private static volatile boolean webViewHidden = false;
+
+    public static void hideWebView() {
+        webViewHidden = true;
+        CFTokenView tokenView = GeneralActivity.getLastCFView();
+        if (tokenView != null) {
+            tokenView.post(() -> tokenView.setVisibility(View.GONE));
+        }
+    }
+
+    @NonNull
+    private final Manager manager;
+    String cookies = null;
+    private CFTokenView web = null;
+
+    public CookieInterceptor(@NonNull Manager manager) {
+        this.manager = manager;
+    }
+
+    private CFTokenView setupWebView() {
+        CFTokenView tokenView = GeneralActivity.getLastCFView();
+        if (tokenView == null) return null;
+        tokenView.post(() -> {
+            CFTokenView.CFTokenWebView webView = tokenView.getWebView();
+            webView.loadUrl(Utility.getBaseUrl());
+        });
+        return tokenView;
+    }
+
+    @NonNull
+    private CFTokenView getWebView() {
+        while (web == null) {
+            Utility.threadSleep(100);
+            web = setupWebView();
+        }
+        return web;
+    }
+
+    private void interceptInternal() {
+        CFTokenView web = getWebView();
+        if(!webViewHidden)
+            web.post(() -> web.setVisibility(View.VISIBLE));
+        CookieManager manager = CookieManager.getInstance();
+        HashMap<String, String> cookiesMap = new HashMap<>();
+        do {
+            Utility.threadSleep(100);
+            cookies = manager.getCookie(Utility.getBaseUrl());
+            if (cookies == null)
+                return;
+            String[] splitCookies = cookies.split("; ");
+            for (String splitCookie : splitCookies) {
+                String[] kv = splitCookie.split("=", 2);
+                if (kv.length == 2) {
+                    if (!kv[1].equals(cookiesMap.put(kv[0], kv[1]))) {
+                        LogUtility.d("Processing cookie: " + kv[0] + "=" + kv[1]);
+                        CookieInterceptor.this.manager.applyCookie(kv[0], kv[1]);
+                    }
+                }
+            }
+        } while (!this.manager.endInterceptor());
+        web.post(() -> web.setVisibility(View.GONE));
+    }
+
+    public void intercept() {
+        while(!manager.endInterceptor()){
+            while (intercepting) {
+                Utility.threadSleep(100);
+            }
+            intercepting = true;
+            synchronized (CookieInterceptor.class) {
+                if (!manager.endInterceptor()) {
+                    interceptInternal();
+                }
+            }
+            intercepting = false;
+        }
+        this.manager.onFinish();
+    }
+
+    public interface Manager {
+        void applyCookie(String key, String value);
+
+        boolean endInterceptor();
+
+        void onFinish();
+    }
+}

+ 92 - 0
app/src/main/java/com/dar/nbook/components/CustomCookieJar.java

@@ -0,0 +1,92 @@
+package com.dar.nbook.components;
+
+import androidx.annotation.NonNull;
+
+import com.franmontiel.persistentcookiejar.ClearableCookieJar;
+import com.franmontiel.persistentcookiejar.cache.CookieCache;
+import com.franmontiel.persistentcookiejar.persistence.CookiePersistor;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+
+import okhttp3.Cookie;
+import okhttp3.HttpUrl;
+
+public class CustomCookieJar implements ClearableCookieJar {
+    private final CookieCache cache;
+    private final CookiePersistor persistor;
+
+    public CustomCookieJar(CookieCache cache, CookiePersistor persistor) {
+        this.cache = cache;
+        this.persistor = persistor;
+
+        this.cache.addAll(persistor.loadAll());
+    }
+
+    private static List<Cookie> filterPersistentCookies(List<Cookie> cookies) {
+        List<Cookie> persistentCookies = new ArrayList<>();
+
+        for (Cookie cookie : cookies) {
+            if (cookie.persistent()) {
+                persistentCookies.add(cookie);
+            }
+        }
+        return persistentCookies;
+    }
+
+    private static boolean isCookieExpired(Cookie cookie) {
+        return cookie.expiresAt() < System.currentTimeMillis();
+    }
+
+    @Override
+    synchronized public void saveFromResponse(@NonNull HttpUrl url, @NonNull List<Cookie> cookies) {
+        cache.addAll(cookies);
+        persistor.saveAll(filterPersistentCookies(cookies));
+    }
+
+    @NonNull
+    @Override
+    synchronized public List<Cookie> loadForRequest(@NonNull HttpUrl url) {
+        List<Cookie> cookiesToRemove = new ArrayList<>();
+        List<Cookie> validCookies = new ArrayList<>();
+
+        for (Iterator<Cookie> it = cache.iterator(); it.hasNext(); ) {
+            Cookie currentCookie = it.next();
+
+            if (isCookieExpired(currentCookie)) {
+                cookiesToRemove.add(currentCookie);
+                it.remove();
+
+            } else {
+                validCookies.add(currentCookie);
+            }
+        }
+
+        persistor.removeAll(cookiesToRemove);
+        return validCookies;
+    }
+
+    @Override
+    synchronized public void clearSession() {
+        cache.clear();
+        cache.addAll(persistor.loadAll());
+    }
+
+    @Override
+    synchronized public void clear() {
+        cache.clear();
+        persistor.clear();
+    }
+
+    public void removeCookie(String name) {
+        List<Cookie> cookies = persistor.loadAll();
+        for (Cookie cookie : cookies) {
+            if (cookie.name().equals(name)) {
+                cache.clear();
+                persistor.removeAll(Collections.singletonList(cookie));
+            }
+        }
+    }
+}

+ 68 - 0
app/src/main/java/com/dar/nbook/components/GlideX.java

@@ -0,0 +1,68 @@
+package com.dar.nbook.components;
+
+import android.app.Activity;
+import android.content.Context;
+import android.view.View;
+
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentActivity;
+
+import com.bumptech.glide.Glide;
+import com.bumptech.glide.RequestManager;
+
+public class GlideX {
+    @Nullable
+    public static Glide get(Context context) {
+        try {
+            return Glide.get(context);
+        } catch (VerifyError | IllegalStateException ignore) {
+            return null;
+        }
+    }
+
+    @Nullable
+    public static RequestManager with(View view) {
+        try {
+            return Glide.with(view);
+        } catch (VerifyError | IllegalStateException ignore) {
+            return null;
+        }
+    }
+
+    @Nullable
+    public static RequestManager with(Context context) {
+        try {
+            return Glide.with(context);
+        } catch (VerifyError | IllegalStateException ignore) {
+            return null;
+        }
+    }
+
+    @Nullable
+    public static RequestManager with(Fragment fragment) {
+        try {
+            return Glide.with(fragment);
+        } catch (VerifyError | IllegalStateException ignore) {
+            return null;
+        }
+    }
+
+    @Nullable
+    public static RequestManager with(FragmentActivity fragmentActivity) {
+        try {
+            return Glide.with(fragmentActivity);
+        } catch (VerifyError | IllegalStateException ignore) {
+            return null;
+        }
+    }
+
+    @Nullable
+    public static RequestManager with(Activity activity) {
+        try {
+            return Glide.with(activity);
+        } catch (VerifyError | IllegalStateException ignore) {
+            return null;
+        }
+    }
+}

+ 20 - 0
app/src/main/java/com/dar/nbook/components/LocaleManager.java

@@ -0,0 +1,20 @@
+package com.dar.nbook.components;
+
+import java.util.Locale;
+
+public class LocaleManager {
+    public static final Locale[] LANGUAGES = new Locale[]{
+        new Locale("en"),
+        new Locale("ar"),
+        new Locale("de"),
+        new Locale("es"),
+        new Locale("fr"),
+        new Locale("it"),
+        new Locale("ja"),
+        new Locale("ru"),
+        new Locale("tr"),
+        new Locale("uk"),
+        new Locale("zh", "CN"),
+        new Locale("zh", "TW"),
+    };
+}

+ 4 - 0
app/src/main/java/com/dar/nbook/components/Module.java

@@ -0,0 +1,4 @@
+package com.dar.nbook.components;
+
+public class Module {
+}

+ 54 - 0
app/src/main/java/com/dar/nbook/components/ThreadAsyncTask.java

@@ -0,0 +1,54 @@
+package com.dar.nbook.components;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.dar.nbook.settings.Global;
+
+public abstract class ThreadAsyncTask<Params, Progress, Result> {
+
+    private final AppCompatActivity activity;
+    private Thread thread;
+    public ThreadAsyncTask(AppCompatActivity activity) {
+        this.activity = activity;
+    }
+
+    public void execute(Params... params) {
+        thread = new AsyncThread(params);
+        thread.start();
+    }
+
+    protected void onPreExecute() {
+    }
+
+    protected void onPostExecute(Result result) {
+    }
+
+    protected void onProgressUpdate(Progress... values) {
+    }
+
+    protected abstract Result doInBackground(Params... params);
+
+    protected final void publishProgress(Progress... values) {
+        if (!Global.isDestroyed(activity))
+            activity.runOnUiThread(() -> onProgressUpdate(values));
+    }
+
+    class AsyncThread extends Thread {
+
+        Params[] params;
+
+        AsyncThread(Params[] params) {
+            this.params = params;
+        }
+
+        @Override
+        public void run() {
+            if (!Global.isDestroyed(activity))
+                activity.runOnUiThread(ThreadAsyncTask.this::onPreExecute);
+            Result result = doInBackground(params);
+            if (!Global.isDestroyed(activity))
+                activity.runOnUiThread(() -> onPostExecute(result));
+        }
+    }
+
+}

+ 57 - 0
app/src/main/java/com/dar/nbook/components/activities/BaseActivity.java

@@ -0,0 +1,57 @@
+package com.dar.nbook.components.activities;
+
+import android.content.res.Configuration;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
+
+import com.dar.nbook.components.widgets.CustomGridLayoutManager;
+
+public abstract class BaseActivity extends GeneralActivity {
+    protected RecyclerView recycler;
+    protected SwipeRefreshLayout refresher;
+    protected ViewGroup masterLayout;
+
+    protected abstract int getPortraitColumnCount();
+
+    protected abstract int getLandscapeColumnCount();
+
+
+    public SwipeRefreshLayout getRefresher() {
+        return refresher;
+    }
+
+    public RecyclerView getRecycler() {
+        return recycler;
+    }
+
+    public ViewGroup getMasterLayout() {
+        return masterLayout;
+    }
+
+    @Override
+    public void onConfigurationChanged(@NonNull Configuration newConfig) {
+        super.onConfigurationChanged(newConfig);
+        if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) {
+            changeLayout(true);
+        } else if (newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) {
+            changeLayout(false);
+        }
+    }
+
+    protected void changeLayout(boolean landscape) {
+        CustomGridLayoutManager manager = (CustomGridLayoutManager) recycler.getLayoutManager();
+        RecyclerView.Adapter adapter = recycler.getAdapter();
+        int count = landscape ? getLandscapeColumnCount() : getPortraitColumnCount();
+        int position = 0;
+
+        if (manager != null)
+            position = manager.findFirstCompletelyVisibleItemPosition();
+        CustomGridLayoutManager gridLayoutManager = new CustomGridLayoutManager(this, count);
+        recycler.setLayoutManager(gridLayoutManager);
+        recycler.setAdapter(adapter);
+        recycler.scrollToPosition(position);
+    }
+}

+ 133 - 0
app/src/main/java/com/dar/nbook/components/activities/CrashApplication.java

@@ -0,0 +1,133 @@
+package com.dar.nbook.components.activities;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.Signature;
+
+import androidx.appcompat.app.AppCompatDelegate;
+import androidx.multidex.MultiDexApplication;
+
+import com.dar.nbook.BuildConfig;
+import com.dar.nbook.R;
+import com.dar.nbook.api.local.LocalGallery;
+import com.dar.nbook.async.ScrapeTags;
+import com.dar.nbook.async.database.DatabaseHelper;
+import com.dar.nbook.async.downloader.DownloadGalleryV2;
+import com.dar.nbook.components.classes.MySenderFactory;
+import com.dar.nbook.settings.Database;
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.settings.TagV2;
+import com.dar.nbook.utility.LogUtility;
+import com.dar.nbook.utility.network.NetworkUtil;
+
+import org.acra.ACRA;
+import org.acra.ReportField;
+import org.acra.annotation.AcraCore;
+
+import java.io.File;
+import java.io.IOException;
+import java.math.BigInteger;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+
+@AcraCore(buildConfigClass = BuildConfig.class, reportSenderFactoryClasses = MySenderFactory.class, reportContent = {
+    ReportField.PACKAGE_NAME,
+    ReportField.BUILD_CONFIG,
+    ReportField.APP_VERSION_CODE,
+    ReportField.STACK_TRACE,
+    ReportField.ANDROID_VERSION,
+    ReportField.LOGCAT
+})
+public class CrashApplication extends MultiDexApplication {
+    private static final String SIGNATURE_GITHUB = "ce96fdbcc89991f083320140c148db5f";
+
+    @Override
+    public void onCreate() {
+        super.onCreate();
+        Global.initLanguage(this);
+        AppCompatDelegate.setCompatVectorFromResourcesEnabled(true);
+        Global.initStorage(this);
+        Database.setDatabase(new DatabaseHelper(getApplicationContext()).getWritableDatabase());
+        String version = Global.getLastVersion(this), actualVersion = Global.getVersionName(this);
+        SharedPreferences preferences = getSharedPreferences("Settings", 0);
+        if (!actualVersion.equals(version))
+            afterUpdateChecks(preferences, version, actualVersion);
+
+        Global.initFromShared(this);
+        NetworkUtil.initConnectivity(this);
+        TagV2.initMinCount(this);
+        TagV2.initSortByName(this);
+        DownloadGalleryV2.loadDownloads(this);
+    }
+
+    private boolean signatureCheck() {
+        try {
+            @SuppressLint("PackageManagerGetSignatures")
+            PackageInfo packageInfo = getPackageManager().getPackageInfo(
+                getPackageName(), PackageManager.GET_SIGNATURES);
+            //note sample just checks the first signature
+
+            for (Signature signature : packageInfo.signatures) {
+                // MD5 is used because it is not a secure data
+                MessageDigest m = MessageDigest.getInstance("MD5");
+                m.update(signature.toByteArray());
+                String hash = new BigInteger(1, m.digest()).toString(16);
+                LogUtility.d("Find signature: " + hash);
+                if (SIGNATURE_GITHUB.equals(hash)) return true;
+            }
+        } catch (NullPointerException | PackageManager.NameNotFoundException | NoSuchAlgorithmException e) {
+            e.printStackTrace();
+        }
+        return false;
+    }
+
+    private void afterUpdateChecks(SharedPreferences preferences, String oldVersion, String actualVersion) {
+        SharedPreferences.Editor editor = preferences.edit();
+        removeOldUpdates();
+        //update tags
+        ScrapeTags.startWork(this);
+        if ("0.0.0".equals(oldVersion))
+            editor.putBoolean(getString(R.string.key_check_update), signatureCheck());
+        editor.apply();
+        Global.setLastVersion(this);
+    }
+
+
+    private void createIdHiddenFile(File folder) {
+        LocalGallery gallery = new LocalGallery(folder);
+        if (gallery.getId() < 0) return;
+        File hiddenFile = new File(folder, "." + gallery.getId());
+        try {
+            hiddenFile.createNewFile();
+        } catch (IOException e) {
+            e.printStackTrace();
+        }
+    }
+
+    private void createIdHiddenFiles() {
+        if (!Global.hasStoragePermission(this)) return;
+        File[] files = Global.DOWNLOADFOLDER.listFiles();
+        if (files == null) return;
+        for (File f : files) {
+            if (f.isDirectory())
+                createIdHiddenFile(f);
+        }
+    }
+
+    private void removeOldUpdates() {
+        if (!Global.hasStoragePermission(this)) return;
+        Global.recursiveDelete(Global.UPDATEFOLDER);
+        Global.UPDATEFOLDER.mkdir();
+    }
+
+    @Override
+    protected void attachBaseContext(Context newBase) {
+        super.attachBaseContext(newBase);
+        ACRA.init(this);
+        ACRA.getErrorReporter().setEnabled(getSharedPreferences("Settings", 0).getBoolean(getString(R.string.key_send_report), false));
+    }
+}

+ 69 - 0
app/src/main/java/com/dar/nbook/components/activities/GeneralActivity.java

@@ -0,0 +1,69 @@
+package com.dar.nbook.components.activities;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.WindowManager;
+import android.widget.Toast;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.dar.nbook.R;
+import com.dar.nbook.components.views.CFTokenView;
+import com.dar.nbook.settings.Global;
+
+import java.lang.ref.WeakReference;
+
+public abstract class GeneralActivity extends AppCompatActivity {
+    private boolean isFastScrollerApplied = false;
+    private static WeakReference<GeneralActivity> lastActivity;
+    private CFTokenView tokenView = null;
+
+    public static @Nullable
+    CFTokenView getLastCFView() {
+        GeneralActivity activity = lastActivity.get();
+        if (activity != null) {
+            activity.runOnUiThread(activity::inflateWebView);
+            return activity.tokenView;
+        }
+        return null;
+    }
+
+    private void inflateWebView() {
+        if (tokenView == null) {
+            Toast.makeText(this, R.string.fetching_cloudflare_token, Toast.LENGTH_SHORT).show();
+            ViewGroup rootView= (ViewGroup) findViewById(android.R.id.content).getRootView();
+            ViewGroup v= (ViewGroup) LayoutInflater.from(this).inflate(R.layout.cftoken_layout,rootView,false);
+            tokenView = new CFTokenView(v);
+            ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
+            tokenView.setVisibility(View.GONE);
+            this.addContentView(v, params);
+        }
+    }
+
+    @Override
+    protected void onPause() {
+        if (Global.hideMultitask())
+            getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);
+        super.onPause();
+    }
+
+    @Override
+    protected void onCreate(@Nullable Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        Global.initActivity(this);
+    }
+
+    @Override
+    protected void onResume() {
+        getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE);
+        super.onResume();
+        lastActivity = new WeakReference<>(this);
+        if (!isFastScrollerApplied) {
+            isFastScrollerApplied = true;
+            Global.applyFastScroller(findViewById(R.id.recycler));
+        }
+    }
+}

+ 67 - 0
app/src/main/java/com/dar/nbook/components/classes/Bookmark.java

@@ -0,0 +1,67 @@
+package com.dar.nbook.components.classes;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import com.dar.nbook.api.InspectorV3;
+import com.dar.nbook.api.components.Tag;
+import com.dar.nbook.api.enums.ApiRequestType;
+import com.dar.nbook.api.enums.SortType;
+import com.dar.nbook.api.enums.SpecialTagIds;
+import com.dar.nbook.api.enums.TagStatus;
+import com.dar.nbook.api.enums.TagType;
+import com.dar.nbook.async.database.Queries;
+
+import java.util.Collections;
+
+public class Bookmark {
+    public final String url;
+    public final int page, tag;
+    private final ApiRequestType requestType;
+    private final Tag tagVal;
+    private final Uri uri;
+
+    public Bookmark(String url, int page, ApiRequestType requestType, int tag) {
+        Tag tagVal1;
+        this.url = url;
+        this.page = page;
+        this.requestType = requestType;
+        this.tag = tag;
+        tagVal1 = Queries.TagTable.getTagById(this.tag);
+        if (tagVal1 == null)
+            tagVal1 = new Tag("english", 0, SpecialTagIds.LANGUAGE_ENGLISH, TagType.LANGUAGE, TagStatus.DEFAULT);
+        this.tagVal = tagVal1;
+        this.uri = Uri.parse(url);
+    }
+
+    public InspectorV3 createInspector(Context context, InspectorV3.InspectorResponse response) {
+        String query = uri.getQueryParameter("q");
+        SortType popular = SortType.findFromAddition(uri.getQueryParameter("sort"));
+        if (requestType == ApiRequestType.FAVORITE)
+            return InspectorV3.favoriteInspector(context, query, page, response);
+        if (requestType == ApiRequestType.BYSEARCH)
+            return InspectorV3.searchInspector(context, query, null, page, popular, null, response);
+        if (requestType == ApiRequestType.BYALL)
+            return InspectorV3.searchInspector(context, "", null, page, SortType.RECENT_ALL_TIME, null, response);
+        if (requestType == ApiRequestType.BYTAG) return InspectorV3.searchInspector(context, "",
+            Collections.singleton(tagVal), page, SortType.findFromAddition(this.url), null, response);
+        return null;
+    }
+
+    public void deleteBookmark() {
+        Queries.BookmarkTable.deleteBookmark(url);
+    }
+
+    @NonNull
+    @Override
+    public String toString() {
+        if (requestType == ApiRequestType.BYTAG)
+            return tagVal.getType().getSingle() + ": " + tagVal.getName();
+        if (requestType == ApiRequestType.FAVORITE) return "Favorite";
+        if (requestType == ApiRequestType.BYSEARCH) return "" + uri.getQueryParameter("q");
+        if (requestType == ApiRequestType.BYALL) return "Main page";
+        return "WTF";
+    }
+}

+ 25 - 0
app/src/main/java/com/dar/nbook/components/classes/ConnectivityReceiver.java

@@ -0,0 +1,25 @@
+package com.dar.nbook.components.classes;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+
+import com.dar.nbook.utility.LogUtility;
+
+public class ConnectivityReceiver extends BroadcastReceiver {
+
+    @Override
+    public void onReceive(final Context context, final Intent intent) {
+        LogUtility.d("Is online? " + isOnline(context));
+    }
+
+    public boolean isOnline(Context context) {
+
+        ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+        NetworkInfo netInfo = cm.getActiveNetworkInfo();
+        //should check null because in airplane mode it will be null
+        return (netInfo != null && netInfo.isConnected());
+    }
+}

+ 97 - 0
app/src/main/java/com/dar/nbook/components/classes/CustomSSLSocketFactory.java

@@ -0,0 +1,97 @@
+package com.dar.nbook.components.classes;
+
+import android.os.Build;
+
+import com.dar.nbook.utility.LogUtility;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.Socket;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLSocket;
+import javax.net.ssl.SSLSocketFactory;
+
+import okhttp3.ConnectionSpec;
+import okhttp3.OkHttpClient;
+import okhttp3.TlsVersion;
+
+public class CustomSSLSocketFactory extends SSLSocketFactory {
+    private static final String[] TLS_V12_ONLY = {"TLSv1.2"};
+
+    private final SSLSocketFactory delegate;
+
+    public CustomSSLSocketFactory(SSLSocketFactory base) {
+        this.delegate = base;
+    }
+
+    @SuppressWarnings("deprecation")
+    public static OkHttpClient.Builder enableTls12OnPreLollipop(OkHttpClient.Builder client) {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN && Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) {
+            try {
+                SSLContext sc = SSLContext.getInstance("TLSv1.2");
+                sc.init(null, null, null);
+                client.sslSocketFactory(new CustomSSLSocketFactory(sc.getSocketFactory()));
+
+                ConnectionSpec cs = new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
+                    .tlsVersions(TlsVersion.TLS_1_2)
+                    .build();
+
+                List<ConnectionSpec> specs = new ArrayList<>();
+                specs.add(cs);
+                specs.add(ConnectionSpec.COMPATIBLE_TLS);
+                specs.add(ConnectionSpec.CLEARTEXT);
+
+                client.connectionSpecs(specs);
+            } catch (Exception exc) {
+                LogUtility.e("Error while setting TLS 1.2", exc);
+            }
+        }
+
+        return client;
+    }
+
+    @Override
+    public String[] getDefaultCipherSuites() {
+        return delegate.getDefaultCipherSuites();
+    }
+
+    @Override
+    public String[] getSupportedCipherSuites() {
+        return delegate.getSupportedCipherSuites();
+    }
+
+    @Override
+    public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException {
+        return patch(delegate.createSocket(s, host, port, autoClose));
+    }
+
+    @Override
+    public Socket createSocket(String host, int port) throws IOException {
+        return patch(delegate.createSocket(host, port));
+    }
+
+    @Override
+    public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException {
+        return patch(delegate.createSocket(host, port, localHost, localPort));
+    }
+
+    @Override
+    public Socket createSocket(InetAddress host, int port) throws IOException {
+        return patch(delegate.createSocket(host, port));
+    }
+
+    @Override
+    public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException {
+        return patch(delegate.createSocket(address, port, localAddress, localPort));
+    }
+
+    private Socket patch(Socket s) {
+        if (s instanceof SSLSocket) {
+            ((SSLSocket) s).setEnabledProtocols(TLS_V12_ONLY);
+        }
+        return s;
+    }
+}

+ 59 - 0
app/src/main/java/com/dar/nbook/components/classes/History.java

@@ -0,0 +1,59 @@
+package com.dar.nbook.components.classes;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+public class History {
+    private final String value;
+    private final Date date;
+
+    public History(String value, boolean set) {
+        if (set) {
+            int p = value.indexOf('|');
+            date = new Date(Long.parseLong(value.substring(0, p)));
+            this.value = value.substring(p + 1);
+        } else {
+            this.value = value;
+            this.date = new Date();
+        }
+    }
+
+    public static List<History> setToList(Set<String> set) {
+        List<History> h = new ArrayList<>(set.size());
+        for (String s : set) h.add(new History(s, true));
+        Collections.sort(h, (o2, o1) -> {
+            int o = o1.date.compareTo(o2.date);
+            if (o == 0) o = o1.value.compareTo(o2.value);
+            return o;
+        });
+
+        return h;
+    }
+
+    public static Set<String> listToSet(List<History> list) {
+        HashSet<String> s = new HashSet<>(list.size());
+        for (History h : list) s.add(h.date.getTime() + "|" + h.value);
+        return s;
+    }
+
+    public String getValue() {
+        return value;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        History history = (History) o;
+        return value.equals(history.value);
+    }
+
+    @Override
+    public int hashCode() {
+        return value.hashCode();
+    }
+}

+ 224 - 0
app/src/main/java/com/dar/nbook/components/classes/MultichoiceAdapter.java

@@ -0,0 +1,224 @@
+package com.dar.nbook.components.classes;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.constraintlayout.widget.ConstraintLayout;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.dar.nbook.R;
+import com.dar.nbook.utility.LogUtility;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+
+public abstract class MultichoiceAdapter<D, T extends RecyclerView.ViewHolder> extends RecyclerView.Adapter<MultichoiceAdapter.MultichoiceViewHolder<T>> {
+    private final List<MultichoiceListener> listeners = new ArrayList<>(3);
+    private Mode mode = Mode.NORMAL;
+    private final HashMap<Long, D> map = new HashMap<Long, D>() {
+        @Nullable
+        @Override
+        public D put(Long key, D value) {
+            D res = super.put(key, value);
+            if (size() == 1) startSelecting();
+            changeSelecting();
+            return res;
+        }
+
+        @Nullable
+        @Override
+        public D remove(@Nullable Object key) {
+            D res = super.remove(key);
+            if (isEmpty()) endSelecting();
+            changeSelecting();
+            return res;
+        }
+
+        @Override
+        public void clear() {
+            super.clear();
+            endSelecting();
+            changeSelecting();
+        }
+    };
+
+    public MultichoiceAdapter() {
+        setHasStableIds(true);
+    }
+
+    private void changeSelecting() {
+        for (MultichoiceListener listener : listeners)
+            listener.choiceChanged();
+    }
+
+    /**
+     * Used only to do a put
+     */
+    protected abstract D getItemAt(int position);
+
+    protected abstract ViewGroup getMaster(T holder);
+
+    protected abstract void defaultMasterAction(int position);
+
+    protected abstract void onBindMultichoiceViewHolder(T holder, int position);
+
+    @NonNull
+    protected abstract T onCreateMultichoiceViewHolder(@NonNull ViewGroup parent, int viewType);
+
+    @Override
+    public abstract long getItemId(int position);
+
+    private void startSelecting() {
+        setMode(Mode.SELECTING);
+        for (MultichoiceListener listener : listeners)
+            listener.firstChoice();
+    }
+
+    private void endSelecting() {
+        setMode(Mode.NORMAL);
+        for (MultichoiceListener listener : listeners)
+            listener.noMoreChoices();
+    }
+
+    public void addListener(MultichoiceListener listener) {
+        this.listeners.add(listener);
+    }
+
+    @NonNull
+    @Override
+    public final MultichoiceViewHolder<T> onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        T innerLayout = onCreateMultichoiceViewHolder(parent, viewType);
+        ViewGroup master = getMaster(innerLayout);
+        ConstraintLayout multiLayout = (ConstraintLayout) LayoutInflater.from(parent.getContext()).inflate(R.layout.multichoice_adapter, master, true);
+        return new MultichoiceViewHolder<>(multiLayout, innerLayout);
+    }
+
+    @Override
+    public final void onBindViewHolder(@NonNull MultichoiceViewHolder<T> holder, final int position) {
+        boolean isSelected = map.containsKey(getItemId(holder.getBindingAdapterPosition()));
+        View master = getMaster(holder.innerHolder);
+        updateLayoutParams(master, holder.censor, isSelected);
+        if (master != null) {
+            master.setOnClickListener(v -> {
+                switch (mode) {
+                    case SELECTING:
+                        toggleSelection(holder.getBindingAdapterPosition());
+                        break;
+                    case NORMAL:
+                        defaultMasterAction(holder.getBindingAdapterPosition());
+                        break;
+                }
+            });
+            master.setOnLongClickListener(v -> {
+                map.put(getItemId(holder.getBindingAdapterPosition()), getItemAt(holder.getBindingAdapterPosition()));
+                notifyItemChanged(holder.getBindingAdapterPosition());
+                return true;
+            });
+        }
+        holder.censor.setVisibility(isSelected ? View.VISIBLE : View.GONE);
+        holder.checkmark.setVisibility(isSelected ? View.VISIBLE : View.GONE);
+        holder.censor.setOnClickListener(v -> toggleSelection(holder.getBindingAdapterPosition()));
+        onBindMultichoiceViewHolder(holder.innerHolder, holder.getBindingAdapterPosition());
+    }
+
+    private void updateLayoutParams(View master, View multichoiceHolder, boolean isSelected) {
+        if (master == null) return;
+        int margin = isSelected ? 8 : 0;
+        ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) master.getLayoutParams();
+        params.setMargins(margin, margin, margin, margin);
+        master.setLayoutParams(params);
+
+        if (isSelected && multichoiceHolder != null) {
+            master.post(() -> {
+                ViewGroup.LayoutParams multiParam = multichoiceHolder.getLayoutParams();
+                multiParam.width = master.getWidth();
+                multiParam.height = master.getHeight();
+                LogUtility.d("Multiparam: " + multiParam.width + ", " + multiParam.height);
+                multichoiceHolder.setLayoutParams(multiParam);
+            });
+        }
+    }
+
+    private void toggleSelection(int position) {
+        long id = getItemId(position);
+        if (map.containsKey(id))
+            map.remove(id);
+        else
+            map.put(id, getItemAt(position));
+        notifyItemChanged(position);
+    }
+
+    public Mode getMode() {
+        return mode;
+    }
+
+    private void setMode(Mode mode) {
+        this.mode = mode;
+    }
+
+    public void selectAll() {
+        final int count = getItemCount();
+        for (int i = 0; i < count; i++)
+            map.put(getItemId(i), getItemAt(i));
+        notifyItemRangeChanged(0, count);
+    }
+
+    public Collection<D> getSelected() {
+        return map.values();
+    }
+
+    public void deselectAll() {
+        map.clear();
+        notifyItemRangeChanged(0, getItemCount());
+    }
+
+    public enum Mode {NORMAL, SELECTING}
+
+    public interface MultichoiceListener {
+        void firstChoice();
+
+        void noMoreChoices();
+
+        void choiceChanged();
+    }
+
+    public static class DefaultMultichoiceListener implements MultichoiceListener {
+
+        @Override
+        public void firstChoice() {
+
+        }
+
+        @Override
+        public void noMoreChoices() {
+
+        }
+
+        @Override
+        public void choiceChanged() {
+
+        }
+    }
+
+    public static class MultichoiceViewHolder<T extends RecyclerView.ViewHolder> extends RecyclerView.ViewHolder {
+        final T innerHolder;
+        final View censor;
+        final ImageView checkmark;
+        final ConstraintLayout multichoiceHolder;
+
+        public MultichoiceViewHolder(@NonNull ConstraintLayout multichoiceHolder, T holder) {
+            super(holder.itemView);
+            this.multichoiceHolder = multichoiceHolder;
+            this.innerHolder = holder;
+            this.censor = multichoiceHolder.findViewById(R.id.censor);
+            this.checkmark = multichoiceHolder.findViewById(R.id.checkmark);
+        }
+    }
+
+}

+ 44 - 0
app/src/main/java/com/dar/nbook/components/classes/MySender.java

@@ -0,0 +1,44 @@
+package com.dar.nbook.components.classes;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+
+import com.dar.nbook.settings.Global;
+import com.dar.nbook.utility.LogUtility;
+import com.dar.nbook.utility.Utility;
+
+import org.acra.data.CrashReportData;
+import org.acra.sender.ReportSender;
+import org.json.JSONException;
+
+import java.io.IOException;
+import java.util.Map;
+
+import okhttp3.FormBody;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+
+public class MySender implements ReportSender {
+    private static final String URL = "dar9586.altervista.org/php/report.php";
+
+    @Override
+    public void send(@NonNull Context context, @NonNull CrashReportData errorContent) {
+        Map<String, Object> m = errorContent.toMap();
+        for (Map.Entry<String, Object> mm : m.entrySet()) {
+            LogUtility.e(mm.getKey() + ": " + mm.getValue());
+        }
+        try {
+            RequestBody requestBody = new FormBody.Builder().add("json", errorContent.toJSON()).build();
+
+            Request.Builder request = new Request.Builder().post(requestBody).url(Utility.PROTOCOL + URL);
+            Response x = Global.getClient().newCall(request.build()).execute();
+
+            LogUtility.d(x.code() + x.body().string());
+            x.close();
+        } catch (JSONException | IOException e) {
+            LogUtility.e(e.getLocalizedMessage(), e);
+        }
+    }
+}

+ 22 - 0
app/src/main/java/com/dar/nbook/components/classes/MySenderFactory.java

@@ -0,0 +1,22 @@
+package com.dar.nbook.components.classes;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+
+import org.acra.config.CoreConfiguration;
+import org.acra.sender.ReportSender;
+import org.acra.sender.ReportSenderFactory;
+
+public class MySenderFactory implements ReportSenderFactory {
+    @NonNull
+    @Override
+    public ReportSender create(@NonNull Context context, @NonNull CoreConfiguration config) {
+        return new MySender();
+    }
+
+    @Override
+    public boolean enabled(@NonNull CoreConfiguration config) {
+        return true;
+    }
+}

+ 64 - 0
app/src/main/java/com/dar/nbook/components/classes/Size.java

@@ -0,0 +1,64 @@
+package com.dar.nbook.components.classes;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+public class Size implements Parcelable {
+    public static final Creator<Size> CREATOR = new Creator<Size>() {
+        @Override
+        public Size createFromParcel(Parcel in) {
+            return new Size(in);
+        }
+
+        @Override
+        public Size[] newArray(int size) {
+            return new Size[size];
+        }
+    };
+    private int width, height;
+
+    public Size(int width, int height) {
+        this.width = width;
+        this.height = height;
+    }
+
+    protected Size(Parcel in) {
+        width = in.readInt();
+        height = in.readInt();
+    }
+
+    public int getWidth() {
+        return width;
+    }
+
+    public void setWidth(int width) {
+        this.width = width;
+    }
+
+    public int getHeight() {
+        return height;
+    }
+
+    public void setHeight(int height) {
+        this.height = height;
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(Parcel dest, int flags) {
+        dest.writeInt(width);
+        dest.writeInt(height);
+    }
+
+    @Override
+    public String toString() {
+        return "Size{" +
+            "width=" + width +
+            ", height=" + height +
+            '}';
+    }
+}

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä