Compare commits

...

135 Commits
v0.1 ... v0.5

Author SHA1 Message Date
Koitharu
2374c96009 Fix source preferences summaries 2020-07-19 11:44:12 +03:00
Koitharu
2dd51117e9 Remove unused resources 2020-07-16 19:27:59 +03:00
Koitharu
6c5f3c7d97 Fix readmanga search 2020-07-16 19:20:59 +03:00
Koitharu
626bb20edb Fix global search 2020-07-12 13:48:52 +03:00
Koitharu
d363869dab Fix cbz thumbnails 2020-07-10 07:22:16 +03:00
Koitharu
774f33c63d Get remote manga for local in tracker 2020-07-10 07:14:41 +03:00
Koitharu
079427346a Update dependencies 2020-07-10 07:09:41 +03:00
Koitharu
a1a3125834 Fix crash on manga downloading 2020-07-06 20:05:41 +03:00
Koitharu
fc9c8f8a79 Update readme 2020-07-05 17:30:44 +03:00
Koitharu
c06923dbdf Merge branch 'devel' 2020-07-05 17:14:37 +03:00
Koitharu
66ca51cc73 Fix MangaTown endless search 2020-07-05 17:09:38 +03:00
Koitharu
bf45480366 Update henchan default domain 2020-07-05 17:01:43 +03:00
Koitharu
28618e394e Fix *chan search 2020-07-05 16:59:20 +03:00
Koitharu
9762a466ce Fix Mangalib pages loading 2020-07-05 16:45:34 +03:00
Koitharu
367a97a95b Fix page error processing 2020-07-01 19:37:15 +03:00
Koitharu
c3ab197aa0 Fix some StrictMode warnings 2020-07-01 19:17:08 +03:00
Koitharu
a0aa33a499 Option to clear updates feed 2020-06-29 13:43:01 +03:00
Koitharu
b27bc86141 Fix search history preference 2020-06-29 13:28:25 +03:00
Koitharu
84ef2af82f Fix MangaDetailsPresenter sharing 2020-06-29 13:26:28 +03:00
Koitharu
a2f09d8763 Restore download after network error 2020-06-29 09:48:17 +03:00
Koitharu
79058440a1 Fix grid span count #11 2020-06-25 19:40:50 +03:00
Koitharu
7f9cfdbf7a Show app update in dialog 2020-06-20 12:10:42 +03:00
Koitharu
85f7477450 Cleaning up traks 2020-06-20 11:35:42 +03:00
Koitharu
0e08d75626 Update dependencies 2020-06-14 17:12:30 +03:00
Koitharu
1b4a65f476 Update pages thumbnails list 2020-06-11 19:59:54 +03:00
Koitharu
2e69395ade Update ui 2020-06-08 19:22:56 +03:00
Koitharu
3f61f13b7b Show related manga 2020-06-07 20:14:44 +03:00
Koitharu
10a0f0ad53 Fix empty tracker log records 2020-06-05 19:25:10 +03:00
Koitharu
680fc66f21 Tracker fixes 2020-06-03 18:52:00 +03:00
Koitharu
e01b74ee3d Pagination loading indicator 2020-05-31 12:10:43 +03:00
Koitharu
3539e6a892 Global search 2020-05-30 11:18:14 +03:00
Koitharu
ff56f5a343 Fix updates feed 2020-05-30 10:25:48 +03:00
Koitharu
9ce43a39c8 Global search 2020-05-30 09:48:04 +03:00
Koitharu
0e3aa3f380 Update readme 2020-05-24 13:25:38 +03:00
Koitharu
7927bf0c9a Refactor 2020-05-24 12:58:17 +03:00
Koitharu
aec2d71688 Manga updates feed 2020-05-22 20:28:14 +03:00
Koitharu
140a0f4d66 Log manga tracking 2020-05-22 19:20:08 +03:00
Koitharu
7cf57535ab Update dependencies 2020-05-21 21:27:21 +03:00
Koitharu
31fe924157 Merge branch 'master' into devel 2020-05-21 21:20:39 +03:00
Koitharu
6d193baa69 Increment version 2020-05-21 20:48:01 +03:00
Koitharu
3bd7b54405 Capitalize MangaLib genres 2020-05-21 20:47:10 +03:00
Koitharu
d99450c5a3 Prepopulate favourite categories 2020-05-21 20:43:24 +03:00
Koitharu
6444122c0a Update database: add tracklogs table 2020-05-21 20:30:10 +03:00
Koitharu
fe14ccb5ec Update readme 2020-05-20 20:05:51 +03:00
Koitharu
e38e5fdf0f Small enhancements 2020-05-20 19:51:28 +03:00
Koitharu
c1c2b11bd8 Show local manga size 2020-05-20 19:31:17 +03:00
Koitharu
7d147b3c37 Check wakelock is held in download service 2020-05-20 19:04:31 +03:00
Koitharu
260ff32cd1 Detect chapters in cbz if index missing 2020-05-20 18:59:30 +03:00
Koitharu
ccc5f3e423 Fix webtoon scroll 2020-05-17 17:25:18 +03:00
Koitharu
8b32a60743 Misc fixes 2020-05-16 10:20:55 +03:00
Koitharu
c1faf2fe06 Remember last opened section 2020-05-16 09:07:09 +03:00
Koitharu
3588270742 Show favourites by categories and manager categories order 2020-05-16 08:49:34 +03:00
Koitharu
01607ec1e2 Fix MangaLib provider 2020-05-13 20:17:37 +03:00
Koitharu
50f8cb9193 Action mode chapters selection 2020-05-11 17:30:59 +03:00
Koitharu
0100974508 Optimize webtoon scroll 2020-05-11 14:04:51 +03:00
Koitharu
b438898456 Add mangalib source 2020-05-10 18:52:00 +03:00
Koitharu
c3c43dce3d Update dependencies and fix deprecations 2020-05-10 13:38:14 +03:00
Koitharu
e33dfd63e4 Downgrade kotlin 2020-05-10 12:36:14 +03:00
Koitharu
1927500f5a Configure shelf appwidget 2020-05-09 14:15:13 +03:00
Koitharu
f9ccd0851d Small refactor 2020-05-09 12:33:09 +03:00
Koitharu
23412e5c17 Optimize webtoon scroll 2020-05-09 10:16:46 +03:00
Koitharu
1b7c8355ec Refactor: fix deprecations 2020-05-03 18:04:26 +03:00
Koitharu
8378b3dd90 Disable save action for local chapters 2020-05-03 17:31:33 +03:00
Koitharu
9ff5bb6352 Update readme 2020-04-26 20:43:26 +03:00
Koitharu
b2bb1d22df Fix default storage 2020-04-26 20:28:15 +03:00
Koitharu
34acf5bb55 Fix json null string 2020-04-26 20:25:46 +03:00
Koitharu
5af32898f8 Add MangaDex source 2020-04-26 20:22:49 +03:00
Koitharu
ef7108f6c9 Fix typo 2020-04-26 16:48:57 +03:00
Koitharu
941d992793 Merge branch 'devel' of https://github.com/nv95/Kotatsu into devel 2020-04-26 16:38:32 +03:00
Koitharu
de9a07a680 Select storage where save manga 2020-04-26 12:22:36 +03:00
Koitharu
0dc74f9188 Update readme 2020-04-25 18:06:11 +03:00
Koitharu
f95cf9b231 Reset new chapters on reading 2020-04-25 17:22:20 +03:00
Koitharu
0d0982b244 Check if in-app update allowed 2020-04-19 08:43:45 +03:00
Koitharu
ef4dd82e92 Fix TextInputDialog keyboard behavior 2020-04-19 08:06:24 +03:00
Koitharu
bc825681a8 Small ui fixes 2020-04-18 20:24:19 +03:00
Koitharu
da6204f44f Remove unused resources 2020-04-18 19:56:12 +03:00
Koitharu
10c68bdd72 Update readme 2020-04-18 17:33:27 +03:00
Koitharu
b1e90dde8f Optimize page loading 2020-04-18 12:20:18 +03:00
Koitharu
e0d45961f8 Add some animations 2020-04-18 11:19:20 +03:00
Koitharu
b732a220f6 Merge remote-tracking branch 'origin/feature/appwidget' into devel 2020-04-12 12:44:54 +03:00
Koitharu
582adae11f Manage favourites categories 2020-04-12 12:40:40 +03:00
Koitharu
3014ebdfd4 Notification settings for pre-Oreo android 2020-04-12 09:58:20 +03:00
Koitharu
12b13f98f8 Migrate to WorkManager 2020-04-11 11:39:40 +03:00
Koitharu
c13c43c616 Recent manga app widget 2020-04-10 20:13:17 +03:00
Koitharu
ab1eacea3f Shelf app widget 2020-04-10 19:56:51 +03:00
Koitharu
ac4b97928a Merge branch 'devel' of https://github.com/nv95/Kotatsu into devel 2020-04-10 17:14:40 +03:00
Koitharu
aa8281678b Update settings 2020-04-10 17:14:33 +03:00
Koitharu
0be4f56538 Refactor shortcut helper 2020-04-08 20:30:44 +03:00
Koitharu
679c06557e Change notification icon 2020-04-08 20:15:29 +03:00
Koitharu
1d387709f2 Cleanup code 2020-04-05 20:40:58 +03:00
Koitharu
a78774d10e Optimize pages thumbnails 2020-04-05 13:04:22 +03:00
Koitharu
390639e9e3 Fix recycled bitmap crash 2020-04-05 12:58:06 +03:00
Koitharu
b98ec2199d Crash info dialog 2020-04-05 12:46:05 +03:00
Koitharu
8b28f1cd74 Small UI fixes 2020-04-03 21:20:41 +03:00
Koitharu
904b78a01e Optimize images in lists 2020-04-03 20:59:34 +03:00
Koitharu
a774d2d915 Read from start quick action 2020-04-03 20:45:18 +03:00
Koitharu
9d19b5fec0 Fix grouple genres list 2020-04-03 20:34:14 +03:00
Koitharu
b6c0f3ca8c Small refactor 2020-04-03 20:24:46 +03:00
Koitharu
e06cb1230f Update dependencies 2020-04-03 19:54:18 +03:00
Koitharu
1720fde4c4 Small fix webtoon reader 2020-04-02 21:59:41 +03:00
Koitharu
4c3dbe1643 Update notification enhancement 2020-04-02 20:39:15 +03:00
Koitharu
3f31bd5ad1 Tracker enhancements 2020-04-02 20:24:46 +03:00
Koitharu
3a79b4667b Merge branch 'master' into devel 2020-04-02 20:10:52 +03:00
Koitharu
de49877178 Fix crash and foreign key issue 2020-03-30 20:55:53 +03:00
Koitharu
65e92fa206 Fix sources preference summary 2020-03-29 21:18:32 +03:00
Koitharu
9cb181d53e Show new chapters on details 2020-03-29 21:08:45 +03:00
Koitharu
a2d4a63eb1 Tracker notifications option in settings 2020-03-29 20:48:53 +03:00
Koitharu
c4f712be3a Improve download notification 2020-03-29 20:41:37 +03:00
Koitharu
9e8367e45e Increase database version 2020-03-29 20:24:06 +03:00
Koitharu
fa2d1de2f2 Merge branch 'San4ito-patch-1' into devel 2020-03-29 18:05:48 +03:00
Koitharu
f8f4573486 Merge branch 'patch-1' of https://github.com/San4ito/Kotatsu into San4ito-patch-1 2020-03-29 18:05:30 +03:00
Koitharu
f15f0ce769 Merge branch 'master' into devel 2020-03-29 18:03:58 +03:00
Koitharu
450daf17fd Fix foreign key error 2020-03-29 17:57:04 +03:00
Koitharu
aad26d24ec Tracking manga updates 2020-03-29 17:33:44 +03:00
Koitharu
80c8344f8d Manga tracking job service 2020-03-29 13:32:25 +03:00
Koitharu
44b23d0b69 Merge branch 'master' into devel 2020-03-29 11:16:45 +03:00
Koitharu
7ee486e4f2 Fix version code 2020-03-29 10:58:24 +03:00
Koitharu
f230f2d198 Update database scheme: tracks table 2020-03-29 10:40:49 +03:00
San4ito
cf50b608a7 Убрал небольшие опечатки
Думаю, так должно быть лучше.
2020-03-28 22:14:21 +03:00
Koitharu
1314c601b2 Simplify settings 2020-03-28 19:40:43 +02:00
Koitharu
c5970c5606 Confirm large manga downloading 2020-03-28 19:01:50 +02:00
Koitharu
85b18d118b Store page scroll position in history 2020-03-28 18:49:01 +02:00
Koitharu
e7a150bd9a Replace history with remote manga when delete local 2020-03-28 12:46:57 +02:00
Koitharu
2c66edda68 Update database schema: foreign keys and indices 2020-03-28 12:28:03 +02:00
Koitharu
1a93cc228d Update dependencies 2020-03-23 18:14:27 +02:00
Koitharu
798ae6aeb7 Fix pages max scale 2020-03-22 17:35:42 +02:00
Koitharu
418d0247f5 Option to hide manga source 2020-03-19 16:52:16 +02:00
Koitharu
db0ee268f9 Browser activity 2020-03-19 14:31:25 +02:00
Koitharu
032d671c38 Add Chucker for debug 2020-03-19 13:39:19 +02:00
Koitharu
127978d3d7 Decrease app update checking interval 2020-03-19 12:53:14 +02:00
Koitharu
fddc3e41cf Remove shortcut if local manga removed 2020-03-19 12:47:36 +02:00
Koitharu
e0e6f0dab4 DesuMe parser 2020-03-18 20:33:57 +02:00
Koitharu
beaa825a9f Add UserAgent 2020-03-18 19:22:10 +02:00
Koitharu
cae27dda05 Fix all parsers issues 2020-03-18 19:12:27 +02:00
Koitharu
0d041e9a0a Fix appending saved chapters 2020-03-17 18:15:15 +02:00
327 changed files with 7176 additions and 1801 deletions

120
.idea/codeStyles generated
View File

@@ -1,120 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectCodeStyleConfiguration">
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
<code_scheme name="Project" version="173">
<codeStyleSettings language="XML">
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
</code_scheme>
</component>
</project>

186
.idea/codeStyles/Project.xml generated Executable file
View File

@@ -0,0 +1,186 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<option name="OTHER_INDENT_OPTIONS">
<value>
<option name="USE_TAB_CHARACTER" value="true" />
</value>
</option>
<AndroidXmlCodeStyleSettings>
<option name="LAYOUT_SETTINGS">
<value>
<option name="INSERT_LINE_BREAK_BEFORE_NAMESPACE_DECLARATION" value="true" />
</value>
</option>
<option name="MANIFEST_SETTINGS">
<value>
<option name="INSERT_LINE_BREAK_BEFORE_NAMESPACE_DECLARATION" value="true" />
</value>
</option>
<option name="OTHER_SETTINGS">
<value>
<option name="INSERT_LINE_BREAK_BEFORE_NAMESPACE_DECLARATION" value="true" />
</value>
</option>
</AndroidXmlCodeStyleSettings>
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="CMake">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Groovy">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="HTML">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JAVA">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JSON">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="ObjectiveC">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Shell Script">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="XML">
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
<option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" />
<option name="BLOCK_COMMENT_AT_FIRST_COLUMN" value="false" />
<option name="LINE_COMMENT_ADD_SPACE" value="true" />
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Executable file
View File

@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

6
.idea/compiler.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="1.8" />
</component>
</project>

View File

@@ -1,6 +1,9 @@
<component name="ProjectDictionaryState">
<dictionary name="admin">
<words>
<w>chucker</w>
<w>desu</w>
<w>failsafe</w>
<w>koin</w>
<w>kotatsu</w>
<w>manga</w>

1
.idea/gradle.xml generated
View File

@@ -15,6 +15,7 @@
</set>
</option>
<option name="resolveModulePerSourceSet" value="false" />
<option name="useQualifiedModuleNames" value="true" />
</GradleProjectSettings>
</option>
</component>

View File

@@ -0,0 +1,7 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="BooleanLiteralArgument" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
<inspection_tool class="TrailingComma" enabled="true" level="INFORMATION" enabled_by_default="true" />
</profile>
</component>

View File

@@ -21,5 +21,15 @@
<option name="name" value="Google" />
<option name="url" value="https://dl.google.com/dl/android/maven2/" />
</remote-repository>
<remote-repository>
<option name="id" value="maven" />
<option name="name" value="maven" />
<option name="url" value="https://jitpack.io" />
</remote-repository>
<remote-repository>
<option name="id" value="maven2" />
<option name="name" value="maven2" />
<option name="url" value="https://dl.bintray.com/kotlin/kotlin-eap" />
</remote-repository>
</component>
</project>

