Merge branch 'devel' into feature/shikimori

pull/125/head
Koitharu 4 years ago
commit 0695103589
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

@ -44,7 +44,7 @@ body:
label: Kotatsu version label: Kotatsu version
description: You can find your Kotatsu version in **Settings → About**. description: You can find your Kotatsu version in **Settings → About**.
placeholder: | placeholder: |
Example: "3.2.2" Example: "3.3"
validations: validations:
required: true required: true
@ -87,7 +87,7 @@ body:
required: true required: true
- label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/nv95/kotatsu-parsers/issues/new). - label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/nv95/kotatsu-parsers/issues/new).
required: true required: true
- label: I have updated the app to version **[3.2.2](https://github.com/nv95/Kotatsu/releases/latest)**. - label: I have updated the app to version **[3.3](https://github.com/nv95/Kotatsu/releases/latest)**.
required: true required: true
- label: I will fill out all of the requested information in this form. - label: I will fill out all of the requested information in this form.
required: true required: true

@ -33,7 +33,7 @@ body:
required: true required: true
- label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/nv95/kotatsu-parsers/issues/new). - label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/nv95/kotatsu-parsers/issues/new).
required: true required: true
- label: I have updated the app to version **[3.2.2](https://github.com/nv95/Kotatsu/releases/latest)**. - label: I have updated the app to version **[3.3](https://github.com/nv95/Kotatsu/releases/latest)**.
required: true required: true
- label: I will fill out all of the requested information in this form. - label: I will fill out all of the requested information in this form.
required: true required: true

2
.gitignore vendored

@ -6,11 +6,13 @@
/.idea/dictionaries /.idea/dictionaries
/.idea/modules.xml /.idea/modules.xml
/.idea/misc.xml /.idea/misc.xml
/.idea/discord.xml
/.idea/workspace.xml /.idea/workspace.xml
/.idea/navEditor.xml /.idea/navEditor.xml
/.idea/assetWizardSettings.xml /.idea/assetWizardSettings.xml
/.idea/kotlinScripting.xml /.idea/kotlinScripting.xml
/.idea/deploymentTargetDropDown.xml /.idea/deploymentTargetDropDown.xml
/.idea/androidTestResultsUserPreferences.xml
.DS_Store .DS_Store
/build /build
/captures /captures

@ -7,7 +7,7 @@
<option name="testRunner" value="GRADLE" /> <option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" /> <option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="Android Studio default JDK" /> <option name="gradleJvm" value="Embedded JDK" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />

@ -0,0 +1,287 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
inkscape:export-ydpi="39.689999"
inkscape:export-xdpi="39.689999"
inkscape:export-filename="/home/admin/Documents/projects/graphics/k/icon4.png"
width="512mm"
height="512mm"
viewBox="0 0 512 512.00002"
version="1.1"
id="svg8"
inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
sodipodi:docname="icon4.svg">
<defs
id="defs2">
<filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter1266">
<feFlood
flood-opacity="0.498039"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood1256" />
<feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite1258" />
<feGaussianBlur
in="composite1"
stdDeviation="3"
result="blur"
id="feGaussianBlur1260" />
<feOffset
dx="6"
dy="6"
result="offset"
id="feOffset1262" />
<feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite1264" />
</filter>
<filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter1059">
<feFlood
flood-opacity="0.498039"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood1049" />
<feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite1051" />
<feGaussianBlur
in="composite1"
stdDeviation="3"
result="blur"
id="feGaussianBlur1053" />
<feOffset
dx="6"
dy="6"
result="offset"
id="feOffset1055" />
<feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite1057" />
</filter>
<filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter1071">
<feFlood
flood-opacity="0.498039"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood1061" />
<feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite1063" />
<feGaussianBlur
in="composite1"
stdDeviation="3"
result="blur"
id="feGaussianBlur1065" />
<feOffset
dx="6"
dy="6"
result="offset"
id="feOffset1067" />
<feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite1069" />
</filter>
</defs>
<sodipodi:namedview
id="base"
pagecolor="#0d47a1"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="0.175"
inkscape:cx="-361.03654"
inkscape:cy="630.78782"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
inkscape:document-rotation="0"
showgrid="false"
inkscape:window-width="1600"
inkscape:window-height="838"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
fit-margin-top="20"
fit-margin-left="20"
fit-margin-right="20"
fit-margin-bottom="20" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Слой 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-51.12025,-104.74797)">
<g
id="g1028"
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
<g
id="g1030"
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
<g
id="g1032"
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
<g
id="g1034"
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
<g
id="g1036"
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
<g
id="g1038"
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
<g
id="g1040"
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
<g
id="g1042"
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
<g
id="g1044"
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
<g
id="g1046"
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
<g
id="g1048"
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
<g
id="g1050"
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
<g
id="g1052"
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
<g
id="g1054"
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
<g
id="g1056"
transform="matrix(6.9754464,0,0,6.9754464,32.42507,404.31391)" />
<path
id="path1128"
d="m 307.12025,310.74755 c -50.53732,0 -91.66608,44.85688 -91.66608,99.99965 0,55.14277 41.12954,99.99964 91.66608,99.99964 50.53653,0 91.66607,-44.85687 91.66607,-99.99964 0,-55.14277 -41.12875,-99.99965 -91.66607,-99.99965 z m -34.21238,78.72707 c -1.46484,2.91327 -4.41092,4.60623 -7.45466,4.60623 -1.25312,0 -2.52265,-0.27656 -3.72733,-0.8789 l -12.9398,-6.4781 -12.9398,6.4781 c -4.13436,2.06718 -9.11481,0.37421 -11.18199,-3.72733 -2.05077,-4.11796 -0.39062,-9.11481 3.72733,-11.18199 l 16.66635,-8.33357 c 2.34374,-1.17187 5.11092,-1.17187 7.45466,0 l 16.66635,8.33357 c 4.11951,2.06718 5.77966,7.06403 3.72889,11.18199 z m 58.33338,-24.99991 c -1.46484,2.91327 -4.41092,4.60623 -7.45466,4.60623 -1.25312,0 -2.52264,-0.27656 -3.72733,-0.8789 l -12.93901,-6.47811 -12.9398,6.47811 c -4.13436,2.06718 -9.11481,0.37421 -11.18199,-3.72733 -2.05078,-4.11796 -0.39063,-9.11482 3.72733,-11.182 l 16.66634,-8.33356 c 2.34375,-1.17187 5.11092,-1.17187 7.45466,0 l 16.66635,8.33356 c 4.11874,2.06718 5.77889,7.06404 3.72811,11.182 z m 54.60606,13.81792 c 4.11795,2.06718 5.7781,7.06403 3.72733,11.18199 -1.46484,2.91327 -4.41092,4.60623 -7.45466,4.60623 -1.25312,0 -2.52265,-0.27656 -3.72733,-0.8789 l -12.9398,-6.4781 -12.9398,6.4781 c -4.11795,2.06718 -9.11481,0.37421 -11.18199,-3.72733 -2.05077,-4.11796 -0.39062,-9.11481 3.72733,-11.18199 l 16.66635,-8.33357 c 2.34374,-1.17187 5.11092,-1.17187 7.45466,0 z"
style="fill:#ffffff;stroke-width:0.781247" />
<path
id="path1130"
d="m 415.36283,274.00237 c -3.48202,-6.90544 -6.92029,-13.41714 -10.20934,-19.37102 l -8.26716,-14.47964 c -6.79607,-11.51871 -12.25699,-19.90305 -14.78354,-23.66554 -0.7164,-43.32797 -19.12415,-53.79356 -21.25617,-54.86777 -3.20624,-1.5789 -7.09607,-0.97656 -9.6195,1.56249 -12.25621,12.25621 -20.23118,24.4632 -24.00695,30.89286 h -40.20141 c -3.77577,-6.42888 -11.75153,-18.63665 -24.00695,-30.89286 -2.52265,-2.53905 -6.39685,-3.14139 -9.6195,-1.56249 -2.13202,1.07421 -20.54055,11.5398 -21.25617,54.86777 -2.52655,3.76327 -7.98669,12.14605 -14.78276,23.66476 l -8.27341,14.49214 c -3.28983,5.95701 -6.73044,12.47105 -10.21403,19.3804 l -7.4445,15.32572 c -17.72572,38.05377 -34.30066,85.4286 -34.30066,129.72766 0,69.32085 58.26776,128.42064 60.75838,130.89485 0.91171,0.91172 2.03437,1.61171 3.25546,2.01796 1.07421,0.35859 26.78974,8.757 69.31928,8.757 2.21327,0 4.32967,-0.8789 5.89217,-2.4414 l 5.89216,-5.89216 h 9.76559 l 5.89217,5.89216 c 1.56249,1.5625 3.67811,2.4414 5.89216,2.4414 42.52954,0 68.24507,-8.39841 69.31929,-8.757 1.22109,-0.40703 2.34374,-1.10703 3.25546,-2.01796 2.48905,-2.47421 60.75681,-61.57322 60.75681,-130.89485 0,-44.29906 -16.57494,-91.67389 -34.30066,-129.72766 z M 348.7865,227.41426 c 4.60624,0 4.41171,7.35466 4.41171,11.96089 4.60623,0 12.25464,0.1 12.25464,4.70624 0,9.19606 -7.47107,16.66634 -16.66635,16.66634 -9.19528,0 -16.66634,-7.47028 -16.66634,-16.66634 0,-9.19606 7.47106,-16.66713 16.66634,-16.66713 z m -57.69823,30.14364 c 1.28593,-3.10858 4.32967,-5.14295 7.69841,-5.14295 h 16.66635 c 3.36952,0 6.41248,2.03437 7.69841,5.14295 1.28593,3.10858 0.56953,6.70545 -1.80703,9.082 l -8.33356,8.33356 c -1.62734,1.62734 -3.76014,2.4414 -5.89217,2.4414 -2.13202,0 -4.26404,-0.81406 -5.89216,-2.4414 l -8.33357,-8.33356 c -2.37421,-2.37655 -3.09061,-5.97342 -1.80468,-9.082 z m -25.63428,-30.14364 c 4.60623,0 4.4117,7.35466 4.4117,11.96089 4.60623,0 12.25465,0.1 12.25465,4.70624 0,9.19606 -7.47107,16.66634 -16.66635,16.66634 -9.19606,0 -16.66635,-7.47106 -16.66635,-16.66634 -7.8e-4,-9.19606 7.47029,-16.66713 16.66635,-16.66713 z m 41.66626,299.99893 c -59.7326,0 -108.33321,-52.34357 -108.33321,-116.66599 0,-64.32243 48.60061,-116.66599 108.33321,-116.66599 59.73259,0 108.3332,52.34356 108.3332,116.66599 0,64.32242 -48.60061,116.66599 -108.3332,116.66599 z"
style="fill:#ffffff;stroke-width:0.781247;filter:url(#filter1059)"
sodipodi:nodetypes="cccccccccccccccsccssccssccsccscsssssssssssccsscsscssssss" />
<g
style="fill:#ffffff"
id="g1138"
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)"
inkscape:groupmode="layer" />
<g
style="fill:#ffffff"
id="g1140"
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
<g
style="fill:#ffffff"
id="g1142"
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
<g
style="fill:#ffffff"
id="g1144"
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
<g
style="fill:#ffffff"
id="g1146"
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
<g
style="fill:#ffffff"
id="g1148"
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
<g
style="fill:#ffffff"
id="g1150"
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
<g
style="fill:#ffffff"
id="g1152"
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
<g
style="fill:#ffffff"
id="g1154"
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
<g
style="fill:#ffffff"
id="g1156"
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
<g
style="fill:#ffffff"
id="g1158"
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
<g
style="fill:#ffffff"
id="g1160"
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
<g
style="fill:#ffffff"
id="g1162"
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
<g
style="fill:#ffffff"
id="g1164"
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
<g
style="fill:#ffffff"
id="g1166"
transform="matrix(0.78124721,0,0,0.78124721,107.12096,160.74809)" />
<path
style="fill:#ef5350;fill-opacity:1;stroke:none;stroke-width:5.18208;stroke-linecap:round;stroke-linejoin:round"
d="m 344.3189,392.83707 c -4.60362,-2.75958 -5.36974,-9.69605 -1.45595,-13.18226 0.54459,-0.48508 5.34567,-3.07035 10.66909,-5.74503 7.5498,-3.79328 10.16725,-4.86303 11.89884,-4.86303 1.73503,0 4.42542,1.10391 12.3172,5.05396 11.72559,5.86898 12.60994,6.68326 12.60994,11.61118 0,3.40408 -0.99553,5.20819 -4.00363,7.25549 -3.08358,2.09867 -5.44113,1.68547 -13.60905,-2.38528 l -7.19926,-3.58796 -7.37198,3.59617 c -8.3911,4.09331 -10.26721,4.39753 -13.8552,2.24676 z"
id="path944" />
<path
style="fill:#ef5350;fill-opacity:1;stroke:none;stroke-width:5.18208;stroke-linecap:round;stroke-linejoin:round"
d="m 285.98437,367.98056 c -3.86343,-2.35557 -5.1524,-8.06518 -2.66781,-11.81734 1.64304,-2.48125 20.719,-12.23981 23.92632,-12.23981 1.56364,0 4.61398,1.26582 12.2153,5.06905 8.53551,4.27064 10.3157,5.36752 11.30239,6.96403 1.75651,2.84207 1.95178,5.62136 0.58856,8.37633 -1.52635,3.08463 -3.36973,4.32306 -6.86644,4.61304 -2.68142,0.22236 -3.36743,-0.003 -10.22731,-3.35873 l -7.35311,-3.59707 -7.04119,3.52834 c -7.90523,3.96133 -10.62609,4.44409 -13.87671,2.46216 z"
id="path946" />
<path
style="fill:#ef5350;fill-opacity:1;stroke:none;stroke-width:5.18208;stroke-linecap:round;stroke-linejoin:round"
d="m 228.11707,393.18031 c -1.0244,-0.54435 -2.42484,-1.80721 -3.11209,-2.80633 -1.05812,-1.53828 -1.2181,-2.32693 -1.04433,-5.14815 0.29039,-4.71472 1.41139,-5.70783 12.90113,-11.42937 7.71258,-3.84061 9.99443,-4.74971 11.92193,-4.74971 1.94819,0 4.22735,0.92952 12.47354,5.08716 8.66324,4.3679 10.26522,5.3693 11.33052,7.08263 3.53608,5.68714 -0.55313,12.95355 -7.28968,12.95355 -1.25225,0 -4.29453,-1.20187 -9.08226,-3.58799 l -7.19927,-3.58796 -7.37197,3.59617 c -8.07507,3.93914 -10.21699,4.34922 -13.52752,2.59 z"
id="path948" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

@ -4,6 +4,9 @@
<inspection_tool class="BooleanLiteralArgument" enabled="false" level="WEAK WARNING" enabled_by_default="false" /> <inspection_tool class="BooleanLiteralArgument" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="Destructure" enabled="true" level="INFO" enabled_by_default="true" /> <inspection_tool class="Destructure" enabled="true" level="INFO" enabled_by_default="true" />
<inspection_tool class="KeySetIterationMayUseEntrySet" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="KeySetIterationMayUseEntrySet" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="KotlinFunctionArgumentsHelper" enabled="true" level="INFORMATION" enabled_by_default="true">
<option name="withoutDefaultValues" value="true" />
</inspection_tool>
<inspection_tool class="ReplaceCollectionCountWithSize" enabled="true" level="WEAK WARNING" enabled_by_default="true" /> <inspection_tool class="ReplaceCollectionCountWithSize" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
<inspection_tool class="TrailingComma" enabled="true" level="INFORMATION" enabled_by_default="true" /> <inspection_tool class="TrailingComma" enabled="true" level="INFORMATION" enabled_by_default="true" />
<inspection_tool class="ZeroLengthArrayInitialization" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="ZeroLengthArrayInitialization" enabled="true" level="WARNING" enabled_by_default="true" />