6
.idea/kotlinc.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Kotlin2JvmCompilerArguments">
<option name="jvmTarget" value="1.8" />
</component>
</project>

2
.idea/misc.xml generated
View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" default="true" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">

9
.idea/vcs.xml generated
View File

@@ -1,5 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GitSharedSettings">
<option name="FORCE_PUSH_PROHIBITED_PATTERNS">
<list>
<option value="master" />
<option value="devel" />
<option value="legacy" />
</list>
</option>
</component>
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>

View File

@@ -2,13 +2,13 @@
Kotatsu is a free and open source manga reader for Android.
![Android minSdk](https://img.shields.io/badge/android-5.0+-brightgreen) ![GitHub top language](https://img.shields.io/github/languages/top/nv95/Kotatsu) [![Build Status](https://travis-ci.org/nv95/Kotatsu.svg?branch=master)](https://travis-ci.org/nv95/Kotatsu) ![License](https://img.shields.io/github/license/nv95/Kotatsu) [![4pda](https://img.shields.io/badge/discuss-4pda-2982CC)](http://4pda.ru/forum/index.php?showtopic=697669)
![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) ![Kotlin](https://img.shields.io/github/languages/top/nv95/Kotatsu) [![Build Status](https://travis-ci.org/nv95/Kotatsu.svg?branch=master)](https://travis-ci.org/nv95/Kotatsu) ![License](https://img.shields.io/github/license/nv95/Kotatsu) [![4pda](https://img.shields.io/badge/discuss-4pda-2982CC)](http://4pda.ru/forum/index.php?showtopic=697669)
### Download
Latest unstable build: [get here](https://github.com/nv95/Kotatsu/releases/latest)
Latest release: [get here](https://github.com/nv95/Kotatsu/releases/latest)
Stable release: _Coming soon_
Legacy build (Android 4.1+): [available here](https://github.com/nv95/Kotatsu/releases/tag/v0.4-legacy)
### Main Features
@@ -20,16 +20,15 @@ Stable release: _Coming soon_
* Tablet-optimized modern UI
* Reading third-party comics from CBZ
* Standard and Webtoon-optimized reader
### Coming Features
* Checking for new chapters
* Notifications about new chapters
* Updates feed
* Global search
### Screenshots
| ![Screenshot_20200226-210337](https://user-images.githubusercontent.com/8948226/75573590-d467f180-5a65-11ea-8338-a34af4679ed6.png) | ![Screenshot_20200226-210310](https://user-images.githubusercontent.com/8948226/75573612-dcc02c80-5a65-11ea-9afb-293dadfb3cfd.png) | ![Screenshot_20200226-210232](https://user-images.githubusercontent.com/8948226/75573621-e0ec4a00-5a65-11ea-92b9-72ab90281a2b.png) |
| ![Screenshot_20200226-210337](https://user-images.githubusercontent.com/8948226/80315102-3478db00-87fe-11ea-9ce8-4bbd1c254b2b.png) | ![Screenshot_20200226-210310](https://user-images.githubusercontent.com/8948226/80315110-3f337000-87fe-11ea-95df-944c196b6667.png) | ![Screenshot_20200226-210232](https://user-images.githubusercontent.com/8948226/80315121-49ee0500-87fe-11ea-8d9b-537a041bbf2f.png) |
|---|---|---|
| ![Screenshot_20200226-210405](https://user-images.githubusercontent.com/8948226/75573629-e34ea400-5a65-11ea-86a1-4496032ac0f0.png) | ![Screenshot_20200226-210151](https://user-images.githubusercontent.com/8948226/75573632-e5186780-5a65-11ea-81b0-7c296157709c.png) | ![Screenshot_20200226-210223](https://user-images.githubusercontent.com/8948226/75573639-e6e22b00-5a65-11ea-84a6-6257f532fd2c.png) |
| ![Screenshot_20200226-210405](https://user-images.githubusercontent.com/8948226/80315130-55d9c700-87fe-11ea-8350-2c8452906eb7.png) | ![Screenshot_20200226-210151](https://user-images.githubusercontent.com/8948226/80315135-612cf280-87fe-11ea-984c-aa18567d5bbc.png) | ![Screenshot_20200226-210223](https://user-images.githubusercontent.com/8948226/80315146-6be78780-87fe-11ea-8439-ca1ca578172b.png) |
### License
[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html)
@@ -42,4 +41,4 @@ published by the Free Software Foundation, either version 3 of the License, or
### Disclaimer
The developers of this application does not have any affiliation with the content providers available.
The developers of this application does not have any affiliation with the content providers available.

View File

@@ -1,10 +1,11 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-android-extensions'
id 'kotlin-kapt'
}
def gitCommits = 'git rev-list --all --count'.execute([], rootDir).text.trim().toInteger()
def gitBranch = 'git branch --show-current'.execute([], rootDir).text.trim()
def gitCommits = 'git rev-list --count HEAD'.execute([], rootDir).text.trim().toInteger()
android {
compileSdkVersion 29
@@ -15,29 +16,28 @@ android {
minSdkVersion 21
targetSdkVersion 29
versionCode gitCommits
versionName '0.1'
buildConfigField 'String', 'GIT_BRANCH', "\"${gitBranch}\""
versionName '0.5'
kapt {
arguments {
arg('room.schemaLocation', "$projectDir/schemas".toString())
arg 'room.schemaLocation', "$projectDir/schemas".toString()
}
}
}
archivesBaseName = "kotatsu_${gitCommits}"
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
freeCompilerArgs += "-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi"
}
buildTypes {
debug {
applicationIdSuffix = '.debug'
}
release {
multiDexEnabled false
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
@@ -58,38 +58,46 @@ androidExtensions {
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.3'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.3'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.7'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.7'
implementation 'androidx.core:core-ktx:1.3.0-alpha02'
implementation 'androidx.fragment:fragment-ktx:1.2.2'
implementation 'androidx.appcompat:appcompat:1.2.0-alpha03'
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0-beta01'
implementation 'androidx.recyclerview:recyclerview:1.2.0-alpha01'
implementation 'androidx.preference:preference:1.1.0'
implementation 'com.google.android.material:material:1.2.0-alpha05'
implementation 'androidx.core:core-ktx:1.5.0-alpha01'
implementation 'androidx.activity:activity-ktx:1.2.0-alpha06'
implementation 'androidx.fragment:fragment-ktx:1.3.0-alpha06'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.0-alpha05'
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta8'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.2.0-alpha04'
implementation 'androidx.viewpager2:viewpager2:1.1.0-alpha01'
implementation 'androidx.preference:preference-ktx:1.1.1'
implementation 'androidx.work:work-runtime-ktx:2.4.0-rc01'
implementation 'com.google.android.material:material:1.3.0-alpha01'
//noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.3.0-alpha05'
implementation 'androidx.room:room-runtime:2.2.4'
implementation 'androidx.room:room-ktx:2.2.4'
kapt 'androidx.room:room-compiler:2.2.4'
implementation 'androidx.room:room-runtime:2.2.5'
implementation 'androidx.room:room-ktx:2.2.5'
kapt 'androidx.room:room-compiler:2.2.5'
implementation 'com.github.moxy-community:moxy:2.1.1'
implementation 'com.github.moxy-community:moxy-androidx:2.1.1'
implementation 'com.github.moxy-community:moxy-material:2.1.1'
implementation 'com.github.moxy-community:moxy-ktx:2.1.1'
kapt 'com.github.moxy-community:moxy-compiler:2.1.1'
implementation 'com.github.moxy-community:moxy:2.1.2'
implementation 'com.github.moxy-community:moxy-androidx:2.1.2'
implementation 'com.github.moxy-community:moxy-material:2.1.2'
implementation 'com.github.moxy-community:moxy-ktx:2.1.2'
kapt 'com.github.moxy-community:moxy-compiler:2.1.2'
implementation 'com.squareup.okhttp3:okhttp:4.4.0'
implementation 'com.squareup.okio:okio:2.4.3'
implementation 'org.jsoup:jsoup:1.12.2'
implementation 'com.squareup.okhttp3:okhttp:4.8.0'
implementation 'com.squareup.okio:okio:2.7.0'
implementation 'org.jsoup:jsoup:1.13.1'
implementation 'org.koin:koin-android:2.1.3'
implementation 'io.coil-kt:coil:0.9.5'
implementation 'org.koin:koin-android:2.1.5'
implementation 'io.coil-kt:coil:0.11.0'
implementation 'com.davemorrissey.labs:subsampling-scale-image-view:3.10.0'
implementation 'com.tomclaw.cache:cache:1.0'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.2'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.3'
debugImplementation 'com.github.ChuckerTeam.Chucker:library:3.2.0'
releaseImplementation 'com.github.ChuckerTeam.Chucker:library-no-op:3.2.0'
testImplementation 'junit:junit:4.13'
testImplementation 'org.json:json:20200518'
}

1
app/libs/.gitkeep Normal file
View File

@@ -0,0 +1 @@

Binary file not shown.

View File

@@ -9,4 +9,5 @@
-keep class org.koitharu.kotatsu.core.db.entity.* { *; }
-keepclassmembers public class * extends org.koitharu.kotatsu.core.parser.MangaRepository {
public <init>(...);
}
}
-dontwarn okhttp3.internal.platform.ConscryptPlatform

View File

@@ -1,13 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="org.koitharu.kotatsu">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:name="org.koitharu.kotatsu.KotatsuApp"
@@ -18,8 +21,9 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<activity android:name=".ui.main.MainActivity">
android:usesCleartextTraffic="true"
tools:ignore="UnusedAttribute">
<activity android:name=".ui.list.MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -42,16 +46,47 @@
android:label="@string/settings" />
<activity
android:name=".ui.reader.SimpleSettingsActivity"
android:label="@string/settings" />
android:label="@string/settings">
<intent-filter>
<action android:name="android.intent.action.MANAGE_NETWORK_USAGE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity android:name=".ui.browser.BrowserActivity" />
<activity
android:name=".ui.utils.CrashActivity"
android:label="@string/error_occurred"
android:theme="@android:style/Theme.DeviceDefault.Dialog"
android:windowSoftInputMode="stateAlwaysHidden" />
<activity
android:name="org.koitharu.kotatsu.ui.list.favourites.categories.CategoriesActivity"
android:label="@string/favourites_categories"
android:windowSoftInputMode="stateAlwaysHidden" />
<activity
android:name=".ui.widget.shelf.ShelfConfigActivity"
android:label="@string/manga_shelf">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<activity android:name=".ui.search.global.GlobalSearchActivity"
android:label="@string/search" />
<service
android:name=".ui.download.DownloadService"
android:foregroundServiceType="dataSync" />
<service android:name=".ui.settings.AppUpdateService" />
<service
android:name=".ui.widget.shelf.ShelfWidgetService"
android:permission="android.permission.BIND_REMOTEVIEWS" />
<service
android:name=".ui.widget.recent.RecentWidgetService"
android:permission="android.permission.BIND_REMOTEVIEWS" />
<provider
android:name=".ui.search.MangaSuggestionsProvider"
android:authorities="${applicationId}.MangaSuggestionsProvider" />
android:authorities="${applicationId}.MangaSuggestionsProvider"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.files"
@@ -62,6 +97,27 @@
android:resource="@xml/filepaths" />
</provider>
<receiver
android:name=".ui.widget.shelf.ShelfWidgetProvider"
android:label="@string/manga_shelf">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_shelf" />
</receiver>
<receiver
android:name=".ui.widget.recent.RecentWidgetProvider"
android:label="@string/recent_manga">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_recent" />
</receiver>
</application>
</manifest>

View File

@@ -1,25 +1,36 @@
package org.koitharu.kotatsu
import android.app.Application
import android.os.StrictMode
import androidx.appcompat.app.AppCompatDelegate
import androidx.room.Room
import coil.Coil
import coil.ImageLoader
import coil.ComponentRegistry
import coil.ImageLoaderBuilder
import coil.util.CoilUtils
import com.itkacher.okhttpprofiler.OkHttpProfilerInterceptor
import com.chuckerteam.chucker.api.ChuckerCollector
import com.chuckerteam.chucker.api.ChuckerInterceptor
import okhttp3.OkHttpClient
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin
import org.koin.dsl.module
import org.koitharu.kotatsu.core.db.DatabasePrePopulateCallback
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.migrations.*
import org.koitharu.kotatsu.core.local.CbzFetcher
import org.koitharu.kotatsu.core.local.PagesCache
import org.koitharu.kotatsu.core.local.cookies.PersistentCookieJar
import org.koitharu.kotatsu.core.local.cookies.cache.SetCookieCache
import org.koitharu.kotatsu.core.local.cookies.persistence.SharedPrefsCookiePersistor
import org.koitharu.kotatsu.core.parser.LocalMangaRepository
import org.koitharu.kotatsu.core.parser.UserAgentInterceptor
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.domain.MangaLoaderContext
import org.koitharu.kotatsu.domain.favourites.FavouritesRepository
import org.koitharu.kotatsu.domain.history.HistoryRepository
import org.koitharu.kotatsu.ui.utils.AppCrashHandler
import org.koitharu.kotatsu.ui.widget.WidgetUpdater
import org.koitharu.kotatsu.utils.CacheUtils
import java.util.concurrent.TimeUnit
@@ -29,11 +40,35 @@ class KotatsuApp : Application() {
PersistentCookieJar(SetCookieCache(), SharedPrefsCookiePersistor(applicationContext))
}
private val chuckerCollector by lazy(LazyThreadSafetyMode.NONE) {
ChuckerCollector(applicationContext)
}
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder()
.detectAll()
.penaltyLog()
.build())
StrictMode.setVmPolicy(StrictMode.VmPolicy.Builder()
.detectAll()
.setClassInstanceLimit(LocalMangaRepository::class.java, 1)
.setClassInstanceLimit(PagesCache::class.java, 1)
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
.penaltyLog()
.build())
}
initKoin()
initCoil()
Thread.setDefaultUncaughtExceptionHandler(AppCrashHandler(applicationContext))
if (BuildConfig.DEBUG) {
initErrorHandler()
}
AppCompatDelegate.setDefaultNightMode(AppSettings(this).theme)
val widgetUpdater = WidgetUpdater(applicationContext)
FavouritesRepository.subscribe(widgetUpdater)
HistoryRepository.subscribe(widgetUpdater)
}
private fun initKoin() {
@@ -53,7 +88,7 @@ class KotatsuApp : Application() {
single {
MangaLoaderContext()
}
factory {
single {
AppSettings(applicationContext)
}
single {
@@ -65,16 +100,27 @@ class KotatsuApp : Application() {
}
private fun initCoil() {
Coil.setDefaultImageLoader(ImageLoader(applicationContext) {
okHttpClient {
okHttp()
.cache(CoilUtils.createDefaultCache(applicationContext))
.build()
}
componentRegistry {
add(CbzFetcher())
}
})
Coil.setImageLoader(
ImageLoaderBuilder(applicationContext)
.okHttpClient(
okHttp()
.cache(CoilUtils.createDefaultCache(applicationContext))
.build()
).componentRegistry(
ComponentRegistry.Builder()
.add(CbzFetcher())
.build()
)
.build()
)
}
private fun initErrorHandler() {
val exceptionHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { t, e ->
chuckerCollector.onError("CRASH", e)
exceptionHandler?.uncaughtException(t, e)
}
}
private fun okHttp() = OkHttpClient.Builder().apply {
@@ -82,8 +128,9 @@ class KotatsuApp : Application() {
readTimeout(60, TimeUnit.SECONDS)
writeTimeout(20, TimeUnit.SECONDS)
cookieJar(cookieJar)
addInterceptor(UserAgentInterceptor)
if (BuildConfig.DEBUG) {
addInterceptor(OkHttpProfilerInterceptor())
addInterceptor(ChuckerInterceptor(applicationContext, collector = chuckerCollector))
}
}
@@ -91,5 +138,6 @@ class KotatsuApp : Application() {
applicationContext,
MangaDatabase::class.java,
"kotatsu-db"
)
).addMigrations(Migration1To2, Migration2To3, Migration3To4, Migration4To5, Migration5To6)
.addCallback(DatabasePrePopulateCallback(resources))
}

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.core.db
import android.content.res.Resources
import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase
import org.koitharu.kotatsu.R
class DatabasePrePopulateCallback(private val resources: Resources) : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
db.execSQL(
"INSERT INTO favourite_categories (created_at, sort_key, title) VALUES (?,?,?)",
arrayOf(System.currentTimeMillis(), 1, resources.getString(R.string.read_later))
)
}
}

View File

@@ -9,12 +9,25 @@ import org.koitharu.kotatsu.core.db.entity.FavouriteCategoryEntity
@Dao
abstract class FavouriteCategoriesDao {
@Query("SELECT category_id,title,created_at FROM favourite_categories ORDER BY :orderBy")
abstract suspend fun findAll(orderBy: String): List<FavouriteCategoryEntity>
@Query("SELECT * FROM favourite_categories ORDER BY sort_key")
abstract suspend fun findAll(): List<FavouriteCategoryEntity>
@Insert(onConflict = OnConflictStrategy.ABORT)
abstract suspend fun insert(category: FavouriteCategoryEntity): Long
@Query("DELETE FROM favourite_categories WHERE category_id = :id")
abstract suspend fun delete(id: Long)
@Query("UPDATE favourite_categories SET title = :title WHERE category_id = :id")
abstract suspend fun update(id: Long, title: String)
@Query("UPDATE favourite_categories SET sort_key = :sortKey WHERE category_id = :id")
abstract suspend fun update(id: Long, sortKey: Int)
@Query("SELECT MAX(sort_key) FROM favourite_categories")
protected abstract suspend fun getMaxSortKey(): Int?
suspend fun getNextSortKey(): Int {
return (getMaxSortKey() ?: 0) + 1
}
}

View File

@@ -3,13 +3,29 @@ package org.koitharu.kotatsu.core.db
import androidx.room.*
import org.koitharu.kotatsu.core.db.entity.FavouriteEntity
import org.koitharu.kotatsu.core.db.entity.FavouriteManga
import org.koitharu.kotatsu.core.db.entity.MangaEntity
@Dao
abstract class FavouritesDao {
@Transaction
@Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY :orderBy LIMIT :limit OFFSET :offset")
abstract suspend fun findAll(offset: Int, limit: Int, orderBy: String): List<FavouriteManga>
@Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at")
abstract suspend fun findAll(): List<FavouriteManga>
@Transaction
@Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at LIMIT :limit OFFSET :offset")
abstract suspend fun findAll(offset: Int, limit: Int): List<FavouriteManga>
@Transaction
@Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at")
abstract suspend fun findAll(categoryId: Long): List<FavouriteManga>
@Transaction
@Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at LIMIT :limit OFFSET :offset")
abstract suspend fun findAll(categoryId: Long, offset: Int, limit: Int): List<FavouriteManga>
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites)")
abstract suspend fun findAllManga(): List<MangaEntity>
@Transaction
@Query("SELECT * FROM favourites WHERE manga_id = :id GROUP BY manga_id")

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.db
import androidx.room.*
import org.koitharu.kotatsu.core.db.entity.HistoryEntity
import org.koitharu.kotatsu.core.db.entity.HistoryWithManga
import org.koitharu.kotatsu.core.db.entity.MangaEntity
@Dao
@@ -15,6 +16,9 @@ abstract class HistoryDao {
@Query("SELECT * FROM history ORDER BY updated_at DESC LIMIT :limit OFFSET :offset")
abstract suspend fun findAll(offset: Int, limit: Int): List<HistoryWithManga>
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history)")
abstract suspend fun findAllManga(): List<MangaEntity>
@Query("SELECT * FROM history WHERE manga_id = :id")
abstract suspend fun find(id: Long): HistoryEntity?
@@ -24,19 +28,20 @@ abstract class HistoryDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(entity: HistoryEntity): Long
@Query("UPDATE history SET page = :page, chapter_id = :chapterId, updated_at = :updatedAt WHERE manga_id = :mangaId")
abstract suspend fun update(mangaId: Long, page: Int, chapterId: Long, updatedAt: Long): Int
@Query("UPDATE history SET page = :page, chapter_id = :chapterId, scroll = :scroll, updated_at = :updatedAt WHERE manga_id = :mangaId")
abstract suspend fun update(mangaId: Long, page: Int, chapterId: Long, scroll: Float, updatedAt: Long): Int
@Query("DELETE FROM history WHERE manga_id = :mangaId")
abstract suspend fun delete(mangaId: Long)
suspend fun update(entity: HistoryEntity) = update(entity.mangaId, entity.page, entity.chapterId, entity.updatedAt)
suspend fun update(entity: HistoryEntity) = update(entity.mangaId, entity.page, entity.chapterId, entity.scroll, entity.updatedAt)
@Transaction
open suspend fun upsert(entity: HistoryEntity) {
if (update(entity) == 0) {
open suspend fun upsert(entity: HistoryEntity): Boolean {
return if (update(entity) == 0) {
insert(entity)
}
true
} else false
}
}

View File

@@ -7,20 +7,25 @@ import org.koitharu.kotatsu.core.db.entity.*
@Database(
entities = [
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class
], version = 1
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
TrackEntity::class, TrackLogEntity::class
], version = 6
)
abstract class MangaDatabase : RoomDatabase() {
abstract fun historyDao(): HistoryDao
abstract val historyDao: HistoryDao
abstract fun tagsDao(): TagsDao
abstract val tagsDao: TagsDao
abstract fun mangaDao(): MangaDao
abstract val mangaDao: MangaDao
abstract fun favouritesDao(): FavouritesDao
abstract val favouritesDao: FavouritesDao
abstract fun preferencesDao(): PreferencesDao
abstract val preferencesDao: PreferencesDao
abstract fun favouriteCategoriesDao(): FavouriteCategoriesDao
abstract val favouriteCategoriesDao: FavouriteCategoriesDao
abstract val tracksDao: TracksDao
abstract val trackLogsDao: TrackLogsDao
}

View File

@@ -0,0 +1,28 @@
package org.koitharu.kotatsu.core.db
import androidx.room.*
import org.koitharu.kotatsu.core.db.entity.TrackLogEntity
import org.koitharu.kotatsu.core.db.entity.TrackLogWithManga
@Dao
interface TrackLogsDao {
@Transaction
@Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
suspend fun findAll(offset: Int, limit: Int): List<TrackLogWithManga>
@Query("DELETE FROM track_logs")
suspend fun clear()
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(entity: TrackLogEntity): Long
@Query("DELETE FROM track_logs WHERE manga_id = :mangaId")
suspend fun removeAll(mangaId: Long)
@Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)")
suspend fun cleanup()
@Query("SELECT COUNT(*) FROM track_logs")
suspend fun count(): Int
}

View File

@@ -0,0 +1,37 @@
package org.koitharu.kotatsu.core.db
import androidx.room.*
import org.koitharu.kotatsu.core.db.entity.TrackEntity
@Dao
abstract class TracksDao {
@Query("SELECT * FROM tracks")
abstract suspend fun findAll(): List<TrackEntity>
@Query("SELECT * FROM tracks WHERE manga_id = :mangaId")
abstract suspend fun find(mangaId: Long): TrackEntity?
@Query("DELETE FROM tracks")
abstract suspend fun clear()
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(entity: TrackEntity): Long
@Update
abstract suspend fun update(entity: TrackEntity): Int
@Query("DELETE FROM tracks WHERE manga_id = :mangaId")
abstract suspend fun delete(mangaId: Long)
@Query("DELETE FROM tracks WHERE manga_id NOT IN (SELECT manga_id FROM history UNION SELECT manga_id FROM favourites)")
abstract suspend fun cleanup()
@Transaction
open suspend fun upsert(entity: TrackEntity) {
if (update(entity) == 0) {
insert(entity)
}
}
}

View File

@@ -11,12 +11,14 @@ data class FavouriteCategoryEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "category_id") val categoryId: Int,
@ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "sort_key") val sortKey: Int,
@ColumnInfo(name = "title") val title: String
) {
fun toFavouriteCategory(id: Long? = null) = FavouriteCategory(
id = id ?: categoryId.toLong(),
title = title,
sortKey = sortKey,
createdAt = Date(createdAt)
)
}

View File

@@ -2,10 +2,26 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
@Entity(tableName = "favourites", primaryKeys = ["manga_id", "category_id"])
@Entity(
tableName = "favourites", primaryKeys = ["manga_id", "category_id"], foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = FavouriteCategoryEntity::class,
parentColumns = ["category_id"],
childColumns = ["category_id"],
onDelete = ForeignKey.CASCADE
)
]
)
data class FavouriteEntity(
@ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "category_id") val categoryId: Long,
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "category_id", index = true) val categoryId: Long,
@ColumnInfo(name = "created_at") val createdAt: Long
)