@ -2,7 +2,7 @@
Kotatsu is a free and open source manga reader for Android. Kotatsu is a free and open source manga reader for Android.
![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) ![Kotlin](https://img.shields.io/github/languages/top/nv95/Kotatsu) ![License](https://img.shields.io/github/license/nv95/Kotatsu) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![4pda](https://img.shields.io/badge/discuss-4pda-2982CC)](http://4pda.ru/forum/index.php?showtopic=697669) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5) ![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) ![Kotlin](https://img.shields.io/github/languages/top/KotatsuApp/Kotatsu) ![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![4pda](https://img.shields.io/badge/discuss-4pda-2982CC)](http://4pda.ru/forum/index.php?showtopic=697669) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5)
### Download ### Download
@ -12,14 +12,13 @@ height="80">](https://f-droid.org/packages/org.koitharu.kotatsu)
Download APK from Github Releases: Download APK from Github Releases:
- [Latest release](https://github.com/nv95/Kotatsu/releases/latest) - [Latest release](https://github.com/KotatsuApp/Kotatsu/releases/latest)
- [Legacy build](https://github.com/nv95/Kotatsu/releases/tag/v0.4-legacy) (with Android 4.1+ support)
### Main Features ### Main Features
* Online manga catalogues * Online manga catalogues
* Search manga by name and genre * Search manga by name and genres
* Reading history * Reading history and bookmarks
* Favourites organized by user-defined categories * Favourites organized by user-defined categories
* Downloading manga and reading it offline. Third-party CBZ archives also supported * Downloading manga and reading it offline. Third-party CBZ archives also supported
* Tablet-optimized material design UI * Tablet-optimized material design UI
@ -30,12 +29,12 @@ Download APK from Github Releases:
### Screenshots ### Screenshots
| ![Screenshot_20200226-210337](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/1.png) | ![](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/2.png) | ![Screenshot_20200226-210232](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/3.png) | | ![Screenshot_20200226-210337](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/1.png) | ![](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/2.png) | ![Screenshot_20200226-210232](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/3.png) |
|-----------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------| |-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|
| ![Screenshot_20200226-210405](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/4.png) | ![Screenshot_20200226-210151](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/5.png) | ![Screenshot_20200226-210223](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/6.png) | | ![Screenshot_20200226-210405](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/4.png) | ![Screenshot_20200226-210151](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/5.png) | ![Screenshot_20200226-210223](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/6.png) |
| ![](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/tenInchScreenshots/1.png) | ![](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/tenInchScreenshots/2.png) | | ![](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/tenInchScreenshots/1.png) | ![](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/tenInchScreenshots/2.png) |
|-----------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------| |-----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------|
### Localization ### Localization
@ -43,9 +42,11 @@ Download APK from Github Releases:
<img src="https://hosted.weblate.org/widgets/kotatsu/-/287x66-white.png" alt="Translation status" /> <img src="https://hosted.weblate.org/widgets/kotatsu/-/287x66-white.png" alt="Translation status" />
</a> </a>
Kotatsu is localized in a number of different languages, if you would like to help improve these or add new languages, please head over to the Weblate <a href="https://hosted.weblate.org/engage/kotatsu/">project page</a> Kotatsu is localized in a number of different languages, if you would like to help improve these or add new languages,
please head over to the Weblate <a href="https://hosted.weblate.org/engage/kotatsu/">project page</a>
### License ### License
[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html) [![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html)
Kotatsu is Free Software: You can use, study share and improve it at your Kotatsu is Free Software: You can use, study share and improve it at your

@ -14,8 +14,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 32 targetSdkVersion 32
versionCode 406 versionCode 410
versionName '3.2.2' versionName '3.3.1'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -49,72 +49,88 @@ android {
kotlinOptions { kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString() jvmTarget = JavaVersion.VERSION_1_8.toString()
freeCompilerArgs += [ freeCompilerArgs += [
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', '-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
'-opt-in=kotlinx.coroutines.FlowPreview', '-opt-in=kotlinx.coroutines.FlowPreview',
'-opt-in=kotlin.contracts.ExperimentalContracts', '-opt-in=kotlin.contracts.ExperimentalContracts',
'-opt-in=coil.annotation.ExperimentalCoilApi', '-opt-in=coil.annotation.ExperimentalCoilApi',
] ]
} }
lint { lint {
abortOnError false abortOnError false
disable 'MissingTranslation', 'PrivateResource' disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged'
} }
testOptions { testOptions {
unitTests.includeAndroidResources = true unitTests.includeAndroidResources = true
unitTests.returnDefaultValues = false unitTests.returnDefaultValues = false
} }
} }
afterEvaluate {
compileDebugKotlin {
kotlinOptions {
freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi']
}
}
}
dependencies { dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
implementation('com.github.nv95:kotatsu-parsers:44e6842025') { implementation('com.github.nv95:kotatsu-parsers:8a3b6df91d') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1'
implementation 'androidx.core:core-ktx:1.7.0' implementation 'androidx.core:core-ktx:1.8.0'
implementation 'androidx.activity:activity-ktx:1.4.0' implementation 'androidx.activity:activity-ktx:1.5.0-rc01'
implementation 'androidx.fragment:fragment-ktx:1.4.1' implementation 'androidx.fragment:fragment-ktx:1.5.0-rc01'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.0-rc01'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.0-rc01'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.1' implementation 'androidx.lifecycle:lifecycle-service:2.5.0-rc01'
implementation 'androidx.lifecycle:lifecycle-service:2.4.1' implementation 'androidx.lifecycle:lifecycle-process:2.5.0-rc01'
implementation 'androidx.lifecycle:lifecycle-process:2.4.1' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.2.1' implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01' implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
implementation 'androidx.preference:preference-ktx:1.2.0' implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.work:work-runtime-ktx:2.7.1' implementation 'androidx.work:work-runtime-ktx:2.7.1'
implementation 'com.google.android.material:material:1.7.0-alpha01' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha04'
implementation 'com.google.android.material:material:1.7.0-alpha02'
//noinspection LifecycleAnnotationProcessorWithJava8 //noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.4.1' kapt 'androidx.lifecycle:lifecycle-compiler:2.5.0-rc01'
implementation 'androidx.room:room-runtime:2.4.2' implementation 'androidx.room:room-runtime:2.4.2'
implementation 'androidx.room:room-ktx:2.4.2' implementation 'androidx.room:room-ktx:2.4.2'
kapt 'androidx.room:room-compiler:2.4.2' kapt 'androidx.room:room-compiler:2.4.2'
implementation 'com.squareup.okhttp3:okhttp:4.9.3' implementation 'com.squareup.okhttp3:okhttp:4.10.0'
implementation 'com.squareup.okio:okio:3.0.0' implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.3'
implementation 'com.squareup.okio:okio:3.1.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation 'io.insert-koin:koin-android:3.1.6' implementation 'io.insert-koin:koin-android:3.2.0'
implementation 'io.coil-kt:coil-base:2.0.0-rc03' implementation 'io.coil-kt:coil-base:2.1.0'
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0' implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'ch.acra:acra-mail:5.9.3'
implementation 'ch.acra:acra-dialog:5.9.3'
debugImplementation 'org.jsoup:jsoup:1.15.1'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.2'
testImplementation 'io.insert-koin:koin-test-junit4:3.1.5'
androidTestImplementation 'androidx.test:runner:1.4.0' androidTestImplementation 'androidx.test:runner:1.4.0'
androidTestImplementation 'androidx.test:rules:1.4.0' androidTestImplementation 'androidx.test:rules:1.4.0'
androidTestImplementation 'androidx.test:core-ktx:1.4.0' androidTestImplementation 'androidx.test:core-ktx:1.4.0'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3' androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.2'
androidTestImplementation 'io.insert-koin:koin-test:3.2.0'
androidTestImplementation 'io.insert-koin:koin-test-junit4:3.2.0'
androidTestImplementation 'androidx.room:room-testing:2.4.2' androidTestImplementation 'androidx.room:room-testing:2.4.2'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.13.0'
} }

@ -0,0 +1,163 @@
{
"id": -2096681732556647985,
"title": "Странствия Эманон",
"url": "/stranstviia_emanon",
"publicUrl": "https://readmanga.io/stranstviia_emanon",
"rating": 0.9400894,
"isNsfw": true,
"coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
"tags": [
{
"title": "Сверхъестественное",
"key": "supernatural",
"source": "READMANGA_RU"
},
{
"title": "Сэйнэн",
"key": "seinen",
"source": "READMANGA_RU"
},
{
"title": "Повседневность",
"key": "slice_of_life",
"source": "READMANGA_RU"
},
{
"title": "Приключения",
"key": "adventure",
"source": "READMANGA_RU"
}
],
"state": "FINISHED",
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
"chapters": [
{
"id": 1552943969433540704,
"name": "1 - 1",
"number": 1,
"url": "/stranstviia_emanon/vol1/1",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433540705,
"name": "1 - 2",
"number": 2,
"url": "/stranstviia_emanon/vol1/2",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433540706,
"name": "1 - 3",
"number": 3,
"url": "/stranstviia_emanon/vol1/3",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433540707,
"name": "1 - 4",
"number": 4,
"url": "/stranstviia_emanon/vol1/4",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433540708,
"name": "1 - 5",
"number": 5,
"url": "/stranstviia_emanon/vol1/5",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433541665,
"name": "2 - 1",
"number": 6,
"url": "/stranstviia_emanon/vol2/1",
"scanlator": "Sup!",
"uploadDate": 1415570400000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433541666,
"name": "2 - 2",
"number": 7,
"url": "/stranstviia_emanon/vol2/2",
"scanlator": "Sup!",
"uploadDate": 1419976800000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433541667,
"name": "2 - 3",
"number": 8,
"url": "/stranstviia_emanon/vol2/3",
"scanlator": "Sup!",
"uploadDate": 1427922000000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433541668,
"name": "2 - 4",
"number": 9,
"url": "/stranstviia_emanon/vol2/4",
"scanlator": "Sup!",
"uploadDate": 1436907600000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433541669,
"name": "2 - 5",
"number": 10,
"url": "/stranstviia_emanon/vol2/5",
"scanlator": "Sup!",
"uploadDate": 1446674400000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433541670,
"name": "2 - 6",
"number": 11,
"url": "/stranstviia_emanon/vol2/6",
"scanlator": "Sup!",
"uploadDate": 1451512800000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433542626,
"name": "3 - 1",
"number": 12,
"url": "/stranstviia_emanon/vol3/1",
"scanlator": "Sup!",
"uploadDate": 1461618000000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433542627,
"name": "3 - 2",
"number": 13,
"url": "/stranstviia_emanon/vol3/2",
"scanlator": "Sup!",
"uploadDate": 1461618000000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433542628,
"name": "3 - 3",
"number": 14,
"url": "/stranstviia_emanon/vol3/3",
"scanlator": "",
"uploadDate": 1465851600000,
"source": "READMANGA_RU"
}
],
"source": "READMANGA_RU"
}

@ -0,0 +1,36 @@
{
"id": -2096681732556647985,
"title": "Странствия Эманон",
"url": "/stranstviia_emanon",
"publicUrl": "https://readmanga.io/stranstviia_emanon",
"rating": 0.9400894,
"isNsfw": true,
"coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
"tags": [
{
"title": "Сверхъестественное",
"key": "supernatural",
"source": "READMANGA_RU"
},
{
"title": "Сэйнэн",
"key": "seinen",
"source": "READMANGA_RU"
},
{
"title": "Повседневность",
"key": "slice_of_life",
"source": "READMANGA_RU"
},
{
"title": "Приключения",
"key": "adventure",
"source": "READMANGA_RU"
}
],
"state": "FINISHED",
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
"chapters": [],
"source": "READMANGA_RU"
}

@ -0,0 +1,136 @@
{
"id": -2096681732556647985,
"title": "Странствия Эманон",
"url": "/stranstviia_emanon",
"publicUrl": "https://readmanga.io/stranstviia_emanon",
"rating": 0.9400894,
"isNsfw": true,
"coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
"tags": [
{
"title": "Сверхъестественное",
"key": "supernatural",
"source": "READMANGA_RU"
},
{
"title": "Сэйнэн",
"key": "seinen",
"source": "READMANGA_RU"
},
{
"title": "Повседневность",
"key": "slice_of_life",
"source": "READMANGA_RU"
},
{
"title": "Приключения",
"key": "adventure",
"source": "READMANGA_RU"
}
],
"state": "FINISHED",
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
"chapters": [
{
"id": 3552943969433540704,
"name": "1 - 1",
"number": 1,
"url": "/stranstviia_emanon/vol1/1",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540705,
"name": "1 - 2",
"number": 2,
"url": "/stranstviia_emanon/vol1/2",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540706,
"name": "1 - 3",
"number": 3,
"url": "/stranstviia_emanon/vol1/3",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540707,
"name": "1 - 4",
"number": 4,
"url": "/stranstviia_emanon/vol1/4",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540708,
"name": "1 - 5",
"number": 5,
"url": "/stranstviia_emanon/vol1/5",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541665,
"name": "2 - 1",
"number": 6,
"url": "/stranstviia_emanon/vol2/1",
"scanlator": "Sup!",
"uploadDate": 1415570400000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541666,
"name": "2 - 2",
"number": 7,
"url": "/stranstviia_emanon/vol2/2",
"scanlator": "Sup!",
"uploadDate": 1419976800000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541667,
"name": "2 - 3",
"number": 8,
"url": "/stranstviia_emanon/vol2/3",
"scanlator": "Sup!",
"uploadDate": 1427922000000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541668,
"name": "2 - 4",
"number": 9,
"url": "/stranstviia_emanon/vol2/4",
"scanlator": "Sup!",
"uploadDate": 1436907600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541669,
"name": "2 - 5",
"number": 10,
"url": "/stranstviia_emanon/vol2/5",
"scanlator": "Sup!",
"uploadDate": 1446674400000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541670,
"name": "2 - 6",
"number": 11,
"url": "/stranstviia_emanon/vol2/6",
"scanlator": "Sup!",
"uploadDate": 1451512800000,
"source": "READMANGA_RU"
}
],
"source": "READMANGA_RU"
}

@ -0,0 +1,163 @@
{
"id": -2096681732556647985,
"title": "Странствия Эманон",
"url": "/stranstviia_emanon",
"publicUrl": "https://readmanga.io/stranstviia_emanon",
"rating": 0.9400894,
"isNsfw": true,
"coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
"tags": [
{
"title": "Сверхъестественное",
"key": "supernatural",
"source": "READMANGA_RU"
},
{
"title": "Сэйнэн",
"key": "seinen",
"source": "READMANGA_RU"
},
{
"title": "Повседневность",
"key": "slice_of_life",
"source": "READMANGA_RU"
},
{
"title": "Приключения",
"key": "adventure",
"source": "READMANGA_RU"
}
],
"state": "FINISHED",
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
"chapters": [
{
"id": 3552943969433540704,
"name": "1 - 1",
"number": 1,
"url": "/stranstviia_emanon/vol1/1",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540705,
"name": "1 - 2",
"number": 2,
"url": "/stranstviia_emanon/vol1/2",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540706,
"name": "1 - 3",
"number": 3,
"url": "/stranstviia_emanon/vol1/3",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540707,
"name": "1 - 4",
"number": 4,
"url": "/stranstviia_emanon/vol1/4",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540708,
"name": "1 - 5",
"number": 5,
"url": "/stranstviia_emanon/vol1/5",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541665,
"name": "2 - 1",
"number": 6,
"url": "/stranstviia_emanon/vol2/1",
"scanlator": "Sup!",
"uploadDate": 1415570400000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541666,
"name": "2 - 2",
"number": 7,
"url": "/stranstviia_emanon/vol2/2",
"scanlator": "Sup!",
"uploadDate": 1419976800000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541667,
"name": "2 - 3",
"number": 8,
"url": "/stranstviia_emanon/vol2/3",
"scanlator": "Sup!",
"uploadDate": 1427922000000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541668,
"name": "2 - 4",
"number": 9,
"url": "/stranstviia_emanon/vol2/4",
"scanlator": "Sup!",
"uploadDate": 1436907600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541669,
"name": "2 - 5",
"number": 10,
"url": "/stranstviia_emanon/vol2/5",
"scanlator": "Sup!",
"uploadDate": 1446674400000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541670,
"name": "2 - 6",
"number": 11,
"url": "/stranstviia_emanon/vol2/6",
"scanlator": "Sup!",
"uploadDate": 1451512800000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433542626,
"name": "3 - 1",
"number": 12,
"url": "/stranstviia_emanon/vol3/1",
"scanlator": "Sup!",
"uploadDate": 1461618000000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433542627,
"name": "3 - 2",
"number": 13,
"url": "/stranstviia_emanon/vol3/2",
"scanlator": "Sup!",
"uploadDate": 1461618000000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433542628,
"name": "3 - 3",
"number": 14,
"url": "/stranstviia_emanon/vol3/3",
"scanlator": "",
"uploadDate": 1465851600000,
"source": "READMANGA_RU"
}
],
"source": "READMANGA_RU"
}

@ -0,0 +1,154 @@
{
"id": -2096681732556647985,
"title": "Странствия Эманон",
"url": "/stranstviia_emanon",
"publicUrl": "https://readmanga.io/stranstviia_emanon",
"rating": 0.9400894,
"isNsfw": true,
"coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
"tags": [
{
"title": "Сверхъестественное",
"key": "supernatural",
"source": "READMANGA_RU"
},
{
"title": "Сэйнэн",
"key": "seinen",
"source": "READMANGA_RU"
},
{
"title": "Повседневность",
"key": "slice_of_life",
"source": "READMANGA_RU"
},
{
"title": "Приключения",
"key": "adventure",
"source": "READMANGA_RU"
}
],
"state": "FINISHED",
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
"chapters": [
{
"id": 3552943969433540704,
"name": "1 - 1",
"number": 1,
"url": "/stranstviia_emanon/vol1/1",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540705,
"name": "1 - 2",
"number": 2,
"url": "/stranstviia_emanon/vol1/2",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540706,
"name": "1 - 3",
"number": 3,
"url": "/stranstviia_emanon/vol1/3",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540707,
"name": "1 - 4",
"number": 4,
"url": "/stranstviia_emanon/vol1/4",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540708,
"name": "1 - 5",
"number": 5,
"url": "/stranstviia_emanon/vol1/5",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541666,
"name": "2 - 2",
"number": 7,
"url": "/stranstviia_emanon/vol2/2",
"scanlator": "Sup!",
"uploadDate": 1419976800000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541667,
"name": "2 - 3",
"number": 8,
"url": "/stranstviia_emanon/vol2/3",
"scanlator": "Sup!",
"uploadDate": 1427922000000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541668,
"name": "2 - 4",
"number": 9,
"url": "/stranstviia_emanon/vol2/4",
"scanlator": "Sup!",
"uploadDate": 1436907600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541669,
"name": "2 - 5",
"number": 10,
"url": "/stranstviia_emanon/vol2/5",
"scanlator": "Sup!",
"uploadDate": 1446674400000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541670,
"name": "2 - 6",
"number": 11,
"url": "/stranstviia_emanon/vol2/6",
"scanlator": "Sup!",
"uploadDate": 1451512800000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433542626,
"name": "3 - 1",
"number": 12,
"url": "/stranstviia_emanon/vol3/1",
"scanlator": "Sup!",
"uploadDate": 1461618000000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433542627,
"name": "3 - 2",
"number": 13,
"url": "/stranstviia_emanon/vol3/2",
"scanlator": "Sup!",
"uploadDate": 1461618000000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433542628,
"name": "3 - 3",
"number": 14,
"url": "/stranstviia_emanon/vol3/3",
"scanlator": "",
"uploadDate": 1465851600000,
"source": "READMANGA_RU"
}
],
"source": "READMANGA_RU"
}

@ -1,14 +1,13 @@
package org.koitharu.kotatsu.core.db package org.koitharu.kotatsu.core.db
import androidx.room.testing.MigrationTestHelper import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import java.io.IOException
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.koitharu.kotatsu.core.db.migrations.* import org.koitharu.kotatsu.core.db.migrations.*
import java.io.IOException
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class MangaDatabaseTest { class MangaDatabaseTest {
@ -16,8 +15,7 @@ class MangaDatabaseTest {
@get:Rule @get:Rule
val helper: MigrationTestHelper = MigrationTestHelper( val helper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(), InstrumentationRegistry.getInstrumentation(),
MangaDatabase::class.java.canonicalName, MangaDatabase::class.java,
FrameworkSQLiteOpenHelperFactory()
) )
@Test @Test
@ -37,7 +35,6 @@ class MangaDatabaseTest {
} }
} }
private companion object { private companion object {
const val TEST_DB = "test-db" const val TEST_DB = "test-db"
@ -50,6 +47,9 @@ class MangaDatabaseTest {
Migration5To6(), Migration5To6(),
Migration6To7(), Migration6To7(),
Migration7To8(), Migration7To8(),
Migration8To9(),
Migration9To10(),
Migration10To11(),
) )
} }
} }