View File

@@ -2,24 +2,36 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.model.MangaHistory
import java.util.*
@Entity(tableName = "history")
@Entity(
tableName = "history", foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE
)
]
)
data class HistoryEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
@ColumnInfo(name = "updated_at") val updatedAt: Long,
@ColumnInfo(name = "chapter_id") val chapterId: Long,
@ColumnInfo(name = "page") val page: Int
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
@ColumnInfo(name = "updated_at") val updatedAt: Long,
@ColumnInfo(name = "chapter_id") val chapterId: Long,
@ColumnInfo(name = "page") val page: Int,
@ColumnInfo(name = "scroll") val scroll: Float
) {
fun toMangaHistory() = MangaHistory(
createdAt = Date(createdAt),
updatedAt = Date(updatedAt),
chapterId = chapterId,
page = page
)
fun toMangaHistory() = MangaHistory(
createdAt = Date(createdAt),
updatedAt = Date(updatedAt),
chapterId = chapterId,
page = page,
scroll = scroll.toInt()
)
}

View File

@@ -2,9 +2,18 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
@Entity(tableName = "preferences")
@Entity(
tableName = "preferences", foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE
)]
)
data class MangaPrefsEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val mangaId: Long,

View File

@@ -2,9 +2,25 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
@Entity(tableName = "manga_tags", primaryKeys = ["manga_id", "tag_id"])
@Entity(
tableName = "manga_tags", primaryKeys = ["manga_id", "tag_id"], foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = TagEntity::class,
parentColumns = ["tag_id"],
childColumns = ["tag_id"],
onDelete = ForeignKey.CASCADE
)
]
)
data class MangaTagsEntity(
@ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "tag_id") val tagId: Long
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "tag_id", index = true) val tagId: Long
)

View File

@@ -0,0 +1,26 @@
package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
@Entity(
tableName = "tracks", foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE
)
]
)
data class TrackEntity (
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "chapters_total") val totalChapters: Int,
@ColumnInfo(name = "last_chapter_id") val lastChapterId: Long,
@ColumnInfo(name = "chapters_new") val newChapters: Int,
@ColumnInfo(name = "last_check") val lastCheck: Long,
@ColumnInfo(name = "last_notified_id") val lastNotifiedChapterId: Long
)

View File

@@ -0,0 +1,24 @@
package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
@Entity(
tableName = "track_logs", foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE
)
]
)
data class TrackLogEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id") val id: Long = 0L,
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "chapters") val chapters: String,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis()
)

View File

@@ -0,0 +1,30 @@
package org.koitharu.kotatsu.core.db.entity
import androidx.room.Embedded
import androidx.room.Junction
import androidx.room.Relation
import org.koitharu.kotatsu.core.model.TrackingLogItem
import java.util.*
data class TrackLogWithManga(
@Embedded val trackLog: TrackLogEntity,
@Relation(
parentColumn = "manga_id",
entityColumn = "manga_id"
)
val manga: MangaEntity,
@Relation(
parentColumn = "manga_id",
entityColumn = "tag_id",
associateBy = Junction(MangaTagsEntity::class)
)
val tags: List<TagEntity>
) {
fun toTrackingLogItem() = TrackingLogItem(
id = trackLog.id,
chapters = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() },
manga = manga.toManga(tags.map { x -> x.toMangaTag() }.toSet()),
createdAt = Date(trackLog.createdAt)
)
}

View File

@@ -0,0 +1,54 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
object Migration1To2 : Migration(1, 2) {
/**
* Adding foreign keys
*/
override fun migrate(database: SupportSQLiteDatabase) {
/* manga_tags */
database.execSQL(
"CREATE TABLE IF NOT EXISTS manga_tags_tmp (manga_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, " +
"PRIMARY KEY(manga_id, tag_id), " +
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE, " +
"FOREIGN KEY(tag_id) REFERENCES tags(tag_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
)
database.execSQL("CREATE INDEX IF NOT EXISTS index_manga_tags_manga_id ON manga_tags_tmp (manga_id)")
database.execSQL("CREATE INDEX IF NOT EXISTS index_manga_tags_tag_id ON manga_tags_tmp (tag_id)")
database.execSQL("INSERT INTO manga_tags_tmp (manga_id, tag_id) SELECT manga_id, tag_id FROM manga_tags")
database.execSQL("DROP TABLE manga_tags")
database.execSQL("ALTER TABLE manga_tags_tmp RENAME TO manga_tags")
/* favourites */
database.execSQL(
"CREATE TABLE IF NOT EXISTS favourites_tmp (manga_id INTEGER NOT NULL, category_id INTEGER NOT NULL, created_at INTEGER NOT NULL, " +
"PRIMARY KEY(manga_id, category_id), " +
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE , " +
"FOREIGN KEY(category_id) REFERENCES favourite_categories(category_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
)
database.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_manga_id ON favourites_tmp (manga_id)")
database.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_category_id ON favourites_tmp (category_id)")
database.execSQL("INSERT INTO favourites_tmp (manga_id, category_id, created_at) SELECT manga_id, category_id, created_at FROM favourites")
database.execSQL("DROP TABLE favourites")
database.execSQL("ALTER TABLE favourites_tmp RENAME TO favourites")
/* history */
database.execSQL(
"CREATE TABLE IF NOT EXISTS history_tmp (manga_id INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, chapter_id INTEGER NOT NULL, page INTEGER NOT NULL, " +
"PRIMARY KEY(manga_id), " +
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
)
database.execSQL("INSERT INTO history_tmp (manga_id, created_at, updated_at, chapter_id, page) SELECT manga_id, created_at, updated_at, chapter_id, page FROM history")
database.execSQL("DROP TABLE history")
database.execSQL("ALTER TABLE history_tmp RENAME TO history")
/* preferences */
database.execSQL(
"CREATE TABLE IF NOT EXISTS preferences_tmp (manga_id INTEGER NOT NULL, mode INTEGER NOT NULL," +
" PRIMARY KEY(manga_id), " +
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
)
database.execSQL("INSERT INTO preferences_tmp (manga_id, mode) SELECT manga_id, mode FROM preferences")
database.execSQL("DROP TABLE preferences")
database.execSQL("ALTER TABLE preferences_tmp RENAME TO preferences")
}
}

View File

@@ -0,0 +1,11 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
object Migration2To3 : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE history ADD COLUMN scroll REAL NOT NULL DEFAULT 0")
}
}

View File

@@ -0,0 +1,11 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
object Migration3To4 : Migration(3, 4) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS tracks (manga_id INTEGER NOT NULL, chapters_total INTEGER NOT NULL, last_chapter_id INTEGER NOT NULL, chapters_new INTEGER NOT NULL, last_check INTEGER NOT NULL, last_notified_id INTEGER NOT NULL, PRIMARY KEY(manga_id), FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )")
}
}

View File

@@ -0,0 +1,11 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
object Migration4To5 : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN sort_key INTEGER NOT NULL DEFAULT 0")
}
}

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
object Migration5To6 : Migration(5, 6) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS track_logs (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, manga_id INTEGER NOT NULL, chapters TEXT NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE)")
database.execSQL("CREATE INDEX IF NOT EXISTS index_track_logs_manga_id ON track_logs (manga_id)")
}
}

View File

@@ -1,5 +1,3 @@
package org.koitharu.kotatsu.core.exceptions
import java.lang.NullPointerException
class MangaNotFoundException(s: String? = null) : RuntimeException(s)

View File

@@ -9,11 +9,6 @@ data class AppVersion(
val name: String,
val url: String,
val apkSize: Long,
val apkUrl: String
) : Parcelable {
fun isGreaterThen(version: String) {
val thisParts = name.substringBeforeLast('-').split('.')
val parts = version.substringBeforeLast('-').split('.')
}
}
val apkUrl: String,
val description: String
) : Parcelable

View File

@@ -22,7 +22,8 @@ class GithubRepository : KoinComponent {
url = json.getString("html_url"),
name = json.getString("name").removePrefix("v"),
apkSize = asset.getLong("size"),
apkUrl = asset.getString("browser_download_url")
apkUrl = asset.getString("browser_download_url"),
description = json.getString("body")
)
}
}

View File