@ -0,0 +1,188 @@
package org.koitharu.kotatsu.tracker.domain
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlinx.coroutines.test.runTest
import okio.buffer
import okio.source
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.test.KoinTest
import org.koin.test.inject
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
@RunWith(AndroidJUnit4::class)
class TrackerTest : KoinTest {
private val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
private val mangaAdapter = moshi.adapter(Manga::class.java)
private val historyRegistry by inject<HistoryRepository>()
private val repository by inject<TrackingRepository>()
private val dataRepository by inject<MangaDataRepository>()
private val tracker by inject<Tracker>()
@Test
fun noUpdates() = runTest {
val manga = loadManga("full.json")
tracker.deleteTrack(manga.id)
tracker.checkUpdates(manga, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(manga.id))
tracker.checkUpdates(manga, commit = true).apply {
assertTrue(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(manga.id))
}
@Test
fun hasUpdates() = runTest {
val mangaFirst = loadManga("first_chapters.json")
val mangaFull = loadManga("full.json")
tracker.deleteTrack(mangaFirst.id)
tracker.checkUpdates(mangaFirst, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
tracker.checkUpdates(mangaFull, commit = true).apply {
assertTrue(isValid)
assertEquals(3, newChapters.size)
}
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
tracker.checkUpdates(mangaFull, commit = true).apply {
assertTrue(isValid)
assert(newChapters.isEmpty())
}
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
}
@Test
fun badIds() = runTest {
val mangaFirst = loadManga("first_chapters.json")
val mangaBad = loadManga("bad_ids.json")
tracker.deleteTrack(mangaFirst.id)
tracker.checkUpdates(mangaFirst, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
tracker.checkUpdates(mangaBad, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
tracker.checkUpdates(mangaFirst, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
}
@Test
fun badIds2() = runTest {
val mangaFirst = loadManga("first_chapters.json")
val mangaBad = loadManga("bad_ids.json")
val mangaFull = loadManga("full.json")
tracker.deleteTrack(mangaFirst.id)
tracker.checkUpdates(mangaFirst, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
tracker.checkUpdates(mangaFull, commit = true).apply {
assertTrue(isValid)
assertEquals(3, newChapters.size)
}
assertEquals(3, repository.getNewChaptersCount(mangaFull.id))
tracker.checkUpdates(mangaBad, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
}
@Test
fun fullReset() = runTest {
val mangaFull = loadManga("full.json")
val mangaFirst = loadManga("first_chapters.json")
val mangaEmpty = loadManga("empty.json")
tracker.deleteTrack(mangaFull.id)
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
tracker.checkUpdates(mangaFull, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
tracker.checkUpdates(mangaEmpty, commit = true).apply {
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
tracker.checkUpdates(mangaFirst, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
tracker.checkUpdates(mangaFull, commit = true).apply {
assertTrue(isValid)
assertEquals(3, newChapters.size)
}
assertEquals(3, repository.getNewChaptersCount(mangaFull.id))
tracker.checkUpdates(mangaEmpty, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
}
@Test
fun syncWithHistory() = runTest {
val mangaFull = loadManga("full.json")
val mangaFirst = loadManga("first_chapters.json")
tracker.deleteTrack(mangaFull.id)
tracker.checkUpdates(mangaFirst, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
tracker.checkUpdates(mangaFull, commit = true).apply {
assertTrue(isValid)
assertEquals(3, newChapters.size)
}
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
val chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex - 1) }
repository.syncWithHistory(mangaFull, chapter.id)
assertEquals(1, repository.getNewChaptersCount(mangaFirst.id))
tracker.checkUpdates(mangaFull, commit = true).apply {
assertTrue(isValid)
assert(newChapters.isEmpty())
}
assertEquals(1, repository.getNewChaptersCount(mangaFirst.id))
}
private suspend fun loadManga(name: String): Manga {
val assets = InstrumentationRegistry.getInstrumentation().context.assets
val manga = assets.open("manga/$name").use {
mangaAdapter.fromJson(it.source().buffer())
} ?: throw RuntimeException("Cannot read manga from json \"$name\"")
dataRepository.storeManga(manga)
return manga
}
}

@ -25,7 +25,7 @@ class DummyParser(override val context: MangaLoaderContext) : MangaParser(MangaS
offset: Int, offset: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder? sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
TODO("Not yet implemented") TODO("Not yet implemented")
} }

@ -0,0 +1,3 @@
package org.koitharu.kotatsu.utils.ext
fun Throwable.printStackTraceDebug() = printStackTrace()

@ -9,6 +9,7 @@
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<application <application
android:name="org.koitharu.kotatsu.KotatsuApp" android:name="org.koitharu.kotatsu.KotatsuApp"
@ -75,11 +76,6 @@
android:name="org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity" android:name="org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
<activity
android:name="org.koitharu.kotatsu.core.ui.CrashActivity"
android:label="@string/error_occurred"
android:theme="@android:style/Theme.DeviceDefault"
android:windowSoftInputMode="stateAlwaysHidden" />
<activity <activity
android:name="org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity" android:name="org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity"
android:label="@string/favourites_categories" android:label="@string/favourites_categories"
@ -94,7 +90,7 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity <activity
android:name="org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity" android:name="org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity"
android:label="@string/search" /> android:label="@string/search" />
<activity <activity
android:name="org.koitharu.kotatsu.main.ui.protect.ProtectActivity" android:name="org.koitharu.kotatsu.main.ui.protect.ProtectActivity"

@ -1,17 +1,24 @@
package org.koitharu.kotatsu package org.koitharu.kotatsu
import android.app.Application import android.app.Application
import android.content.Context
import android.os.StrictMode import android.os.StrictMode
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.fragment.app.strictmode.FragmentStrictMode import androidx.fragment.app.strictmode.FragmentStrictMode
import org.acra.ReportField
import org.acra.config.dialog
import org.acra.config.mailSender
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import org.koitharu.kotatsu.base.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.bookmarks.bookmarksModule
import org.koitharu.kotatsu.core.db.databaseModule import org.koitharu.kotatsu.core.db.databaseModule
import org.koitharu.kotatsu.core.github.githubModule import org.koitharu.kotatsu.core.github.githubModule
import org.koitharu.kotatsu.core.network.networkModule import org.koitharu.kotatsu.core.network.networkModule
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.AppCrashHandler
import org.koitharu.kotatsu.core.ui.uiModule import org.koitharu.kotatsu.core.ui.uiModule
import org.koitharu.kotatsu.details.detailsModule import org.koitharu.kotatsu.details.detailsModule
import org.koitharu.kotatsu.favourites.favouritesModule import org.koitharu.kotatsu.favourites.favouritesModule
@ -40,9 +47,9 @@ class KotatsuApp : Application() {
enableStrictMode() enableStrictMode()
} }
initKoin() initKoin()
Thread.setDefaultUncaughtExceptionHandler(AppCrashHandler(applicationContext))
AppCompatDelegate.setDefaultNightMode(get<AppSettings>().theme) AppCompatDelegate.setDefaultNightMode(get<AppSettings>().theme)
registerActivityLifecycleCallbacks(get<AppProtectHelper>()) registerActivityLifecycleCallbacks(get<AppProtectHelper>())
registerActivityLifecycleCallbacks(get<ActivityRecreationHandle>())
val widgetUpdater = WidgetUpdater(applicationContext) val widgetUpdater = WidgetUpdater(applicationContext)
widgetUpdater.subscribeToFavourites(get()) widgetUpdater.subscribeToFavourites(get())
widgetUpdater.subscribeToHistory(get()) widgetUpdater.subscribeToHistory(get())
@ -69,10 +76,41 @@ class KotatsuApp : Application() {
appWidgetModule, appWidgetModule,
suggestionsModule, suggestionsModule,
shikimoriModule, shikimoriModule,
bookmarksModule,
) )
} }
} }
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
initAcra {
buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.KEY_VALUE_LIST
reportContent = listOf(
ReportField.PACKAGE_NAME,
ReportField.APP_VERSION_CODE,
ReportField.APP_VERSION_NAME,
ReportField.ANDROID_VERSION,
ReportField.PHONE_MODEL,
ReportField.CRASH_CONFIGURATION,
ReportField.STACK_TRACE,
ReportField.SHARED_PREFERENCES,
)
dialog {
text = getString(R.string.crash_text)
title = getString(R.string.error_occurred)
positiveButtonText = getString(R.string.send)
resIcon = R.drawable.ic_alert_outline
resTheme = android.R.style.Theme_Material_Light_Dialog_Alert
}
mailSender {
mailTo = getString(R.string.email_error_report)
reportAsFile = true
reportFileName = "stacktrace.txt"
}
}
}
private fun enableStrictMode() { private fun enableStrictMode() {
StrictMode.setThreadPolicy( StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder() StrictMode.ThreadPolicy.Builder()

@ -9,55 +9,50 @@ import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.get import org.koin.core.component.get
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.medianOrNull
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
import java.util.zip.ZipFile import java.util.zip.ZipFile
import kotlin.math.roundToInt
object MangaUtils : KoinComponent { object MangaUtils : KoinComponent {
private const val MIN_WEBTOON_RATIO = 2
/** /**
* Automatic determine type of manga by page size * Automatic determine type of manga by page size
* @return ReaderMode.WEBTOON if page is wide * @return ReaderMode.WEBTOON if page is wide
*/ */
suspend fun determineMangaIsWebtoon(pages: List<MangaPage>): Boolean? { suspend fun determineMangaIsWebtoon(pages: List<MangaPage>): Boolean {
try { val pageIndex = (pages.size * 0.3).roundToInt()
val page = pages.medianOrNull() ?: return null val page = requireNotNull(pages.getOrNull(pageIndex)) { "No pages" }
val url = MangaRepository(page.source).getPageUrl(page) val url = MangaRepository(page.source).getPageUrl(page)
val uri = Uri.parse(url) val uri = Uri.parse(url)
val size = if (uri.scheme == "cbz") { val size = if (uri.scheme == "cbz") {
runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
val zip = ZipFile(uri.schemeSpecificPart) val zip = ZipFile(uri.schemeSpecificPart)
val entry = zip.getEntry(uri.fragment) val entry = zip.getEntry(uri.fragment)
zip.getInputStream(entry).use { zip.getInputStream(entry).use {
getBitmapSize(it) getBitmapSize(it)
}
}
} else {
val request = Request.Builder()
.url(url)
.get()
.header(CommonHeaders.REFERER, page.referer)
.cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED)
.build()
get<OkHttpClient>().newCall(request).await().use {
runInterruptible(Dispatchers.IO) {
getBitmapSize(it.body?.byteStream())
}
} }
} }
return size.width * 2 < size.height } else {
} catch (e: Exception) { val request = Request.Builder()
if (BuildConfig.DEBUG) { .url(url)
e.printStackTrace() .get()
.header(CommonHeaders.REFERER, page.referer)
.cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED)
.build()
get<OkHttpClient>().newCall(request).await().use {
runInterruptible(Dispatchers.IO) {
getBitmapSize(it.body?.byteStream())
}
} }
return null
} }
return size.width * MIN_WEBTOON_RATIO < size.height
} }
suspend fun getImageMimeType(file: File): String? = runInterruptible(Dispatchers.IO) { suspend fun getImageMimeType(file: File): String? = runInterruptible(Dispatchers.IO) {

@ -0,0 +1,19 @@
package org.koitharu.kotatsu.base.domain
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
fun interface ReversibleHandle {
suspend fun reverse()
}
fun ReversibleHandle.reverseAsync() = processLifecycleScope.launch(Dispatchers.Default) {
reverse()
}
operator fun ReversibleHandle.plus(other: ReversibleHandle) = ReversibleHandle {
this.reverse()
other.reverse()
}

@ -12,7 +12,6 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.ActionBarContextView import androidx.appcompat.widget.ActionBarContextView
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.core.app.ActivityCompat
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
@ -43,9 +42,13 @@ abstract class BaseActivity<B : ViewBinding> :
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
val settings = get<AppSettings>() val settings = get<AppSettings>()
val isAmoled = settings.isAmoledTheme
val isDynamic = settings.isDynamicTheme
// TODO support DialogWhenLarge theme
when { when {
settings.isAmoledTheme -> setTheme(R.style.ThemeOverlay_Kotatsu_AMOLED) isAmoled && isDynamic -> setTheme(R.style.Theme_Kotatsu_Monet_Amoled)
settings.isDynamicTheme -> setTheme(R.style.Theme_Kotatsu_Monet) isAmoled -> setTheme(R.style.Theme_Kotatsu_Amoled)
isDynamic -> setTheme(R.style.Theme_Kotatsu_Monet)
} }
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
@ -79,8 +82,9 @@ abstract class BaseActivity<B : ViewBinding> :
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) { // TODO remove if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) { // TODO remove
ActivityCompat.recreate(this) // ActivityCompat.recreate(this)
return true throw RuntimeException("Test crash")
// return true
} }
return super.onKeyDown(keyCode, event) return super.onKeyDown(keyCode, event)
} }

@ -2,11 +2,11 @@ package org.koitharu.kotatsu.base.ui
import android.app.Dialog import android.app.Dialog
import android.os.Bundle import android.os.Bundle
import android.util.DisplayMetrics
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams import android.view.ViewGroup.LayoutParams
import androidx.appcompat.app.AppCompatDialog
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
@ -14,6 +14,7 @@ import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.dialog.AppBottomSheetDialog import org.koitharu.kotatsu.base.ui.dialog.AppBottomSheetDialog
import org.koitharu.kotatsu.utils.ext.displayCompat
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() { abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
@ -33,6 +34,20 @@ abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
): View { ): View {
val binding = onInflateView(inflater, container) val binding = onInflateView(inflater, container)
viewBinding = binding viewBinding = binding
// Enforce max width for tablets
val width = resources.getDimensionPixelSize(R.dimen.bottom_sheet_width)
if (width > 0) {
behavior?.maxWidth = width
}
// Set peek height to 50% display height
requireContext().displayCompat?.let {
val metrics = DisplayMetrics()
it.getRealMetrics(metrics)
behavior?.peekHeight = (metrics.heightPixels * 0.4).toInt()
}
return binding.root return binding.root
} }
@ -42,11 +57,7 @@ abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
} }
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return if (resources.getBoolean(R.bool.is_tablet)) { return AppBottomSheetDialog(requireContext(), theme)
AppCompatDialog(context, R.style.Theme_Kotatsu_Dialog)
} else {
AppBottomSheetDialog(requireContext(), theme)
}
} }
fun addBottomSheetCallback(callback: BottomSheetBehavior.BottomSheetCallback) { fun addBottomSheetCallback(callback: BottomSheetBehavior.BottomSheetCallback) {

@ -7,10 +7,12 @@ import android.view.View
import android.view.WindowManager import android.view.WindowManager
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
@Suppress("DEPRECATION")
private const val SYSTEM_UI_FLAGS_SHOWN = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or private const val SYSTEM_UI_FLAGS_SHOWN = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
@Suppress("DEPRECATION")
private const val SYSTEM_UI_FLAGS_HIDDEN = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or private const val SYSTEM_UI_FLAGS_HIDDEN = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
@ -18,7 +20,8 @@ private const val SYSTEM_UI_FLAGS_HIDDEN = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
abstract class BaseFullscreenActivity<B : ViewBinding> : BaseActivity<B>(), abstract class BaseFullscreenActivity<B : ViewBinding> :
BaseActivity<B>(),
View.OnSystemUiVisibilityChangeListener { View.OnSystemUiVisibilityChangeListener {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -35,16 +38,19 @@ abstract class BaseFullscreenActivity<B : ViewBinding> : BaseActivity<B>(),
showSystemUI() showSystemUI()
} }
@Suppress("DEPRECATION", "DeprecatedCallableAddReplaceWith")
@Deprecated("Deprecated in Java") @Deprecated("Deprecated in Java")
final override fun onSystemUiVisibilityChange(visibility: Int) { final override fun onSystemUiVisibilityChange(visibility: Int) {
onSystemUiVisibilityChanged(visibility and View.SYSTEM_UI_FLAG_FULLSCREEN == 0) onSystemUiVisibilityChanged(visibility and View.SYSTEM_UI_FLAG_FULLSCREEN == 0)
} }
// TODO WindowInsetsControllerCompat works incorrect // TODO WindowInsetsControllerCompat works incorrect
@Suppress("DEPRECATION")
protected fun hideSystemUI() { protected fun hideSystemUI() {
window.decorView.systemUiVisibility = SYSTEM_UI_FLAGS_HIDDEN window.decorView.systemUiVisibility = SYSTEM_UI_FLAGS_HIDDEN
} }
@Suppress("DEPRECATION")
protected fun showSystemUI() { protected fun showSystemUI() {
window.decorView.systemUiVisibility = SYSTEM_UI_FLAGS_SHOWN window.decorView.systemUiVisibility = SYSTEM_UI_FLAGS_SHOWN
} }

@ -1,18 +1,25 @@
package org.koitharu.kotatsu.base.ui package org.koitharu.kotatsu.base.ui
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.* import kotlinx.coroutines.*
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.base.ui.util.CountedBooleanLiveData import org.koitharu.kotatsu.base.ui.util.CountedBooleanLiveData
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
abstract class BaseViewModel : ViewModel() { abstract class BaseViewModel : ViewModel() {
val onError = SingleLiveEvent<Throwable>() protected val loadingCounter = CountedBooleanLiveData()
val isLoading = CountedBooleanLiveData() protected val errorEvent = SingleLiveEvent<Throwable>()
val onError: LiveData<Throwable>
get() = errorEvent
val isLoading: LiveData<Boolean>
get() = loadingCounter
protected fun launchJob( protected fun launchJob(
context: CoroutineContext = EmptyCoroutineContext, context: CoroutineContext = EmptyCoroutineContext,
@ -25,20 +32,18 @@ abstract class BaseViewModel : ViewModel() {
start: CoroutineStart = CoroutineStart.DEFAULT, start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit block: suspend CoroutineScope.() -> Unit
): Job = viewModelScope.launch(context + createErrorHandler(), start) { ): Job = viewModelScope.launch(context + createErrorHandler(), start) {
isLoading.postValue(true) loadingCounter.increment()
try { try {
block() block()
} finally { } finally {
isLoading.postValue(false) loadingCounter.decrement()
} }
} }
private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable -> private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable ->
if (BuildConfig.DEBUG) { throwable.printStackTraceDebug()
throwable.printStackTrace()
}
if (throwable !is CancellationException) { if (throwable !is CancellationException) {
onError.postCall(throwable) errorEvent.postCall(throwable)
} }
} }
} }

@ -21,7 +21,7 @@ class AppBottomSheetDialog(context: Context, theme: Int) : BottomSheetDialog(con
if (drawEdgeToEdge) { if (drawEdgeToEdge) {
// Copied from super.onAttachedToWindow: // Copied from super.onAttachedToWindow:
val edgeToEdgeFlags = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_STABLE val edgeToEdgeFlags = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
// Fix super-class's window flag bug by respecting the intial system UI visibility: // Fix super-class's window flag bug by respecting the initial system UI visibility:
window.decorView.systemUiVisibility = edgeToEdgeFlags or initialSystemUiVisibility window.decorView.systemUiVisibility = edgeToEdgeFlags or initialSystemUiVisibility
} }
} }

@ -0,0 +1,20 @@
package org.koitharu.kotatsu.base.ui.list
import android.view.View
import android.view.View.OnClickListener
import android.view.View.OnLongClickListener
import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder
class AdapterDelegateClickListenerAdapter<I>(
private val adapterDelegate: AdapterDelegateViewBindingViewHolder<I, *>,
private val clickListener: OnListItemClickListener<I>,
) : OnClickListener, OnLongClickListener {
override fun onClick(v: View) {
clickListener.onItemClick(adapterDelegate.item, v)
}
override fun onLongClick(v: View): Boolean {
return clickListener.onItemLongClick(adapterDelegate.item, v)
}
}

@ -0,0 +1,34 @@
package org.koitharu.kotatsu.base.ui.util
import android.app.Activity
import android.app.Application.ActivityLifecycleCallbacks
import android.os.Bundle
import java.util.*
class ActivityRecreationHandle : ActivityLifecycleCallbacks {
private val activities = WeakHashMap<Activity, Unit>()
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
activities[activity] = Unit
}
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityResumed(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) {
activities.remove(activity)
}
fun recreateAll() {
val snapshot = activities.keys.toList()
snapshot.forEach { it.recreate() }
}
}

@ -1,20 +1,31 @@
package org.koitharu.kotatsu.base.ui.util package org.koitharu.kotatsu.base.ui.util
import androidx.lifecycle.MutableLiveData import androidx.annotation.AnyThread
import androidx.lifecycle.LiveData
import java.util.concurrent.atomic.AtomicInteger
class CountedBooleanLiveData : MutableLiveData<Boolean>(false) { class CountedBooleanLiveData : LiveData<Boolean>(false) {
private var counter = 0 private val counter = AtomicInteger(0)
override fun setValue(value: Boolean) { @AnyThread
if (value) { fun increment() {
counter++ if (counter.getAndIncrement() == 0) {
} else { postValue(true)
counter--
} }
val newValue = counter > 0 }
if (newValue != this.value) {
super.setValue(newValue) @AnyThread
fun decrement() {
if (counter.decrementAndGet() == 0) {
postValue(false)
}
}
@AnyThread
fun reset() {
if (counter.getAndSet(0) != 0) {
postValue(false)
} }
} }
} }