@@ -15,6 +15,7 @@ import java.util.zip.ZipFile
class CbzFetcher : Fetcher<Uri> {
@Suppress("BlockingMethodInNonBlockingContext")
override suspend fun fetch(
pool: BitmapPool,
data: Uri,

View File

@@ -37,7 +37,7 @@ class SetCookieCache : CookieCache {
override fun iterator(): MutableIterator<Cookie> = SetCookieCacheIterator()
private inner class SetCookieCacheIterator internal constructor() : MutableIterator<Cookie> {
private inner class SetCookieCacheIterator : MutableIterator<Cookie> {
private val iterator = cookies.iterator()

View File

@@ -41,27 +41,24 @@ class SharedPrefsCookiePersistor(private val sharedPreferences: SharedPreference
return cookies
}
@SuppressLint("ApplySharedPref")
override fun saveAll(cookies: Collection<Cookie>) {
val editor = sharedPreferences.edit()
for (cookie in cookies) {
editor.putString(createCookieKey(cookie), SerializableCookie().encode(cookie))
}
editor.commit()
editor.apply()
}
@SuppressLint("ApplySharedPref")
override fun removeAll(cookies: Collection<Cookie>) {
val editor = sharedPreferences.edit()
for (cookie in cookies) {
editor.remove(createCookieKey(cookie))
}
editor.commit()
editor.apply()
}
@SuppressLint("ApplySharedPref")
override fun clear() {
sharedPreferences.edit().clear().commit()
sharedPreferences.edit().clear().apply()
}
private companion object {

View File

@@ -8,5 +8,6 @@ import java.util.*
data class FavouriteCategory(
val id: Long,
val title: String,
val sortKey: Int,
val createdAt: Date
) : Parcelable

View File

@@ -9,5 +9,6 @@ data class MangaHistory(
val createdAt: Date,
val updatedAt: Date,
val chapterId: Long,
val page: Int
val page: Int,
val scroll: Int
) : Parcelable

View File

@@ -18,6 +18,10 @@ enum class MangaSource(
MINTMANGA("MintManga", "ru", MintMangaRepository::class.java),
SELFMANGA("SelfManga", "ru", SelfMangaRepository::class.java),
MANGACHAN("Манга-тян", "ru", MangaChanRepository::class.java),
DESUME("Desu.me", "ru", DesuMeRepository::class.java),
HENCHAN("Хентай-тян", "ru", HenChanRepository::class.java),
YAOICHAN("Яой-тян", "ru", YaoiChanRepository::class.java)
YAOICHAN("Яой-тян", "ru", YaoiChanRepository::class.java),
MANGATOWN("MangaTown", "en", MangaTownRepository::class.java),
MANGALIB("MangaLib", "ru", MangaLibRepository::class.java)
// HENTAILIB("HentaiLib", "ru", HentaiLibRepository::class.java)
}

View File

@@ -0,0 +1,14 @@
package org.koitharu.kotatsu.core.model
import android.os.Parcelable
import kotlinx.android.parcel.Parcelize
import java.util.*
@Parcelize
data class MangaTracking (
val manga: Manga,
val knownChaptersCount: Int,
val lastChapterId: Long,
val lastNotifiedChapterId: Long,
val lastCheck: Date?
): Parcelable

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.core.model
import android.os.Parcelable
import kotlinx.android.parcel.Parcelize
import java.util.*
@Parcelize
data class TrackingLogItem (
val id: Long,
val manga: Manga,
val chapters: List<String>,
val createdAt: Date
): Parcelable

View File

@@ -1,7 +1,10 @@
package org.koitharu.kotatsu.core.parser
import android.annotation.SuppressLint
import android.content.Context
import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.collection.ArraySet
import androidx.core.net.toFile
import androidx.core.net.toUri
import org.koin.core.KoinComponent
@@ -14,6 +17,7 @@ import org.koitharu.kotatsu.utils.AlphanumComparator
import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.readText
import org.koitharu.kotatsu.utils.ext.safe
import org.koitharu.kotatsu.utils.ext.sub
import java.io.File
import java.util.*
import java.util.zip.ZipEntry
@@ -29,8 +33,8 @@ class LocalMangaRepository : MangaRepository, KoinComponent {
sortOrder: SortOrder?,
tag: MangaTag?
): List<Manga> {
val files = context.getExternalFilesDirs("manga")
.flatMap { x -> x?.listFiles(CbzFilter())?.toList().orEmpty() }
val files = getAvailableStorageDirs(context)
.flatMap { x -> x.listFiles(CbzFilter())?.toList().orEmpty() }
return files.mapNotNull { x -> safe { getFromFile(x) } }
}
@@ -38,80 +42,114 @@ class LocalMangaRepository : MangaRepository, KoinComponent {
getFromFile(Uri.parse(manga.url).toFile())
} else manga
@Suppress("BlockingMethodInNonBlockingContext")
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val file = Uri.parse(chapter.url).toFile()
val uri = Uri.parse(chapter.url)
val file = uri.toFile()
val zip = ZipFile(file)
val pattern = zip.getEntry(MangaZip.INDEX_ENTRY)?.let(zip::readText)?.let(::MangaIndex)
?.getChapterNamesPattern(chapter)
val entries = if (pattern != null) {
zip.entries().asSequence()
.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) }
val index = zip.getEntry(MangaZip.INDEX_ENTRY)?.let(zip::readText)?.let(::MangaIndex)
var entries = zip.entries().asSequence()
entries = if (index != null) {
val pattern = index.getChapterNamesPattern(chapter)
entries.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) }
} else {
zip.entries().asSequence().filter { x -> !x.isDirectory }
}.toList().sortedWith(compareBy(AlphanumComparator()) { x -> x.name })
return entries.map { x ->
val uri = zipUri(file, x.name)
MangaPage(
id = uri.longHashCode(),
url = uri,
source = MangaSource.LOCAL
)
val parent = uri.fragment.orEmpty()
entries.filter { x ->
!x.isDirectory && x.name.substringBeforeLast(
File.separatorChar,
""
) == parent
}
}
return entries
.toList()
.sortedWith(compareBy(AlphanumComparator()) { x -> x.name })
.map { x ->
val entryUri = zipUri(file, x.name)
MangaPage(
id = entryUri.longHashCode(),
url = entryUri,
source = MangaSource.LOCAL
)
}
}
fun delete(manga: Manga): Boolean {
val file = Uri.parse(manga.url).toFile()
return file.delete()
}
fun getFromFile(file: File): Manga {
val zip = ZipFile(file)
@SuppressLint("DefaultLocale")
fun getFromFile(file: File): Manga = ZipFile(file).use { zip ->
val fileUri = file.toUri().toString()
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
val index = entry?.let(zip::readText)?.let(::MangaIndex)
return index?.let {
it.getMangaInfo()?.let { x ->
x.copy(
source = MangaSource.LOCAL,
url = fileUri,
coverUrl = zipUri(
file,
entryName = it.getCoverEntry()
?: findFirstEntry(zip.entries())?.name.orEmpty()
),
chapters = x.chapters?.map { c -> c.copy(url = fileUri) }
)
}
} ?: run {
val title = file.nameWithoutExtension.replace("_", " ").capitalize()
Manga(
id = file.absolutePath.longHashCode(),
title = title,
url = fileUri,
val info = index?.getMangaInfo()
if (index != null && info != null) {
return info.copy(
source = MangaSource.LOCAL,
coverUrl = zipUri(file, findFirstEntry(zip.entries())?.name.orEmpty()),
chapters = listOf(
MangaChapter(
id = file.absolutePath.longHashCode(),
url = fileUri,
number = 1,
source = MangaSource.LOCAL,
name = title
)
)
url = fileUri,
coverUrl = zipUri(
file,
entryName = index.getCoverEntry()
?: findFirstEntry(zip.entries(), isImage = true)?.name.orEmpty()
),
chapters = info.chapters?.map { c -> c.copy(url = fileUri) }
)
}
// fallback
val title = file.nameWithoutExtension.replace("_", " ").capitalize()
val chapters = ArraySet<String>()
for (x in zip.entries()) {
if (!x.isDirectory) {
chapters += x.name.substringBeforeLast(File.separatorChar, "")
}
}
val uriBuilder = file.toUri().buildUpon()
Manga(
id = file.absolutePath.longHashCode(),
title = title,
url = fileUri,
source = MangaSource.LOCAL,
coverUrl = zipUri(file, findFirstEntry(zip.entries(), isImage = true)?.name.orEmpty()),
chapters = chapters.sortedWith(AlphanumComparator()).mapIndexed { i, s ->
MangaChapter(
id = "$i$s".longHashCode(),
name = if (s.isEmpty()) title else s,
number = i + 1,
source = MangaSource.LOCAL,
url = uriBuilder.fragment(s).build().toString()
)
}
)
}
fun getRemoteManga(localManga: Manga): Manga? {
val file = safe {
Uri.parse(localManga.url).toFile()
} ?: return null
val zip = ZipFile(file)
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
val index = entry?.let(zip::readText)?.let(::MangaIndex) ?: return null
return index.getMangaInfo()
}
private fun zipUri(file: File, entryName: String) =
Uri.fromParts("cbz", file.path, entryName).toString()
private fun findFirstEntry(entries: Enumeration<out ZipEntry>): ZipEntry? {
private fun findFirstEntry(entries: Enumeration<out ZipEntry>, isImage: Boolean): ZipEntry? {
val list = entries.toList()
.filterNot { it.isDirectory }
.sortedWith(compareBy(AlphanumComparator()) { x -> x.name })
return list.firstOrNull()
return if (isImage) {
val map = MimeTypeMap.getSingleton()
list.firstOrNull {
map.getMimeTypeFromExtension(it.name.substringAfterLast('.'))
?.startsWith("image/") == true
}
} else {
list.firstOrNull()
}
}
override val sortOrders = emptySet<SortOrder>()
@@ -122,9 +160,24 @@ class LocalMangaRepository : MangaRepository, KoinComponent {
companion object {
private const val DIR_NAME = "manga"
fun isFileSupported(name: String): Boolean {
val ext = name.substringAfterLast('.').toLowerCase(Locale.ROOT)
return ext == "cbz" || ext == "zip"
}
fun getAvailableStorageDirs(context: Context): List<File> {
val result = ArrayList<File>(5)
result += context.filesDir.sub(DIR_NAME)
result += context.getExternalFilesDirs(DIR_NAME)
return result.distinctBy { it.canonicalPath }.filter { it.exists() || it.mkdir() }
}
fun getFallbackStorageDir(context: Context): File? {
return context.getExternalFilesDir(DIR_NAME) ?: context.filesDir.sub(DIR_NAME).takeIf {
(it.exists() || it.mkdir()) && it.canWrite()
}
}
}
}

View File

@@ -1,18 +1,15 @@
package org.koitharu.kotatsu.core.parser
import org.koin.core.KoinComponent
import org.koin.core.inject
import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.core.model.SortOrder
import org.koitharu.kotatsu.domain.MangaLoaderContext
abstract class RemoteMangaRepository : MangaRepository, KoinComponent {
abstract class RemoteMangaRepository(protected val loaderContext: MangaLoaderContext) : MangaRepository {
protected abstract val source: MangaSource
protected val loaderContext by inject<MangaLoaderContext>()
protected val conf by lazy(LazyThreadSafetyMode.NONE) {
loaderContext.getSettings(source)
}

View File

@@ -0,0 +1,26 @@
package org.koitharu.kotatsu.core.parser
import android.annotation.SuppressLint
import android.os.Build
import okhttp3.Interceptor
import org.koitharu.kotatsu.BuildConfig
import java.util.*
@SuppressLint("ConstantLocale")
object UserAgentInterceptor : Interceptor {
private val userAgent = "Kotatsu/%s (Android %s; %s; %s %s; %s)".format(
BuildConfig.VERSION_NAME,
Build.VERSION.RELEASE,
Build.MODEL,
Build.BRAND,
Build.DEVICE,
Locale.getDefault().language
)
override fun intercept(chain: Interceptor.Chain) = chain.proceed(
chain.request().newBuilder()
.header("User-Agent", userAgent)
.build()
)
}

View File

@@ -4,9 +4,12 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.domain.MangaLoaderContext
import org.koitharu.kotatsu.utils.ext.*
abstract class ChanRepository : RemoteMangaRepository() {
abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(
loaderContext
) {
protected abstract val defaultDomain: String
@@ -20,7 +23,12 @@ abstract class ChanRepository : RemoteMangaRepository() {
): List<Manga> {
val domain = conf.getDomain(defaultDomain)
val url = when {
query != null -> "https://$domain/?do=search&subaction=search&story=${query.urlEncoded()}"
!query.isNullOrEmpty() -> {
if (offset != 0) {
return emptyList()
}
"https://$domain/?do=search&subaction=search&story=${query.urlEncoded()}"
}
tag != null -> "https://$domain/tags/${tag.key}&n=${getSortKey2(sortOrder)}?offset=$offset"
else -> "https://$domain/${getSortKey(sortOrder)}?offset=$offset"
}
@@ -93,7 +101,7 @@ abstract class ChanRepository : RemoteMangaRepository() {
val json = data.substring(pos).substringAfter('[').substringBefore(';')
.substringBeforeLast(']')
return json.split(",").mapNotNull {
it.trim().removeSurrounding('"').takeUnless(String::isBlank)
it.trim().removeSurrounding('"','\'').takeUnless(String::isBlank)
}.map { url ->
MangaPage(
id = url.longHashCode(),

View File

@@ -0,0 +1,141 @@
package org.koitharu.kotatsu.core.parser.site
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.domain.MangaLoaderContext
import org.koitharu.kotatsu.utils.ext.map
import org.koitharu.kotatsu.utils.ext.mapIndexed
import org.koitharu.kotatsu.utils.ext.parseHtml
import org.koitharu.kotatsu.utils.ext.parseJson
class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
override val source = MangaSource.DESUME
override val sortOrders = setOf(
SortOrder.UPDATED,
SortOrder.POPULARITY,
SortOrder.NEWEST,
SortOrder.ALPHABETICAL
)
override suspend fun getList(
offset: Int,
query: String?,
sortOrder: SortOrder?,
tag: MangaTag?
): List<Manga> {
val domain = conf.getDomain(DOMAIN)
val url = buildString {
append("https://")
append(domain)
append("/manga/api/?limit=20&order=")
append(getSortKey(sortOrder))
append("&page=")
append((offset / 20) + 1)
if (tag != null) {
append("&genres=")
append(tag.key)
}
if (query != null) {
append("&search=")
append(query)
}
}
val json = loaderContext.httpGet(url).parseJson().getJSONArray("response")
?: throw ParseException("Invalid response")
val total = json.length()
val list = ArrayList<Manga>(total)
for (i in 0 until total) {
val jo = json.getJSONObject(i)
val cover = jo.getJSONObject("image")
list += Manga(
url = jo.getString("url"),
source = MangaSource.DESUME,
title = jo.getString("russian"),
altTitle = jo.getString("name"),
coverUrl = cover.getString("preview"),
largeCoverUrl = cover.getString("original"),
state = when {
jo.getInt("ongoing") == 1 -> MangaState.ONGOING
else -> null
},
rating = jo.getDouble("score").toFloat().coerceIn(0f, 1f),
id = ID_MASK + jo.getLong("id"),
description = jo.getString("description")
)
}
return list
}
override suspend fun getDetails(manga: Manga): Manga {
val domain = conf.getDomain(DOMAIN)
val url = "https://$domain/manga/api/${manga.id - ID_MASK}"
val json = loaderContext.httpGet(url).parseJson().getJSONObject("response")
?: throw ParseException("Invalid response")
return manga.copy(
tags = json.getJSONArray("genres").map {
MangaTag(
key = it.getString("text"),
title = it.getString("russian"),
source = manga.source
)
}.toSet(),
description = json.getString("description"),
chapters = json.getJSONObject("chapters").getJSONArray("list").mapIndexed { i, it ->
val chid = it.getLong("id")
MangaChapter(
id = ID_MASK + chid,
source = manga.source,
url = "$url/chapter/$chid",
name = it.optString("title", "${manga.title} #${it.getDouble("ch")}"),
number = i + 1
)
}
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val json = loaderContext.httpGet(chapter.url).parseJson().getJSONObject("response")
?: throw ParseException("Invalid response")
return json.getJSONObject("pages").getJSONArray("list").map {
MangaPage(
id = it.getLong("id"),
source = chapter.source,
url = it.getString("img")
)
}
}
override suspend fun getTags(): Set<MangaTag> {
val domain = conf.getDomain(DOMAIN)
val doc = loaderContext.httpGet("https://$domain/manga/").parseHtml()
val root = doc.body().getElementById("animeFilter").selectFirst(".catalog-genres")
return root.select("li").map {
MangaTag(
source = source,
key = it.selectFirst("input").attr("data-genre"),
title = it.selectFirst("label").text()
)
}.toSet()
}
override fun onCreatePreferences() = setOf(R.string.key_parser_domain)
private fun getSortKey(sortOrder: SortOrder?) =
when (sortOrder) {
SortOrder.ALPHABETICAL -> "name"
SortOrder.POPULARITY -> "popular"
SortOrder.UPDATED -> "updated"
SortOrder.NEWEST -> "id"
else -> "updated"
}
private companion object {
private const val ID_MASK = 1000
private const val DOMAIN = "desu.me"
}
}

View File

@@ -4,9 +4,11 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.domain.MangaLoaderContext
import org.koitharu.kotatsu.utils.ext.*
abstract class GroupleRepository : RemoteMangaRepository() {
abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
RemoteMangaRepository(loaderContext) {
protected abstract val defaultDomain: String
@@ -26,9 +28,13 @@ abstract class GroupleRepository : RemoteMangaRepository() {
val doc = when {
!query.isNullOrEmpty() -> loaderContext.httpPost(
"https://$domain/search",
mapOf("q" to query, "offset" to offset.toString())
mapOf("q" to query.urlEncoded(), "offset" to offset.toString())
)
tag == null -> loaderContext.httpGet(
"https://$domain/list?sortType=${getSortKey(
sortOrder
)}&offset=$offset"
)
tag == null -> loaderContext.httpGet("https://$domain/list?sortType=${getSortKey(sortOrder)}&offset=$offset")
else -> loaderContext.httpGet(
"https://$domain/list/genre/${tag.key}?sortType=${getSortKey(
sortOrder
@@ -85,19 +91,29 @@ abstract class GroupleRepository : RemoteMangaRepository() {
override suspend fun getDetails(manga: Manga): Manga {
val domain = conf.getDomain(defaultDomain)
val doc = loaderContext.httpGet(manga.url).parseHtml()
val root = doc.body().getElementById("mangaBox") ?: throw ParseException("Cannot find root")
val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent")
?: throw ParseException("Cannot find root")
return manga.copy(
description = root.selectFirst("div.manga-description")?.html(),
largeCoverUrl = root.selectFirst("div.subject-cower")?.selectFirst("img")?.attr(
"data-full"
),
tags = manga.tags + root.select("div.subject-meta").select("span.elem_genre ")
.mapNotNull {
val a = it.selectFirst("a.element-link") ?: return@mapNotNull null
MangaTag(
title = a.text(),
key = a.attr("href").substringAfterLast('/'),
source = source
)
},
chapters = root.selectFirst("div.chapters-link")?.selectFirst("table")
?.select("a")?.asReversed()?.mapIndexedNotNull { i, a ->
val href =
a.attr("href")?.withDomain(domain) ?: return@mapIndexedNotNull null
MangaChapter(
id = href.longHashCode(),
name = a.ownText(),
name = a.ownText().removePrefix(manga.title).trim(),
number = i + 1,
url = href,
source = source

View File

@@ -4,13 +4,15 @@ import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.domain.MangaLoaderContext
import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.parseHtml
import org.koitharu.kotatsu.utils.ext.withDomain
class HenChanRepository : ChanRepository() {
class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(loaderContext) {
override val defaultDomain = "h-chan.me"
override val defaultDomain = "henchan.pro"
override val source = MangaSource.HENCHAN
override suspend fun getDetails(manga: Manga): Manga {
@@ -18,22 +20,27 @@ class HenChanRepository : ChanRepository() {
val doc = loaderContext.httpGet(manga.url).parseHtml()
val root =
doc.body().getElementById("dle-content") ?: throw ParseException("Cannot find root")
val readLink = manga.url.replace("manga", "online")
return manga.copy(
description = root.getElementById("description")?.html()?.substringBeforeLast("<div"),
largeCoverUrl = root.getElementById("cover")?.attr("src")?.withDomain(domain),
chapters = root.getElementById("right").select("table.table_cha").flatMap { table ->
table.select("div.manga2")
}.mapNotNull { it.selectFirst("a") }.reversed().mapIndexedNotNull { i, a ->
val href = a.attr("href")
?.withDomain(domain) ?: return@mapIndexedNotNull null
MangaChapter(
id = href.longHashCode(),
name = a.text().trim(),
number = i + 1,
url = href,
tags = root.selectFirst("div.sidetags")?.select("li.sidetag")?.map {
val a = it.children().last()
MangaTag(
title = a.text(),
key = a.attr("href").substringAfterLast('/'),
source = source
)
}
}?.toSet() ?: manga.tags,
chapters = listOf(
MangaChapter(
id = readLink.longHashCode(),
url = readLink,
source = source,
number = 1,
name = manga.title
)
)
)
}
}

View File

@@ -0,0 +1,10 @@
package org.koitharu.kotatsu.core.parser.site
/*
class HentaiLibRepository(loaderContext: MangaLoaderContext) : MangaLibRepository(loaderContext) {
protected override val defaultDomain = "hentailib.me"
override val source = MangaSource.HENTAILIB
}*/

View File

@@ -1,8 +1,9 @@
package org.koitharu.kotatsu.core.parser.site
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.domain.MangaLoaderContext
class MangaChanRepository : ChanRepository() {
class MangaChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(loaderContext) {
override val defaultDomain = "manga-chan.me"
override val source = MangaSource.MANGACHAN

View File

@@ -0,0 +1,223 @@
package org.koitharu.kotatsu.core.parser.site
import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.domain.MangaLoaderContext
import org.koitharu.kotatsu.utils.ext.*
open class MangaLibRepository(loaderContext: MangaLoaderContext) :
RemoteMangaRepository(loaderContext) {
protected open val defaultDomain = "mangalib.me"
override val source = MangaSource.MANGALIB
override val sortOrders = setOf(
SortOrder.RATING,
SortOrder.ALPHABETICAL,
SortOrder.POPULARITY,
SortOrder.UPDATED,
SortOrder.NEWEST
)
override suspend fun getList(
offset: Int,
query: String?,
sortOrder: SortOrder?,
tag: MangaTag?
): List<Manga> {
if (!query.isNullOrEmpty()) {
return search(query)
}
val domain = conf.getDomain(defaultDomain)
val page = (offset / 60f).toIntUp()
val url = buildString {
append("https://")
append(domain)
append("/manga-list?dir=")
append(getSortKey(sortOrder))
append("&page=")
append(page)
if (tag != null) {
append("&includeGenres[]=")
append(tag.key)
}
}
val doc = loaderContext.httpGet(url).parseHtml()
val root = doc.body().getElementById("manga-list") ?: throw ParseException("Root not found")
val items = root.selectFirst("div.media-cards-grid").select("div.media-card-wrap")
return items.mapNotNull { card ->
val a = card.selectFirst("a.media-card") ?: return@mapNotNull null
val href = a.attr("href").withDomain(domain)
Manga(
id = href.longHashCode(),
title = card.selectFirst("h3").text(),
coverUrl = a.attr("data-src").withDomain(domain),
altTitle = null,
author = null,
rating = Manga.NO_RATING,
url = href,
tags = emptySet(),
state = null,
source = source
)
}
}
override fun onCreatePreferences() = setOf(R.string.key_parser_domain)
override suspend fun getDetails(manga: Manga): Manga {
val doc = loaderContext.httpGet(manga.url + "?section=info").parseHtml()
val root = doc.body().getElementById("main-page") ?: throw ParseException("Root not found")
val title = root.selectFirst("div.media-header__wrap")?.children()
val info = root.selectFirst("div.media-content")
val chaptersDoc = loaderContext.httpGet(manga.url + "?section=chapters").parseHtml()
val scripts = chaptersDoc.body().select("script")
var chapters: ArrayList<MangaChapter>? = null
scripts@ for (script in scripts) {
val raw = script.html().lines()
for (line in raw) {
if (line.startsWith("window.__CHAPTERS_DATA__")) {
val json = JSONObject(line.substringAfter('=').substringBeforeLast(';'))
val list = json.getJSONArray("list")
val total = list.length()
chapters = ArrayList(total)
for (i in 0 until total) {
val item = list.getJSONObject(i)
val url = buildString {
append(manga.url)
append("/v")
append(item.getInt("chapter_volume"))
append("/c")
append(item.getString("chapter_number"))
append('/')
append(item.getJSONArray("teams").getJSONObject(0).getString("slug"))
}
var name = item.getString("chapter_name")
if (name.isNullOrBlank() || name == "null") {
name = "Том " + item.getInt("chapter_volume") +
" Глава " + item.getString("chapter_number")
}
chapters.add(
MangaChapter(
id = url.longHashCode(),
url = url,
source = source,
number = total - i,
name = name
)
)
}
chapters.reverse()
break@scripts
}
}
}
return manga.copy(
title = title?.getOrNull(0)?.text()?.takeUnless(String::isBlank) ?: manga.title,
altTitle = title?.getOrNull(1)?.text()?.substringBefore('/')?.trim(),
rating = root.selectFirst("div.media-stats-item__score")
?.selectFirst("span")
?.text()?.toFloatOrNull()?.div(5f) ?: manga.rating,
author = info.getElementsMatchingOwnText("Автор").firstOrNull()
?.nextElementSibling()?.text() ?: manga.author,
tags = info.getElementsMatchingOwnText("Жанры")?.firstOrNull()
?.nextElementSibling()?.select("a")?.mapNotNull { a ->
MangaTag(
title = a.text().capitalize(),
key = a.attr("href").substringAfterLast('='),
source = source
)
}?.toSet() ?: manga.tags,
description = info.getElementsMatchingOwnText("Описание")?.firstOrNull()
?.nextElementSibling()?.html(),
chapters = chapters
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val doc = loaderContext.httpGet(chapter.url).parseHtml()
val scripts = doc.head().select("script")
val pg = doc.body().getElementById("pg").html().substringAfter('=').substringBeforeLast(';')
val pages = JSONArray(pg)
for (script in scripts) {
val raw = script.html().trim()
if (raw.startsWith("window.__info")) {
val json = JSONObject(raw.substringAfter('=').substringBeforeLast(';'))
val domain = json.getJSONObject("servers").run {
getStringOrNull("main") ?: getString(
json.getJSONObject("img").getString("server")
)
}
val url = json.getJSONObject("img").getString("url")
return pages.map { x ->
val pageUrl = "$domain$url${x.getString("u")}"
MangaPage(
id = pageUrl.longHashCode(),
source = source,
url = pageUrl
)
}
}
}
throw ParseException("Script with info not found")
}
override suspend fun getTags(): Set<MangaTag> {
val domain = conf.getDomain(defaultDomain)
val url = "https://$domain/manga-list"
val doc = loaderContext.httpGet(url).parseHtml()
val scripts = doc.body().select("script")
for (script in scripts) {
val raw = script.html().trim()
if (raw.startsWith("window.__DATA")) {
val json = JSONObject(raw.substringAfter('=').substringBeforeLast(';'))
val genres = json.getJSONObject("filters").getJSONArray("genres")
val result = HashSet<MangaTag>(genres.length())
for (x in genres) {
result += MangaTag(
source = source,
key = x.getInt("id").toString(),
title = x.getString("name").capitalize()
)
}
return result
}
}
throw ParseException("Script with genres not found")
}
private fun getSortKey(sortOrder: SortOrder?) = when (sortOrder) {
SortOrder.RATING -> "desc&sort=rate"
SortOrder.ALPHABETICAL -> "asc&sort=name"
SortOrder.POPULARITY -> "desc&sort=views"
SortOrder.UPDATED -> "desc&sort=last_chapter_at"
SortOrder.NEWEST -> "desc&sort=created_at"
else -> "desc&sort=last_chapter_at"
}
private suspend fun search(query: String): List<Manga> {
val domain = conf.getDomain(defaultDomain)
val json = loaderContext.httpGet("https://$domain/search?query=${query.urlEncoded()}")
.parseJsonArray()
return json.map { jo ->
val url = "https://$domain/${jo.getString("slug")}"
Manga(
id = url.longHashCode(),
url = url,
title = jo.getString("rus_name"),
altTitle = jo.getString("name"),
author = null,
tags = emptySet(),
rating = Manga.NO_RATING,
state = null,
source = source,
coverUrl = "https://$domain/uploads/cover/${jo.getString("slug")}/${jo.getString("cover")}/cover_thumb.jpg"
)
}
}
}

View File

@@ -0,0 +1,178 @@
package org.koitharu.kotatsu.core.parser.site
import org.intellij.lang.annotations.Language
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.domain.MangaLoaderContext
import org.koitharu.kotatsu.utils.ext.*
import java.util.*
class MangaTownRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
override val source = MangaSource.MANGATOWN
override val sortOrders = setOf(
SortOrder.ALPHABETICAL,
SortOrder.RATING,
SortOrder.POPULARITY,
SortOrder.UPDATED
)
override suspend fun getList(
offset: Int,
query: String?,
sortOrder: SortOrder?,
tag: MangaTag?
): List<Manga> {
val domain = conf.getDomain(DOMAIN)
val ssl = conf.isUseSsl(false)
val scheme = if (ssl) "https" else "http"
val sortKey = when (sortOrder) {
SortOrder.ALPHABETICAL -> "?name.az"
SortOrder.RATING -> "?rating.za"
SortOrder.UPDATED -> "?last_chapter_time.za"
else -> ""
}
val page = (offset / 30) + 1
val url = when {
!query.isNullOrEmpty() -> {
if (offset != 0) {
return emptyList()
}
"$scheme://$domain/search?name=${query.urlEncoded()}"
}
tag != null -> "$scheme://$domain/directory/${tag.key}/$page.htm$sortKey"
else -> "$scheme://$domain/directory/$page.htm$sortKey"
}
val doc = loaderContext.httpGet(url).parseHtml()
val root = doc.body().selectFirst("ul.manga_pic_list")
?: throw ParseException("Root not found")
return root.select("li").mapNotNull { li ->
val a = li.selectFirst("a.manga_cover")
val href = a.attr("href").withDomain(domain, ssl)
val views = li.select("p.view")
val status = views.findOwnText { x -> x.startsWith("Status:") }
?.substringAfter(':')?.trim()?.toLowerCase(Locale.ROOT)
Manga(
id = href.longHashCode(),
title = a.attr("title"),
coverUrl = a.selectFirst("img").attr("src"),
source = MangaSource.MANGATOWN,
altTitle = null,
rating = li.selectFirst("p.score")?.selectFirst("b")
?.ownText()?.toFloatOrNull()?.div(5f) ?: Manga.NO_RATING,
largeCoverUrl = null,
author = views.findText { x -> x.startsWith("Author:") }?.substringAfter(':')
?.trim(),
state = when (status) {
"ongoing" -> MangaState.ONGOING
"completed" -> MangaState.FINISHED
else -> null
},
tags = li.selectFirst("p.keyWord")?.select("a")?.mapNotNull tags@{ x ->
MangaTag(
title = x.attr("title"),
key = x.attr("href").parseTagKey() ?: return@tags null,
source = MangaSource.MANGATOWN
)
}?.toSet().orEmpty(),
url = href
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val domain = conf.getDomain(DOMAIN)
val ssl = conf.isUseSsl(false)
val doc = loaderContext.httpGet(manga.url).parseHtml()
val root = doc.body().selectFirst("section.main")
?.selectFirst("div.article_content") ?: throw ParseException("Cannot find root")
val info = root.selectFirst("div.detail_info").selectFirst("ul")
val chaptersList = root.selectFirst("div.chapter_content")
?.selectFirst("ul.chapter_list")?.select("li")?.asReversed()
return manga.copy(
tags = manga.tags + info.select("li").find { x ->
x.selectFirst("b")?.ownText() == "Genre(s):"
}?.select("a")?.mapNotNull { a ->
MangaTag(
title = a.attr("title"),
key = a.attr("href").parseTagKey() ?: return@mapNotNull null,
source = MangaSource.MANGATOWN
)
}.orEmpty(),
description = info.getElementById("show")?.ownText(),
chapters = chaptersList?.mapIndexedNotNull { i, li ->
val href = li.selectFirst("a").attr("href").withDomain(domain, ssl)
val name = li.select("span").filter { it.className().isEmpty() }.joinToString(" - ") { it.text() }.trim()
MangaChapter(
id = href.longHashCode(),
url = href,
source = MangaSource.MANGATOWN,
number = i + 1,
name = if (name.isEmpty()) "${manga.title} - ${i + 1}" else name
)
}
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val domain = conf.getDomain(DOMAIN)
val ssl = conf.isUseSsl(false)
val doc = loaderContext.httpGet(chapter.url).parseHtml()
val root = doc.body().selectFirst("div.page_select")
?: throw ParseException("Cannot find root")
return root.selectFirst("select").select("option").mapNotNull {
val href = it.attr("value").withDomain(domain, ssl)
if (href.endsWith("featured.html")) {
return@mapNotNull null
}
MangaPage(
id = href.longHashCode(),
url = href,
source = MangaSource.MANGATOWN
)
}
}
override suspend fun getPageFullUrl(page: MangaPage): String {
val domain = conf.getDomain(DOMAIN)
val ssl = conf.isUseSsl(false)
val doc = loaderContext.httpGet(page.url).parseHtml()
return doc.getElementById("image").attr("src").withDomain(domain, ssl)
}
override suspend fun getTags(): Set<MangaTag> {
val domain = conf.getDomain(DOMAIN)
val doc = loaderContext.httpGet("http://$domain/directory/").parseHtml()
val root = doc.body().selectFirst("aside.right")
.getElementsContainingOwnText("Genres")
.first()
.nextElementSibling()
return root.select("li").mapNotNull { li ->
val a = li.selectFirst("a") ?: return@mapNotNull null
val key = a.attr("href").parseTagKey()
if (key.isNullOrEmpty()) {
return@mapNotNull null
}
MangaTag(
source = MangaSource.MANGATOWN,
key = key,
title = a.text()
)
}.toSet()
}
override fun onCreatePreferences() = setOf(R.string.key_parser_domain, R.string.key_parser_ssl)
private fun String.parseTagKey() = split('/').findLast { TAG_REGEX matches it }
private companion object {
@Language("RegExp")
val TAG_REGEX = Regex("[^\\-]+-[^\\-]+-[^\\-]+-[^\\-]+-[^\\-]+-[^\\-]+")
const val DOMAIN = "www.mangatown.com"
}
}

View File

@@ -1,8 +1,9 @@
package org.koitharu.kotatsu.core.parser.site
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.domain.MangaLoaderContext
class MintMangaRepository : GroupleRepository() {
class MintMangaRepository(loaderContext: MangaLoaderContext) : GroupleRepository(loaderContext) {
override val source = MangaSource.MINTMANGA
override val defaultDomain: String = "mintmanga.live"

View File

@@ -1,9 +1,10 @@
package org.koitharu.kotatsu.core.parser.site
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.domain.MangaLoaderContext
class ReadmangaRepository : GroupleRepository() {
class ReadmangaRepository(loaderContext: MangaLoaderContext) : GroupleRepository(loaderContext) {
override val defaultDomain = "readmanga.me"
override val defaultDomain = "readmanga.live"
override val source = MangaSource.READMANGA_RU
}

View File

@@ -1,8 +1,9 @@
package org.koitharu.kotatsu.core.parser.site
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.domain.MangaLoaderContext
class SelfMangaRepository : GroupleRepository() {
class SelfMangaRepository(loaderContext: MangaLoaderContext) : GroupleRepository(loaderContext) {
override val defaultDomain = "selfmanga.ru"
override val source = MangaSource.SELFMANGA

View File

@@ -1,9 +1,40 @@
package org.koitharu.kotatsu.core.parser.site
import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.domain.MangaLoaderContext
import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.parseHtml
import org.koitharu.kotatsu.utils.ext.withDomain
class YaoiChanRepository : ChanRepository() {
class YaoiChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(loaderContext) {
override val source = MangaSource.YAOICHAN
override val defaultDomain = "yaoi-chan.me"
override suspend fun getDetails(manga: Manga): Manga {
val domain = conf.getDomain(defaultDomain)
val doc = loaderContext.httpGet(manga.url).parseHtml()
val root =
doc.body().getElementById("dle-content") ?: throw ParseException("Cannot find root")
return manga.copy(
description = root.getElementById("description")?.html()?.substringBeforeLast("<div"),
largeCoverUrl = root.getElementById("cover")?.attr("src")?.withDomain(domain),
chapters = root.select("table.table_cha").flatMap { table ->
table.select("div.manga")
}.mapNotNull { it.selectFirst("a") }.reversed().mapIndexedNotNull { i, a ->
val href = a.attr("href")
?.withDomain(domain) ?: return@mapIndexedNotNull null
MangaChapter(
id = href.longHashCode(),
name = a.text().trim(),
number = i + 1,
url = href,
source = source
)
}
)
}
}

View File

@@ -0,0 +1,6 @@
package org.koitharu.kotatsu.core.prefs
enum class AppSection {
LOCAL, FAVOURITES, HISTORY, FEED
}

View File

@@ -3,10 +3,14 @@ package org.koitharu.kotatsu.core.prefs
import android.content.Context
import android.content.SharedPreferences
import android.content.res.Resources
import android.provider.Settings
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.LocalMangaRepository
import org.koitharu.kotatsu.utils.delegates.prefs.*
import java.io.File
class AppSettings private constructor(resources: Resources, private val prefs: SharedPreferences) :
SharedPreferences by prefs {
@@ -22,6 +26,12 @@ class AppSettings private constructor(resources: Resources, private val prefs: S
ListMode.DETAILED_LIST
)
var defaultSection by EnumPreferenceDelegate(
AppSection::class.java,
resources.getString(R.string.key_app_section),
AppSection.HISTORY
)
val theme by StringIntPreferenceDelegate(
resources.getString(R.string.key_theme),
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
@@ -52,6 +62,31 @@ class AppSettings private constructor(resources: Resources, private val prefs: S
0L
)
val trackerNotifications by BoolPreferenceDelegate(
resources.getString(R.string.key_tracker_notifications),
true
)
var notificationSound by StringPreferenceDelegate(
resources.getString(R.string.key_notifications_sound),
Settings.System.DEFAULT_NOTIFICATION_URI.toString()
)
val notificationVibrate by BoolPreferenceDelegate(
resources.getString(R.string.key_notifications_vibrate),
false
)
val notificationLight by BoolPreferenceDelegate(
resources.getString(R.string.key_notifications_light),
true
)
val readerAnimation by BoolPreferenceDelegate(
resources.getString(R.string.key_reader_animation),
false
)
private var sourcesOrderStr by NullableStringPreferenceDelegate(resources.getString(R.string.key_sources_order))
var sourcesOrder: List<Int>
@@ -60,6 +95,26 @@ class AppSettings private constructor(resources: Resources, private val prefs: S
sourcesOrderStr = value.joinToString("|")
}
var hiddenSources by StringSetPreferenceDelegate(resources.getString(R.string.key_sources_hidden))
fun getStorageDir(context: Context): File? {
val value = prefs.getString(context.getString(R.string.key_local_storage), null)?.let {
File(it)
}?.takeIf { it.exists() && it.canWrite() }
return value ?: LocalMangaRepository.getFallbackStorageDir(context)
}
fun setStorageDir(context: Context, file: File?) {
val key = context.getString(R.string.key_local_storage)
prefs.edit {
if (file == null) {
remove(key)
} else {
putString(key, file.path)
}
}
}
fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
prefs.registerOnSharedPreferenceChangeListener(listener)
}

View File

@@ -0,0 +1,26 @@
package org.koitharu.kotatsu.core.prefs
import android.content.Context
import android.content.SharedPreferences
import org.koitharu.kotatsu.utils.delegates.prefs.LongPreferenceDelegate
class AppWidgetConfig private constructor(
private val prefs: SharedPreferences,
val widgetId: Int
) : SharedPreferences by prefs {
var categoryId by LongPreferenceDelegate(CATEGORY_ID, 0L)
companion object {
private const val CATEGORY_ID = "cat_id"
fun getInstance(context: Context, widgetId: Int) = AppWidgetConfig(
context.getSharedPreferences(
"appwidget_$widgetId",
Context.MODE_PRIVATE
), widgetId
)
}
}

View File

@@ -1,13 +1,6 @@
package org.koitharu.kotatsu.core.prefs
enum class ListMode(val id: Int) {
enum class ListMode {
LIST(0),
DETAILED_LIST(1),
GRID(2);
companion object {
fun valueOf(id: Int) = values().firstOrNull { it.id == id }
}
LIST, DETAILED_LIST, GRID;
}

View File

@@ -8,16 +8,20 @@ interface SourceConfig {
fun getDomain(defaultValue: String): String
fun isUseSsl(defaultValue: Boolean): Boolean
private class PrefSourceConfig(context: Context, source: MangaSource) : SourceConfig {
private val prefs = context.getSharedPreferences(source.name, Context.MODE_PRIVATE)
private val keyDomain = context.getString(R.string.key_parser_domain)
private val keySsl = context.getString(R.string.key_parser_ssl)
override fun getDomain(defaultValue: String) = prefs.getString(keyDomain, defaultValue)
?.takeUnless(String::isBlank)
?: defaultValue
override fun isUseSsl(defaultValue: Boolean) = prefs.getBoolean(keySsl, defaultValue)
}
companion object {

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.domain
import androidx.room.withTransaction
import org.koin.core.KoinComponent
import org.koin.core.inject
import org.koitharu.kotatsu.core.db.MangaDatabase
@@ -13,24 +14,33 @@ class MangaDataRepository : KoinComponent {
private val db: MangaDatabase by inject()
suspend fun savePreferences(mangaId: Long, mode: ReaderMode) {
db.preferencesDao().upsert(
MangaPrefsEntity(
mangaId = mangaId,
mode = mode.id
suspend fun savePreferences(manga: Manga, mode: ReaderMode) {
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
db.withTransaction {
db.tagsDao.upsert(tags)
db.mangaDao.upsert(MangaEntity.from(manga), tags)
db.preferencesDao.upsert(
MangaPrefsEntity(
mangaId = manga.id,
mode = mode.id
)
)
)
}
}
suspend fun getReaderMode(mangaId: Long): ReaderMode? {
return db.preferencesDao().find(mangaId)?.let { ReaderMode.valueOf(it.mode) }
return db.preferencesDao.find(mangaId)?.let { ReaderMode.valueOf(it.mode) }
}
suspend fun findMangaById(mangaId: Long): Manga? {
return db.mangaDao().find(mangaId)?.toManga()
return db.mangaDao.find(mangaId)?.toManga()
}
suspend fun storeManga(manga: Manga) {
db.mangaDao().upsert(MangaEntity.from(manga), manga.tags.map(TagEntity.Companion::fromMangaTag))
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
db.withTransaction {
db.tagsDao.upsert(tags)
db.mangaDao.upsert(MangaEntity.from(manga), tags)
}
}
}

View File

@@ -2,26 +2,69 @@ package org.koitharu.kotatsu.domain
import org.koin.core.KoinComponent
import org.koin.core.get
import org.koin.core.inject
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.LocalMangaRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import java.lang.ref.WeakReference
import java.util.*
object MangaProviderFactory : KoinComponent {
val sources: List<MangaSource>
get() {
val list = MangaSource.values().toList() - MangaSource.LOCAL
val order = get<AppSettings>().sourcesOrder
return list.sortedBy { x ->
val e = order.indexOf(x.ordinal)
if (e == -1) order.size + x.ordinal else e
private val loaderContext by inject<MangaLoaderContext>()
private val cache =
EnumMap<MangaSource, WeakReference<MangaRepository>>(MangaSource::class.java)
fun getSources(includeHidden: Boolean): List<MangaSource> {
val settings = get<AppSettings>()
val list = MangaSource.values().toList() - MangaSource.LOCAL
val order = settings.sourcesOrder
val hidden = settings.hiddenSources
val sorted = list.sortedBy { x ->
val e = order.indexOf(x.ordinal)
if (e == -1) order.size + x.ordinal else e
}
return if (includeHidden) {
sorted
} else {
sorted.filterNot { x ->
x.name in hidden
}
}
}
fun createLocal() = LocalMangaRepository()
fun createLocal(): LocalMangaRepository {
var instance = cache[MangaSource.LOCAL]?.get()
if (instance == null) {
synchronized(cache) {
instance = cache[MangaSource.LOCAL]?.get()
if (instance == null) {
instance = LocalMangaRepository()
cache[MangaSource.LOCAL] = WeakReference<MangaRepository>(instance)
}
}
}
return instance as LocalMangaRepository
}
@Throws(Throwable::class)
fun create(source: MangaSource): MangaRepository {
return source.cls.newInstance()
var instance = cache[source]?.get()
if (instance == null) {
synchronized(cache) {
instance = cache[source]?.get()
if (instance == null) {
instance = try {
source.cls.getDeclaredConstructor(MangaLoaderContext::class.java)
.newInstance(loaderContext)
} catch (e: NoSuchMethodException) {
source.cls.newInstance()
}
cache[source] = WeakReference(instance!!)
}
}
}
return instance!!
}
}

View File

@@ -0,0 +1,39 @@
package org.koitharu.kotatsu.domain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import org.koin.core.KoinComponent
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.SortOrder
import java.util.*
class MangaSearchRepository : KoinComponent {
fun globalSearch(query: String, batchSize: Int = 4): Flow<List<Manga>> = flow {
val sources = MangaProviderFactory.getSources(false)
val lists = EnumMap<MangaSource, List<Manga>>(MangaSource::class.java)
var i = 0
while (true) {
var isEmitted = false
for (source in sources) {
val list = lists.getOrPut(source) {
try {
MangaProviderFactory.create(source).getList(0, query, SortOrder.POPULARITY)
} catch (e: Throwable) {
e.printStackTrace()
emptyList<Manga>()
}
}
if (i < list.size) {
emit(list.subList(i, (i + batchSize).coerceAtMost(list.lastIndex)))
isEmitted = true
}
}
i += batchSize
if (!isEmitted) {
return@flow
}
}
}
}

View File

@@ -1,7 +1,9 @@
package org.koitharu.kotatsu.domain
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Size
import androidx.annotation.WorkerThread
import okhttp3.OkHttpClient
import okhttp3.Request
import org.koin.core.KoinComponent
@@ -12,6 +14,7 @@ import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.utils.ext.await
import org.koitharu.kotatsu.utils.ext.medianOrNull
import java.io.InputStream
import java.util.zip.ZipFile
object MangaUtils : KoinComponent {
@@ -19,17 +22,28 @@ object MangaUtils : KoinComponent {
* Automatic determine type of manga by page size
* @return ReaderMode.WEBTOON if page is wide
*/
@WorkerThread
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun determineReaderMode(pages: List<MangaPage>): ReaderMode? {
try {
val page = pages.medianOrNull() ?: return null
val url = MangaProviderFactory.create(page.source).getPageFullUrl(page)
val client = get<OkHttpClient>()
val request = Request.Builder()
.url(url)
.get()
.build()
val size = client.newCall(request).await().use {
getBitmapSize(it.body?.byteStream())
val uri = Uri.parse(url)
val size = if (uri.scheme == "cbz") {
val zip = ZipFile(uri.schemeSpecificPart)
val entry = zip.getEntry(uri.fragment)
zip.getInputStream(entry).use {
getBitmapSize(it)
}
} else {
val client = get<OkHttpClient>()
val request = Request.Builder()
.url(url)
.get()
.build()
client.newCall(request).await().use {
getBitmapSize(it.body?.byteStream())
}
}
return when {
size.width * 2 < size.height -> ReaderMode.WEBTOON

View File

@@ -1,5 +1,7 @@
package org.koitharu.kotatsu.domain.favourites
import androidx.collection.ArraySet
import androidx.room.withTransaction
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koin.core.KoinComponent
@@ -11,24 +13,38 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.Manga
import java.util.*
class FavouritesRepository : KoinComponent {
private val db: MangaDatabase by inject()
suspend fun getAllManga(): List<Manga> {
val entities = db.favouritesDao.findAll()
return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) }
}
suspend fun getAllManga(offset: Int): List<Manga> {
val entities = db.favouritesDao().findAll(offset, 20, "created_at")
val entities = db.favouritesDao.findAll(offset, 20)
return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) }
}
suspend fun getManga(categoryId: Long): List<Manga> {
val entities = db.favouritesDao.findAll(categoryId)
return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) }
}
suspend fun getManga(categoryId: Long, offset: Int): List<Manga> {
val entities = db.favouritesDao.findAll(categoryId, offset, 20)
return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) }
}
suspend fun getAllCategories(): List<FavouriteCategory> {
val entities = db.favouriteCategoriesDao().findAll("created_at")
val entities = db.favouriteCategoriesDao.findAll()
return entities.map { it.toFavouriteCategory() }
}
suspend fun getCategories(mangaId: Long): List<FavouriteCategory> {
val entities = db.favouritesDao().find(mangaId)?.categories
val entities = db.favouritesDao.find(mangaId)?.categories
return entities?.map { it.toFavouriteCategory() }.orEmpty()
}
@@ -36,40 +52,60 @@ class FavouritesRepository : KoinComponent {
val entity = FavouriteCategoryEntity(
title = title,
createdAt = System.currentTimeMillis(),
sortKey = db.favouriteCategoriesDao.getNextSortKey(),
categoryId = 0
)
val id = db.favouriteCategoriesDao().insert(entity)
val id = db.favouriteCategoriesDao.insert(entity)
notifyCategoriesChanged()
return entity.toFavouriteCategory(id)
}
suspend fun renameCategory(id: Long, title: String) {
db.favouriteCategoriesDao.update(id, title)
notifyCategoriesChanged()
}
suspend fun removeCategory(id: Long) {
db.favouriteCategoriesDao().delete(id)
db.favouriteCategoriesDao.delete(id)
notifyCategoriesChanged()
}
suspend fun reorderCategories(orderedIds: List<Long>) {
val dao = db.favouriteCategoriesDao
db.withTransaction {
for ((i, id) in orderedIds.withIndex()) {
dao.update(id, i)
}
}
notifyCategoriesChanged()
}
suspend fun addToCategory(manga: Manga, categoryId: Long) {
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
db.tagsDao().upsert(tags)
db.mangaDao().upsert(MangaEntity.from(manga), tags)
val entity = FavouriteEntity(manga.id, categoryId, System.currentTimeMillis())
db.favouritesDao().add(entity)
db.withTransaction {
db.tagsDao.upsert(tags)
db.mangaDao.upsert(MangaEntity.from(manga), tags)
val entity = FavouriteEntity(manga.id, categoryId, System.currentTimeMillis())
db.favouritesDao.add(entity)
}
notifyFavouritesChanged(manga.id)
}
suspend fun removeFromCategory(manga: Manga, categoryId: Long) {
db.favouritesDao().delete(categoryId, manga.id)
db.favouritesDao.delete(categoryId, manga.id)
notifyFavouritesChanged(manga.id)
}
companion object {
private val listeners = HashSet<OnFavouritesChangeListener>()
private val listeners = ArraySet<OnFavouritesChangeListener>()
fun subscribe(listener: OnFavouritesChangeListener) {
listeners += listener
}
fun unsubscribe(listener: OnFavouritesChangeListener) {
listeners += listener
listeners -= listener
}
private suspend fun notifyFavouritesChanged(mangaId: Long) {
@@ -77,5 +113,11 @@ class FavouritesRepository : KoinComponent {
listeners.forEach { x -> x.onFavouritesChanged(mangaId) }
}
}
private suspend fun notifyCategoriesChanged() {
withContext(Dispatchers.Main) {
listeners.forEach { x -> x.onCategoriesChanged() }
}
}
}
}

View File

@@ -3,4 +3,6 @@ package org.koitharu.kotatsu.domain.favourites
interface OnFavouritesChangeListener {
fun onFavouritesChanged(mangaId: Long)
fun onCategoriesChanged()
}

View File

@@ -2,5 +2,5 @@ package org.koitharu.kotatsu.domain.history
enum class ChapterExtra {
READ, CURRENT, UNREAD, NEW
READ, CURRENT, UNREAD, NEW, CHECKED
}

View File

@@ -1,5 +1,7 @@
package org.koitharu.kotatsu.domain.history
import androidx.collection.ArraySet
import androidx.room.withTransaction
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koin.core.KoinComponent
@@ -10,64 +12,74 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.domain.tracking.TrackingRepository
import java.util.*
class HistoryRepository : KoinComponent {
private val db: MangaDatabase by inject()
private val trackingRepository by lazy(::TrackingRepository)
suspend fun getList(offset: Int, limit: Int = 20): List<Manga> {
val entities = db.historyDao().findAll(offset, limit)
val entities = db.historyDao.findAll(offset, limit)
return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) }
}
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int) {
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int) {
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
db.tagsDao().upsert(tags)
db.mangaDao().upsert(MangaEntity.from(manga), tags)
db.historyDao().upsert(
HistoryEntity(
mangaId = manga.id,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(),
chapterId = chapterId,
page = page
db.withTransaction {
db.tagsDao.upsert(tags)
db.mangaDao.upsert(MangaEntity.from(manga), tags)
db.historyDao.upsert(
HistoryEntity(
mangaId = manga.id,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(),
chapterId = chapterId,
page = page,
scroll = scroll.toFloat() // we migrate to int, but decide to not update database
)
)
)
trackingRepository.upsert(manga)
}
notifyHistoryChanged()
}
suspend fun getOne(manga: Manga): MangaHistory? {
return db.historyDao().find(manga.id)?.let {
MangaHistory(
createdAt = Date(it.createdAt),
updatedAt = Date(it.updatedAt),
chapterId = it.chapterId,
page = it.page
)
}
return db.historyDao.find(manga.id)?.toMangaHistory()
}
suspend fun clear() {
db.historyDao().clear()
db.historyDao.clear()
notifyHistoryChanged()
}
suspend fun delete(manga: Manga) {
db.historyDao().delete(manga.id)
db.historyDao.delete(manga.id)
notifyHistoryChanged()
}
/**
* Try to replace one manga with another one
* Useful for replacing saved manga on deleting it with remove source
*/
suspend fun deleteOrSwap(manga: Manga, alternative: Manga?) {
if (alternative == null || db.mangaDao.update(MangaEntity.from(alternative)) <= 0) {
db.historyDao.delete(manga.id)
notifyHistoryChanged()
}
}
companion object {
private val listeners = HashSet<OnHistoryChangeListener>()
private val listeners = ArraySet<OnHistoryChangeListener>()
fun subscribe(listener: OnHistoryChangeListener) {
listeners += listener
}
fun unsubscribe(listener: OnHistoryChangeListener) {
listeners += listener
listeners -= listener
}
private suspend fun notifyHistoryChanged() {

View File

@@ -7,6 +7,7 @@ import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.utils.ext.getStringOrNull
import org.koitharu.kotatsu.utils.ext.map
import org.koitharu.kotatsu.utils.ext.safe
@@ -14,7 +15,7 @@ class MangaIndex(source: String?) {
private val json: JSONObject = source?.let(::JSONObject) ?: JSONObject()
fun setMangaInfo(manga: Manga) {
fun setMangaInfo(manga: Manga, append: Boolean) {
json.put("id", manga.id)
json.put("title", manga.title)
json.put("title_alt", manga.altTitle)
@@ -32,7 +33,9 @@ class MangaIndex(source: String?) {
a.put(jo)
}
})
json.put("chapters", JSONObject())
if (!append || !json.has("chapters")) {
json.put("chapters", JSONObject())
}
json.put("app_id", BuildConfig.APPLICATION_ID)
json.put("app_version", BuildConfig.VERSION_CODE)
}
@@ -42,12 +45,12 @@ class MangaIndex(source: String?) {
Manga(
id = json.getLong("id"),
title = json.getString("title"),
altTitle = json.getString("title_alt"),
altTitle = json.getStringOrNull("title_alt"),
url = json.getString("url"),
source = source,
rating = json.getDouble("rating").toFloat(),
coverUrl = json.getString("cover"),
description = json.getString("description"),
description = json.getStringOrNull("description"),
tags = json.getJSONArray("tags").map { x ->
MangaTag(
title = x.getString("title"),

View File

@@ -5,7 +5,7 @@ import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.utils.ext.sub
import org.koitharu.kotatsu.utils.ext.takeIfReadable
import org.koitharu.kotatsu.utils.ext.toFileName
import org.koitharu.kotatsu.utils.ext.toFileNameSafe
import java.io.File
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
@@ -17,11 +17,12 @@ class MangaZip(val file: File) {
private val dir = file.parentFile?.sub(file.name + ".tmp")?.takeIf { it.mkdir() }
?: throw RuntimeException("Cannot create temporary directory")
private val index = MangaIndex(dir.sub(INDEX_ENTRY).takeIfReadable()?.readText())
private var index = MangaIndex(null)
fun prepare(manga: Manga) {
extract()
index.setMangaInfo(manga)
index = MangaIndex(dir.sub(INDEX_ENTRY).takeIfReadable()?.readText())
index.setMangaInfo(manga, append = true)
}
fun cleanup() {
@@ -90,7 +91,7 @@ class MangaZip(val file: File) {
const val INDEX_ENTRY = "index.json"
fun findInDir(root: File, manga: Manga): MangaZip {
val name = manga.title.toFileName() + ".cbz"
val name = manga.title.toFileNameSafe() + ".cbz"
val file = File(root, name)
return MangaZip(file)
}

View File

@@ -0,0 +1,101 @@
package org.koitharu.kotatsu.domain.tracking
import androidx.room.withTransaction
import org.koin.core.KoinComponent
import org.koin.core.inject
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.TrackEntity
import org.koitharu.kotatsu.core.db.entity.TrackLogEntity
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.domain.MangaProviderFactory
import java.util.*
class TrackingRepository : KoinComponent {
private val db: MangaDatabase by inject()
suspend fun getNewChaptersCount(mangaId: Long): Int {
val entity = db.tracksDao.find(mangaId) ?: return 0
return entity.newChapters
}
suspend fun getAllTracks(): List<MangaTracking> {
val favourites = db.favouritesDao.findAllManga()
val history = db.historyDao.findAllManga()
val mangas = (favourites + history).distinctBy { it.id }
val tracks = db.tracksDao.findAll().groupBy { it.mangaId }
return mangas.mapNotNull { me ->
var manga = me.toManga()
if (manga.source == MangaSource.LOCAL) {
manga = MangaProviderFactory.createLocal().getRemoteManga(manga) ?: return@mapNotNull null
}
val track = tracks[manga.id]?.singleOrNull()
MangaTracking(
manga = manga,
knownChaptersCount = track?.totalChapters ?: -1,
lastChapterId = track?.lastChapterId ?: 0L,
lastNotifiedChapterId = track?.lastNotifiedChapterId ?: 0L,
lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(::Date)
)
}
}
suspend fun getTrackingLog(offset: Int, limit: Int): List<TrackingLogItem> {
return db.trackLogsDao.findAll(offset, limit).map { x ->
x.toTrackingLogItem()
}
}
suspend fun count() = db.trackLogsDao.count()
suspend fun clearLogs() = db.trackLogsDao.clear()
suspend fun cleanup() {
db.withTransaction {
db.tracksDao.cleanup()
db.trackLogsDao.cleanup()
}
}
suspend fun storeTrackResult(
mangaId: Long,
knownChaptersCount: Int,
lastChapterId: Long,
newChapters: List<MangaChapter>,
previousTrackChapterId: Long
) {
db.withTransaction {
val entity = TrackEntity(
mangaId = mangaId,
newChapters = newChapters.size,
lastCheck = System.currentTimeMillis(),
lastChapterId = lastChapterId,
totalChapters = knownChaptersCount,
lastNotifiedChapterId = newChapters.lastOrNull()?.id ?: previousTrackChapterId
)
db.tracksDao.upsert(entity)
val foundChapters = newChapters.takeLastWhile { x -> x.id != previousTrackChapterId }
if (foundChapters.isNotEmpty()) {
val logEntity = TrackLogEntity(
mangaId = mangaId,
chapters = foundChapters.joinToString("\n") { x -> x.name },
createdAt = System.currentTimeMillis()
)
db.trackLogsDao.insert(logEntity)
}
}
}
suspend fun upsert(manga: Manga) {
val chapters = manga.chapters ?: return
val entity = TrackEntity(
mangaId = manga.id,
totalChapters = chapters.size,
lastChapterId = chapters.lastOrNull()?.id ?: 0L,
newChapters = 0,
lastCheck = System.currentTimeMillis(),
lastNotifiedChapterId = 0L
)
db.tracksDao.upsert(entity)
}
}

View File

@@ -0,0 +1,94 @@
package org.koitharu.kotatsu.ui.browser
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.core.view.isVisible
import kotlinx.android.synthetic.main.activity_browser.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.ui.common.BaseActivity
@SuppressLint("SetJavaScriptEnabled")
class BrowserActivity : BaseActivity(), BrowserCallback {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_browser)
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(R.drawable.ic_cross)
}
with(webView.settings) {
javaScriptEnabled = true
}
webView.webViewClient = BrowserClient(this)
val url = intent?.dataString
if (url.isNullOrEmpty()) {
finishAfterTransition()
} else {
webView.loadUrl(url)
}
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.opt_browser, menu)
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
android.R.id.home -> {
webView.stopLoading()
finishAfterTransition()
true
}
R.id.action_browser -> {
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(webView.url)
try {
startActivity(Intent.createChooser(intent, item.title))
} catch (_: ActivityNotFoundException) {
}
true
}
else -> super.onOptionsItemSelected(item)
}
override fun onBackPressed() {
if (webView.canGoBack()) {
webView.goBack()
} else {
super.onBackPressed()
}
}
override fun onPause() {
webView.onPause()
super.onPause()
}
override fun onResume() {
super.onResume()
webView.onResume()
}
override fun onLoadingStateChanged(isLoading: Boolean) {
progressBar.isVisible = isLoading
}
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
this.title = title
supportActionBar?.subtitle = subtitle
}
companion object {
@JvmStatic
fun newIntent(context: Context, url: String) = Intent(context, BrowserActivity::class.java)
.setData(Uri.parse(url))
}
}

View File

@@ -0,0 +1,8 @@
package org.koitharu.kotatsu.ui.browser
interface BrowserCallback {
fun onLoadingStateChanged(isLoading: Boolean)
fun onTitleChanged(title: CharSequence, subtitle: CharSequence?)
}

View File

@@ -0,0 +1,57 @@
package org.koitharu.kotatsu.ui.browser
import android.graphics.Bitmap
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import okhttp3.OkHttpClient
import okhttp3.Request
import org.koin.core.KoinComponent
import org.koin.core.inject
import org.koitharu.kotatsu.utils.ext.safe
class BrowserClient(private val callback: BrowserCallback) : WebViewClient(), KoinComponent {
private val okHttp by inject<OkHttpClient>()
override fun onPageFinished(webView: WebView, url: String) {
super.onPageFinished(webView, url)
callback.onLoadingStateChanged(isLoading = false)
}
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
callback.onLoadingStateChanged(isLoading = true)
}
override fun onPageCommitVisible(view: WebView, url: String?) {
super.onPageCommitVisible(view, url)
callback.onTitleChanged(view.title, url)
}
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?) = false
override fun shouldOverrideUrlLoading(view: WebView, url: String) = false
override fun shouldInterceptRequest(view: WebView?, url: String?): WebResourceResponse? {
return url?.let(::doRequest)
}
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
return request?.url?.toString()?.let(::doRequest)
}
private fun doRequest(url: String): WebResourceResponse? = safe {
val request = Request.Builder()
.url(url)
.build()
val response = okHttp.newCall(request).execute()
val ct = response.body?.contentType()
WebResourceResponse(
"${ct?.type}/${ct?.subtype}",
ct?.charset()?.name() ?: "utf-8",
response.body?.byteStream()
)
}
}

View File

@@ -5,7 +5,7 @@ import android.os.Bundle
import android.view.View
import androidx.annotation.LayoutRes
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import moxy.MvpAppCompatDialogFragment
abstract class AlertDialogFragment(@LayoutRes private val layoutResId: Int) : MvpAppCompatDialogFragment() {
@@ -18,7 +18,7 @@ abstract class AlertDialogFragment(@LayoutRes private val layoutResId: Int) : Mv
if (view != null) {
onViewCreated(view, savedInstanceState)
}
return AlertDialog.Builder(requireContext(), theme)
return MaterialAlertDialogBuilder(requireContext(), theme)
.setView(view)
.also(::onBuildDialog)
.create()

View File

@@ -1,22 +1,14 @@
package org.koitharu.kotatsu.ui.common
import android.content.pm.PackageManager
import android.view.KeyEvent
import android.view.MenuItem
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import moxy.MvpAppCompatActivity
import org.koin.core.KoinComponent
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.ui.common.dialog.StorageSelectDialog
abstract class BaseActivity : MvpAppCompatActivity(), KoinComponent {
private var permissionCallback: ((Boolean) -> Unit)? = null
override fun setContentView(layoutResID: Int) {
super.setContentView(layoutResID)
setupToolbar()
@@ -35,49 +27,4 @@ abstract class BaseActivity : MvpAppCompatActivity(), KoinComponent {
onBackPressed()
true
} else super.onOptionsItemSelected(item)
fun requestPermission(permission: String, callback: (Boolean) -> Unit) {
if (ContextCompat.checkSelfPermission(
this,
permission
) == PackageManager.PERMISSION_GRANTED
) {
callback(true)
} else {
permissionCallback = callback
ActivityCompat.requestPermissions(this, arrayOf(permission), REQUEST_PERMISSION)
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQUEST_PERMISSION) {
grantResults.singleOrNull()?.let {
permissionCallback?.invoke(it == PackageManager.PERMISSION_GRANTED)
}
permissionCallback = null
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
//TODO remove. Just for testing
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
recreate()
return true
}
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
StorageSelectDialog.Builder(this).create().show()
return true
}
return super.onKeyDown(keyCode, event)
}
private companion object {
const val REQUEST_PERMISSION = 30
}
}

View File

@@ -3,12 +3,11 @@ package org.koitharu.kotatsu.ui.common
import androidx.annotation.StringRes
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import org.koin.core.KoinComponent
import org.koin.core.inject
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.core.prefs.AppSettings
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
PreferenceFragmentCompat(), KoinComponent {
PreferenceFragmentCompat() {
protected val settings by inject<AppSettings>()

View File

@@ -7,10 +7,9 @@ import androidx.annotation.CallSuper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.koin.core.KoinComponent
import kotlin.coroutines.CoroutineContext
abstract class BaseService : Service(), KoinComponent, CoroutineScope {
abstract class BaseService : Service(), CoroutineScope {
private val job = SupervisorJob()

View File

@@ -6,7 +6,7 @@ import androidx.annotation.DrawableRes
import com.google.android.material.chip.Chip
import org.koitharu.kotatsu.utils.ext.getThemeColor
class ChipsFactory(private val context: Context) {
class ChipsFactory(val context: Context) {
fun create(convertView: Chip? = null, text: CharSequence, @DrawableRes iconRes: Int = 0,
tag: Any? = null, onClickListener: View.OnClickListener? = null): Chip {

View File

@@ -0,0 +1,25 @@
package org.koitharu.kotatsu.ui.common
import android.util.ArrayMap
import moxy.MvpPresenter
import java.lang.ref.WeakReference
abstract class SharedPresenterHolder<T : MvpPresenter<*>> {
private val cache = ArrayMap<Int, WeakReference<T>>(3)
fun getInstance(key: Int): T {
var instance = cache[key]?.get()
if (instance == null) {
instance = onCreatePresenter(key)
cache[key] = WeakReference(instance)
}
return instance
}
fun clear(key: Int) {
cache.remove(key)
}
protected abstract fun onCreatePresenter(key: Int): T
}

View File

@@ -8,6 +8,7 @@ import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import com.google.android.material.checkbox.MaterialCheckBox
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
class CheckBoxAlertDialog private constructor(private val delegate: AlertDialog) :
@@ -22,7 +23,7 @@ class CheckBoxAlertDialog private constructor(private val delegate: AlertDialog)
.inflate(R.layout.dialog_checkbox, null, false)
private val checkBox = view.findViewById<MaterialCheckBox>(android.R.id.checkbox)
private val delegate = AlertDialog.Builder(context)
private val delegate = MaterialAlertDialogBuilder(context)
.setView(view)
fun setTitle(@StringRes titleResId: Int): Builder {

View File

@@ -2,15 +2,16 @@ package org.koitharu.kotatsu.ui.common.dialog
import android.content.Context
import android.content.DialogInterface
import android.os.Environment
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.android.synthetic.main.item_storage.view.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.ext.findParent
import org.koitharu.kotatsu.core.parser.LocalMangaRepository
import org.koitharu.kotatsu.utils.ext.getStorageName
import org.koitharu.kotatsu.utils.ext.inflate
import org.koitharu.kotatsu.utils.ext.longHashCode
import java.io.File
@@ -20,12 +21,24 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
fun show() = delegate.show()
class Builder(context: Context) {
class Builder(context: Context, defaultValue: File?, listener: OnStorageSelectListener) {
private val delegate = AlertDialog.Builder(context)
.setAdapter(VolumesAdapter(context)) { _, _ ->
private val adapter = VolumesAdapter(context)
private val delegate = MaterialAlertDialogBuilder(context)
init {
if (adapter.isEmpty) {
delegate.setMessage(R.string.cannot_find_available_storage)
} else {
val checked = adapter.volumes.indexOfFirst {
it.first.canonicalPath == defaultValue?.canonicalPath
}
delegate.setSingleChoiceItems(adapter, checked) { d, i ->
listener.onStorageSelected(adapter.getItem(i).first)
d.dismiss()
}
}
}
fun setTitle(@StringRes titleResId: Int): Builder {
delegate.setTitle(titleResId)
@@ -37,12 +50,17 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
return this
}
fun setNegativeButton(@StringRes textId: Int): Builder {
delegate.setNegativeButton(textId, null)
return this
}
fun create() = StorageSelectDialog(delegate.create())
}
private class VolumesAdapter(context: Context): BaseAdapter() {
private class VolumesAdapter(context: Context) : BaseAdapter() {
private val volumes = getAvailableVolumes(context)
val volumes = getAvailableVolumes(context)
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: parent.inflate(R.layout.item_storage)
@@ -52,7 +70,7 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
return view
}
override fun getItem(position: Int): Any = volumes[position]
override fun getItem(position: Int): Pair<File, String> = volumes[position]
override fun getItemId(position: Int) = volumes[position].first.absolutePath.longHashCode()
@@ -60,15 +78,17 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
}
interface OnStorageSelectListener {
fun onStorageSelected(file: File)
}
private companion object {
@JvmStatic
fun getAvailableVolumes(context: Context): List<Pair<File,String>> = context.getExternalFilesDirs(null).mapNotNull {
val root = it.findParent { x -> x.name == "Android" }?.parentFile ?: return@mapNotNull null
root to when {
Environment.isExternalStorageEmulated(root) -> context.getString(R.string.internal_storage)
Environment.isExternalStorageRemovable(root) -> context.getString(R.string.external_storage)
else -> root.name
fun getAvailableVolumes(context: Context): List<Pair<File, String>> {
return LocalMangaRepository.getAvailableStorageDirs(context).map {
it to it.getStorageName(context)
}
}
}

View File

@@ -3,28 +3,17 @@ package org.koitharu.kotatsu.ui.common.dialog
import android.annotation.SuppressLint
import android.content.Context
import android.content.DialogInterface
import android.text.InputFilter
import android.view.LayoutInflater
import android.view.inputmethod.InputMethodManager
import android.widget.TextView
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.android.synthetic.main.dialog_input.view.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.ext.showKeyboard
class TextInputDialog private constructor(private val delegate: AlertDialog) :
DialogInterface by delegate {
init {
delegate.setOnShowListener {
val view = delegate.findViewById<TextView>(R.id.inputEdit)?:return@setOnShowListener
view.post {
view.requestFocus()
view.showKeyboard()
}
}
}
fun show() = delegate.show()
class Builder(context: Context) {
@@ -32,12 +21,8 @@ class TextInputDialog private constructor(private val delegate: AlertDialog) :
@SuppressLint("InflateParams")
private val view = LayoutInflater.from(context).inflate(R.layout.dialog_input, null, false)
private val delegate = AlertDialog.Builder(context)
private val delegate = MaterialAlertDialogBuilder(context)
.setView(view)
.setOnDismissListener {
val imm = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.toggleSoftInput(InputMethodManager.HIDE_IMPLICIT_ONLY, 0)
}
fun setTitle(@StringRes titleResId: Int): Builder {
delegate.setTitle(titleResId)
@@ -54,11 +39,28 @@ class TextInputDialog private constructor(private val delegate: AlertDialog) :
return this
}
fun setMaxLength(maxLength: Int, strict: Boolean): Builder {
with(view.inputLayout) {
counterMaxLength = maxLength
isCounterEnabled = maxLength > 0
}
if (strict && maxLength > 0) {
view.inputEdit.filters += InputFilter.LengthFilter(maxLength)
}
return this
}
fun setInputType(inputType: Int): Builder {
view.inputEdit.inputType = inputType
return this
}
fun setText(text: String): Builder {
view.inputEdit.setText(text)
view.inputEdit.setSelection(text.length)
return this
}
fun setPositiveButton(@StringRes textId: Int, listener: (DialogInterface, String) -> Unit): Builder {
delegate.setPositiveButton(textId) { dialog, _ ->
listener(dialog, view.inputEdit.text?.toString().orEmpty())

View File

@@ -80,19 +80,30 @@ abstract class BaseRecyclerAdapter<T, E>(private val onItemClickListener: OnRecy
onDataSetChanged()
}
override fun onViewRecycled(holder: BaseViewHolder<T, E>) {
holder.onRecycled()
}
final override fun getItemCount() = dataSet.size
final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder<T, E> {
return onCreateViewHolder(parent).setOnItemClickListener(onItemClickListener)
.also(this::onViewHolderCreated)
return onCreateViewHolder(parent)
}
override fun onViewDetachedFromWindow(holder: BaseViewHolder<T, E>) {
holder.setOnItemClickListener(null)
super.onViewDetachedFromWindow(holder)
}
override fun onViewAttachedToWindow(holder: BaseViewHolder<T, E>) {
super.onViewAttachedToWindow(holder)
holder.setOnItemClickListener(onItemClickListener)
}
protected open fun onDataSetChanged() = Unit
protected abstract fun getExtra(item: T, position: Int): E
protected open fun onViewHolderCreated(holder: BaseViewHolder<T, E>) = Unit
protected abstract fun onCreateViewHolder(parent: ViewGroup): BaseViewHolder<T, E>
protected abstract fun onGetItemId(item: T): Long

View File

@@ -26,19 +26,29 @@ abstract class BaseViewHolder<T, E> protected constructor(view: View) :
onBind(data, extra)
}
fun requireData() = boundData ?: throw IllegalStateException("Calling requireData() before bind()")
fun setOnItemClickListener(listener: OnRecyclerItemClickListener<T>?): BaseViewHolder<T, E> {
if (listener != null) {
itemView.setOnClickListener {
listener.onItemClick(boundData ?: return@setOnClickListener, adapterPosition, it)
}
itemView.setOnLongClickListener {
listener.onItemLongClick(boundData ?: return@setOnLongClickListener false, adapterPosition, it)
}
}
return this
fun requireData(): T {
return boundData ?: throw IllegalStateException("Calling requireData() before bind()")
}
fun setOnItemClickListener(listener: OnRecyclerItemClickListener<T>?) {
val listenersAdapter = listener?.let { HolderListenersAdapter(it) }
itemView.setOnClickListener(listenersAdapter)
itemView.setOnLongClickListener(listenersAdapter)
}
open fun onRecycled() = Unit
abstract fun onBind(data: T, extra: E)
private inner class HolderListenersAdapter(private val listener: OnRecyclerItemClickListener<T>) :
View.OnClickListener, View.OnLongClickListener {
override fun onClick(v: View) {
listener.onItemClick(boundData ?: return, bindingAdapterPosition, v)
}
override fun onLongClick(v: View): Boolean {
return listener.onItemLongClick(boundData ?: return false, bindingAdapterPosition, v)
}
}
}

View File

@@ -12,13 +12,15 @@ abstract class BoundsScrollListener(private val offsetTop: Int, private val offs
super.onScrolled(recyclerView, dx, dy)
val layoutManager = (recyclerView.layoutManager as? LinearLayoutManager) ?: return
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
if (firstVisibleItemPosition == RecyclerView.NO_POSITION) {
return
}
if (firstVisibleItemPosition <= offsetTop) {
onScrolledToStart(recyclerView)
return
}
val visibleItemCount = layoutManager.childCount
val totalItemCount = layoutManager.itemCount
if (visibleItemCount + firstVisibleItemPosition >= totalItemCount - offsetBottom && firstVisibleItemPosition >= 0) {
if (visibleItemCount + firstVisibleItemPosition >= totalItemCount - offsetBottom) {
onScrolledToEnd(recyclerView)
}
}

View File

@@ -2,24 +2,31 @@ package org.koitharu.kotatsu.ui.common.list
import androidx.recyclerview.widget.RecyclerView
class PaginationScrollListener(offset: Int, private val callback: Callback) : BoundsScrollListener(0, offset) {
class PaginationScrollListener(offset: Int, private val callback: Callback) :
BoundsScrollListener(0, offset) {
private var lastTotalCount = 0
override fun onScrolledToStart(recyclerView: RecyclerView) = Unit
override fun onScrolledToStart(recyclerView: RecyclerView) = Unit
override fun onScrolledToEnd(recyclerView: RecyclerView) {
val total = recyclerView.adapter?.itemCount ?: 0
val total = callback.getItemsCount()
if (total > lastTotalCount) {
callback.onRequestMoreItems(total)
lastTotalCount = total
callback.onRequestMoreItems(total)
} else if (total < lastTotalCount) {
lastTotalCount = total
}
}
fun reset() {
lastTotalCount = 0
}
interface Callback {
fun onRequestMoreItems(offset: Int)
fun getItemsCount(): Int
}
}

View File

@@ -0,0 +1,24 @@
package org.koitharu.kotatsu.ui.common.list
import android.view.ViewGroup
class ProgressBarAdapter : BaseRecyclerAdapter<Boolean, Unit>() {
var isProgressVisible: Boolean
get() = dataSet.isNotEmpty()
set(value) {
if (value == dataSet.isEmpty()) {
if (value) {
appendItem(true)
} else {
removeItemAt(0)
}
}
}
override fun getExtra(item: Boolean, position: Int) = Unit
override fun onCreateViewHolder(parent: ViewGroup) = ProgressBarHolder(parent)
override fun onGetItemId(item: Boolean) = -1L
}

View File

@@ -0,0 +1,34 @@
package org.koitharu.kotatsu.ui.common.list
import android.view.View
import android.view.ViewGroup
import kotlinx.android.synthetic.main.item_progress.*
import org.koitharu.kotatsu.R
class ProgressBarHolder(parent: ViewGroup) :
BaseViewHolder<Boolean, Unit>(parent, R.layout.item_progress) {
private var pendingVisibility: Int = View.GONE
private val action = Runnable {
progressBar?.visibility = pendingVisibility
pendingVisibility = View.GONE
}
override fun onBind(data: Boolean, extra: Unit) {
val visibility = if (data) {
View.VISIBLE
} else {
View.INVISIBLE
}
if (visibility != progressBar.visibility && visibility != pendingVisibility) {
progressBar.removeCallbacks(action)
pendingVisibility = visibility
progressBar.postDelayed(action, 400)
}
}
override fun onRecycled() {
progressBar.removeCallbacks(action)
super.onRecycled()
}
}

View File

@@ -0,0 +1,67 @@
package org.koitharu.kotatsu.ui.common.widgets
import android.content.Context
import android.util.AttributeSet
import android.widget.Checkable
import androidx.appcompat.widget.AppCompatImageView
class CheckableImageView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : AppCompatImageView(context, attrs, defStyleAttr), Checkable {
private var isCheckedInternal = false
private var isBroadcasting = false
var onCheckedChangeListener: OnCheckedChangeListener? = null
init {
setOnClickListener {
toggle()
}
}
fun setOnCheckedChangeListener(listener: (Boolean) -> Unit) {
onCheckedChangeListener = object : OnCheckedChangeListener {
override fun onCheckedChanged(view: CheckableImageView, isChecked: Boolean) {
listener(isChecked)
}
}
}
override fun isChecked() = isCheckedInternal
override fun toggle() {
isChecked = !isCheckedInternal
}
override fun setChecked(checked: Boolean) {
if (checked != isCheckedInternal) {
isCheckedInternal = checked
refreshDrawableState()
if (!isBroadcasting) {
isBroadcasting = true
onCheckedChangeListener?.onCheckedChanged(this, checked)
isBroadcasting = false
}
}
}
override fun onCreateDrawableState(extraSpace: Int): IntArray {
val state = super.onCreateDrawableState(extraSpace + 1)
if (isCheckedInternal) {
mergeDrawableStates(state, CHECKED_STATE_SET)
}
return state
}
interface OnCheckedChangeListener {
fun onCheckedChanged(view: CheckableImageView, isChecked: Boolean)
}
private companion object {
@JvmStatic
private val CHECKED_STATE_SET = intArrayOf(android.R.attr.state_checked)
}
}

View File

@@ -1,6 +1,8 @@
package org.koitharu.kotatsu.ui.details
import android.graphics.Color
import android.view.ViewGroup
import androidx.core.view.isVisible
import kotlinx.android.synthetic.main.item_chapter.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaChapter
@@ -14,6 +16,7 @@ class ChapterHolder(parent: ViewGroup) :
override fun onBind(data: MangaChapter, extra: ChapterExtra) {
textView_title.text = data.name
textView_number.text = data.number.toString()
imageView_check.isVisible = extra == ChapterExtra.CHECKED
when (extra) {
ChapterExtra.UNREAD -> {
textView_number.setBackgroundResource(R.drawable.bg_badge_default)
@@ -25,12 +28,16 @@ class ChapterHolder(parent: ViewGroup) :
}
ChapterExtra.CURRENT -> {
textView_number.setBackgroundResource(R.drawable.bg_badge_outline_accent)
textView_number.setTextColor(context.getThemeColor(R.attr.colorAccent))
textView_number.setTextColor(context.getThemeColor(androidx.appcompat.R.attr.colorAccent))
}
ChapterExtra.NEW -> {
textView_number.setBackgroundResource(R.drawable.bg_badge_accent)
textView_number.setTextColor(context.getThemeColor(android.R.attr.textColorPrimaryInverse))
}
ChapterExtra.CHECKED -> {
textView_number.background = null
textView_number.setTextColor(Color.TRANSPARENT)
}
}
}
}

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