@ -16,10 +16,7 @@ class WindowInsetsDelegate(
private var lastInsets: Insets? = null private var lastInsets: Insets? = null
override fun onApplyWindowInsets(v: View?, insets: WindowInsetsCompat?): WindowInsetsCompat? { override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
if (insets == null) {
return null
}
val handledInsets = interceptingWindowInsetsListener?.onApplyWindowInsets(v, insets) ?: insets val handledInsets = interceptingWindowInsetsListener?.onApplyWindowInsets(v, insets) ?: insets
val newInsets = if (handleImeInsets) { val newInsets = if (handleImeInsets) {
Insets.max( Insets.max(
@ -49,7 +46,7 @@ class WindowInsetsDelegate(
) { ) {
view.removeOnLayoutChangeListener(this) view.removeOnLayoutChangeListener(this)
if (lastInsets == null) { // Listener may not be called if (lastInsets == null) { // Listener may not be called
onApplyWindowInsets(view, ViewCompat.getRootWindowInsets(view)) onApplyWindowInsets(view, ViewCompat.getRootWindowInsets(view) ?: return)
} }
} }

@ -36,8 +36,7 @@ class ListItemTextView @JvmOverloads constructor(
init { init {
context.withStyledAttributes(attrs, R.styleable.ListItemTextView, defStyleAttr) { context.withStyledAttributes(attrs, R.styleable.ListItemTextView, defStyleAttr) {
val itemRippleColor = getColorStateList(R.styleable.ListItemTextView_rippleColor) val itemRippleColor = getRippleColor(context)
?: getRippleColorFallback(context)
val shape = createShapeDrawable(this) val shape = createShapeDrawable(this)
background = RippleDrawable( background = RippleDrawable(
RippleUtils.sanitizeRippleDrawableColor(itemRippleColor), RippleUtils.sanitizeRippleDrawableColor(itemRippleColor),
@ -108,7 +107,7 @@ class ListItemTextView @JvmOverloads constructor(
ta.getResourceId(R.styleable.ListItemTextView_shapeAppearanceOverlay, 0), ta.getResourceId(R.styleable.ListItemTextView_shapeAppearanceOverlay, 0),
).build() ).build()
val shapeDrawable = MaterialShapeDrawable(shapeAppearance) val shapeDrawable = MaterialShapeDrawable(shapeAppearance)
shapeDrawable.fillColor = ta.getColorStateList(R.styleable.ListItemTextView_backgroundTint) shapeDrawable.fillColor = ta.getColorStateList(R.styleable.ListItemTextView_backgroundFillColor)
return InsetDrawable( return InsetDrawable(
shapeDrawable, shapeDrawable,
ta.getDimensionPixelOffset(R.styleable.ListItemTextView_android_insetLeft, 0), ta.getDimensionPixelOffset(R.styleable.ListItemTextView_android_insetLeft, 0),
@ -118,7 +117,7 @@ class ListItemTextView @JvmOverloads constructor(
) )
} }
private fun getRippleColorFallback(context: Context): ColorStateList { private fun getRippleColor(context: Context): ColorStateList {
return context.getThemeColorStateList(android.R.attr.colorControlHighlight) return context.getThemeColorStateList(android.R.attr.colorControlHighlight)
?: ColorStateList.valueOf(Color.TRANSPARENT) ?: ColorStateList.valueOf(Color.TRANSPARENT)
} }

@ -0,0 +1,10 @@
package org.koitharu.kotatsu.bookmarks
import org.koin.dsl.module
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
val bookmarksModule
get() = module {
factory { BookmarksRepository(get()) }
}

@ -0,0 +1,28 @@
package org.koitharu.kotatsu.bookmarks.data
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import org.koitharu.kotatsu.core.db.entity.MangaEntity
@Entity(
tableName = "bookmarks",
primaryKeys = ["manga_id", "page_id"],
foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE
),
]
)
class BookmarkEntity(
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "page_id", index = true) val pageId: Long,
@ColumnInfo(name = "chapter_id") val chapterId: Long,
@ColumnInfo(name = "page") val page: Int,
@ColumnInfo(name = "scroll") val scroll: Int,
@ColumnInfo(name = "image") val imageUrl: String,
@ColumnInfo(name = "created_at") val createdAt: Long,
)

@ -0,0 +1,23 @@
package org.koitharu.kotatsu.bookmarks.data
import androidx.room.Embedded
import androidx.room.Junction
import androidx.room.Relation
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
class BookmarkWithManga(
@Embedded val bookmark: BookmarkEntity,
@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>,
)

@ -0,0 +1,26 @@
package org.koitharu.kotatsu.bookmarks.data
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
abstract class BookmarksDao {
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page")
abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow<BookmarkEntity?>
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId ORDER BY created_at DESC")
abstract fun observe(mangaId: Long): Flow<List<BookmarkEntity>>
@Insert
abstract suspend fun insert(entity: BookmarkEntity)
@Delete
abstract suspend fun delete(entity: BookmarkEntity)
@Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
abstract suspend fun delete(mangaId: Long, pageId: Long)
}

@ -0,0 +1,31 @@
package org.koitharu.kotatsu.bookmarks.data
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.parsers.model.Manga
import java.util.*
fun BookmarkWithManga.toBookmark() = bookmark.toBookmark(
manga.toManga(tags.toMangaTags())
)
fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
manga = manga,
pageId = pageId,
chapterId = chapterId,
page = page,
scroll = scroll,
imageUrl = imageUrl,
createdAt = Date(createdAt),
)
fun Bookmark.toEntity() = BookmarkEntity(
mangaId = manga.id,
pageId = pageId,
chapterId = chapterId,
page = page,
scroll = scroll,
imageUrl = imageUrl,
createdAt = createdAt.time,
)

@ -0,0 +1,43 @@
package org.koitharu.kotatsu.bookmarks.domain
import org.koitharu.kotatsu.parsers.model.Manga
import java.util.*
class Bookmark(
val manga: Manga,
val pageId: Long,
val chapterId: Long,
val page: Int,
val scroll: Int,
val imageUrl: String,
val createdAt: Date,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Bookmark
if (manga != other.manga) return false
if (pageId != other.pageId) return false
if (chapterId != other.chapterId) return false
if (page != other.page) return false
if (scroll != other.scroll) return false
if (imageUrl != other.imageUrl) return false
if (createdAt != other.createdAt) return false
return true
}
override fun hashCode(): Int {
var result = manga.hashCode()
result = 31 * result + pageId.hashCode()
result = 31 * result + chapterId.hashCode()
result = 31 * result + page
result = 31 * result + scroll
result = 31 * result + imageUrl.hashCode()
result = 31 * result + createdAt.hashCode()
return result
}
}

@ -0,0 +1,38 @@
package org.koitharu.kotatsu.bookmarks.domain
import androidx.room.withTransaction
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.bookmarks.data.toBookmark
import org.koitharu.kotatsu.bookmarks.data.toEntity
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.mapItems
class BookmarksRepository(
private val db: MangaDatabase,
) {
fun observeBookmark(manga: Manga, chapterId: Long, page: Int): Flow<Bookmark?> {
return db.bookmarksDao.observe(manga.id, chapterId, page).map { it?.toBookmark(manga) }
}
fun observeBookmarks(manga: Manga): Flow<List<Bookmark>> {
return db.bookmarksDao.observe(manga.id).mapItems { it.toBookmark(manga) }
}
suspend fun addBookmark(bookmark: Bookmark) {
db.withTransaction {
val tags = bookmark.manga.tags.toEntities()
db.tagsDao.upsert(tags)
db.mangaDao.upsert(bookmark.manga.toEntity(), tags)
db.bookmarksDao.insert(bookmark.toEntity())
}
}
suspend fun removeBookmark(mangaId: Long, pageId: Long) {
db.bookmarksDao.delete(mangaId, pageId)
}
}

@ -0,0 +1,51 @@
package org.koitharu.kotatsu.bookmarks.ui
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import coil.request.Disposable
import coil.size.Scale
import coil.util.CoilUtils
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.databinding.ItemBookmarkBinding
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.referer
fun bookmarkListAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Bookmark>,
) = adapterDelegateViewBinding<Bookmark, Bookmark, ItemBookmarkBinding>(
{ inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) }
) {
var imageRequest: Disposable? = null
val listener = AdapterDelegateClickListenerAdapter(this, clickListener)
binding.root.setOnClickListener(listener)
binding.root.setOnLongClickListener(listener)
bind {
imageRequest?.dispose()
imageRequest = binding.imageViewThumb.newImageRequest(item.imageUrl)
.referer(item.manga.publicUrl)
.placeholder(R.drawable.ic_placeholder)
.fallback(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder)
.allowRgb565(true)
.scale(Scale.FILL)
.lifecycle(lifecycleOwner)
.enqueueWith(coil)
}
onViewRecycled {
imageRequest?.dispose()
imageRequest = null
CoilUtils.dispose(binding.imageViewThumb)
binding.imageViewThumb.setImageDrawable(null)
}
}

@ -0,0 +1,30 @@
package org.koitharu.kotatsu.bookmarks.ui
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
class BookmarksAdapter(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Bookmark>,
) : AsyncListDifferDelegationAdapter<Bookmark>(
DiffCallback(),
bookmarkListAD(coil, lifecycleOwner, clickListener)
) {
private class DiffCallback : DiffUtil.ItemCallback<Bookmark>() {
override fun areItemsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean {
return oldItem.manga.id == newItem.manga.id && oldItem.chapterId == newItem.chapterId
}
override fun areContentsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean {
return oldItem.imageUrl == newItem.imageUrl
}
}
}

@ -4,6 +4,8 @@ import android.content.Context
import androidx.room.Database import androidx.room.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.bookmarks.data.BookmarksDao
import org.koitharu.kotatsu.core.db.dao.* import org.koitharu.kotatsu.core.db.dao.*
import org.koitharu.kotatsu.core.db.entity.* import org.koitharu.kotatsu.core.db.entity.*
import org.koitharu.kotatsu.core.db.migrations.* import org.koitharu.kotatsu.core.db.migrations.*
@ -15,14 +17,17 @@ import org.koitharu.kotatsu.history.data.HistoryDao
import org.koitharu.kotatsu.history.data.HistoryEntity import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.suggestions.data.SuggestionDao import org.koitharu.kotatsu.suggestions.data.SuggestionDao
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao
@Database( @Database(
entities = [ entities = [
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
], ],
version = 10 version = 11,
) )
abstract class MangaDatabase : RoomDatabase() { abstract class MangaDatabase : RoomDatabase() {
@ -43,6 +48,8 @@ abstract class MangaDatabase : RoomDatabase() {
abstract val trackLogsDao: TrackLogsDao abstract val trackLogsDao: TrackLogsDao
abstract val suggestionDao: SuggestionDao abstract val suggestionDao: SuggestionDao
abstract val bookmarksDao: BookmarksDao
} }
fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder( fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder(
@ -59,6 +66,7 @@ fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder(
Migration7To8(), Migration7To8(),
Migration8To9(), Migration8To9(),
Migration9To10(), Migration9To10(),
Migration10To11(),
).addCallback( ).addCallback(
DatabasePrePopulateCallback(context.resources) DatabasePrePopulateCallback(context.resources)
).build() ).build()

@ -1,8 +1,8 @@
package org.koitharu.kotatsu.core.db.dao package org.koitharu.kotatsu.core.db.dao
import androidx.room.* import androidx.room.*
import org.koitharu.kotatsu.core.db.entity.TrackLogEntity import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.core.db.entity.TrackLogWithManga import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
@Dao @Dao
interface TrackLogsDao { interface TrackLogsDao {
@ -21,7 +21,7 @@ interface TrackLogsDao {
suspend fun removeAll(mangaId: Long) suspend fun removeAll(mangaId: Long)
@Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)") @Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)")
suspend fun cleanup() suspend fun gc()
@Query("SELECT COUNT(*) FROM track_logs") @Query("SELECT COUNT(*) FROM track_logs")
suspend fun count(): Int suspend fun count(): Int

@ -1,11 +1,9 @@
package org.koitharu.kotatsu.core.db.entity package org.koitharu.kotatsu.core.db.entity
import java.util.*
import org.koitharu.kotatsu.core.model.TrackingLogItem
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.longHashCode
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.utils.ext.longHashCode
// Entity to model // Entity to model
@ -35,13 +33,6 @@ fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags()) fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
fun TrackLogWithManga.toTrackingLogItem() = TrackingLogItem(
id = trackLog.id,
chapters = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() },
manga = manga.toManga(tags.toMangaTags()),
createdAt = Date(trackLog.createdAt)
)
// Model to entity // Model to entity
fun Manga.toEntity() = MangaEntity( fun Manga.toEntity() = MangaEntity(

@ -0,0 +1,26 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration10To11 : Migration(10, 11) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `bookmarks` (
`manga_id` INTEGER NOT NULL,
`page_id` INTEGER NOT NULL,
`chapter_id` INTEGER NOT NULL,
`page` INTEGER NOT NULL,
`scroll` INTEGER NOT NULL,
`image` TEXT NOT NULL,
`created_at` INTEGER NOT NULL,
PRIMARY KEY(`manga_id`, `page_id`),
FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )
""".trimIndent()
)
database.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_manga_id` ON `bookmarks` (`manga_id`)")
database.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_page_id` ON `bookmarks` (`page_id`)")
}
}

@ -0,0 +1,8 @@
package org.koitharu.kotatsu.core.exceptions
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
class CompositeException(val errors: Collection<Throwable>) : Exception() {
override val message: String = errors.mapNotNullToSet { it.message }.joinToString()
}

@ -4,7 +4,5 @@ import org.koin.dsl.module
val githubModule val githubModule
get() = module { get() = module {
factory { factory { GithubRepository(get()) }
GithubRepository(get())
}
} }

@ -54,27 +54,23 @@ class VersionId(
return result return result
} }
companion object { private fun variantWeight(variantType: String) = when (variantType.lowercase(Locale.ROOT)) {
"a", "alpha" -> 1
private fun variantWeight(variantType: String) = "b", "beta" -> 2
when (variantType.lowercase(Locale.ROOT)) { "rc" -> 4
"a", "alpha" -> 1 "" -> 8
"b", "beta" -> 2 else -> 0
"rc" -> 4
"" -> 8
else -> 0
}
fun parse(versionName: String): VersionId {
val parts = versionName.substringBeforeLast('-').split('.')
val variant = versionName.substringAfterLast('-', "")
return VersionId(
major = parts.getOrNull(0)?.toIntOrNull() ?: 0,
minor = parts.getOrNull(1)?.toIntOrNull() ?: 0,
build = parts.getOrNull(2)?.toIntOrNull() ?: 0,
variantType = variant.filter(Char::isLetter),
variantNumber = variant.filter(Char::isDigit).toIntOrNull() ?: 0
)
}
} }
} }
fun VersionId(versionName: String): VersionId {
val parts = versionName.substringBeforeLast('-').split('.')
val variant = versionName.substringAfterLast('-', "")
return VersionId(
major = parts.getOrNull(0)?.toIntOrNull() ?: 0,
minor = parts.getOrNull(1)?.toIntOrNull() ?: 0,
build = parts.getOrNull(2)?.toIntOrNull() ?: 0,
variantType = variant.filter(Char::isLetter),
variantNumber = variant.filter(Char::isDigit).toIntOrNull() ?: 0,
)
}

@ -0,0 +1,10 @@
package org.koitharu.kotatsu.core.model
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.*
fun MangaSource.getLocaleTitle(): String? {
val lc = Locale(locale ?: return null)
return lc.getDisplayLanguage(lc).toTitleCase(lc)
}

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

@ -0,0 +1,84 @@
package org.koitharu.kotatsu.core.network
import okhttp3.Cache
import okhttp3.Dns
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.dnsoverhttps.DnsOverHttps
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import java.net.InetAddress
import java.net.UnknownHostException
class DoHManager(
cache: Cache,
private val settings: AppSettings,
) : Dns {
private val bootstrapClient = OkHttpClient.Builder().cache(cache).build()
private var cachedDelegate: Dns? = null
private var cachedProvider: DoHProvider? = null
override fun lookup(hostname: String): List<InetAddress> {
return getDelegate().lookup(hostname)
}
@Synchronized
private fun getDelegate(): Dns {
var delegate = cachedDelegate
val provider = settings.dnsOverHttps
if (delegate == null || provider != cachedProvider) {
delegate = createDelegate(provider)
cachedDelegate = delegate
cachedProvider = provider
}
return delegate
}
private fun createDelegate(provider: DoHProvider): Dns = when (provider) {
DoHProvider.NONE -> Dns.SYSTEM
DoHProvider.GOOGLE -> DnsOverHttps.Builder().client(bootstrapClient)
.url("https://dns.google/dns-query".toHttpUrl())
.bootstrapDnsHosts(
listOfNotNull(
tryGetByIp("8.8.4.4"),
tryGetByIp("8.8.8.8"),
tryGetByIp("2001:4860:4860::8888"),
tryGetByIp("2001:4860:4860::8844"),
)
).build()
DoHProvider.CLOUDFLARE -> DnsOverHttps.Builder().client(bootstrapClient)
.url("https://cloudflare-dns.com/dns-query".toHttpUrl())
.bootstrapDnsHosts(
listOfNotNull(
tryGetByIp("162.159.36.1"),
tryGetByIp("162.159.46.1"),
tryGetByIp("1.1.1.1"),
tryGetByIp("1.0.0.1"),
tryGetByIp("162.159.132.53"),
tryGetByIp("2606:4700:4700::1111"),
tryGetByIp("2606:4700:4700::1001"),
tryGetByIp("2606:4700:4700::0064"),
tryGetByIp("2606:4700:4700::6400"),
)
).build()
DoHProvider.ADGUARD -> DnsOverHttps.Builder().client(bootstrapClient)
.url("https://dns-unfiltered.adguard.com/dns-query".toHttpUrl())
.bootstrapDnsHosts(
listOfNotNull(
tryGetByIp("94.140.14.140"),
tryGetByIp("94.140.14.141"),
tryGetByIp("2a10:50c0::1:ff"),
tryGetByIp("2a10:50c0::2:ff"),
)
).build()
}
private fun tryGetByIp(ip: String): InetAddress? = try {
InetAddress.getByName(ip)
} catch (e: UnknownHostException) {
e.printStackTraceDebug()
null
}
}

@ -0,0 +1,6 @@
package org.koitharu.kotatsu.core.network
enum class DoHProvider {
NONE, GOOGLE, CLOUDFLARE, ADGUARD
}

@ -1,6 +1,5 @@
package org.koitharu.kotatsu.core.network package org.koitharu.kotatsu.core.network
import java.util.concurrent.TimeUnit
import okhttp3.CookieJar import okhttp3.CookieJar
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koin.dsl.bind import org.koin.dsl.bind
@ -8,17 +7,20 @@ import org.koin.dsl.module
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import java.util.concurrent.TimeUnit
val networkModule val networkModule
get() = module { get() = module {
single { AndroidCookieJar() } bind CookieJar::class single { AndroidCookieJar() } bind CookieJar::class
single { single {
val cache = get<LocalStorageManager>().createHttpCache()
OkHttpClient.Builder().apply { OkHttpClient.Builder().apply {
connectTimeout(20, TimeUnit.SECONDS) connectTimeout(20, TimeUnit.SECONDS)
readTimeout(60, TimeUnit.SECONDS) readTimeout(60, TimeUnit.SECONDS)
writeTimeout(20, TimeUnit.SECONDS) writeTimeout(20, TimeUnit.SECONDS)
cookieJar(get()) cookieJar(get())
cache(get<LocalStorageManager>().createHttpCache()) dns(DoHManager(cache, get()))
cache(cache)
addInterceptor(UserAgentInterceptor()) addInterceptor(UserAgentInterceptor())
addInterceptor(CloudFlareInterceptor()) addInterceptor(CloudFlareInterceptor())
}.build() }.build()

@ -13,12 +13,9 @@ interface MangaRepository {
val sortOrders: Set<SortOrder> val sortOrders: Set<SortOrder>
suspend fun getList( suspend fun getList(offset: Int, query: String): List<Manga>
offset: Int,
query: String? = null, suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga>
tags: Set<MangaTag>? = null,
sortOrder: SortOrder? = null,
): List<Manga>
suspend fun getDetails(manga: Manga): Manga suspend fun getDetails(manga: Manga): Manga

@ -20,12 +20,13 @@ class RemoteMangaRepository(private val parser: MangaParser) : MangaRepository {
getConfig().defaultSortOrder = value getConfig().defaultSortOrder = value
} }
override suspend fun getList( override suspend fun getList(offset: Int, query: String): List<Manga> {
offset: Int, return parser.getList(offset, query)
query: String?, }
tags: Set<MangaTag>?,
sortOrder: SortOrder?, override suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga> {
): List<Manga> = parser.getList(offset, query, tags, sortOrder) return parser.getList(offset, tags, sortOrder)
}
override suspend fun getDetails(manga: Manga): Manga = parser.getDetails(manga) override suspend fun getDetails(manga: Manga): Manga = parser.getDetails(manga)

@ -4,7 +4,6 @@ import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.Uri import android.net.Uri
import android.os.Build
import android.provider.Settings import android.provider.Settings
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.collection.arraySetOf import androidx.collection.arraySetOf
@ -20,6 +19,7 @@ import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.getEnumValue import org.koitharu.kotatsu.utils.ext.getEnumValue
import org.koitharu.kotatsu.utils.ext.observe import org.koitharu.kotatsu.utils.ext.observe
@ -52,7 +52,7 @@ class AppSettings(context: Context) {
get() = prefs.getString(KEY_THEME, null)?.toIntOrNull() ?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM get() = prefs.getString(KEY_THEME, null)?.toIntOrNull() ?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
val isDynamicTheme: Boolean val isDynamicTheme: Boolean
get() = prefs.getBoolean(KEY_DYNAMIC_THEME, false) get() = DynamicColors.isDynamicColorAvailable() && prefs.getBoolean(KEY_DYNAMIC_THEME, false)
val isAmoledTheme: Boolean val isAmoledTheme: Boolean
get() = prefs.getBoolean(KEY_THEME_AMOLED, false) get() = prefs.getBoolean(KEY_THEME_AMOLED, false)
@ -99,8 +99,11 @@ class AppSettings(context: Context) {
val readerAnimation: Boolean val readerAnimation: Boolean
get() = prefs.getBoolean(KEY_READER_ANIMATION, false) get() = prefs.getBoolean(KEY_READER_ANIMATION, false)
val isPreferRtlReader: Boolean val defaultReaderMode: ReaderMode
get() = prefs.getBoolean(KEY_READER_PREFER_RTL, false) get() = prefs.getEnumValue(KEY_READER_MODE, ReaderMode.STANDARD)
val isReaderModeDetectionEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_MODE_DETECT, true)
var historyGrouping: Boolean var historyGrouping: Boolean
get() = prefs.getBoolean(KEY_HISTORY_GROUPING, true) get() = prefs.getBoolean(KEY_HISTORY_GROUPING, true)
@ -149,7 +152,7 @@ class AppSettings(context: Context) {
} }
fun markKnownSources(sources: Collection<MangaSource>) { fun markKnownSources(sources: Collection<MangaSource>) {
sourcesOrder = sourcesOrder + sources.map { it.name } sourcesOrder = (sourcesOrder + sources.map { it.name }).distinct()
} }
val isPagesNumbersEnabled: Boolean val isPagesNumbersEnabled: Boolean
@ -189,6 +192,9 @@ class AppSettings(context: Context) {
get() = prefs.getBoolean(KEY_SEARCH_SINGLE_SOURCE, false) get() = prefs.getBoolean(KEY_SEARCH_SINGLE_SOURCE, false)
set(value) = prefs.edit { putBoolean(KEY_SEARCH_SINGLE_SOURCE, value) } set(value) = prefs.edit { putBoolean(KEY_SEARCH_SINGLE_SOURCE, value) }
val dnsOverHttps: DoHProvider
get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE)
fun isPagesPreloadAllowed(cm: ConnectivityManager): Boolean { fun isPagesPreloadAllowed(cm: ConnectivityManager): Boolean {
return when (prefs.getString(KEY_PAGES_PRELOAD, null)?.toIntOrNull()) { return when (prefs.getString(KEY_PAGES_PRELOAD, null)?.toIntOrNull()) {
NETWORK_ALWAYS -> true NETWORK_ALWAYS -> true
@ -276,7 +282,8 @@ class AppSettings(context: Context) {
const val KEY_NOTIFICATIONS_LIGHT = "notifications_light" const val KEY_NOTIFICATIONS_LIGHT = "notifications_light"
const val KEY_NOTIFICATIONS_INFO = "tracker_notifications_info" const val KEY_NOTIFICATIONS_INFO = "tracker_notifications_info"
const val KEY_READER_ANIMATION = "reader_animation" const val KEY_READER_ANIMATION = "reader_animation"
const val KEY_READER_PREFER_RTL = "reader_prefer_rtl" const val KEY_READER_MODE = "reader_mode"
const val KEY_READER_MODE_DETECT = "reader_mode_detect"
const val KEY_APP_PASSWORD = "app_password" const val KEY_APP_PASSWORD = "app_password"
const val KEY_PROTECT_APP = "protect_app" const val KEY_PROTECT_APP = "protect_app"
const val KEY_APP_VERSION = "app_version" const val KEY_APP_VERSION = "app_version"
@ -297,6 +304,7 @@ class AppSettings(context: Context) {
const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism" const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism"
const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown" const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown"
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible" const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
const val KEY_DOH = "doh"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"
@ -309,12 +317,5 @@ class AppSettings(context: Context) {
private const val NETWORK_NEVER = 0 private const val NETWORK_NEVER = 0
private const val NETWORK_ALWAYS = 1 private const val NETWORK_ALWAYS = 1
private const val NETWORK_NON_METERED = 2 private const val NETWORK_NON_METERED = 2
val isDynamicColorAvailable: Boolean
get() = DynamicColors.isDynamicColorAvailable() ||
(isSamsung && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
private val isSamsung
get() = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
} }
} }

@ -0,0 +1,35 @@
package org.koitharu.kotatsu.core.prefs
import androidx.lifecycle.liveData
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.flow.flow
fun <T> AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() -> T) = flow {
var lastValue: T = valueProducer()
emit(lastValue)
observe().collect {
if (it == key) {
val value = valueProducer()
if (value != lastValue) {
emit(value)
}
lastValue = value
}
}
}
fun <T> AppSettings.observeAsLiveData(
context: CoroutineContext,
key: String,
valueProducer: AppSettings.() -> T
) = liveData(context) {
emit(valueProducer())
observe().collect {
if (it == key) {
val value = valueProducer()
if (value != latestValue) {
emit(value)
}
}
}
}

@ -1,21 +0,0 @@
package org.koitharu.kotatsu.core.ui
import android.content.Context
import android.content.Intent
import android.util.Log
import kotlin.system.exitProcess
class AppCrashHandler(private val applicationContext: Context) : Thread.UncaughtExceptionHandler {
override fun uncaughtException(t: Thread, e: Throwable) {
val intent = CrashActivity.newIntent(applicationContext, e)
intent.flags = (Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
try {
applicationContext.startActivity(intent)
} catch (t: Throwable) {
t.printStackTrace()
}
Log.e("CRASH", e.message, e)
exitProcess(1)
}
}

@ -1,83 +0,0 @@
package org.koitharu.kotatsu.core.ui
import android.app.Activity
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 android.view.View
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ActivityCrashBinding
import org.koitharu.kotatsu.main.ui.MainActivity
import org.koitharu.kotatsu.parsers.util.ellipsize
import org.koitharu.kotatsu.utils.ShareHelper
class CrashActivity : Activity(), View.OnClickListener {
private lateinit var binding: ActivityCrashBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityCrashBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.textView.text = intent.getStringExtra(Intent.EXTRA_TEXT)
binding.buttonClose.setOnClickListener(this)
binding.buttonRestart.setOnClickListener(this)
binding.buttonReport.setOnClickListener(this)
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.opt_crash, menu)
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_share -> {
ShareHelper(this).shareText(binding.textView.text.toString())
}
else -> return super.onOptionsItemSelected(item)
}
return true
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_close -> {
finish()
}
R.id.button_restart -> {
val intent = Intent(applicationContext, MainActivity::class.java)
intent.flags = (Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
finish()
}
R.id.button_report -> {
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse("https://github.com/nv95/Kotatsu/issues")
try {
startActivity(Intent.createChooser(intent, getString(R.string.report_github)))
} catch (_: ActivityNotFoundException) {
}
}
}
}
companion object {
private const val MAX_TRACE_SIZE = 131071
fun newIntent(context: Context, error: Throwable): Intent {
val crashInfo = error
.stackTraceToString()
.trimIndent()
.ellipsize(MAX_TRACE_SIZE)
val intent = Intent(context, CrashActivity::class.java)
intent.putExtra(Intent.EXTRA_TEXT, crashInfo)
return intent
}
}
}

@ -0,0 +1,6 @@
package org.koitharu.kotatsu.details.domain
class BranchComparator : Comparator<String?> {
override fun compare(o1: String?, o2: String?): Int = compareValues(o1, o2)
}

@ -9,6 +9,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.MenuProvider
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
@ -27,6 +28,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback
import org.koitharu.kotatsu.utils.ext.addMenuProvider
import kotlin.math.roundToInt import kotlin.math.roundToInt
class ChaptersFragment : class ChaptersFragment :
@ -43,11 +45,6 @@ class ChaptersFragment :
private var actionMode: ActionMode? = null private var actionMode: ActionMode? = null
private var selectionDecoration: ChaptersSelectionDecoration? = null private var selectionDecoration: ChaptersSelectionDecoration? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onInflateView( override fun onInflateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup? container: ViewGroup?
@ -72,6 +69,7 @@ class ChaptersFragment :
binding.textViewHolder.isVisible = it binding.textViewHolder.isVisible = it
activity?.invalidateOptionsMenu() activity?.invalidateOptionsMenu()
} }
addMenuProvider(ChaptersMenuProvider())
} }
override fun onDestroyView() { override fun onDestroyView() {
@ -81,31 +79,6 @@ class ChaptersFragment :
super.onDestroyView() super.onDestroyView()
} }
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.opt_chapters, menu)
val searchMenuItem = menu.findItem(R.id.action_search)
searchMenuItem.setOnActionExpandListener(this)
val searchView = searchMenuItem.actionView as SearchView
searchView.setOnQueryTextListener(this)
searchView.setIconifiedByDefault(false)
searchView.queryHint = searchMenuItem.title
}
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
menu.findItem(R.id.action_reversed).isChecked = viewModel.isChaptersReversed.value == true
menu.findItem(R.id.action_search).isVisible = viewModel.isChaptersEmpty.value == false
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.action_reversed -> {
viewModel.setChaptersReversed(!item.isChecked)
true
}
else -> super.onOptionsItemSelected(item)
}
override fun onItemClick(item: ChapterListItem, view: View) { override fun onItemClick(item: ChapterListItem, view: View) {
if (selectionDecoration?.checkedItemsCount != 0) { if (selectionDecoration?.checkedItemsCount != 0) {
selectionDecoration?.toggleItemChecked(item.chapter.id) selectionDecoration?.toggleItemChecked(item.chapter.id)
@ -121,13 +94,7 @@ class ChaptersFragment :
(activity as? DetailsActivity)?.showChapterMissingDialog(item.chapter.id) (activity as? DetailsActivity)?.showChapterMissingDialog(item.chapter.id)
return return
} }
val options = ActivityOptions.makeScaleUpAnimation( val options = ActivityOptions.makeScaleUpAnimation(view, 0, 0, view.width, view.height)
view,
0,
0,
view.measuredWidth,
view.measuredHeight
)
startActivity( startActivity(
ReaderActivity.newIntent( ReaderActivity.newIntent(
context = view.context, context = view.context,
@ -274,4 +241,30 @@ class ChaptersFragment :
private fun onLoadingStateChanged(isLoading: Boolean) { private fun onLoadingStateChanged(isLoading: Boolean) {
binding.progressBar.isVisible = isLoading binding.progressBar.isVisible = isLoading
} }
private inner class ChaptersMenuProvider : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_chapters, menu)
val searchMenuItem = menu.findItem(R.id.action_search)
searchMenuItem.setOnActionExpandListener(this@ChaptersFragment)
val searchView = searchMenuItem.actionView as SearchView
searchView.setOnQueryTextListener(this@ChaptersFragment)
searchView.setIconifiedByDefault(false)
searchView.queryHint = searchMenuItem.title
}
override fun onPrepareMenu(menu: Menu) {
menu.findItem(R.id.action_reversed).isChecked = viewModel.isChaptersReversed.value == true
menu.findItem(R.id.action_search).isVisible = viewModel.isChaptersEmpty.value == false
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_reversed -> {
viewModel.setChaptersReversed(!menuItem.isChecked)
true
}
else -> false
}
}
} }

@ -15,8 +15,6 @@ import android.widget.Toast
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
@ -44,9 +42,8 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
import org.koitharu.kotatsu.shikimori.ui.selector.ShikimoriSelectorBottomSheet import org.koitharu.kotatsu.shikimori.ui.selector.ShikimoriSelectorBottomSheet
import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class DetailsActivity : class DetailsActivity :
@ -84,6 +81,9 @@ class DetailsActivity :
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged) viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
viewModel.onMangaRemoved.observe(this, ::onMangaRemoved) viewModel.onMangaRemoved.observe(this, ::onMangaRemoved)
viewModel.onError.observe(this, ::onError) viewModel.onError.observe(this, ::onError)
viewModel.onShowToast.observe(this) {
binding.snackbar.show(messageText = getString(it), longDuration = false)
}
registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE)) registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE))
} }
@ -161,16 +161,6 @@ class DetailsActivity :
} }
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.action_share -> {
viewModel.manga.value?.let {
if (it.source == MangaSource.LOCAL) {
ShareHelper(this).shareCbz(listOf(it.url.toUri().toFile()))
} else {
ShareHelper(this).shareMangaLink(it)
}
}
true
}
R.id.action_delete -> { R.id.action_delete -> {
val title = viewModel.manga.value?.title.orEmpty() val title = viewModel.manga.value?.title.orEmpty()
MaterialAlertDialogBuilder(this) MaterialAlertDialogBuilder(this)
@ -203,7 +193,7 @@ class DetailsActivity :
} }
R.id.action_related -> { R.id.action_related -> {
viewModel.manga.value?.let { viewModel.manga.value?.let {
startActivity(GlobalSearchActivity.newIntent(this, it.title)) startActivity(MultiSearchActivity.newIntent(this, it.title))
} }
true true
} }

@ -8,8 +8,11 @@ import android.view.*
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.text.parseAsHtml import androidx.core.text.parseAsHtml
import androidx.core.view.MenuProvider
import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import coil.ImageLoader import coil.ImageLoader
@ -21,7 +24,11 @@ import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.base.ui.widgets.ChipsView import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.BookmarksAdapter
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet
@ -36,22 +43,19 @@ import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.shikimori.data.model.ShikimoriMangaInfo import org.koitharu.kotatsu.shikimori.data.model.ShikimoriMangaInfo
import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
class DetailsFragment : class DetailsFragment :
BaseFragment<FragmentDetailsBinding>(), BaseFragment<FragmentDetailsBinding>(),
View.OnClickListener, View.OnClickListener,
View.OnLongClickListener, View.OnLongClickListener,
ChipsView.OnChipClickListener { ChipsView.OnChipClickListener,
OnListItemClickListener<Bookmark> {
private val viewModel by sharedViewModel<DetailsViewModel>() private val viewModel by sharedViewModel<DetailsViewModel>()
private val coil by inject<ImageLoader>(mode = LazyThreadSafetyMode.NONE) private val coil by inject<ImageLoader>(mode = LazyThreadSafetyMode.NONE)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onInflateView( override fun onInflateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@ -70,11 +74,26 @@ class DetailsFragment :
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged) viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged) viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged)
viewModel.readingHistory.observe(viewLifecycleOwner, ::onHistoryChanged) viewModel.readingHistory.observe(viewLifecycleOwner, ::onHistoryChanged)
viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged)
addMenuProvider(DetailsMenuProvider())
}
override fun onItemClick(item: Bookmark, view: View) {
val options = ActivityOptions.makeScaleUpAnimation(view, 0, 0, view.width, view.height)
startActivity(ReaderActivity.newIntent(view.context, item), options.toBundle())
} }
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { override fun onItemLongClick(item: Bookmark, view: View): Boolean {
super.onCreateOptionsMenu(menu, inflater) val menu = PopupMenu(view.context, view)
inflater.inflate(R.menu.opt_details_info, menu) menu.inflate(R.menu.popup_bookmark)
menu.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.action_remove -> viewModel.removeBookmark(item)
}
true
}
menu.show()
return true
} }
private fun onMangaUpdated(manga: Manga) { private fun onMangaUpdated(manga: Manga) {
@ -177,6 +196,20 @@ class DetailsFragment :
} }
} }
private fun onBookmarksChanged(bookmarks: List<Bookmark>) {
var adapter = binding.recyclerViewBookmarks.adapter as? BookmarksAdapter
binding.groupBookmarks.isGone = bookmarks.isEmpty()
if (adapter != null) {
adapter.items = bookmarks
} else {
adapter = BookmarksAdapter(coil, viewLifecycleOwner, this)
adapter.items = bookmarks
binding.recyclerViewBookmarks.adapter = adapter
val spacing = resources.getDimensionPixelOffset(R.dimen.bookmark_list_spacing)
binding.recyclerViewBookmarks.addItemDecoration(SpacingItemDecoration(spacing))
}
}
override fun onClick(v: View) { override fun onClick(v: View) {
val manga = viewModel.manga.value ?: return val manga = viewModel.manga.value ?: return
when (v.id) { when (v.id) {
@ -207,13 +240,9 @@ class DetailsFragment :
) )
} }
R.id.imageView_cover -> { R.id.imageView_cover -> {
val options = ActivityOptions.makeSceneTransitionAnimation( val options = ActivityOptions.makeScaleUpAnimation(v, 0, 0, v.width, v.height)
requireActivity(),
binding.imageViewCover,
binding.imageViewCover.transitionName,
)
startActivity( startActivity(
ImageActivity.newIntent(v.context, manga.largeCoverUrl ?: manga.coverUrl), ImageActivity.newIntent(v.context, manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }),
options.toBundle() options.toBundle()
) )
} }
@ -279,20 +308,42 @@ class DetailsFragment :
} }
private fun loadCover(manga: Manga) { private fun loadCover(manga: Manga) {
val currentCover = binding.imageViewCover.drawable val imageUrl = manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }
val lastResult = CoilUtils.result(binding.imageViewCover)
if (lastResult?.request?.data == imageUrl) {
return
}
val request = ImageRequest.Builder(context ?: return) val request = ImageRequest.Builder(context ?: return)
.target(binding.imageViewCover) .target(binding.imageViewCover)
if (currentCover != null) { .data(imageUrl)
request.data(manga.largeCoverUrl ?: return) .crossfade(true)
.placeholderMemoryCacheKey(CoilUtils.result(binding.imageViewCover)?.request?.memoryCacheKey) .referer(manga.publicUrl)
.fallback(currentCover)
} else {
request.crossfade(true)
.data(manga.coverUrl)
.fallback(R.drawable.ic_placeholder)
}
request.referer(manga.publicUrl)
.lifecycle(viewLifecycleOwner) .lifecycle(viewLifecycleOwner)
.enqueueWith(coil) lastResult?.drawable?.let {
request.fallback(it)
} ?: request.fallback(R.drawable.ic_placeholder)
request.enqueueWith(coil)
}
private inner class DetailsMenuProvider : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_details_info, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_share -> {
viewModel.manga.value?.let {
val context = requireContext()
if (it.source == MangaSource.LOCAL) {
ShareHelper(context).shareCbz(listOf(it.url.toUri().toFile()))
} else {
ShareHelper(context).shareMangaLink(it)
}
}
true
}
else -> false
}
} }
} }

@ -1,125 +1,117 @@
package org.koitharu.kotatsu.details.ui package org.koitharu.kotatsu.details.ui
import androidx.core.os.LocaleListCompat import androidx.lifecycle.LiveData
import androidx.lifecycle.asFlow import androidx.lifecycle.asFlow
import androidx.lifecycle.asLiveData import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import java.io.IOException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.exceptions.MangaNotFoundException import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.details.domain.BranchComparator
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.shikimori.data.ShikimoriRepository import org.koitharu.kotatsu.shikimori.data.ShikimoriRepository
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.iterator import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import java.io.IOException
class DetailsViewModel( class DetailsViewModel(
private val intent: MangaIntent, intent: MangaIntent,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val favouritesRepository: FavouritesRepository, favouritesRepository: FavouritesRepository,
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val trackingRepository: TrackingRepository, trackingRepository: TrackingRepository,
private val mangaDataRepository: MangaDataRepository, mangaDataRepository: MangaDataRepository,
private val bookmarksRepository: BookmarksRepository,
private val settings: AppSettings, private val settings: AppSettings,
private val shikimoriRepository: ShikimoriRepository, private val shikimoriRepository: ShikimoriRepository,
) : BaseViewModel() { ) : BaseViewModel() {
private val delegate = MangaDetailsDelegate(
intent = intent,
settings = settings,
mangaDataRepository = mangaDataRepository,
historyRepository = historyRepository,
localMangaRepository = localMangaRepository,
)
private var loadingJob: Job private var loadingJob: Job
private val mangaData = MutableStateFlow(intent.manga)
private val selectedBranch = MutableStateFlow<String?>(null)
private val history = mangaData.mapNotNull { it?.id } val onShowToast = SingleLiveEvent<Int>()
.distinctUntilChanged()
.flatMapLatest { mangaId ->
historyRepository.observeOne(mangaId)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
private val favourite = mangaData.mapNotNull { it?.id } private val history = historyRepository.observeOne(delegate.mangaId)
.distinctUntilChanged() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
.flatMapLatest { mangaId ->
favouritesRepository.observeCategoriesIds(mangaId).map { it.isNotEmpty() } private val favourite = favouritesRepository.observeCategoriesIds(delegate.mangaId).map { it.isNotEmpty() }
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
private val newChapters = mangaData.mapNotNull { it?.id } private val newChapters = trackingRepository.observeNewChaptersCount(delegate.mangaId)
.distinctUntilChanged() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
.mapLatest { mangaId ->
trackingRepository.getNewChaptersCount(mangaId)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
// Remote manga for saved and saved for remote
private val relatedManga = MutableStateFlow<Manga?>(null)
private val chaptersQuery = MutableStateFlow("") private val chaptersQuery = MutableStateFlow("")
private val chaptersReversed = settings.observe() private val chaptersReversed = settings.observeAsFlow(AppSettings.KEY_REVERSE_CHAPTERS) { chaptersReverse }
.filter { it == AppSettings.KEY_REVERSE_CHAPTERS }
.map { settings.chaptersReverse }
.onStart { emit(settings.chaptersReverse) }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
val manga = mangaData.filterNotNull() val manga = delegate.manga.filterNotNull().asLiveData(viewModelScope.coroutineContext)
.asLiveData(viewModelScope.coroutineContext) val favouriteCategories = favourite.asLiveData(viewModelScope.coroutineContext)
val favouriteCategories = favourite val newChaptersCount = newChapters.asLiveData(viewModelScope.coroutineContext)
.asLiveData(viewModelScope.coroutineContext) val readingHistory = history.asLiveData(viewModelScope.coroutineContext)
val newChaptersCount = newChapters val isChaptersReversed = chaptersReversed.asLiveData(viewModelScope.coroutineContext)
.asLiveData(viewModelScope.coroutineContext)
val readingHistory = history val bookmarks = delegate.manga.flatMapLatest {
.asLiveData(viewModelScope.coroutineContext) if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList())
val isChaptersReversed = chaptersReversed }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
.asLiveData(viewModelScope.coroutineContext)
val onMangaRemoved = SingleLiveEvent<Manga>() val onMangaRemoved = SingleLiveEvent<Manga>()
val isShikimoriAvailable: Boolean val isShikimoriAvailable: Boolean
get() = shikimoriRepository.isAuthorized get() = shikimoriRepository.isAuthorized
val branches = mangaData.map { val branches: LiveData<List<String?>> = delegate.manga.map {
it?.chapters?.mapToSet { x -> x.branch }?.sortedBy { x -> x }.orEmpty() val chapters = it?.chapters ?: return@map emptyList()
chapters.mapToSet { x -> x.branch }.sortedWith(BranchComparator())
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
val selectedBranchIndex = combine( val selectedBranchIndex = combine(
branches.asFlow(), branches.asFlow(),
selectedBranch delegate.selectedBranch
) { branches, selected -> ) { branches, selected ->
branches.indexOf(selected) branches.indexOf(selected)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
val isChaptersEmpty = mangaData.mapNotNull { m -> val isChaptersEmpty: LiveData<Boolean> = combine(
m?.run { chapters.isNullOrEmpty() } delegate.manga,
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, false) isLoading.asFlow(),
) { m, loading ->
m != null && m.chapters.isNullOrEmpty() && !loading
}.asLiveDataDistinct(viewModelScope.coroutineContext, false)
val chapters = combine( val chapters = combine(
combine( combine(
mangaData.map { it?.chapters.orEmpty() }, delegate.manga,
relatedManga, delegate.relatedManga,
history.map { it?.chapterId }, history,
delegate.selectedBranch,
newChapters, newChapters,
selectedBranch ) { manga, related, history, branch, news ->
) { chapters, related, currentId, newCount, branch -> delegate.mapChapters(manga, related, history, news, branch)
val relatedChapters = related?.chapters
if (related?.source != MangaSource.LOCAL && !relatedChapters.isNullOrEmpty()) {
mapChaptersWithSource(chapters, relatedChapters, currentId, newCount, branch)
} else {
mapChapters(chapters, relatedChapters, currentId, newCount, branch)
}
}, },
chaptersReversed, chaptersReversed,
chaptersQuery, chaptersQuery,
@ -128,7 +120,7 @@ class DetailsViewModel(
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default) }.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
val selectedBranchValue: String? val selectedBranchValue: String?
get() = selectedBranch.value get() = delegate.selectedBranch.value
init { init {
loadingJob = doLoad() loadingJob = doLoad()
@ -140,7 +132,11 @@ class DetailsViewModel(
} }
fun deleteLocal() { fun deleteLocal() {
val m = mangaData.value ?: return val m = delegate.manga.value
if (m == null) {
onShowToast.call(R.string.file_not_found)
return
}
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
val manga = if (m.source == MangaSource.LOCAL) m else localMangaRepository.findSavedManga(m) val manga = if (m.source == MangaSource.LOCAL) m else localMangaRepository.findSavedManga(m)
checkNotNull(manga) { "Cannot find saved manga for ${m.title}" } checkNotNull(manga) { "Cannot find saved manga for ${m.title}" }
@ -153,16 +149,23 @@ class DetailsViewModel(
} }
} }
fun removeBookmark(bookmark: Bookmark) {
launchJob {
bookmarksRepository.removeBookmark(bookmark.manga.id, bookmark.pageId)
onShowToast.call(R.string.bookmark_removed)
}
}
fun setChaptersReversed(newValue: Boolean) { fun setChaptersReversed(newValue: Boolean) {
settings.chaptersReverse = newValue settings.chaptersReverse = newValue
} }
fun setSelectedBranch(branch: String?) { fun setSelectedBranch(branch: String?) {
selectedBranch.value = branch delegate.selectedBranch.value = branch
} }
fun getRemoteManga(): Manga? { fun getRemoteManga(): Manga? {
return relatedManga.value?.takeUnless { it.source == MangaSource.LOCAL } return delegate.relatedManga.value?.takeUnless { it.source == MangaSource.LOCAL }
} }
fun performChapterSearch(query: String?) { fun performChapterSearch(query: String?) {
@ -170,7 +173,7 @@ class DetailsViewModel(
} }
fun onDownloadComplete(downloadedManga: Manga) { fun onDownloadComplete(downloadedManga: Manga) {
val currentManga = mangaData.value ?: return val currentManga = delegate.manga.value ?: return
if (currentManga.id != downloadedManga.id) { if (currentManga.id != downloadedManga.id) {
return return
} }
@ -181,142 +184,16 @@ class DetailsViewModel(
runCatching { runCatching {
localMangaRepository.getDetails(downloadedManga) localMangaRepository.getDetails(downloadedManga)
}.onSuccess { }.onSuccess {
relatedManga.value = it delegate.relatedManga.value = it
}.onFailure { }.onFailure {
if (BuildConfig.DEBUG) { it.printStackTraceDebug()
it.printStackTrace()
}
} }
} }
} }
} }
private fun doLoad() = launchLoadingJob(Dispatchers.Default) { private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
var manga = mangaDataRepository.resolveIntent(intent) delegate.doLoad()
?: throw MangaNotFoundException("Cannot find manga")
mangaData.value = manga
manga = MangaRepository(manga.source).getDetails(manga)
// find default branch
val hist = historyRepository.getOne(manga)
selectedBranch.value = if (hist != null) {
val currentChapter = manga.chapters?.find { it.id == hist.chapterId }
if (currentChapter != null) currentChapter.branch else predictBranch(manga.chapters)
} else {
predictBranch(manga.chapters)
}
mangaData.value = manga
relatedManga.value = runCatching {
if (manga.source == MangaSource.LOCAL) {
val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatching null
MangaRepository(m.source).getDetails(m)
} else {
localMangaRepository.findSavedManga(manga)
}
}.onFailure { error ->
if (BuildConfig.DEBUG) error.printStackTrace()
}.getOrNull()
}
private fun mapChapters(
chapters: List<MangaChapter>,
downloadedChapters: List<MangaChapter>?,
currentId: Long?,
newCount: Int,
branch: String?,
): List<ChapterListItem> {
val result = ArrayList<ChapterListItem>(chapters.size)
val dateFormat = settings.getDateFormat()
val currentIndex = chapters.indexOfFirst { it.id == currentId }
val firstNewIndex = chapters.size - newCount
val downloadedIds = downloadedChapters?.mapToSet { it.id }
for (i in chapters.indices) {
val chapter = chapters[i]
if (chapter.branch != branch) {
continue
}
result += chapter.toListItem(
isCurrent = i == currentIndex,
isUnread = i > currentIndex,
isNew = i >= firstNewIndex,
isMissing = false,
isDownloaded = downloadedIds?.contains(chapter.id) == true,
dateFormat = dateFormat,
)
}
return result
}
private fun mapChaptersWithSource(
chapters: List<MangaChapter>,
sourceChapters: List<MangaChapter>,
currentId: Long?,
newCount: Int,
branch: String?,
): List<ChapterListItem> {
val chaptersMap = chapters.associateByTo(HashMap(chapters.size)) { it.id }
val result = ArrayList<ChapterListItem>(sourceChapters.size)
val currentIndex = sourceChapters.indexOfFirst { it.id == currentId }
val firstNewIndex = sourceChapters.size - newCount
val dateFormat = settings.getDateFormat()
for (i in sourceChapters.indices) {
val chapter = sourceChapters[i]
val localChapter = chaptersMap.remove(chapter.id)
if (chapter.branch != branch) {
continue
}
result += localChapter?.toListItem(
isCurrent = i == currentIndex,
isUnread = i > currentIndex,
isNew = i >= firstNewIndex,
isMissing = false,
isDownloaded = false,
dateFormat = dateFormat,
) ?: chapter.toListItem(
isCurrent = i == currentIndex,
isUnread = i > currentIndex,
isNew = i >= firstNewIndex,
isMissing = true,
isDownloaded = false,
dateFormat = dateFormat,
)
}
if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source
result.ensureCapacity(result.size + chaptersMap.size)
chaptersMap.values.mapNotNullTo(result) {
if (it.branch == branch) {
it.toListItem(
isCurrent = false,
isUnread = true,
isNew = false,
isMissing = false,
isDownloaded = false,
dateFormat = dateFormat,
)
} else {
null
}
}
result.sortBy { it.chapter.number }
}
return result
}
private fun predictBranch(chapters: List<MangaChapter>?): String? {
if (chapters.isNullOrEmpty()) {
return null
}
val groups = chapters.groupBy { it.branch }
for (locale in LocaleListCompat.getAdjustedDefault()) {
var language = locale.getDisplayLanguage(locale).toTitleCase(locale)
if (groups.containsKey(language)) {
return language
}
language = locale.getDisplayName(locale).toTitleCase(locale)
if (groups.containsKey(language)) {
return language
}
}
return groups.maxByOrNull { it.value.size }?.key
} }
private fun List<ChapterListItem>.filterSearch(query: String): List<ChapterListItem> { private fun List<ChapterListItem>.filterSearch(query: String): List<ChapterListItem> {

@ -0,0 +1,184 @@
package org.koitharu.kotatsu.details.ui
import androidx.core.os.LocaleListCompat
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.core.exceptions.MangaNotFoundException
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.utils.ext.iterator
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
class MangaDetailsDelegate(
private val intent: MangaIntent,
private val settings: AppSettings,
private val mangaDataRepository: MangaDataRepository,
private val historyRepository: HistoryRepository,
private val localMangaRepository: LocalMangaRepository,
) {
private val mangaData = MutableStateFlow(intent.manga)
val selectedBranch = MutableStateFlow<String?>(null)
// Remote manga for saved and saved for remote
val relatedManga = MutableStateFlow<Manga?>(null)
val manga: StateFlow<Manga?>
get() = mangaData
val mangaId = intent.manga?.id ?: intent.mangaId
suspend fun doLoad() {
var manga = mangaDataRepository.resolveIntent(intent)
?: throw MangaNotFoundException("Cannot find manga")
mangaData.value = manga
manga = MangaRepository(manga.source).getDetails(manga)
// find default branch
val hist = historyRepository.getOne(manga)
selectedBranch.value = if (hist != null) {
val currentChapter = manga.chapters?.find { it.id == hist.chapterId }
if (currentChapter != null) currentChapter.branch else predictBranch(manga.chapters)
} else {
predictBranch(manga.chapters)
}
mangaData.value = manga
relatedManga.value = runCatching {
if (manga.source == MangaSource.LOCAL) {
val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatching null
MangaRepository(m.source).getDetails(m)
} else {
localMangaRepository.findSavedManga(manga)
}
}.onFailure { error ->
error.printStackTraceDebug()
}.getOrNull()
}
fun mapChapters(
manga: Manga?,
related: Manga?,
history: MangaHistory?,
newCount: Int,
branch: String?,
): List<ChapterListItem> {
val chapters = manga?.chapters ?: return emptyList()
val relatedChapters = related?.chapters
return if (related?.source != MangaSource.LOCAL && !relatedChapters.isNullOrEmpty()) {
mapChaptersWithSource(chapters, relatedChapters, history?.chapterId, newCount, branch)
} else {
mapChapters(chapters, relatedChapters, history?.chapterId, newCount, branch)
}
}
private fun mapChapters(
chapters: List<MangaChapter>,
downloadedChapters: List<MangaChapter>?,
currentId: Long?,
newCount: Int,
branch: String?,
): List<ChapterListItem> {
val result = ArrayList<ChapterListItem>(chapters.size)
val dateFormat = settings.getDateFormat()
val currentIndex = chapters.indexOfFirst { it.id == currentId }
val firstNewIndex = chapters.size - newCount
val downloadedIds = downloadedChapters?.mapToSet { it.id }
for (i in chapters.indices) {
val chapter = chapters[i]
if (chapter.branch != branch) {
continue
}
result += chapter.toListItem(
isCurrent = i == currentIndex,
isUnread = i > currentIndex,
isNew = i >= firstNewIndex,
isMissing = false,
isDownloaded = downloadedIds?.contains(chapter.id) == true,
dateFormat = dateFormat,
)
}
return result
}
private fun mapChaptersWithSource(
chapters: List<MangaChapter>,
sourceChapters: List<MangaChapter>,
currentId: Long?,
newCount: Int,
branch: String?,
): List<ChapterListItem> {
val chaptersMap = chapters.associateByTo(HashMap(chapters.size)) { it.id }
val result = ArrayList<ChapterListItem>(sourceChapters.size)
val currentIndex = sourceChapters.indexOfFirst { it.id == currentId }
val firstNewIndex = sourceChapters.size - newCount
val dateFormat = settings.getDateFormat()
for (i in sourceChapters.indices) {
val chapter = sourceChapters[i]
val localChapter = chaptersMap.remove(chapter.id)
if (chapter.branch != branch) {
continue
}
result += localChapter?.toListItem(
isCurrent = i == currentIndex,
isUnread = i > currentIndex,
isNew = i >= firstNewIndex,
isMissing = false,
isDownloaded = false,
dateFormat = dateFormat,
) ?: chapter.toListItem(
isCurrent = i == currentIndex,
isUnread = i > currentIndex,
isNew = i >= firstNewIndex,
isMissing = true,
isDownloaded = false,
dateFormat = dateFormat,
)
}
if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source
result.ensureCapacity(result.size + chaptersMap.size)
chaptersMap.values.mapNotNullTo(result) {
if (it.branch == branch) {
it.toListItem(
isCurrent = false,
isUnread = true,
isNew = false,
isMissing = false,
isDownloaded = false,
dateFormat = dateFormat,
)
} else {
null
}
}
result.sortBy { it.chapter.number }
}
return result
}
private fun predictBranch(chapters: List<MangaChapter>?): String? {
if (chapters.isNullOrEmpty()) {
return null
}
val groups = chapters.groupBy { it.branch }
for (locale in LocaleListCompat.getAdjustedDefault()) {
var language = locale.getDisplayLanguage(locale).toTitleCase(locale)
if (groups.containsKey(language)) {
return language
}
language = locale.getDisplayName(locale).toTitleCase(locale)
if (groups.containsKey(language)) {
return language
}
}
return groups.maxByOrNull { it.value.size }?.key
}
}

@ -1,9 +1,9 @@
package org.koitharu.kotatsu.details.ui.adapter package org.koitharu.kotatsu.details.ui.adapter
import android.view.View
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemChapterBinding import org.koitharu.kotatsu.databinding.ItemChapterBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
@ -21,11 +21,7 @@ fun chapterListItemAD(
{ inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) }
) { ) {
val eventListener = object : View.OnClickListener, View.OnLongClickListener { val eventListener = AdapterDelegateClickListenerAdapter(this, clickListener)
override fun onClick(v: View) = clickListener.onItemClick(item, v)
override fun onLongClick(v: View) = clickListener.onItemLongClick(item, v)
}
itemView.setOnClickListener(eventListener) itemView.setOnClickListener(eventListener)
itemView.setOnLongClickListener(eventListener) itemView.setOnLongClickListener(eventListener)

@ -12,7 +12,6 @@ import kotlinx.coroutines.sync.Semaphore
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okio.IOException import okio.IOException
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
@ -24,6 +23,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.referer import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.waitForNetwork import org.koitharu.kotatsu.utils.ext.waitForNetwork
import org.koitharu.kotatsu.utils.progress.ProgressJob import org.koitharu.kotatsu.utils.progress.ProgressJob
@ -156,9 +156,7 @@ class DownloadManager(
outState.value = DownloadState.Cancelled(startId, manga, cover) outState.value = DownloadState.Cancelled(startId, manga, cover)
throw e throw e
} catch (e: Throwable) { } catch (e: Throwable) {
if (BuildConfig.DEBUG) { e.printStackTraceDebug()
e.printStackTrace()
}
outState.value = DownloadState.Error(startId, manga, cover, e) outState.value = DownloadState.Error(startId, manga, cover, e)
} finally { } finally {
withContext(NonCancellable) { withContext(NonCancellable) {

@ -3,10 +3,8 @@ package org.koitharu.kotatsu.download.ui
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.ViewGroup
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
@ -17,7 +15,7 @@ import org.koin.android.ext.android.get
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.utils.LifecycleAwareServiceConnection import org.koitharu.kotatsu.utils.bindServiceWithLifecycle
class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() { class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
@ -28,11 +26,10 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
val adapter = DownloadsAdapter(lifecycleScope, get()) val adapter = DownloadsAdapter(lifecycleScope, get())
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
LifecycleAwareServiceConnection.bindService( bindServiceWithLifecycle(
this, owner = this,
this, service = Intent(this, DownloadService::class.java),
Intent(this, DownloadService::class.java), flags = 0,
0
).service.flatMapLatest { binder -> ).service.flatMapLatest { binder ->
(binder as? DownloadService.DownloadBinder)?.downloads ?: flowOf(null) (binder as? DownloadService.DownloadBinder)?.downloads ?: flowOf(null)
}.onEach { }.onEach {

@ -11,8 +11,8 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import com.google.android.material.R as materialR
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.CrashActivity
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.DownloadsActivity import org.koitharu.kotatsu.download.ui.DownloadsActivity
@ -20,7 +20,6 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.utils.PendingIntentCompat import org.koitharu.kotatsu.utils.PendingIntentCompat
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import com.google.android.material.R as materialR
class DownloadNotification(private val context: Context, startId: Int) { class DownloadNotification(private val context: Context, startId: Int) {
@ -59,6 +58,13 @@ class DownloadNotification(private val context: Context, startId: Int) {
builder.setStyle(null) builder.setStyle(null)
builder.setLargeIcon(state.cover?.toBitmap()) builder.setLargeIcon(state.cover?.toBitmap())
builder.clearActions() builder.clearActions()
builder.setVisibility(
if (state.manga.isNsfw) {
NotificationCompat.VISIBILITY_PRIVATE
} else {
NotificationCompat.VISIBILITY_PUBLIC
}
)
when (state) { when (state) {
is DownloadState.Cancelled -> { is DownloadState.Cancelled -> {
builder.setProgress(1, 0, true) builder.setProgress(1, 0, true)
@ -85,14 +91,6 @@ class DownloadNotification(private val context: Context, startId: Int) {
builder.setContentText(message) builder.setContentText(message)
builder.setAutoCancel(true) builder.setAutoCancel(true)
builder.setOngoing(false) builder.setOngoing(false)
builder.setContentIntent(
PendingIntent.getActivity(
context,
state.manga.hashCode(),
CrashActivity.newIntent(context, state.error),
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
)
builder.setCategory(NotificationCompat.CATEGORY_ERROR) builder.setCategory(NotificationCompat.CATEGORY_ERROR)
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message)) builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
} }

@ -99,39 +99,42 @@ class DownloadService : BaseService() {
private fun listenJob(job: ProgressJob<DownloadState>) { private fun listenJob(job: ProgressJob<DownloadState>) {
lifecycleScope.launch { lifecycleScope.launch {
val startId = job.progressValue.startId val startId = job.progressValue.startId
val timeLeftEstimator = TimeLeftEstimator()
val notification = DownloadNotification(this@DownloadService, startId) val notification = DownloadNotification(this@DownloadService, startId)
notificationSwitcher.notify(startId, notification.create(job.progressValue, -1L)) try {
job.progressAsFlow() val timeLeftEstimator = TimeLeftEstimator()
.onEach { state -> notificationSwitcher.notify(startId, notification.create(job.progressValue, -1L))
if (state is DownloadState.Progress) { job.progressAsFlow()
timeLeftEstimator.tick(value = state.progress, total = state.max) .onEach { state ->
} else { if (state is DownloadState.Progress) {
timeLeftEstimator.emptyTick() timeLeftEstimator.tick(value = state.progress, total = state.max)
} else {
timeLeftEstimator.emptyTick()
}
} }
.throttle { state -> if (state is DownloadState.Progress) 400L else 0L }
.whileActive()
.collect { state ->
val timeLeft = timeLeftEstimator.getEstimatedTimeLeft()
notificationSwitcher.notify(startId, notification.create(state, timeLeft))
}
job.join()
} finally {
(job.progressValue as? DownloadState.Done)?.let {
sendBroadcast(
Intent(ACTION_DOWNLOAD_COMPLETE)
.putExtra(EXTRA_MANGA, ParcelableManga(it.localManga, withChapters = false))
)
} }
.throttle { state -> if (state is DownloadState.Progress) 400L else 0L } notificationSwitcher.detach(
.whileActive() startId,
.collect { state -> if (job.isCancelled) {
val timeLeft = timeLeftEstimator.getEstimatedTimeLeft() null
notificationSwitcher.notify(startId, notification.create(state, timeLeft)) } else {
} notification.create(job.progressValue, -1L)
job.join() }
(job.progressValue as? DownloadState.Done)?.let {
sendBroadcast(
Intent(ACTION_DOWNLOAD_COMPLETE)
.putExtra(EXTRA_MANGA, ParcelableManga(it.localManga, withChapters = false))
) )
stopSelf(startId)
} }
notificationSwitcher.detach(
startId,
if (job.isCancelled) {
null
} else {
notification.create(job.progressValue, -1L)
}
)
stopSelf(startId)
} }
} }

@ -1,7 +1,9 @@
package org.koitharu.kotatsu.favourites.ui package org.koitharu.kotatsu.favourites.ui
import android.os.Bundle import android.os.Bundle
import android.view.* import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
@ -19,12 +21,12 @@ import org.koitharu.kotatsu.base.ui.util.ActionModeListener
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.databinding.FragmentFavouritesBinding import org.koitharu.kotatsu.databinding.FragmentFavouritesBinding
import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate
import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
import org.koitharu.kotatsu.main.ui.AppBarOwner import org.koitharu.kotatsu.main.ui.AppBarOwner
import org.koitharu.kotatsu.utils.ext.addMenuProvider
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.measureHeight import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.resolveDp import org.koitharu.kotatsu.utils.ext.resolveDp
@ -43,11 +45,6 @@ class FavouritesContainerFragment :
private var pagerAdapter: FavouritesPagerAdapter? = null private var pagerAdapter: FavouritesPagerAdapter? = null
private var stubBinding: ItemEmptyStateBinding? = null private var stubBinding: ItemEmptyStateBinding? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onInflateView( override fun onInflateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup? container: ViewGroup?
@ -61,6 +58,7 @@ class FavouritesContainerFragment :
pagerAdapter = adapter pagerAdapter = adapter
TabLayoutMediator(binding.tabs, binding.pager, adapter).attach() TabLayoutMediator(binding.tabs, binding.pager, adapter).attach()
actionModeDelegate.addListener(this, viewLifecycleOwner) actionModeDelegate.addListener(this, viewLifecycleOwner)
addMenuProvider(FavouritesContainerMenuProvider(view.context))
viewModel.visibleCategories.observe(viewLifecycleOwner, ::onCategoriesChanged) viewModel.visibleCategories.observe(viewLifecycleOwner, ::onCategoriesChanged)
viewModel.onError.observe(viewLifecycleOwner, ::onError) viewModel.onError.observe(viewLifecycleOwner, ::onError)
@ -115,21 +113,6 @@ class FavouritesContainerFragment :
} }
} }
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.opt_favourites, menu)
super.onCreateOptionsMenu(menu, inflater)
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.action_categories -> {
context?.let {
startActivity(CategoriesActivity.newIntent(it))
}
true
}
else -> super.onOptionsItemSelected(item)
}
private fun onError(e: Throwable) { private fun onError(e: Throwable) {
Snackbar.make(binding.pager, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show() Snackbar.make(binding.pager, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
} }

@ -0,0 +1,28 @@
package org.koitharu.kotatsu.favourites.ui
import android.content.Context
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
class FavouritesContainerMenuProvider(
private val context: Context,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_favourites, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_categories -> {
context.startActivity(CategoriesActivity.newIntent(context))
true
}
else -> false
}
}
}

@ -3,10 +3,11 @@ package org.koitharu.kotatsu.favourites.ui.categories
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
@ -70,9 +71,7 @@ class FavouritesCategoriesViewModel(
return result return result
} }
private fun observeAllCategoriesVisible() = settings.observe() private fun observeAllCategoriesVisible() = settings.observeAsFlow(AppSettings.KEY_ALL_FAVOURITES_VISIBLE) {
.filter { it == AppSettings.KEY_ALL_FAVOURITES_VISIBLE } isAllFavouritesVisible
.map { settings.isAllFavouritesVisible } }
.onStart { emit(settings.isAllFavouritesVisible) }
.distinctUntilChanged()
} }

@ -16,7 +16,7 @@ fun categoryAD(
clickListener.onItemClick(item.category, it) clickListener.onItemClick(item.category, it)
} }
@Suppress("ClickableViewAccessibility") @Suppress("ClickableViewAccessibility")
binding.imageViewHandle.setOnTouchListener { v, event -> binding.imageViewHandle.setOnTouchListener { _, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) { if (event.actionMasked == MotionEvent.ACTION_DOWN) {
clickListener.onItemLongClick(item.category, itemView) clickListener.onItemLongClick(item.category, itemView)
} else { } else {

@ -6,10 +6,12 @@ import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView import android.widget.AdapterView
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@ -84,9 +86,9 @@ class FavouritesCategoryEditActivity : BaseActivity<ActivityCategoryEditBinding>
right = insets.right, right = insets.right,
bottom = insets.bottom, bottom = insets.bottom,
) )
binding.toolbar.updatePadding( binding.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
top = insets.top, topMargin = insets.top
) }
} }
override fun onItemClick(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { override fun onItemClick(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {

@ -28,7 +28,7 @@ class FavouriteCategoriesBottomSheet :
BaseBottomSheet<DialogFavoriteCategoriesBinding>(), BaseBottomSheet<DialogFavoriteCategoriesBinding>(),
OnListItemClickListener<MangaCategoryItem>, OnListItemClickListener<MangaCategoryItem>,
CategoriesEditDelegate.CategoriesEditCallback, CategoriesEditDelegate.CategoriesEditCallback,
Toolbar.OnMenuItemClickListener { Toolbar.OnMenuItemClickListener, View.OnClickListener {
private val viewModel by viewModel<MangaCategoriesViewModel> { private val viewModel by viewModel<MangaCategoriesViewModel> {
parametersOf(requireNotNull(arguments?.getParcelableArrayList<ParcelableManga>(KEY_MANGA_LIST)).map { it.manga }) parametersOf(requireNotNull(arguments?.getParcelableArrayList<ParcelableManga>(KEY_MANGA_LIST)).map { it.manga })
@ -46,6 +46,7 @@ class FavouriteCategoriesBottomSheet :
adapter = MangaCategoriesAdapter(this) adapter = MangaCategoriesAdapter(this)
binding.recyclerViewCategories.adapter = adapter binding.recyclerViewCategories.adapter = adapter
binding.toolbar.setOnMenuItemClickListener(this) binding.toolbar.setOnMenuItemClickListener(this)
binding.itemCreate.setOnClickListener(this)
viewModel.content.observe(viewLifecycleOwner, this::onContentChanged) viewModel.content.observe(viewLifecycleOwner, this::onContentChanged)
viewModel.onError.observe(viewLifecycleOwner, ::onError) viewModel.onError.observe(viewLifecycleOwner, ::onError)
@ -58,14 +59,20 @@ class FavouriteCategoriesBottomSheet :
override fun onMenuItemClick(item: MenuItem): Boolean { override fun onMenuItemClick(item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
R.id.action_create -> { R.id.action_done -> {
startActivity(FavouritesCategoryEditActivity.newIntent(requireContext())) dismiss()
true true
} }
else -> false else -> false
} }
} }
override fun onClick(v: View) {
when (v.id) {
R.id.item_create -> startActivity(FavouritesCategoryEditActivity.newIntent(requireContext()))
}
}
override fun onItemClick(item: MangaCategoryItem, view: View) { override fun onItemClick(item: MangaCategoryItem, view: View) {
viewModel.setChecked(item.id, !item.isChecked) viewModel.setChecked(item.id, !item.isChecked)
} }

@ -2,18 +2,15 @@ package org.koitharu.kotatsu.favourites.ui.list
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.core.view.iterator
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.titleRes
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.addMenuProvider
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
class FavouritesListFragment : MangaListFragment() { class FavouritesListFragment : MangaListFragment() {
@ -30,46 +27,13 @@ class FavouritesListFragment : MangaListFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
viewModel.sortOrder.observe(viewLifecycleOwner) { activity?.invalidateOptionsMenu() } viewModel.sortOrder.observe(viewLifecycleOwner) { activity?.invalidateOptionsMenu() }
}
override fun onScrolledToEnd() = Unit
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
if (categoryId != NO_ID) { if (categoryId != NO_ID) {
inflater.inflate(R.menu.opt_favourites_list, menu) addMenuProvider(FavouritesListMenuProvider(viewModel))
menu.findItem(R.id.action_order)?.subMenu?.let { submenu ->
for ((i, item) in CategoriesActivity.SORT_ORDERS.withIndex()) {
val menuItem = submenu.add(R.id.group_order, Menu.NONE, i, item.titleRes)
menuItem.isCheckable = true
}
submenu.setGroupCheckable(R.id.group_order, true, true)
}
} }
} }
override fun onPrepareOptionsMenu(menu: Menu) { override fun onScrolledToEnd() = Unit
super.onPrepareOptionsMenu(menu)
menu.findItem(R.id.action_order)?.subMenu?.let { submenu ->
val selectedOrder = viewModel.sortOrder.value
for (item in submenu) {
val order = CategoriesActivity.SORT_ORDERS.getOrNull(item.order)
item.isChecked = order == selectedOrder
}
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when {
item.itemId == R.id.action_order -> false
item.groupId == R.id.group_order -> {
val order = CategoriesActivity.SORT_ORDERS.getOrNull(item.order) ?: return false
viewModel.setSortOrder(order)
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_favourites, menu) mode.menuInflater.inflate(R.menu.mode_favourites, menu)

@ -0,0 +1,48 @@
package org.koitharu.kotatsu.favourites.ui.list
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import androidx.core.view.iterator
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.titleRes
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
class FavouritesListMenuProvider(
private val viewModel: FavouritesListViewModel,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_favourites_list, menu)
menu.findItem(R.id.action_order)?.subMenu?.let { submenu ->
for ((i, item) in CategoriesActivity.SORT_ORDERS.withIndex()) {
val menuItem = submenu.add(R.id.group_order, Menu.NONE, i, item.titleRes)
menuItem.isCheckable = true
}
submenu.setGroupCheckable(R.id.group_order, true, true)
}
}
override fun onPrepareMenu(menu: Menu) {
menu.findItem(R.id.action_order)?.subMenu?.let { submenu ->
val selectedOrder = viewModel.sortOrder.value
for (item in submenu) {
val order = CategoriesActivity.SORT_ORDERS.getOrNull(item.order)
item.isChecked = order == selectedOrder
}
}
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when {
menuItem.itemId == R.id.action_order -> false
menuItem.groupId == R.id.group_order -> {
val order = CategoriesActivity.SORT_ORDERS.getOrNull(menuItem.order) ?: return false
viewModel.setSortOrder(order)
true
}
else -> false
}
}
}

@ -15,6 +15,10 @@ abstract class HistoryDao {
@Query("SELECT * FROM history ORDER BY updated_at DESC LIMIT :limit OFFSET :offset") @Query("SELECT * FROM history ORDER BY updated_at DESC LIMIT :limit OFFSET :offset")
abstract suspend fun findAll(offset: Int, limit: Int): List<HistoryWithManga> abstract suspend fun findAll(offset: Int, limit: Int): List<HistoryWithManga>
@Transaction
@Query("SELECT * FROM history WHERE manga_id IN (:ids)")
abstract suspend fun findAll(ids: Collection<Long>): List<HistoryEntity?>
@Transaction @Transaction
@Query("SELECT * FROM history ORDER BY updated_at DESC") @Query("SELECT * FROM history ORDER BY updated_at DESC")
abstract fun observeAll(): Flow<List<HistoryWithManga>> abstract fun observeAll(): Flow<List<HistoryWithManga>>
@ -69,4 +73,13 @@ abstract class HistoryDao {
true true
} else false } else false
} }
@Transaction
open suspend fun upsert(entities: Iterable<HistoryEntity>) {
for (e in entities) {
if (update(e) == 0) {
insert(e)
}
}
}
} }

@ -4,6 +4,7 @@ import androidx.room.withTransaction
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.* import org.koitharu.kotatsu.core.db.entity.*
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
@ -76,7 +77,7 @@ class HistoryRepository(
scroll = scroll.toFloat(), // we migrate to int, but decide to not update database scroll = scroll.toFloat(), // we migrate to int, but decide to not update database
) )
) )
trackingRepository.upsert(manga) trackingRepository.syncWithHistory(manga, chapterId)
} }
} }
@ -100,6 +101,19 @@ class HistoryRepository(
} }
} }
suspend fun deleteReversible(ids: Collection<Long>): ReversibleHandle {
val entities = db.withTransaction {
val entities = db.historyDao.findAll(ids.toList()).filterNotNull()
for (id in ids) {
db.historyDao.delete(id)
}
entities
}
return ReversibleHandle {
db.historyDao.upsert(entities)
}
}
/** /**
* Try to replace one manga with another one * Try to replace one manga with another one
* Useful for replacing saved manga on deleting it with remove source * Useful for replacing saved manga on deleting it with remove source

@ -2,15 +2,17 @@ package org.koitharu.kotatsu.history.ui
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.base.domain.reverseAsync
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.addMenuProvider
class HistoryListFragment : MangaListFragment() { class HistoryListFragment : MangaListFragment() {
@ -19,44 +21,15 @@ class HistoryListFragment : MangaListFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
addMenuProvider(HistoryListMenuProvider(view.context, viewModel))
viewModel.isGroupingEnabled.observe(viewLifecycleOwner) { viewModel.isGroupingEnabled.observe(viewLifecycleOwner) {
activity?.invalidateOptionsMenu() activity?.invalidateOptionsMenu()
} }
viewModel.onItemsRemoved.observe(viewLifecycleOwner, ::onItemsRemoved)
} }
override fun onScrolledToEnd() = Unit override fun onScrolledToEnd() = Unit
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.opt_history, menu)
super.onCreateOptionsMenu(menu, inflater)
}
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
menu.findItem(R.id.action_history_grouping)?.isChecked =
viewModel.isGroupingEnabled.value == true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_clear_history -> {
MaterialAlertDialogBuilder(context ?: return false)
.setTitle(R.string.clear_history)
.setMessage(R.string.text_clear_history_prompt)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ ->
viewModel.clearHistory()
}.show()
true
}
R.id.action_history_grouping -> {
viewModel.setGrouping(!item.isChecked)
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_history, menu) mode.menuInflater.inflate(R.menu.mode_history, menu)
return super.onCreateActionMode(mode, menu) return super.onCreateActionMode(mode, menu)
@ -80,6 +53,12 @@ class HistoryListFragment : MangaListFragment() {
} }
} }
private fun onItemsRemoved(reversibleHandle: ReversibleHandle) {
Snackbar.make(binding.recyclerView, R.string.removed_from_history, Snackbar.LENGTH_LONG)
.setAction(R.string.undo) { reversibleHandle.reverseAsync() }
.show()
}
companion object { companion object {
fun newInstance() = HistoryListFragment() fun newInstance() = HistoryListFragment()

@ -0,0 +1,41 @@
package org.koitharu.kotatsu.history.ui
import android.content.Context
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
class HistoryListMenuProvider(
private val context: Context,
private val viewModel: HistoryListViewModel,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_history, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_clear_history -> {
MaterialAlertDialogBuilder(context)
.setTitle(R.string.clear_history)
.setMessage(R.string.text_clear_history_prompt)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ ->
viewModel.clearHistory()
}.show()
true
}
R.id.action_history_grouping -> {
viewModel.setGrouping(!menuItem.isChecked)
true
}
else -> false
}
override fun onPrepareMenu(menu: Menu) {
menu.findItem(R.id.action_history_grouping).isChecked = viewModel.isGroupingEnabled.value == true
}
}

@ -5,17 +5,24 @@ import androidx.lifecycle.viewModelScope
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.base.domain.plus
import org.koitharu.kotatsu.core.os.ShortcutsRepository import org.koitharu.kotatsu.core.os.ShortcutsRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.DateTimeAgo import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.MangaWithHistory import org.koitharu.kotatsu.history.domain.MangaWithHistory
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.* import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.daysDiff import org.koitharu.kotatsu.utils.ext.daysDiff
import org.koitharu.kotatsu.utils.ext.onFirst import org.koitharu.kotatsu.utils.ext.onFirst
@ -28,12 +35,9 @@ class HistoryListViewModel(
) : MangaListViewModel(settings) { ) : MangaListViewModel(settings) {
val isGroupingEnabled = MutableLiveData<Boolean>() val isGroupingEnabled = MutableLiveData<Boolean>()
val onItemsRemoved = SingleLiveEvent<ReversibleHandle>()
private val historyGrouping = settings.observe() private val historyGrouping = settings.observeAsFlow(AppSettings.KEY_HISTORY_GROUPING) { historyGrouping }
.filter { it == AppSettings.KEY_HISTORY_GROUPING }
.map { settings.historyGrouping }
.onStart { emit(settings.historyGrouping) }
.distinctUntilChanged()
.onEach { isGroupingEnabled.postValue(it) } .onEach { isGroupingEnabled.postValue(it) }
override val content = combine( override val content = combine(
@ -52,8 +56,10 @@ class HistoryListViewModel(
) )
else -> mapList(list, grouped, mode) else -> mapList(list, grouped, mode)
} }
}.onStart {
loadingCounter.increment()
}.onFirst { }.onFirst {
isLoading.postValue(false) loadingCounter.decrement()
}.catch { }.catch {
it.toErrorState(canRetry = false) it.toErrorState(canRetry = false)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
@ -73,9 +79,12 @@ class HistoryListViewModel(
if (ids.isEmpty()) { if (ids.isEmpty()) {
return return
} }
launchJob { launchJob(Dispatchers.Default) {
repository.delete(ids) val handle = repository.deleteReversible(ids) + ReversibleHandle {
shortcutsRepository.updateShortcuts()
}
shortcutsRepository.updateShortcuts() shortcutsRepository.updateShortcuts()
onItemsRemoved.postCall(handle)
} }
} }

@ -9,6 +9,7 @@ import androidx.collection.ArraySet
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.isNotEmpty import androidx.core.view.isNotEmpty
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
@ -67,11 +68,6 @@ abstract class MangaListFragment :
protected val selectedItems: Set<Manga> protected val selectedItems: Set<Manga>
get() = collectSelectedItems() get() = collectSelectedItems()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onInflateView( override fun onInflateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup? container: ViewGroup?
@ -98,6 +94,7 @@ abstract class MangaListFragment :
setOnRefreshListener(this@MangaListFragment) setOnRefreshListener(this@MangaListFragment)
isEnabled = isSwipeRefreshEnabled isEnabled = isSwipeRefreshEnabled
} }
addMenuProvider(MangaListMenuProvider(childFragmentManager))
viewModel.listMode.observe(viewLifecycleOwner, ::onListModeChanged) viewModel.listMode.observe(viewLifecycleOwner, ::onListModeChanged)
viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged) viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged)
@ -114,19 +111,6 @@ abstract class MangaListFragment :
super.onDestroyView() super.onDestroyView()
} }
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.opt_list, menu)
super.onCreateOptionsMenu(menu, inflater)
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.action_list_mode -> {
ListModeSelectDialog.show(childFragmentManager)
true
}
else -> super.onOptionsItemSelected(item)
}
override fun onItemClick(item: Manga, view: View) { override fun onItemClick(item: Manga, view: View) {
if (selectionDecoration?.checkedItemsCount != 0) { if (selectionDecoration?.checkedItemsCount != 0) {
selectionDecoration?.toggleItemChecked(item.id) selectionDecoration?.toggleItemChecked(item.id)

@ -0,0 +1,25 @@
package org.koitharu.kotatsu.list.ui
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import androidx.fragment.app.FragmentManager
import org.koitharu.kotatsu.R
class MangaListMenuProvider(
private val fragmentManager: FragmentManager,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_list, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_list_mode -> {
ListModeSelectDialog.show(fragmentManager)
true
}
else -> false
}
}

@ -4,16 +4,14 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.prefs.observeAsLiveData
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
abstract class MangaListViewModel( abstract class MangaListViewModel(
private val settings: AppSettings, private val settings: AppSettings,
@ -21,20 +19,15 @@ abstract class MangaListViewModel(
abstract val content: LiveData<List<ListModel>> abstract val content: LiveData<List<ListModel>>
val listMode = MutableLiveData<ListMode>() val listMode = MutableLiveData<ListMode>()
val gridScale = settings.observe() val gridScale = settings.observeAsLiveData(
.filter { it == AppSettings.KEY_GRID_SIZE } context = viewModelScope.coroutineContext + Dispatchers.Default,
.map { settings.gridSize / 100f } key = AppSettings.KEY_GRID_SIZE,
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.IO) { valueProducer = { gridSize / 100f },
settings.gridSize / 100f )
}
open fun onRemoveFilterTag(tag: MangaTag) = Unit open fun onRemoveFilterTag(tag: MangaTag) = Unit
protected fun createListModeFlow() = settings.observe() protected fun createListModeFlow() = settings.observeAsFlow(AppSettings.KEY_LIST_MODE) { listMode }
.filter { it == AppSettings.KEY_LIST_MODE }
.map { settings.listMode }
.onStart { emit(settings.listMode) }
.distinctUntilChanged()
.onEach { .onEach {
if (listMode.value != it) { if (listMode.value != it) {
listMode.postValue(it) listMode.postValue(it)

@ -13,7 +13,7 @@ fun currentFilterAD(
val chipGroup = itemView as ChipsView val chipGroup = itemView as ChipsView
chipGroup.onChipCloseClickListener = ChipsView.OnChipCloseClickListener { chip, data -> chipGroup.onChipCloseClickListener = ChipsView.OnChipCloseClickListener { _, data ->
listener.onTagRemoveClick(data as? MangaTag ?: return@OnChipCloseClickListener) listener.onTagRemoveClick(data as? MangaTag ?: return@OnChipCloseClickListener)
} }

@ -1,5 +1,6 @@
package org.koitharu.kotatsu.list.ui.adapter package org.koitharu.kotatsu.list.ui.adapter
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import coil.request.Disposable import coil.request.Disposable
@ -13,6 +14,7 @@ import org.koitharu.kotatsu.databinding.ItemMangaGridBinding
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaGridModel import org.koitharu.kotatsu.list.ui.model.MangaGridModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.search.ui.multi.adapter.ItemSizeResolver
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.referer import org.koitharu.kotatsu.utils.ext.referer
@ -21,6 +23,7 @@ fun mangaGridItemAD(
coil: ImageLoader, coil: ImageLoader,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Manga>, clickListener: OnListItemClickListener<Manga>,
sizeResolver: ItemSizeResolver?,
) = adapterDelegateViewBinding<MangaGridModel, ListModel, ItemMangaGridBinding>( ) = adapterDelegateViewBinding<MangaGridModel, ListModel, ItemMangaGridBinding>(
{ inflater, parent -> ItemMangaGridBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemMangaGridBinding.inflate(inflater, parent, false) }
) { ) {
@ -34,6 +37,11 @@ fun mangaGridItemAD(
itemView.setOnLongClickListener { itemView.setOnLongClickListener {
clickListener.onItemLongClick(item.manga, it) clickListener.onItemLongClick(item.manga, it)
} }
if (sizeResolver != null) {
itemView.updateLayoutParams {
width = sizeResolver.cellWidth
}
}
bind { bind {
binding.textViewTitle.text = item.title binding.textViewTitle.text = item.title

@ -18,7 +18,7 @@ class MangaListAdapter(
delegatesManager delegatesManager
.addDelegate(ITEM_TYPE_MANGA_LIST, mangaListItemAD(coil, lifecycleOwner, listener)) .addDelegate(ITEM_TYPE_MANGA_LIST, mangaListItemAD(coil, lifecycleOwner, listener))
.addDelegate(ITEM_TYPE_MANGA_LIST_DETAILED, mangaListDetailedItemAD(coil, lifecycleOwner, listener)) .addDelegate(ITEM_TYPE_MANGA_LIST_DETAILED, mangaListDetailedItemAD(coil, lifecycleOwner, listener))
.addDelegate(ITEM_TYPE_MANGA_GRID, mangaGridItemAD(coil, lifecycleOwner, listener)) .addDelegate(ITEM_TYPE_MANGA_GRID, mangaGridItemAD(coil, lifecycleOwner, listener, null))
.addDelegate(ITEM_TYPE_LOADING_FOOTER, loadingFooterAD()) .addDelegate(ITEM_TYPE_LOADING_FOOTER, loadingFooterAD())
.addDelegate(ITEM_TYPE_LOADING_STATE, loadingStateAD()) .addDelegate(ITEM_TYPE_LOADING_STATE, loadingStateAD())
.addDelegate(ITEM_TYPE_DATE, relatedDateItemAD()) .addDelegate(ITEM_TYPE_DATE, relatedDateItemAD())

@ -6,7 +6,6 @@ import android.os.Bundle
import android.view.* import android.view.*
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import org.koin.androidx.viewmodel.ViewModelOwner.Companion.from
import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet import org.koitharu.kotatsu.base.ui.BaseBottomSheet
@ -14,11 +13,14 @@ import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel
import org.koitharu.kotatsu.utils.BottomSheetToolbarController import org.koitharu.kotatsu.utils.BottomSheetToolbarController
class FilterBottomSheet : BaseBottomSheet<SheetFilterBinding>(), MenuItem.OnActionExpandListener, class FilterBottomSheet :
SearchView.OnQueryTextListener, DialogInterface.OnKeyListener { BaseBottomSheet<SheetFilterBinding>(),
MenuItem.OnActionExpandListener,
SearchView.OnQueryTextListener,
DialogInterface.OnKeyListener {
private val viewModel by sharedViewModel<RemoteListViewModel>( private val viewModel by sharedViewModel<RemoteListViewModel>(
owner = { from(requireParentFragment(), requireParentFragment()) } owner = { requireParentFragment() }
) )
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {

@ -1,17 +1,19 @@
package org.koitharu.kotatsu.list.ui.filter package org.koitharu.kotatsu.list.ui.filter
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.lifecycle.LiveData
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import java.text.Collator
import java.util.* import java.util.*
class FilterCoordinator( class FilterCoordinator(
@ -27,7 +29,7 @@ class FilterCoordinator(
} }
private var availableTagsDeferred = loadTagsAsync() private var availableTagsDeferred = loadTagsAsync()
val items = getItemsFlow() val items: LiveData<List<FilterItem>> = getItemsFlow()
.asLiveDataDistinct(coroutineScope.coroutineContext + Dispatchers.Default) .asLiveDataDistinct(coroutineScope.coroutineContext + Dispatchers.Default)
init { init {
@ -104,7 +106,7 @@ class FilterCoordinator(
query: String, query: String,
): List<FilterItem> { ): List<FilterItem> {
val sortOrders = repository.sortOrders.sortedBy { it.ordinal } val sortOrders = repository.sortOrders.sortedBy { it.ordinal }
val tags = mergeTags(state.tags, allTags.tags).sortedBy { it.title } val tags = mergeTags(state.tags, allTags.tags).toList()
val list = ArrayList<FilterItem>(tags.size + sortOrders.size + 3) val list = ArrayList<FilterItem>(tags.size + sortOrders.size + 3)
if (query.isEmpty()) { if (query.isEmpty()) {
if (sortOrders.isNotEmpty()) { if (sortOrders.isNotEmpty()) {
@ -113,7 +115,7 @@ class FilterCoordinator(
FilterItem.Sort(it, isSelected = it == state.sortOrder) FilterItem.Sort(it, isSelected = it == state.sortOrder)
} }
} }
if(allTags.isLoading || allTags.isError || tags.isNotEmpty()) { if (allTags.isLoading || allTags.isError || tags.isNotEmpty()) {
list.add(FilterItem.Header(R.string.genres, state.tags.size)) list.add(FilterItem.Header(R.string.genres, state.tags.size))
tags.mapTo(list) { tags.mapTo(list) {
FilterItem.Tag(it, isChecked = it in state.tags) FilterItem.Tag(it, isChecked = it in state.tags)
@ -153,14 +155,12 @@ class FilterCoordinator(
runCatching { runCatching {
repository.getTags() repository.getTags()
}.onFailure { error -> }.onFailure { error ->
if (BuildConfig.DEBUG) { error.printStackTraceDebug()
error.printStackTrace()
}
}.getOrNull() }.getOrNull()
} }
private fun mergeTags(primary: Set<MangaTag>, secondary: Set<MangaTag>): Set<MangaTag> { private fun mergeTags(primary: Set<MangaTag>, secondary: Set<MangaTag>): Set<MangaTag> {
val result = TreeSet(TagTitleComparator()) val result = TreeSet(TagTitleComparator(repository.source.locale))
result.addAll(secondary) result.addAll(secondary)
result.addAll(primary) result.addAll(primary)
return result return result
@ -193,11 +193,14 @@ class FilterCoordinator(
} }
} }
private class TagTitleComparator : Comparator<MangaTag> { private class TagTitleComparator(lc: String?) : Comparator<MangaTag> {
private val collator = lc?.let { Collator.getInstance(Locale(it)) }
override fun compare(o1: MangaTag, o2: MangaTag) = compareValues( override fun compare(o1: MangaTag, o2: MangaTag): Int {
o1.title.lowercase(), val t1 = o1.title.lowercase()
o2.title.lowercase(), val t2 = o2.title.lowercase()
) return collator?.compare(t1, t2) ?: compareValues(t1, t2)
}
} }
} }

@ -40,7 +40,7 @@ fun Manga.toGridModel(counter: Int) = MangaGridModel(
suspend fun List<Manga>.toUi( suspend fun List<Manga>.toUi(
mode: ListMode, mode: ListMode,
countersProvider: CountersProvider, countersProvider: CountersProvider,
): List<ListModel> = when (mode) { ): List<MangaItemModel> = when (mode) {
ListMode.LIST -> map { it.toListModel(countersProvider.getCounter(it.id)) } ListMode.LIST -> map { it.toListModel(countersProvider.getCounter(it.id)) }
ListMode.DETAILED_LIST -> map { it.toListDetailedModel(countersProvider.getCounter(it.id)) } ListMode.DETAILED_LIST -> map { it.toListDetailedModel(countersProvider.getCounter(it.id)) }
ListMode.GRID -> map { it.toGridModel(countersProvider.getCounter(it.id)) } ListMode.GRID -> map { it.toGridModel(countersProvider.getCounter(it.id)) }
@ -58,7 +58,7 @@ suspend fun <C : MutableCollection<ListModel>> List<Manga>.toUi(
fun List<Manga>.toUi( fun List<Manga>.toUi(
mode: ListMode, mode: ListMode,
): List<ListModel> = when (mode) { ): List<MangaItemModel> = when (mode) {
ListMode.LIST -> map { it.toListModel(0) } ListMode.LIST -> map { it.toListModel(0) }
ListMode.DETAILED_LIST -> map { it.toListDetailedModel(0) } ListMode.DETAILED_LIST -> map { it.toListDetailedModel(0) }
ListMode.GRID -> map { it.toGridModel(0) } ListMode.GRID -> map { it.toGridModel(0) }

@ -2,13 +2,13 @@ package org.koitharu.kotatsu.local.data
import android.content.Context import android.content.Context
import com.tomclaw.cache.DiskLruCache import com.tomclaw.cache.DiskLruCache
import java.io.File
import java.io.InputStream
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.parsers.util.longHashCode
import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.subdir import org.koitharu.kotatsu.utils.ext.subdir
import org.koitharu.kotatsu.utils.ext.takeIfReadable import org.koitharu.kotatsu.utils.ext.takeIfReadable
import java.io.File
import java.io.InputStream
class PagesCache(context: Context) { class PagesCache(context: Context) {

@ -7,6 +7,12 @@ import androidx.annotation.WorkerThread
import androidx.collection.ArraySet import androidx.collection.ArraySet
import androidx.core.net.toFile import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import java.io.File
import java.io.IOException
import java.util.*
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.* import kotlinx.coroutines.*
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
@ -15,19 +21,13 @@ import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.local.data.MangaIndex import org.koitharu.kotatsu.local.data.MangaIndex
import org.koitharu.kotatsu.local.data.TempFileFilter import org.koitharu.kotatsu.local.data.TempFileFilter
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.longHashCode
import org.koitharu.kotatsu.parsers.util.toCamelCase import org.koitharu.kotatsu.parsers.util.toCamelCase
import org.koitharu.kotatsu.utils.AlphanumComparator import org.koitharu.kotatsu.utils.AlphanumComparator
import org.koitharu.kotatsu.utils.CompositeMutex import org.koitharu.kotatsu.utils.CompositeMutex
import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.readText import org.koitharu.kotatsu.utils.ext.readText
import org.koitharu.kotatsu.utils.ext.resolveName import org.koitharu.kotatsu.utils.ext.resolveName
import java.io.File
import java.io.IOException
import java.util.*
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import kotlin.coroutines.CoroutineContext
private const val MAX_PARALLELISM = 4 private const val MAX_PARALLELISM = 4
@ -37,28 +37,25 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
private val filenameFilter = CbzFilter() private val filenameFilter = CbzFilter()
private val locks = CompositeMutex<Long>() private val locks = CompositeMutex<Long>()
override suspend fun getList( override suspend fun getList(offset: Int, query: String): List<Manga> {
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder?
): List<Manga> {
if (offset > 0) { if (offset > 0) {
return emptyList() return emptyList()
} }
val files = getAllFiles() val list = getRawList()
val list = coroutineScope { if (query.isNotEmpty()) {
val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM)
files.map { file ->
getFromFileAsync(file, dispatcher)
}.awaitAll()
}.filterNotNullTo(ArrayList(files.size))
if (!query.isNullOrEmpty()) {
list.retainAll { x -> list.retainAll { x ->
x.title.contains(query, ignoreCase = true) || x.title.contains(query, ignoreCase = true) ||
x.altTitle?.contains(query, ignoreCase = true) == true x.altTitle?.contains(query, ignoreCase = true) == true
} }
} }
return list
}
override suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga> {
if (offset > 0) {
return emptyList()
}
val list = getRawList()
if (!tags.isNullOrEmpty()) { if (!tags.isNullOrEmpty()) {
list.retainAll { x -> list.retainAll { x ->
x.tags.containsAll(tags) x.tags.containsAll(tags)
@ -244,7 +241,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
} }
} }
override val sortOrders = emptySet<SortOrder>() override val sortOrders = setOf(SortOrder.ALPHABETICAL)
override suspend fun getPageUrl(page: MangaPage) = page.url override suspend fun getPageUrl(page: MangaPage) = page.url
@ -295,6 +292,16 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
locks.unlock(id) locks.unlock(id)
} }
private suspend fun getRawList(): ArrayList<Manga> {
val files = getAllFiles()
return coroutineScope {
val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM)
files.map { file ->
getFromFileAsync(file, dispatcher)
}.awaitAll()
}.filterNotNullTo(ArrayList(files.size))
}
private suspend fun getAllFiles() = storageManager.getReadableDirs().flatMap { dir -> private suspend fun getAllFiles() = storageManager.getReadableDirs().flatMap { dir ->
dir.listFiles(filenameFilter)?.toList().orEmpty() dir.listFiles(filenameFilter)?.toList().orEmpty()
} }

@ -4,7 +4,6 @@ import android.content.*
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.activity.result.ActivityResultCallback import androidx.activity.result.ActivityResultCallback
@ -15,11 +14,12 @@ import androidx.core.net.toUri
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.addMenuProvider
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.progress.Progress import org.koitharu.kotatsu.utils.progress.Progress
class LocalListFragment : MangaListFragment(), ActivityResultCallback<List<@JvmSuppressWildcards Uri>> { class LocalListFragment : MangaListFragment(), ActivityResultCallback<List<@JvmSuppressWildcards Uri>> {
@ -48,6 +48,7 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<List<@JvmS
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
addMenuProvider(LocalListMenuProvider(this::onEmptyActionClick))
viewModel.onMangaRemoved.observe(viewLifecycleOwner) { onItemRemoved() } viewModel.onMangaRemoved.observe(viewLifecycleOwner) { onItemRemoved() }
viewModel.importProgress.observe(viewLifecycleOwner, ::onImportProgressChanged) viewModel.importProgress.observe(viewLifecycleOwner, ::onImportProgressChanged)
} }
@ -68,9 +69,7 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<List<@JvmS
try { try {
importCall.launch(arrayOf("*/*")) importCall.launch(arrayOf("*/*"))
} catch (e: ActivityNotFoundException) { } catch (e: ActivityNotFoundException) {
if (BuildConfig.DEBUG) { e.printStackTraceDebug()
e.printStackTrace()
}
Snackbar.make( Snackbar.make(
binding.recyclerView, binding.recyclerView,
R.string.operation_not_supported, R.string.operation_not_supported,
@ -79,21 +78,6 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<List<@JvmS
} }
} }
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.opt_local, menu)
super.onCreateOptionsMenu(menu, inflater)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_import -> {
onEmptyActionClick()
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onActivityResult(result: List<@JvmSuppressWildcards Uri>) { override fun onActivityResult(result: List<@JvmSuppressWildcards Uri>) {
if (result.isEmpty()) return if (result.isEmpty()) return
viewModel.importFiles(result) viewModel.importFiles(result)

@ -0,0 +1,26 @@
package org.koitharu.kotatsu.local.ui
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.R
class LocalListMenuProvider(
private val onImportClick: Function0<Unit>,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_local, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_import -> {
onImportClick()
true
}
else -> false
}
}
}

@ -10,7 +10,6 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.ShortcutsRepository import org.koitharu.kotatsu.core.os.ShortcutsRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
@ -22,6 +21,7 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.progress.Progress import org.koitharu.kotatsu.utils.progress.Progress
import java.io.IOException import java.io.IOException
@ -115,7 +115,7 @@ class LocalListViewModel(
private suspend fun doRefresh() { private suspend fun doRefresh() {
try { try {
listError.value = null listError.value = null
mangaList.value = repository.getList(0) mangaList.value = repository.getList(0, null, null)
} catch (e: Throwable) { } catch (e: Throwable) {
listError.value = e listError.value = e
} }
@ -127,9 +127,7 @@ class LocalListViewModel(
runCatching { runCatching {
repository.cleanup() repository.cleanup()
}.onFailure { error -> }.onFailure { error ->
if (BuildConfig.DEBUG) { error.printStackTraceDebug()
error.printStackTrace()
}
} }
} }
} }

@ -3,6 +3,7 @@ package org.koitharu.kotatsu.main
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
import org.koitharu.kotatsu.base.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.core.os.ShortcutsRepository import org.koitharu.kotatsu.core.os.ShortcutsRepository
import org.koitharu.kotatsu.main.ui.MainViewModel import org.koitharu.kotatsu.main.ui.MainViewModel
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
@ -11,6 +12,7 @@ import org.koitharu.kotatsu.main.ui.protect.ProtectViewModel
val mainModule val mainModule
get() = module { get() = module {
single { AppProtectHelper(get()) } single { AppProtectHelper(get()) }
single { ActivityRecreationHandle() }
factory { ShortcutsRepository(androidContext(), get(), get(), get()) } factory { ShortcutsRepository(androidContext(), get(), get(), get()) }
viewModel { MainViewModel(get(), get()) } viewModel { MainViewModel(get(), get()) }
viewModel { ProtectViewModel(get(), get()) } viewModel { ProtectViewModel(get(), get()) }

@ -2,7 +2,6 @@ package org.koitharu.kotatsu.main.ui
import android.app.ActivityOptions import android.app.ActivityOptions
import android.content.res.Configuration import android.content.res.Configuration
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
@ -20,7 +19,6 @@ import androidx.fragment.app.FragmentTransaction
import androidx.fragment.app.commit import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.transition.TransitionManager import androidx.transition.TransitionManager
import com.google.android.material.R as materialR
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.appbar.AppBarLayout.LayoutParams.* import com.google.android.material.appbar.AppBarLayout.LayoutParams.*
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
@ -47,7 +45,7 @@ import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionFragment import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionFragment
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
@ -61,6 +59,7 @@ import org.koitharu.kotatsu.tracker.ui.FeedFragment
import org.koitharu.kotatsu.tracker.work.TrackWorker import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.VoiceInputContract import org.koitharu.kotatsu.utils.VoiceInputContract
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
import com.google.android.material.R as materialR
private const val TAG_PRIMARY = "primary" private const val TAG_PRIMARY = "primary"
private const val TAG_SEARCH = "search" private const val TAG_SEARCH = "search"
@ -141,6 +140,7 @@ class MainActivity :
viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged) viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged)
viewModel.remoteSources.observe(this, this::updateSideMenu) viewModel.remoteSources.observe(this, this::updateSideMenu)
viewModel.isSuggestionsEnabled.observe(this, this::setSuggestionsEnabled) viewModel.isSuggestionsEnabled.observe(this, this::setSuggestionsEnabled)
viewModel.isTrackerEnabled.observe(this, this::setTrackerEnabled)
} }
override fun onRestoreInstanceState(savedInstanceState: Bundle) { override fun onRestoreInstanceState(savedInstanceState: Bundle) {
@ -233,6 +233,8 @@ class MainActivity :
} }
binding.toolbarCard.updateLayoutParams<MarginLayoutParams> { binding.toolbarCard.updateLayoutParams<MarginLayoutParams> {
topMargin = insets.top + bottomMargin topMargin = insets.top + bottomMargin
leftMargin = insets.left
rightMargin = insets.right
} }
binding.root.updatePadding( binding.root.updatePadding(
left = insets.left, left = insets.left,
@ -268,7 +270,7 @@ class MainActivity :
if (source != null) { if (source != null) {
startActivity(SearchActivity.newIntent(this, source, query)) startActivity(SearchActivity.newIntent(this, source, query))
} else { } else {
startActivity(GlobalSearchActivity.newIntent(this, query)) startActivity(MultiSearchActivity.newIntent(this, query))
} }
searchSuggestionViewModel.saveQuery(query) searchSuggestionViewModel.saveQuery(query)
} }
@ -317,15 +319,7 @@ class MainActivity :
} }
private fun onOpenReader(manga: Manga) { private fun onOpenReader(manga: Manga) {
val options = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val options = ActivityOptions.makeScaleUpAnimation(binding.fab, 0, 0, binding.fab.width, binding.fab.height)
ActivityOptions.makeClipRevealAnimation(
binding.fab, 0, 0, binding.fab.measuredWidth, binding.fab.measuredHeight
)
} else {
ActivityOptions.makeScaleUpAnimation(
binding.fab, 0, 0, binding.fab.measuredWidth, binding.fab.measuredHeight
)
}
startActivity(ReaderActivity.newIntent(this, manga), options?.toBundle()) startActivity(ReaderActivity.newIntent(this, manga), options?.toBundle())
} }
@ -359,6 +353,14 @@ class MainActivity :
item.isVisible = isEnabled item.isVisible = isEnabled
} }
private fun setTrackerEnabled(isEnabled: Boolean) {
val item = binding.navigationView.menu.findItem(R.id.nav_feed) ?: return
if (!isEnabled && item.isChecked) {
binding.navigationView.setCheckedItem(R.id.nav_history)
}
item.isVisible = isEnabled
}
private fun openDefaultSection() { private fun openDefaultSection() {
when (viewModel.defaultSection) { when (viewModel.defaultSection) {
AppSection.LOCAL -> { AppSection.LOCAL -> {

@ -7,7 +7,9 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.prefs.AppSection
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsLiveData
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
@ -15,17 +17,27 @@ import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
class MainViewModel( class MainViewModel(
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
settings: AppSettings private val settings: AppSettings,
) : BaseViewModel() { ) : BaseViewModel() {
val onOpenReader = SingleLiveEvent<Manga>() val onOpenReader = SingleLiveEvent<Manga>()
var defaultSection by settings::defaultSection var defaultSection: AppSection
get() = settings.defaultSection
set(value) {
settings.defaultSection = value
}
val isSuggestionsEnabled = settings.observe() val isSuggestionsEnabled = settings.observeAsLiveData(
.filter { it == AppSettings.KEY_SUGGESTIONS } context = viewModelScope.coroutineContext + Dispatchers.Default,
.onStart { emit("") } key = AppSettings.KEY_SUGGESTIONS,
.map { settings.isSuggestionsEnabled } valueProducer = { isSuggestionsEnabled },
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) )
val isTrackerEnabled = settings.observeAsLiveData(
context = viewModelScope.coroutineContext + Dispatchers.Default,
key = AppSettings.KEY_TRACKER_ENABLED,
valueProducer = { isTrackerEnabled },
)
val isResumeEnabled = historyRepository val isResumeEnabled = historyRepository
.observeHasItems() .observeHasItems()

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

Loading…
Cancel
Save